Files
fusion/packages/dashboard/app/components/TaskCard.test.tsx
gsxdsm 9d5b0865b8 feat(FN-1544): add viewport-gated loading and lightweight memo comparison
- Add viewport-gated fetching to TaskCard component so only visible cards load their data
- Implement lightweight memo comparison to prevent unnecessary re-renders
- Add lazy enable gates to useSessionFiles and useTaskDiffStats hooks with caching
- Add comprehensive tests for useSessionFiles and useTaskDiffStats hooks
- Document viewport-gated loading patterns in memory and dashboard-load performance docs
2026-04-10 10:44:48 -07:00

462 lines
14 KiB
TypeScript

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> = {}): 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(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
expect(screen.getByText("FN-001")).toBeDefined();
});
it("renders the status badge when task.status is set", () => {
render(
<TaskCard
task={makeTask({ status: "executing" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.getByText("executing")).toBeDefined();
});
it("renders the status badge after the card ID in DOM order", () => {
const { container } = render(
<TaskCard
task={makeTask({ status: "executing" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard task={makeTask({ status: undefined as any })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-status-badge")).toBeNull();
});
it("shows drop indicator on file dragover and removes on dragleave", () => {
const { container } = render(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
);
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(
<TaskCard task={makeTask()} onOpenDetail={noop} addToast={addToast} />,
);
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(
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={makeTask({ size: undefined })} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={makeTask({ size })} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={makeTask({ size: "M" })} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard task={makeTask({ size: "S" })} onOpenDetail={noop} addToast={noop} />,
);
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(
<TaskCard
task={makeTask({ column: "todo", size: "S" })}
onOpenDetail={noop}
addToast={noop}
onUpdateTask={async () => 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(
<TaskCard
task={makeTask({ column: "done", size: "L" })}
onOpenDetail={noop}
addToast={noop}
onArchiveTask={async () => 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(
<TaskCard
task={makeTask({ missionId: "M-ABC123" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask({ missionId: "M-LONG1" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask({ missionId: "M-ERR99" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask({ missionId: "M-TITLE" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask({ missionId: "M-SHORT" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask({ assignedAgentId: "agent-001" })}
onOpenDetail={noop}
addToast={noop}
/>,
);
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(
<TaskCard
task={makeTask()}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
});
});