From 6c897d7faf2943788bb8366f6a67bc19dfb88de7 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 22:39:33 +0000 Subject: [PATCH 1/3] test(frontend): add TaskBoard + TaskCard coverage and full coverage config --- frontend/package.json | 1 + .../components/molecules/TaskCard.test.tsx | 67 ++++++++ .../components/organisms/TaskBoard.test.tsx | 153 +++++++++++++++++- frontend/vitest.full-coverage.config.ts | 37 +++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/molecules/TaskCard.test.tsx create mode 100644 frontend/vitest.full-coverage.config.ts 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, + }, + }, + }, +}); From 27e94197d0abc37c8b00b08998d42ee04b005372 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Wed, 11 Feb 2026 22:44:55 +0000 Subject: [PATCH 2/3] test(frontend): fix TaskBoard test typings for CI tsc --- frontend/src/components/organisms/TaskBoard.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/organisms/TaskBoard.test.tsx b/frontend/src/components/organisms/TaskBoard.test.tsx index 1f37b7c1..22166ecd 100644 --- a/frontend/src/components/organisms/TaskBoard.test.tsx +++ b/frontend/src/components/organisms/TaskBoard.test.tsx @@ -123,11 +123,13 @@ describe("TaskBoard", () => { render(); const reviewHeading = screen.getByRole("heading", { name: "Review" }); - const reviewColumn = reviewHeading.closest(".kanban-column"); + const reviewColumn = reviewHeading.closest(".kanban-column") as HTMLElement | null; expect(reviewColumn).toBeTruthy(); if (!reviewColumn) return; - const header = reviewColumn.querySelector(".column-header"); + const header = reviewColumn.querySelector( + ".column-header", + ) as HTMLElement | null; expect(header).toBeTruthy(); if (!header) return; @@ -174,7 +176,7 @@ describe("TaskBoard", () => { const dropTarget = screen .getByRole("heading", { name: "Done" }) - .closest(".kanban-column"); + .closest(".kanban-column") as HTMLElement | null; expect(dropTarget).toBeTruthy(); if (!dropTarget) return; From 91ee668b54ad16beab7aa80c08a0c3a3966034e8 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Tue, 3 Mar 2026 04:51:30 +0530 Subject: [PATCH 3/3] test(frontend): address PR feedback for TaskBoard test robustness --- .../components/organisms/TaskBoard.test.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/organisms/TaskBoard.test.tsx b/frontend/src/components/organisms/TaskBoard.test.tsx index 22166ecd..d4ff977d 100644 --- a/frontend/src/components/organisms/TaskBoard.test.tsx +++ b/frontend/src/components/organisms/TaskBoard.test.tsx @@ -1,19 +1,10 @@ import { fireEvent, render, screen, within } from "@testing-library/react"; +import type { ComponentProps } from "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; -}; +type Task = ComponentProps["tasks"][number]; const buildTask = (overrides: Partial = {}): Task => ({ id: `task-${Math.random().toString(16).slice(2)}`, @@ -83,16 +74,44 @@ describe("TaskBoard", () => { 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(); + const inboxHeading = screen.getByRole("heading", { name: "Inbox" }); + const inProgressHeading = screen.getByRole("heading", { + name: "In Progress", + }); + const reviewHeading = screen.getByRole("heading", { name: "Review" }); + const doneHeading = screen.getByRole("heading", { name: "Done" }); - // 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(inboxHeading).toBeInTheDocument(); + expect(inProgressHeading).toBeInTheDocument(); + expect(reviewHeading).toBeInTheDocument(); + expect(doneHeading).toBeInTheDocument(); + + const inboxColumn = inboxHeading.closest(".kanban-column") as HTMLElement | null; + const inProgressColumn = inProgressHeading.closest( + ".kanban-column", + ) as HTMLElement | null; + const reviewColumn = reviewHeading.closest(".kanban-column") as HTMLElement | null; + const doneColumn = doneHeading.closest(".kanban-column") as HTMLElement | null; + expect(inboxColumn).toBeTruthy(); + expect(inProgressColumn).toBeTruthy(); + expect(reviewColumn).toBeTruthy(); + expect(doneColumn).toBeTruthy(); + if (!inboxColumn || !inProgressColumn || !reviewColumn || !doneColumn) return; + + const getColumnCountBadge = (column: HTMLElement) => + column.querySelector( + ".column-header span.h-6.w-6.rounded-full", + ) as HTMLElement | null; + + const inboxCountBadge = getColumnCountBadge(inboxColumn); + const inProgressCountBadge = getColumnCountBadge(inProgressColumn); + const reviewCountBadge = getColumnCountBadge(reviewColumn); + const doneCountBadge = getColumnCountBadge(doneColumn); + + expect(inboxCountBadge).toHaveTextContent("2"); + expect(inProgressCountBadge).toHaveTextContent("1"); + expect(reviewCountBadge).toHaveTextContent("1"); + expect(doneCountBadge).toHaveTextContent("1"); expect(screen.getByText("Inbox A")).toBeInTheDocument(); expect(screen.getByText("Inbox B")).toBeInTheDocument();