import { describe, it, expect, vi } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { GanttChart } from "./GanttChart"; import { TaskStatus, TaskPriority } from "@mosaic/shared"; import type { GanttTask } from "./types"; describe("GanttChart", () => { const baseDate = new Date("2026-02-01T00:00:00Z"); const createGanttTask = (overrides: Partial = {}): GanttTask => ({ id: `task-${Math.random()}`, workspaceId: "workspace-1", title: "Sample Task", description: null, status: TaskStatus.NOT_STARTED, priority: TaskPriority.MEDIUM, dueDate: new Date("2026-02-15T00:00:00Z"), assigneeId: null, creatorId: "user-1", projectId: null, parentId: null, sortOrder: 0, metadata: {}, completedAt: null, createdAt: baseDate, updatedAt: baseDate, startDate: new Date("2026-02-01T00:00:00Z"), endDate: new Date("2026-02-15T00:00:00Z"), ...overrides, }); describe("Rendering", () => { it("should render without crashing with empty task list", () => { render(); expect(screen.getByRole("region", { name: /gantt chart/i })).toBeInTheDocument(); }); it("should render task names in the task list", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Design mockups" }), createGanttTask({ id: "task-2", title: "Implement frontend" }), ]; render(); // Tasks appear in both the list and bars, so use getAllByText expect(screen.getAllByText("Design mockups").length).toBeGreaterThan(0); expect(screen.getAllByText("Implement frontend").length).toBeGreaterThan(0); }); it("should render timeline bars for each task", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Task 1" }), createGanttTask({ id: "task-2", title: "Task 2" }), ]; render(); const bars = screen.getAllByRole("button", { name: /gantt bar/i }); expect(bars).toHaveLength(2); }); it("should display date headers for the timeline", () => { const tasks = [ createGanttTask({ startDate: new Date("2026-02-01"), endDate: new Date("2026-02-10"), }), ]; render(); // Should show month or date indicators const timeline = screen.getByRole("region", { name: /timeline/i }); expect(timeline).toBeInTheDocument(); }); }); describe("Task Status Indicators", () => { it("should visually distinguish completed tasks", () => { const tasks = [ createGanttTask({ id: "completed-task", title: "Completed Task", status: TaskStatus.COMPLETED, completedAt: new Date("2026-02-10"), }), ]; render(); const taskRow = screen.getAllByText("Completed Task")[0].closest("[role='row']"); expect(taskRow?.className).toMatch(/Completed/i); }); it("should visually distinguish in-progress tasks", () => { const tasks = [ createGanttTask({ id: "active-task", title: "Active Task", status: TaskStatus.IN_PROGRESS, }), ]; render(); const taskRow = screen.getAllByText("Active Task")[0].closest("[role='row']"); expect(taskRow?.className).toMatch(/InProgress/i); }); }); describe("PDA-friendly language", () => { it('should show "Target passed" for tasks past their end date', () => { const pastTask = createGanttTask({ id: "past-task", title: "Past Task", startDate: new Date("2020-01-01"), endDate: new Date("2020-01-15"), status: TaskStatus.NOT_STARTED, }); render(); // Should show "Target passed" not "OVERDUE" expect(screen.getByText(/target passed/i)).toBeInTheDocument(); expect(screen.queryByText(/overdue/i)).not.toBeInTheDocument(); }); it('should show "Approaching target" for tasks near their end date', () => { const today = new Date(); const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); const soonTask = createGanttTask({ id: "soon-task", title: "Soon Task", startDate: today, endDate: tomorrow, status: TaskStatus.IN_PROGRESS, }); render(); expect(screen.getByText(/approaching target/i)).toBeInTheDocument(); }); }); describe("Task Interactions", () => { it("should call onTaskClick when a task bar is clicked", async () => { const user = userEvent.setup(); const onTaskClick = vi.fn(); const task = createGanttTask({ id: "clickable-task", title: "Click Me" }); render(); const taskBar = screen.getByRole("button", { name: /gantt bar.*click me/i }); await user.click(taskBar); expect(onTaskClick).toHaveBeenCalledWith(task); }); it("should not crash when clicking a task without onTaskClick handler", async () => { const user = userEvent.setup(); const task = createGanttTask({ id: "task-1", title: "No Handler" }); render(); const taskBar = screen.getByRole("button", { name: /gantt bar/i }); await user.click(taskBar); // Should not throw expect(taskBar).toBeInTheDocument(); }); }); describe("Timeline Calculations", () => { it("should calculate timeline range from task dates", () => { const tasks = [ createGanttTask({ id: "early-task", startDate: new Date("2026-01-01"), endDate: new Date("2026-01-10"), }), createGanttTask({ id: "late-task", startDate: new Date("2026-03-01"), endDate: new Date("2026-03-31"), }), ]; render(); // Timeline should span from earliest start to latest end const timeline = screen.getByRole("region", { name: /timeline/i }); expect(timeline).toBeInTheDocument(); }); it("should position task bars proportionally to their dates", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Task 1", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-05"), // 4 days }), createGanttTask({ id: "task-2", title: "Task 2", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-11"), // 10 days }), ]; render(); const bars = screen.getAllByRole("button", { name: /gantt bar/i }); expect(bars).toHaveLength(2); // Second bar should be wider (more days) const bar1Width = bars[0].style.width; const bar2Width = bars[1].style.width; // Basic check that widths are set (exact values depend on implementation) expect(bar1Width).toBeTruthy(); expect(bar2Width).toBeTruthy(); }); }); describe("Accessibility", () => { it("should have proper ARIA labels for the chart region", () => { render(); expect(screen.getByRole("region", { name: /gantt chart/i })).toBeInTheDocument(); }); it("should have proper ARIA labels for task bars", () => { const task = createGanttTask({ id: "task-1", title: "Accessible Task", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-15"), }); render(); const taskBar = screen.getByRole("button", { name: /gantt bar.*accessible task/i, }); expect(taskBar).toHaveAccessibleName(); }); it("should be keyboard navigable", async () => { const user = userEvent.setup(); const onTaskClick = vi.fn(); const task = createGanttTask({ id: "task-1", title: "Keyboard Task" }); render(); const taskBar = screen.getByRole("button", { name: /gantt bar/i }); // Tab to focus await user.tab(); expect(taskBar).toHaveFocus(); // Enter to activate await user.keyboard("{Enter}"); expect(onTaskClick).toHaveBeenCalled(); }); }); describe("Responsive Design", () => { it("should accept custom height prop", () => { const tasks = [createGanttTask({ id: "task-1" })]; render(); const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toHaveStyle({ height: "600px" }); }); it("should use default height when not specified", () => { const tasks = [createGanttTask({ id: "task-1" })]; render(); const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toBeInTheDocument(); // Default height should be set in implementation }); }); describe("Edge Cases", () => { it("should handle tasks with same start and end date", () => { const sameDay = new Date("2026-02-01"); const task = createGanttTask({ id: "same-day", title: "Same Day Task", startDate: sameDay, endDate: sameDay, }); render(); expect(screen.getAllByText("Same Day Task").length).toBeGreaterThan(0); const bar = screen.getByRole("button", { name: /gantt bar/i }); expect(bar).toBeInTheDocument(); // Bar should have minimum width }); it("should handle tasks with very long duration", () => { const task = createGanttTask({ id: "long-task", title: "Long Task", startDate: new Date("2026-01-01"), endDate: new Date("2027-12-31"), // 2 years }); render(); expect(screen.getAllByText("Long Task").length).toBeGreaterThan(0); }); it("should sort tasks by start date", () => { const tasks = [ createGanttTask({ id: "late-task", title: "Late Task", startDate: new Date("2026-03-01"), }), createGanttTask({ id: "early-task", title: "Early Task", startDate: new Date("2026-01-01"), }), createGanttTask({ id: "mid-task", title: "Mid Task", startDate: new Date("2026-02-01"), }), ]; render(); const taskNames = screen.getAllByRole("row").map((row) => row.textContent); // Early Task should appear first const earlyIndex = taskNames.findIndex((name) => name?.includes("Early Task")); const lateIndex = taskNames.findIndex((name) => name?.includes("Late Task")); expect(earlyIndex).toBeLessThan(lateIndex); }); }); describe("Dependencies", () => { it("should render dependency lines when showDependencies is true", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Foundation", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-10"), }), createGanttTask({ id: "task-2", title: "Build on top", startDate: new Date("2026-02-11"), endDate: new Date("2026-02-20"), dependencies: ["task-1"], }), ]; render(); // Check if dependency SVG exists const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toBeInTheDocument(); // Look for dependency path element const svg = chart.querySelector(".gantt-dependencies"); expect(svg).toBeInTheDocument(); const paths = chart.querySelectorAll(".dependency-line"); expect(paths).toHaveLength(1); }); it("should not render dependencies by default", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Task 1", }), createGanttTask({ id: "task-2", title: "Task 2", dependencies: ["task-1"], }), ]; render(); // Dependencies should not be shown by default const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toBeInTheDocument(); const svg = chart.querySelector(".gantt-dependencies"); expect(svg).not.toBeInTheDocument(); }); it("should handle tasks with non-existent dependencies gracefully", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Task 1", dependencies: ["non-existent-task"], }), ]; render(); // Should not crash const chart = screen.getByRole("region", { name: /gantt chart/i }); expect(chart).toBeInTheDocument(); }); it("should render multiple dependency lines", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Task 1", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-05"), }), createGanttTask({ id: "task-2", title: "Task 2", startDate: new Date("2026-02-01"), endDate: new Date("2026-02-05"), }), createGanttTask({ id: "task-3", title: "Task 3", startDate: new Date("2026-02-06"), endDate: new Date("2026-02-10"), dependencies: ["task-1", "task-2"], }), ]; render(); const chart = screen.getByRole("region", { name: /gantt chart/i }); const paths = chart.querySelectorAll(".dependency-line"); expect(paths).toHaveLength(2); }); }); describe("Milestones", () => { it("should render milestone as diamond shape", () => { const milestone = createGanttTask({ id: "milestone-1", title: "Phase 1 Complete", startDate: new Date("2026-02-15"), endDate: new Date("2026-02-15"), isMilestone: true, }); render(); const milestoneElement = screen.getByRole("button", { name: /milestone.*phase 1 complete/i, }); expect(milestoneElement).toBeInTheDocument(); expect(milestoneElement).toHaveClass("gantt-milestone"); }); it("should handle click on milestone", async () => { const user = userEvent.setup(); const onTaskClick = vi.fn(); const milestone = createGanttTask({ id: "milestone-1", title: "Milestone Task", isMilestone: true, }); render(); const milestoneElement = screen.getByRole("button", { name: /milestone.*milestone task/i, }); await user.click(milestoneElement); expect(onTaskClick).toHaveBeenCalledWith(milestone); }); it("should support keyboard navigation for milestones", async () => { const user = userEvent.setup(); const onTaskClick = vi.fn(); const milestone = createGanttTask({ id: "milestone-1", title: "Keyboard Milestone", isMilestone: true, }); render(); const milestoneElement = screen.getByRole("button", { name: /milestone/i, }); await user.tab(); expect(milestoneElement).toHaveFocus(); await user.keyboard("{Enter}"); expect(onTaskClick).toHaveBeenCalled(); }); it("should render milestones and regular tasks together", () => { const tasks = [ createGanttTask({ id: "task-1", title: "Regular Task", isMilestone: false, }), createGanttTask({ id: "milestone-1", title: "Milestone", isMilestone: true, }), ]; render(); expect(screen.getByRole("button", { name: /gantt bar.*regular task/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /milestone.*milestone/i })).toBeInTheDocument(); }); }); });