Files
kandev/apps/web/components/task/task-dependency-chip.test.tsx
T
2026-09-21 18:22:29 +01:00

176 lines
6.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getTaskDependenciesMock = vi.hoisted(() => vi.fn());
const touchDrawerMock = vi.hoisted(() => ({ value: false }));
vi.mock("@/lib/api/domains/task-dependencies-api", () => ({
getTaskDependencies: getTaskDependenciesMock,
}));
vi.mock("@/hooks/use-compact-task-chrome", () => ({
useTouchDrawer: () => touchDrawerMock.value,
}));
import { cleanup, createEvent, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import {
clearNavigationBlockerForTests,
setNavigationBlocker,
} from "@/lib/routing/navigation-guard";
import { TaskDependencyChip } from "./task-dependency-chip";
import type { KanbanState } from "@/lib/state/slices/kanban/types";
beforeEach(() => {
getTaskDependenciesMock.mockReset();
touchDrawerMock.value = false;
// Default: the server also reports no edges, so the fetch fallback cannot be
// what makes a "renders nothing" assertion pass.
getTaskDependenciesMock.mockResolvedValue({ id: "unknown", depends_on: [], blocks: [] });
});
afterEach(() => {
cleanup();
clearNavigationBlockerForTests();
});
type KanbanTask = KanbanState["tasks"][number];
const task = (over: Partial<KanbanTask> & { id: string }): KanbanTask =>
({
workflowStepId: "step-1",
title: over.id.toUpperCase(),
position: 0,
...over,
}) as KanbanTask;
const CHIP_TESTID = "task-dependency-chip";
function renderChip(tasks: KanbanTask[], taskId: string) {
return render(
<StateProvider initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks } } as never}>
<TaskDependencyChip taskId={taskId} />
</StateProvider>,
);
}
// eslint-disable-next-line max-lines-per-function -- the suite covers both disclosure surfaces.
describe("TaskDependencyChip", () => {
it("renders nothing when the task has no edges in either direction", () => {
renderChip([task({ id: "a" })], "a");
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
});
it("renders nothing for an unknown task", () => {
renderChip([task({ id: "a" })], "missing");
expect(screen.queryByTestId(CHIP_TESTID)).toBeNull();
});
it("renders when the task only has dependents (it blocks others but is not blocked)", () => {
// The reverse direction alone must surface the chip: "two tasks are waiting
// on this one" is exactly the context the chip exists to show.
renderChip([task({ id: "a", blocks: [{ id: "b" }, { id: "c" }] })], "a");
expect(screen.getByTestId(CHIP_TESTID)).toBeTruthy();
});
it("renders both directions with counts", () => {
renderChip(
[
task({
id: "b",
blocked: true,
blockedReason: "pending",
dependsOn: [{ id: "a", title: "A", status: "pending" }],
blocks: [{ id: "c", title: "C" }],
}),
],
"b",
);
const chip = screen.getByTestId(CHIP_TESTID);
expect(chip.textContent).toMatch(/1/);
});
it("uses canonical guarded links for both dependency directions", () => {
renderChip(
[
task({
id: "b",
dependsOn: [{ id: "a", title: "A", status: "pending" }],
blocks: [{ id: "c", title: "C" }],
}),
],
"b",
);
const chip = screen.getByTestId(CHIP_TESTID);
fireEvent.click(chip);
const entries = screen.getAllByTestId("task-dependency-entry");
expect(entries.map((entry) => entry.getAttribute("href"))).toEqual(["/t/a", "/t/c"]);
const click = createEvent.click(entries[0]);
fireEvent(entries[0], click);
expect(click.defaultPrevented).toBe(true);
});
it("keeps the disclosure open until guarded navigation commits", async () => {
let proceed: () => void = () => undefined;
setNavigationBlocker((intent) => {
proceed = intent.proceed;
});
renderChip([task({ id: "b", dependsOn: [{ id: "a", title: "A", status: "pending" }] })], "b");
fireEvent.click(screen.getByTestId(CHIP_TESTID));
fireEvent.click(screen.getByTestId("task-dependency-entry"));
expect(screen.getByTestId("task-dependency-chip-popover")).toBeTruthy();
proceed();
await waitFor(() => expect(screen.queryByTestId("task-dependency-chip-popover")).toBeNull());
});
it("keeps dependency rows touch-sized in the drawer", () => {
touchDrawerMock.value = true;
renderChip([task({ id: "b", dependsOn: [{ id: "a", title: "A", status: "pending" }] })], "b");
fireEvent.click(screen.getByTestId(CHIP_TESTID));
expect(screen.getByTestId("task-dependency-entry").className).toContain(
"[@media(pointer:coarse)]:min-h-11",
);
});
it("renders for a resolved dependency so the chain is still visible after it unblocks", () => {
renderChip(
[
task({
id: "b",
blocked: false,
dependsOn: [{ id: "a", title: "A", status: "resolved" }],
}),
],
"b",
);
expect(screen.getByTestId(CHIP_TESTID)).toBeTruthy();
});
it("drops fetched data as soon as the store can answer 'no edges'", async () => {
// Regression: `fromStore ?? fetched` fell through on the store's `null`,
// which is an explicit "this task has no edges" answer. Switching from a
// task resolved by fetch to one the store knows is edge-free then kept
// rendering the previous task's dependencies.
getTaskDependenciesMock.mockResolvedValue({
id: "fetched-task",
blocked: true,
depends_on: [{ id: "p", title: "P", status: "pending" }],
blocks: [],
});
// Explicit empty lists = the store's real "no edges" answer. A task with no
// projection at all is "unknown", which correctly falls through to a fetch.
const store = [task({ id: "a", blocked: false, dependsOn: [], blocks: [] })];
const { rerender } = renderChip(store, "fetched-task");
await screen.findByTestId(CHIP_TESTID);
rerender(
<StateProvider
initialState={{ kanban: { workflowId: "wf-1", steps: [], tasks: store } } as never}
>
<TaskDependencyChip taskId="a" />
</StateProvider>,
);
await waitFor(() => expect(screen.queryByTestId(CHIP_TESTID)).toBeNull());
});
});