diff --git a/frontend/package.json b/frontend/package.json index b7305124..eae80f94 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "vitest run --passWithNoTests --coverage", + "test:full-coverage": "vitest run --passWithNoTests --coverage --config ./vitest.full-coverage.config.ts", "test:watch": "vitest", "dev:lan": "next dev --hostname 0.0.0.0 --port 3000", "api:gen": "orval --config ./orval.config.ts", diff --git a/frontend/src/components/molecules/TaskCard.test.tsx b/frontend/src/components/molecules/TaskCard.test.tsx new file mode 100644 index 00000000..fde0daba --- /dev/null +++ b/frontend/src/components/molecules/TaskCard.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { TaskCard } from "./TaskCard"; + +describe("TaskCard", () => { + it("renders title, assignee, and due date", () => { + render( + , + ); + + expect(screen.getByText("Fix flaky test")).toBeInTheDocument(); + expect(screen.getByText("Zara")).toBeInTheDocument(); + expect(screen.getByText("Feb 11")).toBeInTheDocument(); + expect(screen.getByText("HIGH")).toBeInTheDocument(); + }); + + it("shows blocked state with count", () => { + render( + , + ); + + expect(screen.getByText(/Blocked · 2/i)).toBeInTheDocument(); + }); + + it("shows approvals pending indicator", () => { + render( + , + ); + + expect(screen.getByText(/Approval needed · 3/i)).toBeInTheDocument(); + }); + + it("shows lead review indicator when status is review with no approvals and not blocked", () => { + render(); + + expect(screen.getByText(/Waiting for lead review/i)).toBeInTheDocument(); + }); + + it("invokes onClick for mouse and keyboard activation", () => { + const onClick = vi.fn(); + + render(); + + fireEvent.click(screen.getByRole("button", { name: /Clickable/i })); + expect(onClick).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(screen.getByRole("button", { name: /Clickable/i }), { + key: "Enter", + }); + expect(onClick).toHaveBeenCalledTimes(2); + + fireEvent.keyDown(screen.getByRole("button", { name: /Clickable/i }), { + key: " ", + }); + expect(onClick).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/src/components/organisms/TaskBoard.test.tsx b/frontend/src/components/organisms/TaskBoard.test.tsx index 35fb111f..1f37b7c1 100644 --- a/frontend/src/components/organisms/TaskBoard.test.tsx +++ b/frontend/src/components/organisms/TaskBoard.test.tsx @@ -1,8 +1,31 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { TaskBoard } from "./TaskBoard"; +type TaskStatus = "inbox" | "in_progress" | "review" | "done"; + +type Task = { + id: string; + title: string; + status: TaskStatus; + priority: string; + approvals_pending_count?: number; + blocked_by_task_ids?: string[]; + is_blocked?: boolean; +}; + +const buildTask = (overrides: Partial = {}): Task => ({ + id: `task-${Math.random().toString(16).slice(2)}`, + title: "Task", + status: "inbox", + priority: "medium", + approvals_pending_count: 0, + blocked_by_task_ids: [], + is_blocked: false, + ...overrides, +}); + describe("TaskBoard", () => { it("uses a mobile-first stacked layout (no horizontal scroll) with responsive kanban columns on larger screens", () => { render( @@ -48,4 +71,130 @@ describe("TaskBoard", () => { // Ensure we didn't accidentally keep unscoped sticky behavior. expect(header?.className).not.toContain("sticky top-0"); }); + + it("renders the 4 columns and shows per-column counts", () => { + const tasks: Task[] = [ + buildTask({ id: "t1", title: "Inbox A", status: "inbox" }), + buildTask({ id: "t2", title: "Doing A", status: "in_progress" }), + buildTask({ id: "t3", title: "Review A", status: "review" }), + buildTask({ id: "t4", title: "Done A", status: "done" }), + buildTask({ id: "t5", title: "Inbox B", status: "inbox" }), + ]; + + render(); + + expect(screen.getByRole("heading", { name: "Inbox" })).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "In Progress" }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Review" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Done" })).toBeInTheDocument(); + + // Column count badges are plain spans; easiest stable check is text occurrence. + expect(screen.getAllByText("2").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("1").length).toBeGreaterThanOrEqual(1); + + expect(screen.getByText("Inbox A")).toBeInTheDocument(); + expect(screen.getByText("Inbox B")).toBeInTheDocument(); + }); + + it("filters the review column by bucket", () => { + const tasks: Task[] = [ + buildTask({ + id: "blocked", + title: "Blocked Review", + status: "review", + is_blocked: true, + blocked_by_task_ids: ["dep-1"], + }), + buildTask({ + id: "approval", + title: "Needs Approval", + status: "review", + approvals_pending_count: 2, + }), + buildTask({ + id: "lead", + title: "Lead Review", + status: "review", + }), + ]; + + render(); + + const reviewHeading = screen.getByRole("heading", { name: "Review" }); + const reviewColumn = reviewHeading.closest(".kanban-column"); + expect(reviewColumn).toBeTruthy(); + if (!reviewColumn) return; + + const header = reviewColumn.querySelector(".column-header"); + expect(header).toBeTruthy(); + if (!header) return; + + const headerQueries = within(header); + + expect(headerQueries.getByRole("button", { name: /All · 3/i })).toBeInTheDocument(); + expect( + headerQueries.getByRole("button", { name: /Approval needed · 1/i }), + ).toBeInTheDocument(); + expect( + headerQueries.getByRole("button", { name: /Lead review · 1/i }), + ).toBeInTheDocument(); + expect( + headerQueries.getByRole("button", { name: /Blocked · 1/i }), + ).toBeInTheDocument(); + + fireEvent.click(headerQueries.getByRole("button", { name: /Blocked · 1/i })); + expect(screen.getByText("Blocked Review")).toBeInTheDocument(); + expect(screen.queryByText("Needs Approval")).not.toBeInTheDocument(); + expect(screen.queryByText("Lead Review")).not.toBeInTheDocument(); + + fireEvent.click( + headerQueries.getByRole("button", { name: /Approval needed · 1/i }), + ); + expect(screen.getByText("Needs Approval")).toBeInTheDocument(); + expect(screen.queryByText("Blocked Review")).not.toBeInTheDocument(); + expect(screen.queryByText("Lead Review")).not.toBeInTheDocument(); + + fireEvent.click( + headerQueries.getByRole("button", { name: /Lead review · 1/i }), + ); + expect(screen.getByText("Lead Review")).toBeInTheDocument(); + expect(screen.queryByText("Blocked Review")).not.toBeInTheDocument(); + expect(screen.queryByText("Needs Approval")).not.toBeInTheDocument(); + }); + + it("invokes onTaskMove when a task is dropped onto a different column", () => { + const onTaskMove = vi.fn(); + const tasks: Task[] = [ + buildTask({ id: "t1", title: "Inbox A", status: "inbox" }), + ]; + + render(); + + const dropTarget = screen + .getByRole("heading", { name: "Done" }) + .closest(".kanban-column"); + expect(dropTarget).toBeTruthy(); + if (!dropTarget) return; + + fireEvent.drop(dropTarget, { + dataTransfer: { + getData: () => JSON.stringify({ taskId: "t1", status: "inbox" }), + }, + }); + + expect(onTaskMove).toHaveBeenCalledWith("t1", "done"); + }); + + it("does not allow dragging when readOnly is true", () => { + const tasks: Task[] = [buildTask({ id: "t1", title: "Inbox A" })]; + + render(); + + expect(screen.getByRole("button", { name: /Inbox A/i })).toHaveAttribute( + "draggable", + "false", + ); + }); }); diff --git a/frontend/vitest.full-coverage.config.ts b/frontend/vitest.full-coverage.config.ts new file mode 100644 index 00000000..59eea1d4 --- /dev/null +++ b/frontend/vitest.full-coverage.config.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +import { defineConfig } from "vitest/config"; + +// Broader coverage config used for gap analysis. +// - Does NOT enforce 100% thresholds. +// - Includes most source files (excluding generated artifacts and types). +// Keep the default vitest.config.ts as the scoped coverage gate. +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "jsdom", + setupFiles: ["./src/setupTests.ts"], + globals: true, + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + reportsDirectory: "./coverage-full", + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "**/*.d.ts", + "src/**/__generated__/**", + "src/**/generated/**", + ], + thresholds: { + lines: 0, + statements: 0, + functions: 0, + branches: 0, + }, + }, + }, +});