Initial release
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import { registerTaskTools } from "../src/tools.ts";
|
||||
|
||||
function registeredTools(state = new TaskState()) {
|
||||
const tools = new Map<string, any>();
|
||||
let changes = 0;
|
||||
const pi = {
|
||||
registerTool(tool: any) {
|
||||
tools.set(tool.name, tool);
|
||||
},
|
||||
};
|
||||
registerTaskTools(pi as never, state, () => {
|
||||
changes += 1;
|
||||
});
|
||||
return { tools, state, changes: () => changes };
|
||||
}
|
||||
|
||||
async function execute(tool: any, params: unknown) {
|
||||
return tool.execute("call", params, undefined, undefined, {});
|
||||
}
|
||||
|
||||
test("registers the five requested tools with minimal top-level parameters", () => {
|
||||
const { tools } = registeredTools();
|
||||
assert.deepEqual([...tools.keys()], ["add_task", "update_task", "rm_task", "list_task", "get_task"]);
|
||||
assert.deepEqual(Object.keys(tools.get("add_task").parameters.properties), ["tasks"]);
|
||||
assert.deepEqual(Object.keys(tools.get("update_task").parameters.properties), ["tasks"]);
|
||||
assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids"]);
|
||||
assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []);
|
||||
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
|
||||
});
|
||||
|
||||
test("mutations return compact content and complete persistence snapshots", async () => {
|
||||
const harness = registeredTools();
|
||||
const result = await execute(harness.tools.get("add_task"), {
|
||||
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
|
||||
});
|
||||
|
||||
assert.equal(result.content[0].text, "Added 2 tasks: 1, 2. 0/2 done.");
|
||||
assert.equal(result.details.snapshot.tasks.length, 2);
|
||||
assert.equal(result.details.snapshot.nextId, 3);
|
||||
assert.equal(harness.changes(), 1);
|
||||
});
|
||||
|
||||
test("tool validation errors are summarized while preserving atomic state", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("add_task"), {
|
||||
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
|
||||
});
|
||||
const before = harness.state.snapshot();
|
||||
|
||||
await assert.rejects(
|
||||
execute(harness.tools.get("update_task"), { tasks: [{ id: 1, parentId: 2 }] }),
|
||||
/Update failed · 1 validation error\n- task [12]: parentId creates a cycle/,
|
||||
);
|
||||
assert.deepEqual(harness.state.snapshot(), before);
|
||||
assert.equal(harness.changes(), 1);
|
||||
});
|
||||
|
||||
test("list is compact and get returns full JSON for multiple IDs", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("add_task"), {
|
||||
tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child", description: "Child detail" }] }],
|
||||
});
|
||||
|
||||
const list = await execute(harness.tools.get("list_task"), {});
|
||||
assert.equal(list.content[0].text, "1 [pending] Parent\n└─ 2 [pending] Child");
|
||||
|
||||
const get = await execute(harness.tools.get("get_task"), { ids: [1, 2] });
|
||||
const parsed = JSON.parse(get.content[0].text);
|
||||
assert.equal(parsed[0].description, "Detailed");
|
||||
assert.deepEqual(parsed[0].childrenIds, [2]);
|
||||
assert.equal(parsed[1].parentId, 1);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { currentTaskLabel } from "../src/herdr.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import {
|
||||
renderProgressHeader,
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
renderTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { updateTaskWidget } from "../src/widget.ts";
|
||||
|
||||
const plainTheme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~${text}~`,
|
||||
};
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function fixture(): TaskState {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Build auth",
|
||||
description: "Auth work",
|
||||
status: "in-progress",
|
||||
children: [
|
||||
{
|
||||
name: "Resolve token issue",
|
||||
description: "Investigate token validation and provider behavior.",
|
||||
status: "blocked",
|
||||
children: [{ name: "Inspect provider response", description: "Inspect response" }],
|
||||
},
|
||||
{ name: "Add middleware", description: "Middleware" },
|
||||
],
|
||||
},
|
||||
{ name: "Define schema", description: "Schema", status: "done" },
|
||||
]);
|
||||
return state;
|
||||
}
|
||||
|
||||
test("renders an adaptive progress bar and stays within width", () => {
|
||||
const stats = fixture().stats();
|
||||
const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40));
|
||||
assert.match(wide, /^ Tasks 1\/5 · !1 ━{4}─{16}$/);
|
||||
assert.ok(visibleWidth(wide) <= 40);
|
||||
|
||||
const narrow = renderProgressHeader(stats, plainTheme as never, 12);
|
||||
assert.ok(visibleWidth(narrow) <= 12);
|
||||
assert.doesNotMatch(stripAnsi(narrow), /━|─/);
|
||||
});
|
||||
|
||||
test("renders lightweight connectors without a detached root continuation", () => {
|
||||
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(output, [
|
||||
" ◉ 1. Build auth",
|
||||
" ├─ ! 2. Resolve token issue",
|
||||
" │ └─ ○ 3. Inspect provider response",
|
||||
" └─ ○ 4. Add middleware",
|
||||
" ✓ 5. ~Define schema~",
|
||||
]);
|
||||
});
|
||||
|
||||
test("truncates every task row at narrow widths", () => {
|
||||
const lines = renderTree(fixture().list(), plainTheme as never, 20);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
|
||||
assert.ok(lines.some((line) => stripAnsi(line).includes("...")));
|
||||
});
|
||||
|
||||
test("uses semantic icon colors and old-plugin text styling", () => {
|
||||
const calls: Array<{ color: string; text: string }> = [];
|
||||
const theme = {
|
||||
fg: (color: string, text: string) => {
|
||||
calls.push({ color, text });
|
||||
return text;
|
||||
},
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~${text}~`,
|
||||
};
|
||||
const tasks = fixture().list();
|
||||
for (const task of tasks) renderTaskLine(task, "", theme as never, 80);
|
||||
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "○"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉"));
|
||||
assert.ok(calls.some((call) => call.color === "error" && call.text === "!"));
|
||||
assert.ok(calls.some((call) => call.color === "success" && call.text === "✓"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth"));
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~"));
|
||||
assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue"));
|
||||
});
|
||||
|
||||
test("selects the most specific in-progress task and compresses parallel leaves", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Child", description: "Child", status: "in-progress" }],
|
||||
},
|
||||
{ name: "Parallel", description: "Parallel", status: "in-progress" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#2 Child +1");
|
||||
});
|
||||
|
||||
test("uses blocked tasks only when nothing is in progress", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{ name: "Blocked one", description: "One", status: "blocked" },
|
||||
{ name: "Blocked two", description: "Two", status: "blocked" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1");
|
||||
state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]);
|
||||
assert.equal(currentTaskLabel(state.list()), null);
|
||||
});
|
||||
|
||||
test("hides an empty widget and renders a populated one", () => {
|
||||
let widget: unknown = "unset";
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
const state = new TaskState();
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(widget, undefined);
|
||||
|
||||
state.add([{ name: "Task", description: "Task" }]);
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(typeof widget, "function");
|
||||
const component = (widget as unknown as (tui: unknown, theme: unknown) => { render(width: number): string[] })(
|
||||
{},
|
||||
plainTheme,
|
||||
);
|
||||
const lines = component.render(30);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||
});
|
||||
|
||||
test("tool results collapse summaries and expand themed details", () => {
|
||||
const state = fixture();
|
||||
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
||||
const result = { content: [{ type: "text", text: "list" }], details };
|
||||
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]);
|
||||
|
||||
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.equal(expanded.length, 6);
|
||||
assert.match(expanded[1] ?? "", /Build auth/);
|
||||
});
|
||||
|
||||
test("tool errors show one summary line until expanded", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }],
|
||||
isError: true,
|
||||
};
|
||||
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]);
|
||||
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]);
|
||||
});
|
||||
|
||||
test("snapshot stats count every task node", () => {
|
||||
assert.deepEqual(snapshotStats(fixture().list()), {
|
||||
total: 5,
|
||||
pending: 2,
|
||||
inProgress: 1,
|
||||
blocked: 1,
|
||||
done: 1,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user