mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 16:46:17 +08:00
578b1c9a6b
description in tools fix widget refresh flickering
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { TaskStore } from "../src/task-store.js";
|
|
import { existsSync, rmSync, mkdirSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { tmpdir } from "node:os";
|
|
|
|
describe("TaskStore (in-memory)", () => {
|
|
let store: TaskStore;
|
|
|
|
beforeEach(() => {
|
|
store = new TaskStore(); // no listId = in-memory
|
|
});
|
|
|
|
it("creates tasks with auto-incrementing IDs", () => {
|
|
const t1 = store.create("First task", "Description 1");
|
|
const t2 = store.create("Second task", "Description 2");
|
|
|
|
expect(t1.id).toBe("1");
|
|
expect(t2.id).toBe("2");
|
|
expect(t1.status).toBe("pending");
|
|
expect(t1.subject).toBe("First task");
|
|
expect(t1.description).toBe("Description 1");
|
|
});
|
|
|
|
it("creates tasks with optional fields", () => {
|
|
const t = store.create("Task", "Desc", "Running task", { key: "value" });
|
|
|
|
expect(t.activeForm).toBe("Running task");
|
|
expect(t.metadata).toEqual({ key: "value" });
|
|
});
|
|
|
|
it("gets a task by ID", () => {
|
|
store.create("Test", "Desc");
|
|
const task = store.get("1");
|
|
|
|
expect(task).toBeDefined();
|
|
expect(task!.subject).toBe("Test");
|
|
});
|
|
|
|
it("returns undefined for non-existent task", () => {
|
|
expect(store.get("999")).toBeUndefined();
|
|
});
|
|
|
|
it("lists all tasks sorted by ID", () => {
|
|
store.create("Task 3", "Desc");
|
|
store.create("Task 1", "Desc");
|
|
store.create("Task 2", "Desc");
|
|
|
|
const tasks = store.list();
|
|
expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]);
|
|
});
|
|
|
|
it("updates task status", () => {
|
|
store.create("Test", "Desc");
|
|
const { task, changedFields } = store.update("1", { status: "in_progress" });
|
|
|
|
expect(task!.status).toBe("in_progress");
|
|
expect(changedFields).toEqual(["status"]);
|
|
});
|
|
|
|
it("updates multiple fields at once", () => {
|
|
store.create("Test", "Desc");
|
|
const { changedFields } = store.update("1", {
|
|
subject: "Updated subject",
|
|
description: "Updated desc",
|
|
owner: "agent-1",
|
|
});
|
|
|
|
expect(changedFields).toContain("subject");
|
|
expect(changedFields).toContain("description");
|
|
expect(changedFields).toContain("owner");
|
|
|
|
const task = store.get("1")!;
|
|
expect(task.subject).toBe("Updated subject");
|
|
expect(task.owner).toBe("agent-1");
|
|
});
|
|
|
|
it("deletes a task with status: deleted", () => {
|
|
store.create("Test", "Desc");
|
|
const { changedFields } = store.update("1", { status: "deleted" });
|
|
|
|
expect(changedFields).toEqual(["deleted"]);
|
|
expect(store.get("1")).toBeUndefined();
|
|
expect(store.list()).toHaveLength(0);
|
|
});
|
|
|
|
it("preserves ID counter after deletion", () => {
|
|
store.create("Task 1", "Desc");
|
|
store.create("Task 2", "Desc");
|
|
store.update("1", { status: "deleted" });
|
|
|
|
const t3 = store.create("Task 3", "Desc");
|
|
expect(t3.id).toBe("3"); // Not "1" — counter continues
|
|
});
|
|
|
|
it("merges metadata with null key deletion", () => {
|
|
store.create("Test", "Desc", undefined, { a: 1, b: 2, c: 3 });
|
|
store.update("1", { metadata: { b: null, d: 4 } });
|
|
|
|
const task = store.get("1")!;
|
|
expect(task.metadata).toEqual({ a: 1, c: 3, d: 4 });
|
|
});
|
|
|
|
it("sets up bidirectional blocks via addBlocks", () => {
|
|
store.create("Blocker", "Desc");
|
|
store.create("Blocked", "Desc");
|
|
|
|
store.update("1", { addBlocks: ["2"] });
|
|
|
|
const t1 = store.get("1")!;
|
|
const t2 = store.get("2")!;
|
|
expect(t1.blocks).toContain("2");
|
|
expect(t2.blockedBy).toContain("1");
|
|
});
|
|
|
|
it("sets up bidirectional blocks via addBlockedBy", () => {
|
|
store.create("Blocker", "Desc");
|
|
store.create("Blocked", "Desc");
|
|
|
|
store.update("2", { addBlockedBy: ["1"] });
|
|
|
|
const t1 = store.get("1")!;
|
|
const t2 = store.get("2")!;
|
|
expect(t1.blocks).toContain("2");
|
|
expect(t2.blockedBy).toContain("1");
|
|
});
|
|
|
|
it("does not duplicate dependency edges", () => {
|
|
store.create("A", "Desc");
|
|
store.create("B", "Desc");
|
|
|
|
store.update("1", { addBlocks: ["2"] });
|
|
store.update("1", { addBlocks: ["2"] }); // duplicate
|
|
|
|
const t1 = store.get("1")!;
|
|
expect(t1.blocks.filter(id => id === "2")).toHaveLength(1);
|
|
});
|
|
|
|
it("cleans up dependency edges on deletion", () => {
|
|
store.create("A", "Desc");
|
|
store.create("B", "Desc");
|
|
store.update("1", { addBlocks: ["2"] });
|
|
|
|
store.update("1", { status: "deleted" });
|
|
|
|
const t2 = store.get("2")!;
|
|
expect(t2.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("clears completed tasks", () => {
|
|
store.create("Completed", "Desc");
|
|
store.create("Pending", "Desc");
|
|
store.update("1", { status: "completed" });
|
|
|
|
const count = store.clearCompleted();
|
|
|
|
expect(count).toBe(1);
|
|
expect(store.list()).toHaveLength(1);
|
|
expect(store.list()[0].id).toBe("2");
|
|
});
|
|
|
|
it("returns not found for update on non-existent task", () => {
|
|
const { task, changedFields } = store.update("999", { status: "completed" });
|
|
expect(task).toBeUndefined();
|
|
expect(changedFields).toEqual([]);
|
|
});
|
|
|
|
it("delete method works", () => {
|
|
store.create("Test", "Desc");
|
|
expect(store.delete("1")).toBe(true);
|
|
expect(store.delete("1")).toBe(false); // already deleted
|
|
expect(store.list()).toHaveLength(0);
|
|
});
|
|
|
|
it("creates tasks with metadata via TaskCreate", () => {
|
|
const t = store.create("With meta", "Desc", undefined, { pr: "123", reviewer: "alice" });
|
|
expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" });
|
|
|
|
const retrieved = store.get("1")!;
|
|
expect(retrieved.metadata).toEqual({ pr: "123", reviewer: "alice" });
|
|
});
|
|
|
|
it("allows circular dependencies with warning", () => {
|
|
store.create("A", "Desc");
|
|
store.create("B", "Desc");
|
|
store.update("1", { addBlocks: ["2"] });
|
|
const { warnings } = store.update("2", { addBlocks: ["1"] });
|
|
|
|
expect(store.get("1")!.blocks).toContain("2");
|
|
expect(store.get("2")!.blocks).toContain("1");
|
|
expect(warnings).toContain("cycle: #2 and #1 block each other");
|
|
});
|
|
|
|
it("allows self-dependency with warning", () => {
|
|
store.create("Self", "Desc");
|
|
const { warnings } = store.update("1", { addBlocks: ["1"] });
|
|
expect(store.get("1")!.blocks).toContain("1");
|
|
expect(warnings).toContain("#1 blocks itself");
|
|
});
|
|
|
|
it("stores dangling edge IDs with warning", () => {
|
|
store.create("Real", "Desc");
|
|
const { warnings } = store.update("1", { addBlocks: ["9999"] });
|
|
expect(store.get("1")!.blocks).toContain("9999");
|
|
expect(warnings).toContain("#9999 does not exist");
|
|
});
|
|
|
|
it("returns no warnings for valid dependencies", () => {
|
|
store.create("A", "Desc");
|
|
store.create("B", "Desc");
|
|
const { warnings } = store.update("1", { addBlocks: ["2"] });
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
it("accepts whitespace-only subjects (matches Claude Code)", () => {
|
|
const t = store.create(" ", "Desc");
|
|
expect(t.subject).toBe(" ");
|
|
});
|
|
|
|
it("updates activeForm field", () => {
|
|
store.create("Test", "Desc");
|
|
const { changedFields } = store.update("1", { activeForm: "Running tests" });
|
|
expect(changedFields).toContain("activeForm");
|
|
expect(store.get("1")!.activeForm).toBe("Running tests");
|
|
});
|
|
|
|
it("updates description field", () => {
|
|
store.create("Test", "Original desc");
|
|
const { changedFields } = store.update("1", { description: "Updated desc" });
|
|
expect(changedFields).toContain("description");
|
|
expect(store.get("1")!.description).toBe("Updated desc");
|
|
});
|
|
|
|
it("returns empty changedFields when updating non-existent task", () => {
|
|
const { task, changedFields, warnings } = store.update("999", { status: "completed" });
|
|
expect(task).toBeUndefined();
|
|
expect(changedFields).toEqual([]);
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
it("clearCompleted cleans up dependency edges", () => {
|
|
store.create("Blocker", "Desc");
|
|
store.create("Blocked", "Desc");
|
|
store.update("1", { addBlocks: ["2"] });
|
|
store.update("1", { status: "completed" });
|
|
|
|
store.clearCompleted();
|
|
|
|
const t2 = store.get("2")!;
|
|
expect(t2.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("handles multiple addBlocks in one call", () => {
|
|
store.create("Blocker", "Desc");
|
|
store.create("B1", "Desc");
|
|
store.create("B2", "Desc");
|
|
|
|
store.update("1", { addBlocks: ["2", "3"] });
|
|
|
|
expect(store.get("1")!.blocks).toEqual(["2", "3"]);
|
|
expect(store.get("2")!.blockedBy).toContain("1");
|
|
expect(store.get("3")!.blockedBy).toContain("1");
|
|
});
|
|
|
|
it("addBlockedBy warns on self-dependency", () => {
|
|
store.create("Self", "Desc");
|
|
const { warnings } = store.update("1", { addBlockedBy: ["1"] });
|
|
expect(store.get("1")!.blockedBy).toContain("1");
|
|
expect(warnings).toContain("#1 blocks itself");
|
|
});
|
|
|
|
it("addBlockedBy warns on dangling ref", () => {
|
|
store.create("Real", "Desc");
|
|
const { warnings } = store.update("1", { addBlockedBy: ["9999"] });
|
|
expect(store.get("1")!.blockedBy).toContain("9999");
|
|
expect(warnings).toContain("#9999 does not exist");
|
|
});
|
|
|
|
it("addBlockedBy warns on cycle", () => {
|
|
store.create("A", "Desc");
|
|
store.create("B", "Desc");
|
|
store.update("1", { addBlocks: ["2"] });
|
|
const { warnings } = store.update("1", { addBlockedBy: ["2"] });
|
|
expect(warnings).toContain("cycle: #1 and #2 block each other");
|
|
});
|
|
|
|
it("clearCompleted returns 0 when no completed tasks", () => {
|
|
store.create("Pending", "Desc");
|
|
expect(store.clearCompleted()).toBe(0);
|
|
});
|
|
|
|
it("list sorts pending → in_progress → completed with all three present", () => {
|
|
store.create("Pending task", "Desc");
|
|
store.create("Completed task", "Desc");
|
|
store.create("In-progress task", "Desc");
|
|
store.create("Another pending", "Desc");
|
|
|
|
store.update("2", { status: "completed" });
|
|
store.update("3", { status: "in_progress" });
|
|
|
|
const tasks = store.list();
|
|
// Store returns by ID; TaskList tool sorts by status group
|
|
// Here we verify the raw list order (by ID), then test status-grouped sort
|
|
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
|
|
const sorted = [...tasks].sort((a, b) => {
|
|
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
|
if (so !== 0) return so;
|
|
return Number(a.id) - Number(b.id);
|
|
});
|
|
|
|
expect(sorted.map(t => t.id)).toEqual(["1", "4", "3", "2"]);
|
|
expect(sorted.map(t => t.status)).toEqual(["pending", "pending", "in_progress", "completed"]);
|
|
});
|
|
});
|
|
|
|
describe("TaskStore (file-backed)", () => {
|
|
const testListId = `test-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tasksDir = join(homedir(), ".pi", "tasks");
|
|
const filePath = join(tasksDir, `${testListId}.json`);
|
|
|
|
afterEach(() => {
|
|
// Clean up test file
|
|
try { rmSync(filePath); } catch { /* */ }
|
|
try { rmSync(filePath + ".lock"); } catch { /* */ }
|
|
try { rmSync(filePath + ".tmp"); } catch { /* */ }
|
|
});
|
|
|
|
it("persists tasks to disk", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Persistent task", "Should survive reload");
|
|
|
|
// Create a new store instance pointing to same file
|
|
const store2 = new TaskStore(testListId);
|
|
const tasks = store2.list();
|
|
|
|
expect(tasks).toHaveLength(1);
|
|
expect(tasks[0].subject).toBe("Persistent task");
|
|
});
|
|
|
|
it("persists in_progress updates to disk", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Task", "Desc");
|
|
store1.update("1", { status: "in_progress" });
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
expect(store2.get("1")!.status).toBe("in_progress");
|
|
});
|
|
|
|
it("does not persist completed tasks to disk", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Done task", "Desc");
|
|
store1.create("Pending task", "Desc");
|
|
store1.update("1", { status: "completed" });
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
expect(store2.get("1")).toBeUndefined();
|
|
expect(store2.get("2")).toBeDefined();
|
|
expect(store2.list()).toHaveLength(1);
|
|
});
|
|
|
|
it("only restores pending and in_progress tasks across instances", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Pending", "Desc");
|
|
store1.create("In progress", "Desc");
|
|
store1.create("Done", "Desc");
|
|
store1.update("2", { status: "in_progress" });
|
|
store1.update("3", { status: "completed" });
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
const tasks = store2.list();
|
|
expect(tasks).toHaveLength(2);
|
|
expect(tasks.map(t => t.id)).toContain("1");
|
|
expect(tasks.map(t => t.id)).toContain("2");
|
|
expect(tasks.map(t => t.id)).not.toContain("3");
|
|
});
|
|
|
|
it("persists ID counter across instances", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Task 1", "Desc");
|
|
store1.create("Task 2", "Desc");
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
const t3 = store2.create("Task 3", "Desc");
|
|
expect(t3.id).toBe("3");
|
|
});
|
|
});
|
|
|
|
describe("TaskStore (absolute path)", () => {
|
|
const absFilePath = join(tmpdir(), `pi-tasks-test-${Date.now()}.json`);
|
|
|
|
afterEach(() => {
|
|
try { rmSync(absFilePath); } catch { /* */ }
|
|
try { rmSync(absFilePath + ".lock"); } catch { /* */ }
|
|
try { rmSync(absFilePath + ".tmp"); } catch { /* */ }
|
|
});
|
|
|
|
it("accepts absolute path and persists tasks", () => {
|
|
const store1 = new TaskStore(absFilePath);
|
|
store1.create("Abs path task", "Desc");
|
|
|
|
const store2 = new TaskStore(absFilePath);
|
|
expect(store2.list()).toHaveLength(1);
|
|
expect(store2.list()[0].subject).toBe("Abs path task");
|
|
});
|
|
|
|
it("does not persist completed tasks when using absolute path", () => {
|
|
const store1 = new TaskStore(absFilePath);
|
|
store1.create("Pending", "Desc");
|
|
store1.create("Completed", "Desc");
|
|
store1.update("2", { status: "completed" });
|
|
|
|
const raw = JSON.parse(readFileSync(absFilePath, "utf-8"));
|
|
expect(raw.tasks).toHaveLength(1);
|
|
expect(raw.tasks[0].id).toBe("1");
|
|
});
|
|
});
|