- 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
280 lines
8.7 KiB
TypeScript
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:/);
|
|
});
|
|
});
|
|
});
|