mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 15:31:29 +08:00
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TaskStore } from "../src/task-store.js";
|
|
import { TaskWidget, type Theme, type UICtx } from "../src/ui/task-widget.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 }, requestRender() {} };
|
|
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", "done");
|
|
widget.update();
|
|
|
|
const lines = renderWidget(ui.state);
|
|
expect(lines).toHaveLength(2); // header + 1 task
|
|
expect(lines[0]).toContain("1 goals");
|
|
expect(lines[0]).toContain("1 open");
|
|
expect(lines[1]).toContain("◻");
|
|
expect(lines[1]).toContain("Do something");
|
|
expect(lines[1]).not.toContain("done");
|
|
});
|
|
|
|
it("renders in-progress tasks with ◼ icon", () => {
|
|
store.create("Working on it", "Desc", "done");
|
|
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("hides the widget when only completed tasks remain", () => {
|
|
store.create("Done task", "Desc", "done");
|
|
store.complete("1");
|
|
widget.update();
|
|
|
|
const lines = renderWidget(ui.state);
|
|
expect(lines).toEqual([]);
|
|
});
|
|
|
|
it("does not render proof badges on collapsed rows", () => {
|
|
store.create("Open task", "Desc", "done");
|
|
store.create("Done task", "Desc", "done");
|
|
store.update("2", {
|
|
metadata: {
|
|
robot_review_observations: ["Observed output drift on seed 2"],
|
|
lgtm_evidence: "verbatim output",
|
|
},
|
|
});
|
|
store.complete("2");
|
|
widget.update();
|
|
|
|
const lines = renderWidget(ui.state);
|
|
expect(lines[1]).toContain("Open task");
|
|
expect(lines[1]).not.toContain("[");
|
|
expect(lines[1]).not.toContain("robot_review_observations");
|
|
expect(lines[1]).not.toContain("lgtm_evidence");
|
|
});
|
|
|
|
it("renders active tasks with spinner icon", () => {
|
|
store.create("Running thing", "Desc", "done criterion", "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", "done");
|
|
store.create("Blocked", "Desc", "done");
|
|
store.update("2", { add_blocked_by: ["1"] });
|
|
widget.update();
|
|
|
|
const lines = renderWidget(ui.state);
|
|
const blockedLine = lines.find((l) => l.includes("Blocked"));
|
|
// blocked-by suffix is only added via dim theme helper, which in mock is identity
|
|
// So we should see the raw text. Check for the relevant subject line having blocked-by info
|
|
expect(blockedLine).toContain("blocked by #1");
|
|
});
|
|
|
|
it("hides completed blockers in blocked-by suffix", () => {
|
|
store.create("Blocker", "Desc", "done");
|
|
store.create("Blocked", "Desc", "done");
|
|
store.update("2", { add_blocked_by: ["1"] });
|
|
store.complete("1");
|
|
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", "done");
|
|
store.create("Task B", "Desc", "done");
|
|
store.create("Task C", "Desc", "done");
|
|
store.complete("1");
|
|
store.update("2", { status: "in_progress" });
|
|
widget.update();
|
|
|
|
const lines = renderWidget(ui.state);
|
|
expect(lines[0]).toContain("3 goals");
|
|
expect(lines[0]).toContain("1 done hidden");
|
|
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", "done");
|
|
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", "done");
|
|
}
|
|
widget.update();
|
|
|
|
const lines = renderWidget(ui.state);
|
|
// header + 5 visible tasks + "...and 10 more open"
|
|
expect(lines).toHaveLength(7);
|
|
expect(lines[6]).toContain("10 more open");
|
|
});
|
|
|
|
it("tracks token usage for active tasks", () => {
|
|
store.create("Active task", "Desc", "done criterion", "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", "done criterion", "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", "done");
|
|
store.update("1", { status: "in_progress" });
|
|
widget.setActiveTask("1", true);
|
|
|
|
// Complete the task externally
|
|
store.complete("1");
|
|
widget.update();
|
|
|
|
// Completed tasks are hidden from the default widget
|
|
const lines = renderWidget(ui.state);
|
|
expect(lines).toEqual([]);
|
|
});
|
|
|
|
it("supports multiple active tasks simultaneously", () => {
|
|
store.create("Task A", "Desc", "done criterion", "Processing A");
|
|
store.create("Task B", "Desc", "done criterion", "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", "done criterion", "A");
|
|
store.create("Task B", "Desc", "done criterion", "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", "done");
|
|
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", "done");
|
|
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", "done criterion", "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", "done criterion", "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", "done criterion", "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", "done");
|
|
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", "done");
|
|
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", "done criterion", "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", "done criterion", "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", "done criterion", "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", "done criterion", "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", "done criterion", "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", "done criterion", "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"
|
|
});
|
|
});
|