8892920514
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
import { render, screen, cleanup } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mockAppState = {
|
|
auth: {
|
|
mode: "enabled",
|
|
authenticated: true,
|
|
user: { id: "user-1" },
|
|
ssoProviders: [],
|
|
},
|
|
workspaces: { activeId: "ws-1" },
|
|
kanban: { tasks: [] as Array<{ id: string; title: string; parentTaskId?: string }> },
|
|
kanbanMulti: {
|
|
snapshots: {} as Record<string, { tasks: Array<{ id: string; title: string }> }>,
|
|
},
|
|
taskPRs: { byTaskId: {} as Record<string, unknown> },
|
|
};
|
|
|
|
vi.mock("@/components/state-provider", () => ({
|
|
useAppStore: (selector: (state: typeof mockAppState) => unknown) => selector(mockAppState),
|
|
}));
|
|
|
|
vi.mock("@/components/github/pr-task-icon", () => ({
|
|
PRTaskIcon: () => <span data-testid="pr-task-icon" />,
|
|
}));
|
|
|
|
vi.mock("@/components/gitlab/mr-task-icon", () => ({
|
|
MRTaskIcon: () => <span data-testid="mr-task-icon" />,
|
|
}));
|
|
|
|
import { pluginRegistry } from "@/lib/plugins/registry";
|
|
import { t } from "@/lib/i18n";
|
|
import { KanbanCardBody, renderTaskStatusIcon } from "./kanban-card-content";
|
|
import type { Task } from "./kanban-card";
|
|
|
|
const TASK: Task = {
|
|
id: "task-1",
|
|
title: "Fix the bug",
|
|
workflowStepId: "step-1",
|
|
};
|
|
|
|
const NOTES_PLUGIN_ID = "kandev-plugin-notes";
|
|
const SECOND_PLUGIN_ID = "kandev-plugin-second";
|
|
const TAGS_TEST_ID = "plugin-tags";
|
|
const TAGS_SLOT = "task-card-tags";
|
|
const INDICATOR_TEST_ID = "plugin-indicator";
|
|
const TITLE_TEST_ID = "task-card-title";
|
|
const SLOT_PROPS_TEXT = "task-1|ws-1|step-1";
|
|
|
|
function SlotPropsProbe({ testId, slotProps }: { testId: string; slotProps?: unknown }) {
|
|
const props = slotProps as { taskId: string; workspaceId: string; workflowStepId: string };
|
|
return (
|
|
<span data-testid={testId}>
|
|
{props.taskId}|{props.workspaceId}|{props.workflowStepId}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
mockAppState.kanban.tasks = [];
|
|
mockAppState.kanbanMulti.snapshots = {};
|
|
pluginRegistry.unregisterPlugin(NOTES_PLUGIN_ID);
|
|
pluginRegistry.unregisterPlugin(SECOND_PLUGIN_ID);
|
|
});
|
|
|
|
describe("KanbanCardBody — task-card-indicators slot", () => {
|
|
it("renders no extra markup when no plugin is registered for the slot (AC14)", () => {
|
|
const { container } = render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
expect(container.querySelector(`[data-testid="${INDICATOR_TEST_ID}"]`)).toBeNull();
|
|
});
|
|
|
|
it("renders a registered indicator with taskId/workspaceId/workflowStepId as slotProps (AC13)", () => {
|
|
function Indicator({ slotProps }: { slotProps?: unknown }) {
|
|
return <SlotPropsProbe testId={INDICATOR_TEST_ID} slotProps={slotProps} />;
|
|
}
|
|
pluginRegistry.forPlugin(NOTES_PLUGIN_ID).registerComponent("task-card-indicators", Indicator);
|
|
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
|
|
expect(screen.getByTestId(INDICATOR_TEST_ID).textContent).toBe(SLOT_PROPS_TEXT);
|
|
});
|
|
});
|
|
|
|
describe("KanbanCardBody — priority indicator", () => {
|
|
it("renders the priority indicator inside the title row for a critical task", () => {
|
|
const { container } = render(
|
|
<KanbanCardBody task={{ ...TASK, priority: "critical" }} repositoryChips={[]} />,
|
|
);
|
|
const titleRow = container.querySelector('[data-testid="kanban-card-title-row"]');
|
|
expect(
|
|
titleRow?.querySelector('[data-testid="kanban-card-priority-indicator"]'),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("renders no priority indicator for a medium-priority task", () => {
|
|
const { container } = render(
|
|
<KanbanCardBody task={{ ...TASK, priority: "medium" }} repositoryChips={[]} />,
|
|
);
|
|
expect(container.querySelector('[data-testid="kanban-card-priority-indicator"]')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("Kanban task status motion", () => {
|
|
it("animates the fallback running status on an HTML wrapper", () => {
|
|
const { container } = render(<>{renderTaskStatusIcon(TASK, true, false, false)}</>);
|
|
const animated = container.querySelector(".animate-spin");
|
|
|
|
expect(animated?.tagName).toBe("SPAN");
|
|
const svg = animated?.querySelector("svg");
|
|
expect(svg).not.toBeNull();
|
|
expect(svg?.classList.contains("animate-spin")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("KanbanCardBody — task-card-tags slot", () => {
|
|
it("renders no extra markup when no plugin is registered for the slot (AC3)", () => {
|
|
const { container } = render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
expect(container.querySelector(`[data-testid="${TAGS_TEST_ID}"]`)).toBeNull();
|
|
});
|
|
|
|
it("renders a registered tags component with taskId/workspaceId/workflowStepId as slotProps (AC2)", () => {
|
|
function Tags({ slotProps }: { slotProps?: unknown }) {
|
|
return <SlotPropsProbe testId={TAGS_TEST_ID} slotProps={slotProps} />;
|
|
}
|
|
pluginRegistry.forPlugin(NOTES_PLUGIN_ID).registerComponent(TAGS_SLOT, Tags);
|
|
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
|
|
expect(screen.getByTestId(TAGS_TEST_ID).textContent).toBe(SLOT_PROPS_TEXT);
|
|
});
|
|
|
|
it("renders as its own row, distinct from the title row that hosts task-card-indicators (AC2)", () => {
|
|
function Tags() {
|
|
return <span data-testid={TAGS_TEST_ID} />;
|
|
}
|
|
function Indicator() {
|
|
return <span data-testid={INDICATOR_TEST_ID} />;
|
|
}
|
|
pluginRegistry.forPlugin(NOTES_PLUGIN_ID).registerComponent(TAGS_SLOT, Tags);
|
|
pluginRegistry.forPlugin(NOTES_PLUGIN_ID).registerComponent("task-card-indicators", Indicator);
|
|
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
|
|
const titleRow = screen.getByTestId("kanban-card-title-row");
|
|
const tags = screen.getByTestId(TAGS_TEST_ID);
|
|
const indicator = screen.getByTestId(INDICATOR_TEST_ID);
|
|
expect(titleRow.contains(indicator)).toBe(true);
|
|
expect(titleRow.contains(tags)).toBe(false);
|
|
});
|
|
|
|
it("renders both plugins registered for the slot, in registration order (AC4)", () => {
|
|
function First() {
|
|
return <span data-testid="tags-first" />;
|
|
}
|
|
function Second() {
|
|
return <span data-testid="tags-second" />;
|
|
}
|
|
pluginRegistry.forPlugin(NOTES_PLUGIN_ID).registerComponent(TAGS_SLOT, First);
|
|
pluginRegistry.forPlugin(SECOND_PLUGIN_ID).registerComponent(TAGS_SLOT, Second);
|
|
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
|
|
const first = screen.getByTestId("tags-first");
|
|
const second = screen.getByTestId("tags-second");
|
|
expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
|
|
it("isolates a throwing task-card-tags component so the card and other slot components still render (AC6)", () => {
|
|
function ThrowingTags(): never {
|
|
throw new Error("boom");
|
|
}
|
|
function OtherIndicator() {
|
|
return <span data-testid={INDICATOR_TEST_ID} />;
|
|
}
|
|
// Suppress the expected React error-boundary console noise for this test.
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
try {
|
|
pluginRegistry.forPlugin(NOTES_PLUGIN_ID).registerComponent(TAGS_SLOT, ThrowingTags);
|
|
pluginRegistry
|
|
.forPlugin(NOTES_PLUGIN_ID)
|
|
.registerComponent("task-card-indicators", OtherIndicator);
|
|
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
|
|
expect(screen.getByTestId(TITLE_TEST_ID).textContent).toBe(TASK.title);
|
|
expect(screen.getByTestId(INDICATOR_TEST_ID)).toBeTruthy();
|
|
} finally {
|
|
consoleError.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("KanbanCardBody — linked PR/MR badges (AC30)", () => {
|
|
it("renders both the PR badge and the MR badge, PR first", () => {
|
|
const { container } = render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
const title = screen.getByTestId(TITLE_TEST_ID);
|
|
const row = title.parentElement;
|
|
expect(row).not.toBeNull();
|
|
const prIcon = screen.getByTestId("pr-task-icon");
|
|
const mrIcon = screen.getByTestId("mr-task-icon");
|
|
expect(container.contains(prIcon)).toBe(true);
|
|
expect(container.contains(mrIcon)).toBe(true);
|
|
// AC30: PR badge before MR badge in document order.
|
|
expect(prIcon.compareDocumentPosition(mrIcon) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("KanbanCardBody — title hover card gating", () => {
|
|
it("does not mount a hover card trigger when enableTitleHover is unset (drag preview)", () => {
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} />);
|
|
const title = screen.getByTestId(TITLE_TEST_ID);
|
|
expect(title.getAttribute("data-slot")).not.toBe("hover-card-trigger");
|
|
});
|
|
|
|
it("does not mount a hover card trigger for a childless task", () => {
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} enableTitleHover />);
|
|
const title = screen.getByTestId(TITLE_TEST_ID);
|
|
expect(title.closest('[data-testid="task-title-preview-trigger"]')).toBeNull();
|
|
});
|
|
|
|
it("mounts the hover card trigger when an active direct subtask exists", () => {
|
|
mockAppState.kanban.tasks = [{ id: "child-1", title: "Child", parentTaskId: TASK.id }];
|
|
render(<KanbanCardBody task={TASK} repositoryChips={[]} enableTitleHover />);
|
|
const title = screen.getByTestId(TITLE_TEST_ID);
|
|
expect(title.closest('[data-testid="task-title-preview-trigger"]')).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("KanbanCardBody — parent relationship (KanbanCardRelationship)", () => {
|
|
const CHILD_TASK: Task = { ...TASK, id: "task-2", parentTaskId: "root-1" };
|
|
|
|
it("resolves the parent title from the active board", () => {
|
|
mockAppState.kanban.tasks = [{ id: "root-1", title: "Root task" }];
|
|
render(<KanbanCardBody task={CHILD_TASK} repositoryChips={[]} />);
|
|
const relationship = screen.getByTestId("task-parent-relationship");
|
|
expect(relationship.textContent).toContain("Root task");
|
|
});
|
|
|
|
it("resolves the parent title from a loaded cross-workflow snapshot when the parent isn't on the active board", () => {
|
|
mockAppState.kanbanMulti.snapshots = {
|
|
"wf-2": { tasks: [{ id: "root-1", title: "Snapshot Root" }] },
|
|
};
|
|
render(<KanbanCardBody task={CHILD_TASK} repositoryChips={[]} />);
|
|
const relationship = screen.getByTestId("task-parent-relationship");
|
|
expect(relationship.textContent).toContain("Snapshot Root");
|
|
});
|
|
|
|
it("falls back to the localized generic label when the parent title cannot be resolved", () => {
|
|
render(<KanbanCardBody task={CHILD_TASK} repositoryChips={[]} />);
|
|
const relationship = screen.getByTestId("task-parent-relationship");
|
|
expect(relationship.textContent).toContain(t("task:subtask"));
|
|
});
|
|
});
|