Files
pi-lgtm/test/task-widget.test.ts
T
tintinweb 86d2b64779 v0.1.0
2026-03-12 00:47:54 +01:00

412 lines
12 KiB
TypeScript

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<string, any>;
statuses: Map<string, string | undefined>;
} = {
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<typeof mockUICtx>["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<typeof mockUICtx>;
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<typeof mockUICtx>;
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"
});
});