import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { TaskCard } from "./TaskCard"; import type { Task } from "@fusion/core"; // Mock lucide-react to avoid SVG rendering issues in test env vi.mock("lucide-react", () => ({ Link: () => null, Clock: () => null, Pencil: () => null, Layers: () => null, ChevronDown: () => null, Folder: () => null, GitPullRequest: () => null, CircleDot: () => null, Target: () => null, Bot: () => null, })); // Mock the api module vi.mock("../api", () => ({ fetchTaskDetail: vi.fn(), uploadAttachment: vi.fn(), fetchMission: vi.fn(), fetchAgent: vi.fn(), })); import { uploadAttachment, fetchMission, fetchAgent } from "../api"; function makeTask(overrides: Partial = {}): Task { return { id: "FN-001", title: "Test task", column: "in-progress", status: undefined as any, steps: [], dependencies: [], description: "", ...overrides, } as Task; } const noop = () => {}; describe("TaskCard", () => { it("renders the card ID text", () => { render(); expect(screen.getByText("FN-001")).toBeDefined(); }); it("renders the status badge when task.status is set", () => { render( , ); expect(screen.getByText("executing")).toBeDefined(); }); it("renders the status badge after the card ID in DOM order", () => { const { container } = render( , ); const cardId = container.querySelector(".card-id")!; const badge = container.querySelector(".card-status-badge")!; expect(cardId).toBeDefined(); expect(badge).toBeDefined(); // Badge should be the next sibling of card-id expect(cardId.nextElementSibling).toBe(badge); }); it("does not render a status badge when task.status is falsy", () => { const { container } = render( , ); expect(container.querySelector(".card-status-badge")).toBeNull(); }); it("shows drop indicator on file dragover and removes on dragleave", () => { const { container } = render( , ); const card = container.querySelector(".card")!; // Simulate file dragover fireEvent.dragOver(card, { dataTransfer: { types: ["Files"], dropEffect: "none" }, }); expect(card.classList.contains("file-drop-target")).toBe(true); // Simulate dragleave fireEvent.dragLeave(card, { dataTransfer: { types: ["Files"] }, }); expect(card.classList.contains("file-drop-target")).toBe(false); }); it("does not show drop indicator for non-file drag", () => { const { container } = render( , ); const card = container.querySelector(".card")!; // Simulate card dragover (not files) fireEvent.dragOver(card, { dataTransfer: { types: ["text/plain"], dropEffect: "none" }, }); expect(card.classList.contains("file-drop-target")).toBe(false); }); it("calls uploadAttachment on file drop", async () => { const mockUpload = vi.mocked(uploadAttachment); mockUpload.mockResolvedValue({ filename: "abc-test.png", originalName: "test.png", mimeType: "image/png", size: 1024, createdAt: new Date().toISOString(), }); const addToast = vi.fn(); const { container } = render( , ); const card = container.querySelector(".card")!; const file = new File(["content"], "test.png", { type: "image/png" }); fireEvent.drop(card, { dataTransfer: { types: ["Files"], files: [file] }, }); await waitFor(() => { expect(mockUpload).toHaveBeenCalledWith("FN-001", file, undefined); expect(addToast).toHaveBeenCalledWith( expect.stringContaining("Attached test.png"), "success", ); }); }); it("shows error toast when upload fails", async () => { const mockUpload = vi.mocked(uploadAttachment); mockUpload.mockRejectedValue(new Error("Upload failed")); const addToast = vi.fn(); const { container } = render( , ); const card = container.querySelector(".card")!; const file = new File(["content"], "bad.png", { type: "image/png" }); fireEvent.drop(card, { dataTransfer: { types: ["Files"], files: [file] }, }); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining("Failed to attach bad.png"), "error", ); }); }); // Size badge positioning regression tests (KB-197) it("renders size badge for sized tasks", () => { const { container } = render( , ); expect(container.querySelector(".card-size-badge")).not.toBeNull(); expect(screen.getByText("S")).toBeDefined(); }); it("does not render size badge when task has no size", () => { const { container } = render( , ); expect(container.querySelector(".card-size-badge")).toBeNull(); }); it("renders all three size values with correct CSS classes", () => { const sizes: Array<"S" | "M" | "L"> = ["S", "M", "L"]; const expectedClasses = ["size-s", "size-m", "size-l"]; sizes.forEach((size, index) => { const { container } = render( , ); const badge = container.querySelector(".card-size-badge"); expect(badge).not.toBeNull(); expect(badge?.classList.contains(expectedClasses[index])).toBe(true); // Clean up for next iteration container.remove(); }); }); it("places size badge inside card-header-actions container", () => { const { container } = render( , ); const actionsContainer = container.querySelector(".card-header-actions"); const sizeBadge = container.querySelector(".card-size-badge"); expect(actionsContainer).not.toBeNull(); expect(sizeBadge).not.toBeNull(); expect(actionsContainer?.contains(sizeBadge)).toBe(true); }); it("places card-header-actions after card-id in DOM order", () => { const { container } = render( , ); const cardId = container.querySelector(".card-id")!; const actionsContainer = container.querySelector(".card-header-actions")!; expect(cardId).not.toBeNull(); expect(actionsContainer).not.toBeNull(); // The actions container should come after card-id expect( cardId.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); }); it("renders edit button inside card-header-actions for editable columns", () => { const { container } = render( makeTask()} />, ); const actionsContainer = container.querySelector(".card-header-actions"); const editBtn = container.querySelector(".card-edit-btn"); expect(actionsContainer).not.toBeNull(); expect(editBtn).not.toBeNull(); expect(actionsContainer?.contains(editBtn)).toBe(true); }); it("renders archive button inside card-header-actions for done column", () => { const { container } = render( makeTask()} />, ); const actionsContainer = container.querySelector(".card-header-actions"); const archiveBtn = container.querySelector(".card-archive-btn"); expect(actionsContainer).not.toBeNull(); expect(archiveBtn).not.toBeNull(); expect(actionsContainer?.contains(archiveBtn)).toBe(true); }); }); describe("TaskCard mission badge", () => { // Access the internal cache reset helper let clearCache: () => void; beforeAll(async () => { const mod = await import("./TaskCard"); clearCache = (mod as any).__test_clearMissionTitleCache; }); beforeEach(() => { clearCache?.(); vi.mocked(fetchMission).mockReset(); }); it("displays mission title instead of missionId", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-ABC123", title: "Database Optimization", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { // MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..." expect(badge?.textContent).toContain("Database ..."); }); }); it("abbreviates long mission titles with ellipsis", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-LONG1", title: "This Is A Very Long Mission Title That Exceeds Twenty Characters", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { // MAX_MISSION_TITLE_LENGTH is 12, so first 9 chars + "..." expect(badge?.textContent).toContain("This Is A..."); }); }); it("falls back to missionId on fetch error", async () => { vi.mocked(fetchMission).mockRejectedValue(new Error("Network error")); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { expect(badge?.textContent).toContain("M-ERR99"); }); }); it("shows mission title in title attribute", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-TITLE", title: "Refactor Auth", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { expect(badge?.getAttribute("title")).toBe("Mission: Refactor Auth"); }); }); it("shows short mission title without abbreviation", async () => { vi.mocked(fetchMission).mockResolvedValue({ id: "M-SHORT", title: "Auth Fix", status: "active", interviewState: "completed", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); const { container } = render( , ); const badge = container.querySelector(".card-mission-badge"); expect(badge).not.toBeNull(); await waitFor(() => { // "Auth Fix" is 8 chars, well under 20 — no abbreviation needed expect(badge?.textContent).toContain("Auth Fix"); expect(badge?.textContent).not.toContain("..."); }); }); }); describe("TaskCard agent badge", () => { let clearAgentCache: () => void; beforeAll(async () => { const mod = await import("./TaskCard"); clearAgentCache = (mod as { __test_clearAgentNameCache?: () => void }).__test_clearAgentNameCache ?? (() => undefined); }); beforeEach(() => { clearAgentCache?.(); vi.mocked(fetchAgent).mockReset(); }); it("renders agent badge when task has assignedAgentId", async () => { vi.mocked(fetchAgent).mockResolvedValue({ id: "agent-001", name: "Task Robot", role: "executor", state: "active", metadata: {}, heartbeatHistory: [], completedRuns: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", } as any); render( , ); await waitFor(() => { expect(screen.getByTitle("Assigned to Task Robot")).toBeDefined(); expect(screen.getByText("Task Robot")).toBeDefined(); }); }); it("does not render agent badge when assignedAgentId is undefined", () => { render( , ); expect(screen.queryByTitle(/Assigned to/)).toBeNull(); }); });