276 lines
7.2 KiB
TypeScript
276 lines
7.2 KiB
TypeScript
|
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||
|
|
import { describe, expect, it, vi } from "vitest";
|
||
|
|
import type { ReactNode } from "react";
|
||
|
|
import { Board } from "./Board";
|
||
|
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||
|
|
import type { StateResponse, Topology } from "../api";
|
||
|
|
|
||
|
|
/** Wrap in TooltipProvider — the real app mounts this at the root. */
|
||
|
|
function renderWithProviders(ui: ReactNode) {
|
||
|
|
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||
|
|
}
|
||
|
|
|
||
|
|
const TOPO: Topology = {
|
||
|
|
trees: [
|
||
|
|
{ id: "core", label: "Core" },
|
||
|
|
{ id: "sdlc", label: "SDLC" },
|
||
|
|
],
|
||
|
|
nodes: [
|
||
|
|
{ id: "intake", label: "Intake", agent: "coordinator", tree: "core", kind: "phase", gated: false },
|
||
|
|
{ id: "clarify", label: "Clarify", agent: "coordinator", tree: "core", kind: "gate", gated: true },
|
||
|
|
{ id: "plan", label: "Plan", agent: "Claude", tree: "sdlc", kind: "phase", gated: false },
|
||
|
|
],
|
||
|
|
edges: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
function makeState(): StateResponse {
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
error: null,
|
||
|
|
generated_at: "now",
|
||
|
|
db_path: "x",
|
||
|
|
stages: [
|
||
|
|
{
|
||
|
|
key: "intake",
|
||
|
|
label: "Intake",
|
||
|
|
agent: "coordinator",
|
||
|
|
phases: ["intake"],
|
||
|
|
gated: false,
|
||
|
|
role_node: false,
|
||
|
|
state: "idle",
|
||
|
|
count: 1,
|
||
|
|
tasks: [
|
||
|
|
{
|
||
|
|
thread_id: "aaaa1111",
|
||
|
|
short_id: "aaaa1111",
|
||
|
|
task: "remediate CVE",
|
||
|
|
current_phase: "intake",
|
||
|
|
status: "active",
|
||
|
|
waiting: false,
|
||
|
|
waiting_since: null,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: "clarify",
|
||
|
|
label: "Clarify",
|
||
|
|
agent: "coordinator",
|
||
|
|
phases: ["clarify"],
|
||
|
|
gated: true,
|
||
|
|
role_node: false,
|
||
|
|
state: "awaiting_human",
|
||
|
|
count: 1,
|
||
|
|
tasks: [
|
||
|
|
{
|
||
|
|
thread_id: "bbbb2222",
|
||
|
|
short_id: "bbbb2222",
|
||
|
|
task: "fix login bug",
|
||
|
|
current_phase: "clarify",
|
||
|
|
status: "waiting_human",
|
||
|
|
waiting: true,
|
||
|
|
waiting_since: "2024-01-01T00:00:00Z",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
],
|
||
|
|
tasks: [
|
||
|
|
{
|
||
|
|
thread_id: "aaaa1111",
|
||
|
|
short_id: "aaaa1111",
|
||
|
|
task: "remediate CVE",
|
||
|
|
current_phase: "intake",
|
||
|
|
status: "active",
|
||
|
|
waiting: false,
|
||
|
|
waiting_since: null,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
thread_id: "bbbb2222",
|
||
|
|
short_id: "bbbb2222",
|
||
|
|
task: "fix login bug",
|
||
|
|
current_phase: "clarify",
|
||
|
|
status: "waiting_human",
|
||
|
|
waiting: true,
|
||
|
|
waiting_since: "2024-01-01T00:00:00Z",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
waiting: [],
|
||
|
|
question_counts: {},
|
||
|
|
summary: { active: 1, parked: 0, waiting: 1, open_questions: 0, total: 2 },
|
||
|
|
budget: { available: false },
|
||
|
|
nodes: {},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("Board", () => {
|
||
|
|
it("renders a column per stage with correct labels", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(screen.getByLabelText("Stage: Intake")).toBeInTheDocument();
|
||
|
|
expect(screen.getByLabelText("Stage: Clarify")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows task count badge in each column header", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
const intakeCol = screen.getByLabelText("Stage: Intake");
|
||
|
|
// Both columns have 1 task each
|
||
|
|
expect(within(intakeCol).getByText("1")).toBeInTheDocument();
|
||
|
|
|
||
|
|
const clarifyCol = screen.getByLabelText("Stage: Clarify");
|
||
|
|
expect(within(clarifyCol).getByText("1")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("renders task descriptions inside columns", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
||
|
|
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows a waiting affordance (clock icon) for a waiting task", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
// The Clock icon has aria-label="awaiting human"
|
||
|
|
expect(screen.getByLabelText("awaiting human")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows lock icon on a gated stage column", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(screen.getByLabelText("human gate")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("clicking a card fires onSelect with the thread_id", () => {
|
||
|
|
const onSelect = vi.fn();
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={onSelect}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
fireEvent.click(screen.getByText("remediate CVE"));
|
||
|
|
expect(onSelect).toHaveBeenCalledWith("aaaa1111");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("selected card gets aria-selected=true", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId="aaaa1111"
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
const cards = screen.getAllByRole("option", { selected: true });
|
||
|
|
expect(cards).toHaveLength(1);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows node filter chip when nodeFilter is set", () => {
|
||
|
|
const onClearNodeFilter = vi.fn();
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={makeState()}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter="intake"
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={onClearNodeFilter}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
const chip = screen.getByLabelText("Clear node filter: intake");
|
||
|
|
expect(chip).toBeInTheDocument();
|
||
|
|
fireEvent.click(chip);
|
||
|
|
expect(onClearNodeFilter).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("falls back to deriving stages from tasks when stages is undefined", () => {
|
||
|
|
const stateNoStages: StateResponse = {
|
||
|
|
...makeState(),
|
||
|
|
stages: undefined,
|
||
|
|
};
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={stateNoStages}
|
||
|
|
topology={TOPO}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
// Should still show the tasks grouped by phase
|
||
|
|
expect(screen.getByText("remediate CVE")).toBeInTheDocument();
|
||
|
|
expect(screen.getByText("fix login bug")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("renders null state gracefully with loading message", () => {
|
||
|
|
renderWithProviders(
|
||
|
|
<Board
|
||
|
|
state={null}
|
||
|
|
topology={null}
|
||
|
|
selectedId={null}
|
||
|
|
nodeFilter={null}
|
||
|
|
onSelect={() => {}}
|
||
|
|
onClearNodeFilter={() => {}}
|
||
|
|
/>,
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|