Files
pi-todo-herdr/tests/state.test.ts
T
2026-07-22 23:35:04 +08:00

179 lines
6.0 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { TaskState } from "../src/state.ts";
import { TaskValidationError } from "../src/types.ts";
function names(state: TaskState): string[] {
return state.list().filter((task) => task.parentId === null).map((task) => task.name);
}
test("adds a nested batch with generated preorder IDs and derived relations", () => {
const state = new TaskState();
const added = state.add([
{
name: "Parent",
description: "Parent description",
children: [
{ name: "First child", description: "First" },
{ name: "Second child", description: "Second", status: "blocked" },
],
},
]);
assert.deepEqual(added.map((task) => task.id), [1, 2, 3]);
assert.deepEqual(state.get([1])[0]?.childrenIds, [2, 3]);
assert.equal(state.get([2])[0]?.parentId, 1);
assert.equal(state.get([2])[0]?.status, "pending");
assert.equal(state.snapshot().nextId, 4);
});
test("rejects invalid nested parentId atomically", () => {
const state = new TaskState();
state.add([{ name: "Existing", description: "Existing" }]);
const before = state.snapshot();
assert.throws(
() =>
state.add([
{
name: "Parent",
description: "Parent",
children: [{ name: "Child", description: "Child", parentId: 1 }],
},
]),
TaskValidationError,
);
assert.deepEqual(state.snapshot(), before);
});
test("enforces done descendants against the final atomic update state", () => {
const state = new TaskState();
state.add([{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }]);
const before = state.snapshot();
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
assert.deepEqual(state.snapshot(), before);
state.update([
{ id: 2, status: "done" },
{ id: 1, status: "done" },
]);
assert.deepEqual(state.list().map((task) => task.status), ["done", "done"]);
assert.throws(() => state.update([{ id: 2, status: "in-progress" }]), /unfinished descendants/);
state.update([
{ id: 1, status: "in-progress" },
{ id: 2, status: "in-progress" },
]);
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
});
test("rejects cycles and duplicate patches without partial changes", () => {
const state = new TaskState();
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
const before = state.snapshot();
assert.throws(() => state.update([{ id: 1, parentId: 2 }, { id: 2, name: "Changed" }]), /cycle/);
assert.deepEqual(state.snapshot(), before);
assert.throws(() => state.update([{ id: 2, name: "One" }, { id: 2, name: "Two" }]), /duplicate task patches/);
assert.deepEqual(state.snapshot(), before);
});
test("reorders roots with zero-based sequential positions", () => {
const state = new TaskState();
state.add([
{ name: "A", description: "A" },
{ name: "B", description: "B" },
{ name: "C", description: "C" },
]);
state.update([{ id: 1, position: 2 }]);
assert.deepEqual(names(state), ["B", "C", "A"]);
state.update([
{ id: 1, position: 0 },
{ id: 2, position: 2 },
]);
assert.deepEqual(names(state), ["A", "C", "B"]);
});
test("reparents and positions tasks among their final siblings", () => {
const state = new TaskState();
state.add([
{
name: "One",
description: "One",
children: [
{ name: "A", description: "A" },
{ name: "B", description: "B" },
],
},
{ name: "Two", description: "Two", children: [{ name: "C", description: "C" }] },
]);
state.update([{ id: 3, parentId: 4, position: 0 }]);
assert.deepEqual(state.get([4])[0]?.childrenIds, [3, 5]);
assert.deepEqual(state.get([1])[0]?.childrenIds, [2]);
});
test("removes overlapping parent and child IDs as one cascade", () => {
const state = new TaskState();
state.add([
{
name: "Root",
description: "Root",
children: [{ name: "Child", description: "Child", children: [{ name: "Leaf", description: "Leaf" }] }],
},
{ name: "Keep", description: "Keep" },
]);
const removed = state.remove([1, 2, 1]);
assert.deepEqual(removed.map((task) => task.id), [1, 2, 3]);
assert.deepEqual(state.list().map((task) => task.id), [4]);
assert.equal(state.snapshot().nextId, 5);
const before = state.snapshot();
assert.throws(() => state.remove([999]), /do not exist/);
assert.deepEqual(state.snapshot(), before);
});
test("restores snapshots and never reuses deleted IDs", () => {
const state = new TaskState();
state.add([{ name: "One", description: "One" }, { name: "Two", description: "Two" }]);
state.remove([2]);
const snapshot = state.snapshot();
const restored = new TaskState();
restored.restore(snapshot);
const added = restored.add([{ name: "Three", description: "Three" }]);
assert.equal(added[0]?.id, 3);
});
test("loads the latest valid mutation snapshot from a session branch", () => {
const first = new TaskState();
first.add([{ name: "First", description: "First" }]);
const second = new TaskState();
second.restore(first.snapshot());
second.add([{ name: "Second", description: "Second" }]);
const branch = [first.snapshot(), second.snapshot()].map((snapshot, index) => ({
type: "message",
message: {
role: "toolResult",
toolName: index === 0 ? "add_task" : "update_task",
details: { action: "add", snapshot, affected: [] },
},
}));
const state = new TaskState();
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("rejects multiline names while allowing multiline descriptions", () => {
const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
state.add([{ name: "Valid", description: "Line one\nLine two" }]);
assert.equal(state.list()[0]?.description, "Line one\nLine two");
});