import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { TaskWidget, type UICtx, type Theme, type TaskMetrics } from "../src/ui/task-widget.js"; import { TaskStore } from "../src/task-store.js"; /** Create a mock theme that returns raw text (no ANSI escapes). */ function mockTheme(): Theme { return { fg: (_color: string, text: string) => text, bold: (text: string) => text, strikethrough: (text: string) => `~~${text}~~`, }; } /** Create a mock UICtx that captures setWidget calls. */ function mockUICtx() { const state: { widgets: Map; statuses: Map; } = { widgets: new Map(), statuses: new Map(), }; const ctx: UICtx = { setWidget(key, content, options) { state.widgets.set(key, { content, options }); }, setStatus(key, text) { state.statuses.set(key, text); }, }; return { ctx, state }; } /** Render the widget and return its lines. */ function renderWidget(state: ReturnType["state"]): string[] { const entry = state.widgets.get("tasks"); if (!entry?.content) return []; const theme = mockTheme(); const tui = { terminal: { columns: 200 } }; const result = entry.content(tui, theme); return result.render(); } describe("TaskWidget", () => { let store: TaskStore; let widget: TaskWidget; let ui: ReturnType; beforeEach(() => { vi.useFakeTimers(); store = new TaskStore(); widget = new TaskWidget(store); ui = mockUICtx(); widget.setUICtx(ui.ctx); }); afterEach(() => { widget.dispose(); vi.useRealTimers(); }); it("shows nothing when no tasks exist", () => { widget.update(); const entry = ui.state.widgets.get("tasks"); expect(entry?.content).toBeUndefined(); }); it("renders pending tasks with ◻ icon", () => { store.create("Do something", "Desc"); widget.update(); const lines = renderWidget(ui.state); expect(lines).toHaveLength(2); // header + 1 task expect(lines[0]).toContain("1 tasks"); expect(lines[0]).toContain("1 open"); expect(lines[1]).toContain("◻"); expect(lines[1]).toContain("Do something"); }); it("renders in-progress tasks with ◼ icon", () => { store.create("Working on it", "Desc"); store.update("1", { status: "in_progress" }); widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("◼"); expect(lines[1]).toContain("Working on it"); }); it("renders completed tasks with ✔ icon and strikethrough", () => { store.create("Done task", "Desc"); store.update("1", { status: "completed" }); widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("✔"); expect(lines[1]).toContain("~~Done task~~"); }); it("renders active tasks with spinner icon", () => { store.create("Running thing", "Desc", "Processing data"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); const lines = renderWidget(ui.state); // Should show activeForm text with "…" suffix expect(lines[1]).toContain("Processing data…"); // Should NOT show ◼ for active task expect(lines[1]).not.toContain("◼"); }); it("shows blocked-by info for pending tasks", () => { store.create("Blocker", "Desc"); store.create("Blocked", "Desc"); store.update("2", { addBlockedBy: ["1"] }); widget.update(); const lines = renderWidget(ui.state); const blockedLine = lines.find(l => l.includes("Blocked")); expect(blockedLine).toContain("blocked by #1"); }); it("hides completed blockers in blocked-by suffix", () => { store.create("Blocker", "Desc"); store.create("Blocked", "Desc"); store.update("2", { addBlockedBy: ["1"] }); store.update("1", { status: "completed" }); widget.update(); const lines = renderWidget(ui.state); const blockedLine = lines.find(l => l.includes("Blocked")); expect(blockedLine).not.toContain("blocked by"); }); it("shows status summary in header", () => { store.create("Task A", "Desc"); store.create("Task B", "Desc"); store.create("Task C", "Desc"); store.update("1", { status: "completed" }); store.update("2", { status: "in_progress" }); widget.update(); const lines = renderWidget(ui.state); expect(lines[0]).toContain("3 tasks"); expect(lines[0]).toContain("1 done"); expect(lines[0]).toContain("1 in progress"); expect(lines[0]).toContain("1 open"); }); it("clears widget when all tasks are deleted", () => { store.create("Task", "Desc"); widget.update(); expect(ui.state.widgets.get("tasks")?.content).toBeDefined(); store.update("1", { status: "deleted" }); widget.update(); expect(ui.state.widgets.get("tasks")?.content).toBeUndefined(); }); it("limits visible tasks to MAX_VISIBLE_TASKS", () => { for (let i = 0; i < 15; i++) { store.create(`Task ${i + 1}`, "Desc"); } widget.update(); const lines = renderWidget(ui.state); // header + 10 tasks + "… and 5 more" expect(lines).toHaveLength(12); expect(lines[11]).toContain("5 more"); }); it("tracks token usage for active tasks", () => { store.create("Active task", "Desc", "Running"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); widget.addTokenUsage(1000, 500); widget.addTokenUsage(500, 300); const lines = renderWidget(ui.state); const activeLine = lines.find(l => l.includes("Running…")); expect(activeLine).toContain("↑ 1.5k"); expect(activeLine).toContain("↓ 800"); }); it("deactivates a task with setActiveTask(id, false)", () => { store.create("Task", "Desc", "Doing work"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); // Should be active (spinner) let lines = renderWidget(ui.state); expect(lines[1]).toContain("Doing work…"); widget.setActiveTask("1", false); lines = renderWidget(ui.state); // Should now show as regular in_progress (◼) expect(lines[1]).toContain("◼"); expect(lines[1]).not.toContain("Doing work…"); }); it("prunes stale active IDs on update", () => { store.create("Task", "Desc"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); // Complete the task externally store.update("1", { status: "completed" }); widget.update(); // Should render as completed, not active const lines = renderWidget(ui.state); expect(lines[1]).toContain("✔"); expect(lines[1]).toContain("~~Task~~"); }); it("supports multiple active tasks simultaneously", () => { store.create("Task A", "Desc", "Processing A"); store.create("Task B", "Desc", "Processing B"); store.update("1", { status: "in_progress" }); store.update("2", { status: "in_progress" }); widget.setActiveTask("1", true); widget.setActiveTask("2", true); const lines = renderWidget(ui.state); expect(lines[1]).toContain("Processing A…"); expect(lines[2]).toContain("Processing B…"); }); it("distributes token usage across all active tasks", () => { store.create("Task A", "Desc", "A"); store.create("Task B", "Desc", "B"); store.update("1", { status: "in_progress" }); store.update("2", { status: "in_progress" }); widget.setActiveTask("1", true); widget.setActiveTask("2", true); widget.addTokenUsage(100, 50); const lines = renderWidget(ui.state); // Both tasks should have the same token counts expect(lines[1]).toContain("↑ 100"); expect(lines[2]).toContain("↑ 100"); }); it("dispose clears widget and timer", () => { store.create("Task", "Desc"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); widget.dispose(); expect(ui.state.widgets.get("tasks")?.content).toBeUndefined(); }); it("uses subject as fallback when no activeForm", () => { store.create("My Subject", "Desc"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); const lines = renderWidget(ui.state); expect(lines[1]).toContain("My Subject…"); }); it("shows elapsed time but no token arrows when tokens are zero", () => { store.create("No tokens", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); // No addTokenUsage calls — tokens stay at 0 vi.advanceTimersByTime(5000); widget.update(); const lines = renderWidget(ui.state); const activeLine = lines.find(l => l.includes("Working…")); expect(activeLine).toContain("5s"); expect(activeLine).not.toContain("↑"); expect(activeLine).not.toContain("↓"); }); it("cleans up metrics when stale active IDs are pruned", () => { store.create("Task", "Desc", "Running"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); widget.addTokenUsage(100, 50); // Delete task externally store.update("1", { status: "deleted" }); widget.update(); // Reactivate with same ID (new task) — should get fresh metrics store.create("Task 2", "Desc", "Running"); // ID 2 store.update("2", { status: "in_progress" }); widget.setActiveTask("2", true); const lines = renderWidget(ui.state); // Should not carry over old tokens expect(lines[1]).not.toContain("↑ 100"); }); it("indents task lines under header", () => { store.create("Indented task", "Desc"); widget.update(); const lines = renderWidget(ui.state); // Task line should start with 2 spaces expect(lines[1]).toMatch(/^\s{2}/); }); it("widget is placed aboveEditor", () => { store.create("Task", "Desc"); widget.update(); const entry = ui.state.widgets.get("tasks"); expect(entry?.options?.placement).toBe("aboveEditor"); }); }); describe("formatDuration (via widget rendering)", () => { let store: TaskStore; let widget: TaskWidget; let ui: ReturnType; beforeEach(() => { vi.useFakeTimers(); store = new TaskStore(); widget = new TaskWidget(store); ui = mockUICtx(); widget.setUICtx(ui.ctx); }); afterEach(() => { widget.dispose(); vi.useRealTimers(); }); it("shows seconds for short durations", () => { store.create("Quick", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); vi.advanceTimersByTime(30_000); // 30s widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("30s"); }); it("shows hours for long durations", () => { store.create("Long", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); vi.advanceTimersByTime(3_723_000); // 1h 2m 3s → "1h 2m" widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("1h 2m"); }); it("shows exact hours without minutes", () => { store.create("Exact", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); vi.advanceTimersByTime(7_200_000); // 2h exactly widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("2h)"); }); it("shows minutes and seconds", () => { store.create("Medium", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); vi.advanceTimersByTime(169_000); // 2m 49s widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("2m 49s"); }); it("formats small token counts without k suffix", () => { store.create("Small", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); widget.addTokenUsage(500, 200); widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("↑ 500"); expect(lines[1]).toContain("↓ 200"); }); it("formats token counts with k suffix and removes .0", () => { store.create("Large", "Desc", "Working"); store.update("1", { status: "in_progress" }); widget.setActiveTask("1", true); widget.addTokenUsage(2000, 4100); widget.update(); const lines = renderWidget(ui.state); expect(lines[1]).toContain("↑ 2k"); // 2000 → "2k" (not "2.0k") expect(lines[1]).toContain("↓ 4.1k"); // 4100 → "4.1k" }); });