test(frontend): add TaskBoard + TaskCard coverage and full coverage config

This commit is contained in:
Abhimanyu Saharan
2026-02-11 22:39:33 +00:00
committed by Abhimanyu Saharan
parent d4f9831ecb
commit 6c897d7faf
4 changed files with 256 additions and 2 deletions

View File

@@ -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",

View File

@@ -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(
<TaskCard
title="Fix flaky test"
assignee="Zara"
due="Feb 11"
priority="high"
/>,
);
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(
<TaskCard title="Blocked task" isBlocked blockedByCount={2} priority="low" />,
);
expect(screen.getByText(/Blocked · 2/i)).toBeInTheDocument();
});
it("shows approvals pending indicator", () => {
render(
<TaskCard
title="Needs approval"
approvalsPendingCount={3}
priority="medium"
/>,
);
expect(screen.getByText(/Approval needed · 3/i)).toBeInTheDocument();
});
it("shows lead review indicator when status is review with no approvals and not blocked", () => {
render(<TaskCard title="Waiting" status="review" approvalsPendingCount={0} />);
expect(screen.getByText(/Waiting for lead review/i)).toBeInTheDocument();
});
it("invokes onClick for mouse and keyboard activation", () => {
const onClick = vi.fn();
render(<TaskCard title="Clickable" onClick={onClick} />);
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);
});
});

View File

@@ -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> = {}): 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(<TaskBoard tasks={tasks} />);
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(<TaskBoard tasks={tasks} />);
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(<TaskBoard tasks={tasks} onTaskMove={onTaskMove} />);
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(<TaskBoard tasks={tasks} readOnly />);
expect(screen.getByRole("button", { name: /Inbox A/i })).toHaveAttribute(
"draggable",
"false",
);
});
});

View File

@@ -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,
},
},
},
});