Merge pull request #99 from abhi1693/test/gap-analysis-taskcard-taskboard

test(frontend): add TaskBoard + TaskCard tests
This commit is contained in:
Abhimanyu Saharan
2026-03-03 05:02:02 +05:30
committed by GitHub
4 changed files with 277 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,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
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 Task = ComponentProps<typeof TaskBoard>["tasks"][number];
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 +62,160 @@ 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} />);
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" });
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();
});
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") as HTMLElement | null;
expect(reviewColumn).toBeTruthy();
if (!reviewColumn) return;
const header = reviewColumn.querySelector(
".column-header",
) as HTMLElement | null;
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") as HTMLElement | null;
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,
},
},
},
});