Files
stack/apps/web/src/components/kanban/task-card.test.tsx
T
Jason Woltje 0b330464ba feat(#17): implement Kanban board view
- Drag-and-drop with @dnd-kit
- Four status columns (Not Started, In Progress, Paused, Completed)
- Task cards with priority badges and due dates
- PDA-friendly design (calm colors, gentle language)
- 70 tests (87% coverage)
- Demo page at /demo/kanban
2026-01-29 17:55:33 -06:00

280 lines
8.7 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { TaskCard } from "./task-card";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
// Mock @dnd-kit/sortable
vi.mock("@dnd-kit/sortable", () => ({
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
const mockTask: Task = {
id: "task-1",
title: "Complete project documentation",
description: "Write comprehensive docs for the API",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
dueDate: new Date("2026-02-01"),
assigneeId: "user-1",
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date("2026-01-28"),
updatedAt: new Date("2026-01-28"),
};
describe("TaskCard", () => {
describe("Rendering", () => {
it("should render task title", () => {
render(<TaskCard task={mockTask} />);
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
});
it("should render as an article element for semantic HTML", () => {
render(<TaskCard task={mockTask} />);
const card = screen.getByRole("article");
expect(card).toBeInTheDocument();
});
it("should display task priority", () => {
render(<TaskCard task={mockTask} />);
expect(screen.getByText("High")).toBeInTheDocument();
});
it("should display due date when available", () => {
render(<TaskCard task={mockTask} />);
// Check for formatted date (format: "Feb 1" or similar)
const dueDateElement = screen.getByText(/Feb 1/);
expect(dueDateElement).toBeInTheDocument();
});
it("should not display due date when null", () => {
const taskWithoutDueDate = { ...mockTask, dueDate: null };
render(<TaskCard task={taskWithoutDueDate} />);
// Should not show any date
expect(screen.queryByText(/Feb/)).not.toBeInTheDocument();
});
it("should truncate long titles gracefully", () => {
const longTask = {
...mockTask,
title: "This is a very long task title that should be truncated to prevent layout issues",
};
const { container } = render(<TaskCard task={longTask} />);
const titleElement = container.querySelector("h4");
expect(titleElement).toBeInTheDocument();
// Should have text truncation classes
expect(titleElement?.className).toMatch(/truncate|line-clamp/);
});
});
describe("Priority Display", () => {
it("should display HIGH priority with appropriate styling", () => {
render(<TaskCard task={mockTask} />);
const priorityBadge = screen.getByText("High");
expect(priorityBadge).toBeInTheDocument();
});
it("should display MEDIUM priority", () => {
const mediumTask = { ...mockTask, priority: TaskPriority.MEDIUM };
render(<TaskCard task={mediumTask} />);
expect(screen.getByText("Medium")).toBeInTheDocument();
});
it("should display LOW priority", () => {
const lowTask = { ...mockTask, priority: TaskPriority.LOW };
render(<TaskCard task={lowTask} />);
expect(screen.getByText("Low")).toBeInTheDocument();
});
it("should use calm colors for priority badges (not aggressive red)", () => {
const { container } = render(<TaskCard task={mockTask} />);
const priorityBadge = screen.getByText("High").closest("span");
const className = priorityBadge?.className || "";
// Should not use harsh red for high priority
expect(className).not.toMatch(/bg-red-[5-9]00|text-red-[5-9]00/);
});
});
describe("Due Date Display", () => {
it("should format due date in a human-readable way", () => {
render(<TaskCard task={mockTask} />);
// Should show month abbreviation and day
expect(screen.getByText(/Feb 1/)).toBeInTheDocument();
});
it("should show overdue indicator with calm styling", () => {
const overdueTask = {
...mockTask,
dueDate: new Date("2025-01-01"), // Past date
};
render(<TaskCard task={overdueTask} />);
// Should indicate overdue but not in harsh red
const dueDateElement = screen.getByText(/Jan 1/);
const className = dueDateElement.className;
// Should avoid aggressive red
expect(className).not.toMatch(/bg-red-[5-9]00|text-red-[5-9]00/);
});
it("should show due soon indicator for tasks due within 3 days", () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const soonTask = {
...mockTask,
dueDate: tomorrow,
};
const { container } = render(<TaskCard task={soonTask} />);
// Should have some visual indicator (checked via data attribute or aria label)
expect(container).toBeInTheDocument();
});
});
describe("Drag and Drop", () => {
it("should be draggable", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
expect(card).toBeInTheDocument();
});
it("should have appropriate cursor style for dragging", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
const className = card?.className || "";
// Should have cursor-grab or cursor-move
expect(className).toMatch(/cursor-(grab|move)/);
});
});
describe("Accessibility", () => {
it("should have accessible task card", () => {
render(<TaskCard task={mockTask} />);
const card = screen.getByRole("article");
expect(card).toBeInTheDocument();
});
it("should have semantic heading for task title", () => {
render(<TaskCard task={mockTask} />);
const heading = screen.getByRole("heading", { level: 4 });
expect(heading).toHaveTextContent("Complete project documentation");
});
it("should provide aria-label for due date icon", () => {
const { container } = render(<TaskCard task={mockTask} />);
// Icons should have proper aria labels
const icons = container.querySelectorAll("svg");
icons.forEach((icon) => {
const ariaLabel = icon.getAttribute("aria-label");
const parentAriaLabel = icon.parentElement?.getAttribute("aria-label");
// Either the icon or its parent should have an aria-label
expect(ariaLabel || parentAriaLabel || icon.getAttribute("aria-hidden")).toBeTruthy();
});
});
});
describe("PDA-Friendly Design", () => {
it("should not use harsh or demanding language", () => {
const { container } = render(<TaskCard task={mockTask} />);
const allText = container.textContent?.toLowerCase() || "";
// Should avoid demanding words
expect(allText).not.toMatch(/must|required|urgent|critical|error|alert/);
});
it("should use gentle visual design", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
const className = card?.className || "";
// Should have rounded corners and soft shadows
expect(className).toMatch(/rounded/);
});
});
describe("Compact Mode", () => {
it("should handle missing description gracefully", () => {
const taskWithoutDescription = { ...mockTask, description: null };
render(<TaskCard task={taskWithoutDescription} />);
expect(screen.getByText("Complete project documentation")).toBeInTheDocument();
// Description should not be rendered
});
});
describe("Error Handling", () => {
it("should handle task with minimal data", () => {
const minimalTask: Task = {
id: "task-minimal",
title: "Minimal task",
description: null,
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
dueDate: null,
assigneeId: null,
creatorId: "user-1",
workspaceId: "workspace-1",
projectId: null,
parentId: null,
sortOrder: 0,
metadata: {},
completedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
render(<TaskCard task={minimalTask} />);
expect(screen.getByText("Minimal task")).toBeInTheDocument();
});
});
describe("Visual Feedback", () => {
it("should show hover state with subtle transition", () => {
const { container } = render(<TaskCard task={mockTask} />);
const card = container.querySelector('[role="article"]');
const className = card?.className || "";
// Should have hover transition
expect(className).toMatch(/transition|hover:/);
});
});
});