mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 15:31:29 +08:00
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
import { readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { TaskStore } from "../src/task-store.js";
|
|
|
|
// Helper: create a subtask, which can be ticked off directly.
|
|
function createSubtask(store: TaskStore, subject: string) {
|
|
const parent = store.create(`${subject} parent`, "Desc", "done criterion");
|
|
return store.create(
|
|
subject,
|
|
"Desc",
|
|
"done criterion",
|
|
undefined,
|
|
undefined,
|
|
parent.id,
|
|
);
|
|
}
|
|
|
|
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", "criterion 1");
|
|
const t2 = store.create("Second task", "Description 2", "criterion 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");
|
|
expect(t1.done_criterion).toBe("criterion 1");
|
|
});
|
|
|
|
it("creates tasks with optional fields", () => {
|
|
const t = store.create("Task", "Desc", "done criterion", "Running task", {
|
|
key: "value",
|
|
});
|
|
|
|
expect(t.progress_label).toBe("Running task");
|
|
expect(t.metadata).toEqual({ key: "value" });
|
|
});
|
|
|
|
it("gets a task by ID", () => {
|
|
store.create("Test", "Desc", "done");
|
|
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", "done");
|
|
store.create("Task 1", "Desc", "done");
|
|
store.create("Task 2", "Desc", "done");
|
|
|
|
const tasks = store.list();
|
|
expect(tasks.map((t) => t.id)).toEqual(["1", "2", "3"]);
|
|
});
|
|
|
|
it("updates task status", () => {
|
|
store.create("Test", "Desc", "done");
|
|
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", "done");
|
|
const { changedFields } = store.update("1", {
|
|
subject: "Updated subject",
|
|
description: "Updated desc",
|
|
metadata: { owner: "agent-1" },
|
|
});
|
|
|
|
expect(changedFields).toContain("subject");
|
|
expect(changedFields).toContain("description");
|
|
expect(changedFields).toContain("metadata");
|
|
|
|
const task = store.get("1")!;
|
|
expect(task.subject).toBe("Updated subject");
|
|
expect(task.metadata.owner).toBe("agent-1");
|
|
});
|
|
|
|
it("deletes a task with status: deleted", () => {
|
|
store.create("Test", "Desc", "done");
|
|
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", "done");
|
|
store.create("Task 2", "Desc", "done");
|
|
store.update("1", { status: "deleted" });
|
|
|
|
const t3 = store.create("Task 3", "Desc", "done");
|
|
expect(t3.id).toBe("3"); // Not "1" — counter continues
|
|
});
|
|
|
|
it("merges metadata with null key deletion", () => {
|
|
store.create("Test", "Desc", "done", 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 add_blocks", () => {
|
|
store.create("Blocker", "Desc", "done");
|
|
store.create("Blocked", "Desc", "done");
|
|
|
|
store.update("1", { add_blocks: ["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 add_blocked_by", () => {
|
|
store.create("Blocker", "Desc", "done");
|
|
store.create("Blocked", "Desc", "done");
|
|
|
|
store.update("2", { add_blocked_by: ["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", "done");
|
|
store.create("B", "Desc", "done");
|
|
|
|
store.update("1", { add_blocks: ["2"] });
|
|
store.update("1", { add_blocks: ["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", "done");
|
|
store.create("B", "Desc", "done");
|
|
store.update("1", { add_blocks: ["2"] });
|
|
|
|
store.update("1", { status: "deleted" });
|
|
|
|
const t2 = store.get("2")!;
|
|
expect(t2.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("clears completed tasks", () => {
|
|
store.create("Completed", "Desc", "done");
|
|
store.create("Pending", "Desc", "done");
|
|
store.complete("1");
|
|
|
|
const count = store.clearCompleted();
|
|
|
|
expect(count).toBe(1);
|
|
expect(store.list()).toHaveLength(1);
|
|
expect(store.list()[0].id).toBe("2");
|
|
});
|
|
|
|
it("allows TaskUpdate(status=completed) for subtasks", () => {
|
|
createSubtask(store, "Checklist item");
|
|
const { task, changedFields } = store.update("2", { status: "completed" });
|
|
expect(task!.status).toBe("completed");
|
|
expect(changedFields).toContain("status");
|
|
});
|
|
|
|
it("blocks TaskUpdate(status=completed) for top-level tasks", () => {
|
|
store.create("Goal", "Desc", "done");
|
|
expect(() => store.update("1", { status: "completed" })).toThrow(
|
|
"Top-level task #1 requires proof",
|
|
);
|
|
});
|
|
|
|
it("keeps top-level completion gated even after proof evidence exists", () => {
|
|
store.create("Escalated", "Desc", "done");
|
|
store.update("1", { metadata: { lgtm_evidence: "literal output" } });
|
|
expect(() => store.update("1", { status: "completed" })).toThrow(
|
|
"TaskClaimDone",
|
|
);
|
|
});
|
|
|
|
it("rejects changing parentId after creation", () => {
|
|
store.create("Parent", "Desc", "done");
|
|
store.create("Child", "Desc", "done");
|
|
expect(() => store.update("2", { parentId: "1" })).toThrow(
|
|
"parentId is creation-only",
|
|
);
|
|
});
|
|
|
|
it("returns not found for update on non-existent task", () => {
|
|
const { task, changedFields } = store.update("999", {
|
|
status: "in_progress",
|
|
});
|
|
expect(task).toBeUndefined();
|
|
expect(changedFields).toEqual([]);
|
|
});
|
|
|
|
it("complete() is the internal proof-review completion path", () => {
|
|
store.create("Test", "Desc", "done");
|
|
const task = store.complete("1");
|
|
expect(task.status).toBe("completed");
|
|
});
|
|
|
|
it("complete() also works for subtasks", () => {
|
|
createSubtask(store, "Test");
|
|
const task = store.complete("2");
|
|
expect(task.status).toBe("completed");
|
|
});
|
|
|
|
it("complete() throws on non-existent task", () => {
|
|
expect(() => store.complete("999")).toThrow("not found");
|
|
});
|
|
|
|
it("delete method works", () => {
|
|
store.create("Test", "Desc", "done");
|
|
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", "done", 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", "done");
|
|
store.create("B", "Desc", "done");
|
|
store.update("1", { add_blocks: ["2"] });
|
|
const { warnings } = store.update("2", { add_blocks: ["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", "done");
|
|
const { warnings } = store.update("1", { add_blocks: ["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", "done");
|
|
const { warnings } = store.update("1", { add_blocks: ["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", "done");
|
|
store.create("B", "Desc", "done");
|
|
const { warnings } = store.update("1", { add_blocks: ["2"] });
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
it("accepts whitespace-only subjects (matches Claude Code)", () => {
|
|
const t = store.create(" ", "Desc", "done");
|
|
expect(t.subject).toBe(" ");
|
|
});
|
|
|
|
it("updates progress_label field", () => {
|
|
store.create("Test", "Desc", "done");
|
|
const { changedFields } = store.update("1", {
|
|
progress_label: "Running tests",
|
|
});
|
|
expect(changedFields).toContain("progress_label");
|
|
expect(store.get("1")!.progress_label).toBe("Running tests");
|
|
});
|
|
|
|
it("updates description field", () => {
|
|
store.create("Test", "Original desc", "done");
|
|
const { changedFields } = store.update("1", {
|
|
description: "Updated desc",
|
|
});
|
|
expect(changedFields).toContain("description");
|
|
expect(store.get("1")!.description).toBe("Updated desc");
|
|
});
|
|
|
|
it("updates done_criterion field", () => {
|
|
store.create("Test", "Desc", "original criterion");
|
|
const { changedFields } = store.update("1", {
|
|
done_criterion: "updated criterion",
|
|
});
|
|
expect(changedFields).toContain("done_criterion");
|
|
expect(store.get("1")!.done_criterion).toBe("updated criterion");
|
|
});
|
|
|
|
it("returns empty changedFields when updating non-existent task", () => {
|
|
const { task, changedFields, warnings } = store.update("999", {
|
|
status: "in_progress",
|
|
});
|
|
expect(task).toBeUndefined();
|
|
expect(changedFields).toEqual([]);
|
|
expect(warnings).toEqual([]);
|
|
});
|
|
|
|
it("clearCompleted cleans up dependency edges", () => {
|
|
store.create("Blocker", "Desc", "done");
|
|
store.create("Blocked", "Desc", "done");
|
|
store.update("1", { add_blocks: ["2"] });
|
|
// complete() is the internal proof-review completion path.
|
|
store.complete("1");
|
|
|
|
store.clearCompleted();
|
|
|
|
const t2 = store.get("2")!;
|
|
expect(t2.blockedBy).toEqual([]);
|
|
});
|
|
|
|
it("handles multiple add_blocks in one call", () => {
|
|
store.create("Blocker", "Desc", "done");
|
|
store.create("B1", "Desc", "done");
|
|
store.create("B2", "Desc", "done");
|
|
|
|
store.update("1", { add_blocks: ["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("add_blocked_by warns on self-dependency", () => {
|
|
store.create("Self", "Desc", "done");
|
|
const { warnings } = store.update("1", { add_blocked_by: ["1"] });
|
|
expect(store.get("1")!.blockedBy).toContain("1");
|
|
expect(warnings).toContain("#1 blocks itself");
|
|
});
|
|
|
|
it("add_blocked_by warns on dangling ref", () => {
|
|
store.create("Real", "Desc", "done");
|
|
const { warnings } = store.update("1", { add_blocked_by: ["9999"] });
|
|
expect(store.get("1")!.blockedBy).toContain("9999");
|
|
expect(warnings).toContain("#9999 does not exist");
|
|
});
|
|
|
|
it("add_blocked_by warns on cycle", () => {
|
|
store.create("A", "Desc", "done");
|
|
store.create("B", "Desc", "done");
|
|
store.update("1", { add_blocks: ["2"] });
|
|
const { warnings } = store.update("1", { add_blocked_by: ["2"] });
|
|
expect(warnings).toContain("cycle: #1 and #2 block each other");
|
|
});
|
|
|
|
it("clearCompleted returns 0 when no completed tasks", () => {
|
|
store.create("Pending", "Desc", "done");
|
|
expect(store.clearCompleted()).toBe(0);
|
|
});
|
|
|
|
it("list sorts pending → in_progress → completed with all three present", () => {
|
|
store.create("Pending task", "Desc", "done");
|
|
store.create("Completed task", "Desc", "done");
|
|
store.create("In-progress task", "Desc", "done");
|
|
store.create("Another pending", "Desc", "done");
|
|
|
|
store.complete("2");
|
|
store.update("3", { status: "in_progress" });
|
|
|
|
const tasks = store.list();
|
|
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(process.cwd(), ".pi", "tasks");
|
|
const filePath = join(tasksDir, `${testListId}.json`);
|
|
|
|
afterEach(() => {
|
|
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", "done");
|
|
|
|
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", "done");
|
|
store1.update("1", { status: "in_progress" });
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
expect(store2.get("1")!.status).toBe("in_progress");
|
|
});
|
|
|
|
it("persists completed tasks to disk", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Done task", "Desc", "done");
|
|
store1.create("Pending task", "Desc", "done");
|
|
store1.complete("1");
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
expect(store2.get("1")).toBeDefined();
|
|
expect(store2.get("1")!.status).toBe("completed");
|
|
expect(store2.get("2")).toBeDefined();
|
|
expect(store2.list()).toHaveLength(2);
|
|
});
|
|
|
|
it("restores all tasks across instances", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Pending", "Desc", "done");
|
|
store1.create("In progress", "Desc", "done");
|
|
store1.create("Done", "Desc", "done");
|
|
store1.update("2", { status: "in_progress" });
|
|
store1.complete("3");
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
const tasks = store2.list();
|
|
expect(tasks).toHaveLength(3);
|
|
expect(tasks.map((t) => t.id)).toContain("1");
|
|
expect(tasks.map((t) => t.id)).toContain("2");
|
|
expect(tasks.map((t) => t.id)).toContain("3");
|
|
});
|
|
|
|
it("persists ID counter across instances", () => {
|
|
const store1 = new TaskStore(testListId);
|
|
store1.create("Task 1", "Desc", "done");
|
|
store1.create("Task 2", "Desc", "done");
|
|
|
|
const store2 = new TaskStore(testListId);
|
|
const t3 = store2.create("Task 3", "Desc", "done");
|
|
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", "done");
|
|
|
|
const store2 = new TaskStore(absFilePath);
|
|
expect(store2.list()).toHaveLength(1);
|
|
expect(store2.list()[0].subject).toBe("Abs path task");
|
|
});
|
|
|
|
it("persists completed tasks when using absolute path", () => {
|
|
const store1 = new TaskStore(absFilePath);
|
|
store1.create("Pending", "Desc", "done");
|
|
store1.create("Completed", "Desc", "done");
|
|
store1.complete("2");
|
|
|
|
const raw = JSON.parse(readFileSync(absFilePath, "utf-8"));
|
|
expect(raw.tasks).toHaveLength(2);
|
|
});
|
|
});
|