179 lines
6.0 KiB
TypeScript
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");
|
|
});
|