@xynogen/pix-todo 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/todo.test.ts +0 -1012
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-todo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Pi tool — durable execution checklist (todo)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"src",
|
|
12
|
+
"!src/**/*.test.*",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
package/src/todo.test.ts
DELETED
|
@@ -1,1012 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import registerTodo, { renderTodoLines, renderTodoSummaryLine, type TodoItem } from "./todo.ts";
|
|
3
|
-
|
|
4
|
-
// registerTodo wraps its body in once(pi, "pix-todo") — a per-instance
|
|
5
|
-
// WeakMap guard that dedupes activation across pix-core + a standalone install.
|
|
6
|
-
// Tests re-register a fresh host per case. Clear the registry between tests so
|
|
7
|
-
// that the same pi object can be re-used without cross-test interference.
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
// Stub theme tags each fragment with its color/bold so assertions can verify
|
|
13
|
-
// which status got which tint, without depending on real ANSI codes.
|
|
14
|
-
const tagTheme = {
|
|
15
|
-
fg: (color: string, text: string) => `[${color}]${text}[/]`,
|
|
16
|
-
bold: (text: string) => `<b>${text}</b>`,
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
/** Create a mock ExtensionAPI that captures the registered tool's execute fn. */
|
|
22
|
-
function makeHost(
|
|
23
|
-
initialEntries: Array<{
|
|
24
|
-
type: string;
|
|
25
|
-
customType?: string;
|
|
26
|
-
data?: unknown;
|
|
27
|
-
}> = [],
|
|
28
|
-
) {
|
|
29
|
-
let capturedParameters: unknown;
|
|
30
|
-
let capturedRenderShell: unknown;
|
|
31
|
-
let capturedExecute:
|
|
32
|
-
| ((
|
|
33
|
-
id: string,
|
|
34
|
-
params: Record<string, unknown>,
|
|
35
|
-
) => Promise<{
|
|
36
|
-
content: Array<{ type: string; text: string }>;
|
|
37
|
-
details?: unknown;
|
|
38
|
-
isError?: boolean;
|
|
39
|
-
}>)
|
|
40
|
-
| null = null;
|
|
41
|
-
const appendCalls: Array<{ type: string; data: unknown }> = [];
|
|
42
|
-
const handlers: Record<string, Array<(event: unknown, ctx?: unknown) => unknown>> = {};
|
|
43
|
-
|
|
44
|
-
let capturedRender:
|
|
45
|
-
| ((
|
|
46
|
-
result: unknown,
|
|
47
|
-
options: unknown,
|
|
48
|
-
theme: unknown,
|
|
49
|
-
context: unknown,
|
|
50
|
-
) => { render(width: number): string[] })
|
|
51
|
-
| null = null;
|
|
52
|
-
let capturedRenderCall:
|
|
53
|
-
| ((args: unknown, theme: unknown, context: unknown) => { render(width: number): string[] })
|
|
54
|
-
| null = null;
|
|
55
|
-
|
|
56
|
-
const pi = {
|
|
57
|
-
registerTool(def: {
|
|
58
|
-
name: string;
|
|
59
|
-
parameters: unknown;
|
|
60
|
-
execute: typeof capturedExecute;
|
|
61
|
-
renderShell?: unknown;
|
|
62
|
-
renderCall?: typeof capturedRenderCall;
|
|
63
|
-
renderResult?: typeof capturedRender;
|
|
64
|
-
}) {
|
|
65
|
-
capturedParameters = def.parameters;
|
|
66
|
-
capturedRenderShell = def.renderShell;
|
|
67
|
-
capturedExecute = def.execute;
|
|
68
|
-
if (def.renderCall) capturedRenderCall = def.renderCall;
|
|
69
|
-
if (def.renderResult) capturedRender = def.renderResult;
|
|
70
|
-
},
|
|
71
|
-
appendEntry(type: string, data: unknown) {
|
|
72
|
-
appendCalls.push({ type, data });
|
|
73
|
-
},
|
|
74
|
-
on(ev: string, fn: (event: unknown, ctx?: unknown) => unknown) {
|
|
75
|
-
if (!handlers[ev]) handlers[ev] = [];
|
|
76
|
-
handlers[ev].push(fn);
|
|
77
|
-
},
|
|
78
|
-
async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
|
|
79
|
-
let last: unknown;
|
|
80
|
-
for (const fn of handlers[ev] ?? []) {
|
|
81
|
-
const result = await fn(event, ctx);
|
|
82
|
-
if (result !== undefined) last = result;
|
|
83
|
-
}
|
|
84
|
-
return last;
|
|
85
|
-
},
|
|
86
|
-
} as never;
|
|
87
|
-
|
|
88
|
-
const sessionManager = {
|
|
89
|
-
getEntries() {
|
|
90
|
-
return initialEntries;
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
return {
|
|
95
|
-
pi,
|
|
96
|
-
sessionManager,
|
|
97
|
-
get parameters() {
|
|
98
|
-
if (!capturedParameters) throw new Error("parameters not captured");
|
|
99
|
-
return capturedParameters;
|
|
100
|
-
},
|
|
101
|
-
get execute() {
|
|
102
|
-
if (!capturedExecute) throw new Error("execute not captured");
|
|
103
|
-
return capturedExecute;
|
|
104
|
-
},
|
|
105
|
-
get renderShell() {
|
|
106
|
-
return capturedRenderShell;
|
|
107
|
-
},
|
|
108
|
-
get renderCall() {
|
|
109
|
-
if (!capturedRenderCall) throw new Error("renderCall not captured");
|
|
110
|
-
return capturedRenderCall;
|
|
111
|
-
},
|
|
112
|
-
get render() {
|
|
113
|
-
if (!capturedRender) throw new Error("render not captured");
|
|
114
|
-
return capturedRender;
|
|
115
|
-
},
|
|
116
|
-
appendCalls,
|
|
117
|
-
async emit(ev: string, event?: unknown, ctx?: unknown): Promise<unknown> {
|
|
118
|
-
let last: unknown;
|
|
119
|
-
for (const fn of handlers[ev] ?? []) {
|
|
120
|
-
const result = await fn(event, ctx);
|
|
121
|
-
if (result !== undefined) last = result;
|
|
122
|
-
}
|
|
123
|
-
return last;
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function run(
|
|
129
|
-
execute: (
|
|
130
|
-
id: string,
|
|
131
|
-
params: Record<string, unknown>,
|
|
132
|
-
) => Promise<{
|
|
133
|
-
content: Array<{ type: string; text: string }>;
|
|
134
|
-
details?: unknown;
|
|
135
|
-
isError?: boolean;
|
|
136
|
-
}>,
|
|
137
|
-
params: Record<string, unknown>,
|
|
138
|
-
) {
|
|
139
|
-
return execute("call-1", params);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function text(result: { content: Array<{ type: string; text: string }> }) {
|
|
143
|
-
return result.content.map((c) => c.text).join("\n");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ─── Tool schema ────────────────────────────────────────────────────────────
|
|
147
|
-
|
|
148
|
-
test("todo exposes action and status as guided string enums", () => {
|
|
149
|
-
const host = makeHost();
|
|
150
|
-
registerTodo(host.pi);
|
|
151
|
-
const schema = host.parameters as {
|
|
152
|
-
properties: {
|
|
153
|
-
action: { type?: string; enum?: string[]; description?: string };
|
|
154
|
-
status: { type?: string; enum?: string[]; description?: string };
|
|
155
|
-
};
|
|
156
|
-
};
|
|
157
|
-
const action = schema.properties.action;
|
|
158
|
-
const status = schema.properties.status;
|
|
159
|
-
|
|
160
|
-
expect(action.type).toBe("string");
|
|
161
|
-
expect(action.enum).toEqual(["list", "set", "add", "update", "clear"]);
|
|
162
|
-
expect(action.description).toContain('"list" shows items');
|
|
163
|
-
expect(action.description).toContain('"update" changes one item by id');
|
|
164
|
-
expect(status?.type).toBe("string");
|
|
165
|
-
expect(status?.enum).toEqual(["pending", "in_progress", "done", "blocked"]);
|
|
166
|
-
expect(status?.description).toContain('"pending" = not started');
|
|
167
|
-
expect(status?.description).toContain('"blocked" = cannot proceed');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ─── parseItems (via set/add) ───────────────────────────────────────────────
|
|
171
|
-
|
|
172
|
-
describe("todo actions", () => {
|
|
173
|
-
test("list on empty returns (no todos)", async () => {
|
|
174
|
-
const host = makeHost();
|
|
175
|
-
registerTodo(host.pi);
|
|
176
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
177
|
-
const result = await run(host.execute, { action: "list" });
|
|
178
|
-
expect(text(result)).toBe("(no todos)");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("set creates items from newline text", async () => {
|
|
182
|
-
const host = makeHost();
|
|
183
|
-
registerTodo(host.pi);
|
|
184
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
185
|
-
const result = await run(host.execute, {
|
|
186
|
-
action: "set",
|
|
187
|
-
items: "alpha\nbravo\ncharlie",
|
|
188
|
-
});
|
|
189
|
-
const out = text(result);
|
|
190
|
-
expect(out).toContain("Todos 0/3 done");
|
|
191
|
-
expect(out).toContain("○ 1. alpha");
|
|
192
|
-
expect(out).toContain("○ 2. bravo");
|
|
193
|
-
expect(out).toContain("○ 3. charlie");
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("set creates items from numbered list", async () => {
|
|
197
|
-
const host = makeHost();
|
|
198
|
-
registerTodo(host.pi);
|
|
199
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
200
|
-
const result = await run(host.execute, {
|
|
201
|
-
action: "set",
|
|
202
|
-
items: "1. alpha\n2. bravo",
|
|
203
|
-
});
|
|
204
|
-
expect(text(result)).toContain("○ 1. alpha");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("set creates items from bullet list", async () => {
|
|
208
|
-
const host = makeHost();
|
|
209
|
-
registerTodo(host.pi);
|
|
210
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
211
|
-
const result = await run(host.execute, {
|
|
212
|
-
action: "set",
|
|
213
|
-
items: "- alpha\n* bravo",
|
|
214
|
-
});
|
|
215
|
-
expect(text(result)).toContain("○ 1. alpha");
|
|
216
|
-
expect(text(result)).toContain("○ 2. bravo");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
test("set ignores empty lines", async () => {
|
|
220
|
-
const host = makeHost();
|
|
221
|
-
registerTodo(host.pi);
|
|
222
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
223
|
-
const result = await run(host.execute, {
|
|
224
|
-
action: "set",
|
|
225
|
-
items: "alpha\n\nbravo\n \ncharlie",
|
|
226
|
-
});
|
|
227
|
-
expect(text(result)).toContain("Todos 0/3 done");
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test("set with empty items returns error", async () => {
|
|
231
|
-
const host = makeHost();
|
|
232
|
-
registerTodo(host.pi);
|
|
233
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
234
|
-
const result = await run(host.execute, { action: "set", items: "" });
|
|
235
|
-
expect(result.isError).toBe(true);
|
|
236
|
-
expect(text(result)).toContain("non-empty");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test("set with only whitespace returns error", async () => {
|
|
240
|
-
const host = makeHost();
|
|
241
|
-
registerTodo(host.pi);
|
|
242
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
243
|
-
const result = await run(host.execute, { action: "set", items: " \n " });
|
|
244
|
-
expect(result.isError).toBe(true);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("set resets ids on re-set", async () => {
|
|
248
|
-
const host = makeHost();
|
|
249
|
-
registerTodo(host.pi);
|
|
250
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
251
|
-
await run(host.execute, { action: "set", items: "first\nsecond" });
|
|
252
|
-
const result = await run(host.execute, { action: "set", items: "new" });
|
|
253
|
-
expect(text(result)).toContain("○ 1. new");
|
|
254
|
-
expect(text(result)).toContain("Todos 0/1 done");
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("add appends items", async () => {
|
|
258
|
-
const host = makeHost();
|
|
259
|
-
registerTodo(host.pi);
|
|
260
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
261
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
262
|
-
const result = await run(host.execute, {
|
|
263
|
-
action: "add",
|
|
264
|
-
items: "bravo\ncharlie",
|
|
265
|
-
});
|
|
266
|
-
const out = text(result);
|
|
267
|
-
expect(out).toContain("○ 1. alpha");
|
|
268
|
-
expect(out).toContain("○ 2. bravo");
|
|
269
|
-
expect(out).toContain("○ 3. charlie");
|
|
270
|
-
expect(out).toContain("Todos 0/3 done");
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
test("add with ids continuing sequence", async () => {
|
|
274
|
-
const host = makeHost();
|
|
275
|
-
registerTodo(host.pi);
|
|
276
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
277
|
-
await run(host.execute, { action: "set", items: "first\nsecond\nthird" });
|
|
278
|
-
const result = await run(host.execute, { action: "add", items: "fourth" });
|
|
279
|
-
expect(text(result)).toContain("○ 4. fourth");
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("add with empty items returns error", async () => {
|
|
283
|
-
const host = makeHost();
|
|
284
|
-
registerTodo(host.pi);
|
|
285
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
286
|
-
const result = await run(host.execute, { action: "add", items: "" });
|
|
287
|
-
expect(result.isError).toBe(true);
|
|
288
|
-
expect(text(result)).toContain("non-empty");
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test("update changes status", async () => {
|
|
292
|
-
const host = makeHost();
|
|
293
|
-
registerTodo(host.pi);
|
|
294
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
295
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
296
|
-
const result = await run(host.execute, {
|
|
297
|
-
action: "update",
|
|
298
|
-
id: 1,
|
|
299
|
-
status: "done",
|
|
300
|
-
});
|
|
301
|
-
const out = text(result);
|
|
302
|
-
expect(out).toContain("● 1. alpha");
|
|
303
|
-
expect(out).toContain("○ 2. bravo");
|
|
304
|
-
expect(out).toContain("Todos 1/2 done");
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("update changes text", async () => {
|
|
308
|
-
const host = makeHost();
|
|
309
|
-
registerTodo(host.pi);
|
|
310
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
311
|
-
await run(host.execute, { action: "set", items: "old name" });
|
|
312
|
-
const result = await run(host.execute, {
|
|
313
|
-
action: "update",
|
|
314
|
-
id: 1,
|
|
315
|
-
text: "new name",
|
|
316
|
-
});
|
|
317
|
-
expect(text(result)).toContain("○ 1. new name");
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
test("update changes status and text together", async () => {
|
|
321
|
-
const host = makeHost();
|
|
322
|
-
registerTodo(host.pi);
|
|
323
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
324
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
325
|
-
const result = await run(host.execute, {
|
|
326
|
-
action: "update",
|
|
327
|
-
id: 1,
|
|
328
|
-
status: "blocked",
|
|
329
|
-
text: "alpha (waiting)",
|
|
330
|
-
});
|
|
331
|
-
const out = text(result);
|
|
332
|
-
expect(out).toContain("⊘ 1. alpha (waiting)");
|
|
333
|
-
expect(out).toContain("Todos 0/1 done");
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("opening a new in_progress closes the previous one", async () => {
|
|
337
|
-
const host = makeHost();
|
|
338
|
-
registerTodo(host.pi);
|
|
339
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
340
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
341
|
-
await run(host.execute, { action: "update", id: 1, status: "in_progress" });
|
|
342
|
-
const result = await run(host.execute, {
|
|
343
|
-
action: "update",
|
|
344
|
-
id: 2,
|
|
345
|
-
status: "in_progress",
|
|
346
|
-
});
|
|
347
|
-
const out = text(result);
|
|
348
|
-
expect(out).toContain("● 1. a"); // auto-closed to done
|
|
349
|
-
expect(out).toContain("◐ 2. b"); // now active
|
|
350
|
-
expect(out).toContain("○ 3. c");
|
|
351
|
-
expect(out).toContain("Todos 1/3 done");
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("opening a later item cascade-closes skipped pending items", async () => {
|
|
355
|
-
const host = makeHost();
|
|
356
|
-
registerTodo(host.pi);
|
|
357
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
358
|
-
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
359
|
-
// Jump straight to id 4 without opening 1-3; they should all auto-close.
|
|
360
|
-
const result = await run(host.execute, {
|
|
361
|
-
action: "update",
|
|
362
|
-
id: 4,
|
|
363
|
-
status: "in_progress",
|
|
364
|
-
});
|
|
365
|
-
const out = text(result);
|
|
366
|
-
expect(out).toContain("● 1. a");
|
|
367
|
-
expect(out).toContain("● 2. b");
|
|
368
|
-
expect(out).toContain("● 3. c");
|
|
369
|
-
expect(out).toContain("◐ 4. d");
|
|
370
|
-
expect(out).toContain("Todos 3/4 done");
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
test("cascade-close leaves a blocked earlier item untouched", async () => {
|
|
374
|
-
const host = makeHost();
|
|
375
|
-
registerTodo(host.pi);
|
|
376
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
377
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
378
|
-
await run(host.execute, { action: "update", id: 1, status: "blocked" });
|
|
379
|
-
const result = await run(host.execute, {
|
|
380
|
-
action: "update",
|
|
381
|
-
id: 3,
|
|
382
|
-
status: "in_progress",
|
|
383
|
-
});
|
|
384
|
-
const out = text(result);
|
|
385
|
-
expect(out).toContain("⊘ 1. a"); // still blocked, not force-closed
|
|
386
|
-
expect(out).toContain("● 2. b"); // pending -> done
|
|
387
|
-
expect(out).toContain("◐ 3. c");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("update unknown id returns error", async () => {
|
|
391
|
-
const host = makeHost();
|
|
392
|
-
registerTodo(host.pi);
|
|
393
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
394
|
-
const result = await run(host.execute, {
|
|
395
|
-
action: "update",
|
|
396
|
-
id: 999,
|
|
397
|
-
status: "done",
|
|
398
|
-
});
|
|
399
|
-
expect(result.isError).toBe(true);
|
|
400
|
-
expect(text(result)).toContain("999");
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
test("update without status or text does nothing", async () => {
|
|
404
|
-
const host = makeHost();
|
|
405
|
-
registerTodo(host.pi);
|
|
406
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
407
|
-
await run(host.execute, { action: "set", items: "unchanged" });
|
|
408
|
-
const result = await run(host.execute, { action: "update", id: 1 });
|
|
409
|
-
expect(text(result)).toContain("○ 1. unchanged");
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
test("clear empties list", async () => {
|
|
413
|
-
const host = makeHost();
|
|
414
|
-
registerTodo(host.pi);
|
|
415
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
416
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
417
|
-
const result = await run(host.execute, { action: "clear" });
|
|
418
|
-
expect(text(result)).toContain("Todos cleared");
|
|
419
|
-
// next list should show empty
|
|
420
|
-
const list = await run(host.execute, { action: "list" });
|
|
421
|
-
expect(text(list)).toBe("(no todos)");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test("clear resets id counter", async () => {
|
|
425
|
-
const host = makeHost();
|
|
426
|
-
registerTodo(host.pi);
|
|
427
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
428
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo\ncharlie" });
|
|
429
|
-
await run(host.execute, { action: "clear" });
|
|
430
|
-
const result = await run(host.execute, { action: "set", items: "new" });
|
|
431
|
-
expect(text(result)).toContain("○ 1. new");
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
test("unknown action returns error", async () => {
|
|
435
|
-
const host = makeHost();
|
|
436
|
-
registerTodo(host.pi);
|
|
437
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
438
|
-
const result = await run(host.execute, { action: "bogus" });
|
|
439
|
-
expect(result.isError).toBe(true);
|
|
440
|
-
expect(text(result)).toContain("Unknown action");
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
test("all status glyphs render correctly", async () => {
|
|
444
|
-
const host = makeHost();
|
|
445
|
-
registerTodo(host.pi);
|
|
446
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
447
|
-
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
448
|
-
// Open id 2 first (cascade-closes nothing earlier we assert on), then set
|
|
449
|
-
// the others directly so each glyph is exercised without cascade interfering.
|
|
450
|
-
await run(host.execute, { action: "update", id: 2, status: "in_progress" });
|
|
451
|
-
await run(host.execute, { action: "update", id: 1, status: "pending" });
|
|
452
|
-
await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
453
|
-
await run(host.execute, { action: "update", id: 4, status: "blocked" });
|
|
454
|
-
const out = text(await run(host.execute, { action: "list" }));
|
|
455
|
-
expect(out).toContain("○ 1. a");
|
|
456
|
-
expect(out).toContain("◐ 2. b");
|
|
457
|
-
expect(out).toContain("● 3. c");
|
|
458
|
-
expect(out).toContain("⊘ 4. d");
|
|
459
|
-
expect(out).toContain("Todos 1/4 done");
|
|
460
|
-
});
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
// ─── Persistence ────────────────────────────────────────────────────────────
|
|
464
|
-
|
|
465
|
-
describe("persistence", () => {
|
|
466
|
-
type AppendCall = { type: string; data: unknown };
|
|
467
|
-
test("set persists todos", async () => {
|
|
468
|
-
const host = makeHost();
|
|
469
|
-
registerTodo(host.pi);
|
|
470
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
471
|
-
host.appendCalls.length = 0;
|
|
472
|
-
await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
473
|
-
expect(host.appendCalls.length).toBe(1);
|
|
474
|
-
const ac0 = host.appendCalls[0] as AppendCall;
|
|
475
|
-
expect(ac0.type).toBe("todo-state");
|
|
476
|
-
const data = ac0.data as {
|
|
477
|
-
todos: Array<{ id: number; text: string; status: string }>;
|
|
478
|
-
nextTodoId: number;
|
|
479
|
-
};
|
|
480
|
-
expect(data.todos).toHaveLength(2);
|
|
481
|
-
expect(data.nextTodoId).toBe(3);
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
test("add persists todos", async () => {
|
|
485
|
-
const host = makeHost();
|
|
486
|
-
registerTodo(host.pi);
|
|
487
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
488
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
489
|
-
host.appendCalls.length = 0;
|
|
490
|
-
await run(host.execute, { action: "add", items: "bravo" });
|
|
491
|
-
expect(host.appendCalls.length).toBe(1);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
test("update persists todos", async () => {
|
|
495
|
-
const host = makeHost();
|
|
496
|
-
registerTodo(host.pi);
|
|
497
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
498
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
499
|
-
host.appendCalls.length = 0;
|
|
500
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
501
|
-
expect(host.appendCalls.length).toBe(1);
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
test("clear persists", async () => {
|
|
505
|
-
const host = makeHost();
|
|
506
|
-
registerTodo(host.pi);
|
|
507
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
508
|
-
await run(host.execute, { action: "set", items: "alpha" });
|
|
509
|
-
host.appendCalls.length = 0;
|
|
510
|
-
await run(host.execute, { action: "clear" });
|
|
511
|
-
expect(host.appendCalls.length).toBe(1);
|
|
512
|
-
const data = (host.appendCalls[0] as AppendCall).data as {
|
|
513
|
-
todos: Array<unknown>;
|
|
514
|
-
nextTodoId: number;
|
|
515
|
-
};
|
|
516
|
-
expect(data.todos).toEqual([]);
|
|
517
|
-
expect(data.nextTodoId).toBe(1);
|
|
518
|
-
});
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
// ─── Session restore ────────────────────────────────────────────────────────
|
|
522
|
-
|
|
523
|
-
describe("restore", () => {
|
|
524
|
-
test("restores todos from last todo-state entry", async () => {
|
|
525
|
-
const host = makeHost([
|
|
526
|
-
{
|
|
527
|
-
type: "custom",
|
|
528
|
-
customType: "todo-state",
|
|
529
|
-
data: {
|
|
530
|
-
todos: [{ id: 1, text: "restored", status: "done" }],
|
|
531
|
-
nextTodoId: 2,
|
|
532
|
-
},
|
|
533
|
-
},
|
|
534
|
-
]);
|
|
535
|
-
registerTodo(host.pi);
|
|
536
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
537
|
-
const result = await run(host.execute, { action: "list" });
|
|
538
|
-
expect(text(result)).toContain("● 1. restored");
|
|
539
|
-
expect(text(result)).toContain("Todos 1/1 done");
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
test("restores nextTodoId so new items continue sequence", async () => {
|
|
543
|
-
const host = makeHost([
|
|
544
|
-
{
|
|
545
|
-
type: "custom",
|
|
546
|
-
customType: "todo-state",
|
|
547
|
-
data: {
|
|
548
|
-
todos: [{ id: 5, text: "existing", status: "pending" }],
|
|
549
|
-
nextTodoId: 6,
|
|
550
|
-
},
|
|
551
|
-
},
|
|
552
|
-
]);
|
|
553
|
-
registerTodo(host.pi);
|
|
554
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
555
|
-
const result = await run(host.execute, { action: "add", items: "new" });
|
|
556
|
-
expect(text(result)).toContain("○ 6. new");
|
|
557
|
-
});
|
|
558
|
-
|
|
559
|
-
test("restores nextTodoId from max id when nextTodoId missing", async () => {
|
|
560
|
-
const host = makeHost([
|
|
561
|
-
{
|
|
562
|
-
type: "custom",
|
|
563
|
-
customType: "todo-state",
|
|
564
|
-
data: {
|
|
565
|
-
todos: [
|
|
566
|
-
{ id: 3, text: "old", status: "done" },
|
|
567
|
-
{ id: 7, text: "newer", status: "pending" },
|
|
568
|
-
],
|
|
569
|
-
},
|
|
570
|
-
},
|
|
571
|
-
]);
|
|
572
|
-
registerTodo(host.pi);
|
|
573
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
574
|
-
const result = await run(host.execute, { action: "add", items: "next" });
|
|
575
|
-
expect(text(result)).toContain("○ 8. next");
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
test("ignores non-todo-state entries", async () => {
|
|
579
|
-
const host = makeHost([
|
|
580
|
-
{ type: "message", data: "hello" },
|
|
581
|
-
{ type: "custom", customType: "other-thing", data: {} },
|
|
582
|
-
{
|
|
583
|
-
type: "custom",
|
|
584
|
-
customType: "todo-state",
|
|
585
|
-
data: {
|
|
586
|
-
todos: [{ id: 1, text: "real", status: "pending" }],
|
|
587
|
-
nextTodoId: 2,
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
]);
|
|
591
|
-
registerTodo(host.pi);
|
|
592
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
593
|
-
const result = await run(host.execute, { action: "list" });
|
|
594
|
-
expect(text(result)).toContain("○ 1. real");
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
test("no todo-state entries starts empty", async () => {
|
|
598
|
-
const host = makeHost([{ type: "message", data: "hello" }]);
|
|
599
|
-
registerTodo(host.pi);
|
|
600
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
601
|
-
const result = await run(host.execute, { action: "list" });
|
|
602
|
-
expect(text(result)).toBe("(no todos)");
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
test("empty entries list starts empty", async () => {
|
|
606
|
-
const host = makeHost([]);
|
|
607
|
-
registerTodo(host.pi);
|
|
608
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
609
|
-
const result = await run(host.execute, { action: "list" });
|
|
610
|
-
expect(text(result)).toBe("(no todos)");
|
|
611
|
-
});
|
|
612
|
-
|
|
613
|
-
test("restore with empty todos array works", async () => {
|
|
614
|
-
const host = makeHost([
|
|
615
|
-
{
|
|
616
|
-
type: "custom",
|
|
617
|
-
customType: "todo-state",
|
|
618
|
-
data: { todos: [], nextTodoId: 1 },
|
|
619
|
-
},
|
|
620
|
-
]);
|
|
621
|
-
registerTodo(host.pi);
|
|
622
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
623
|
-
const result = await run(host.execute, { action: "list" });
|
|
624
|
-
expect(text(result)).toBe("(no todos)");
|
|
625
|
-
});
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
// ─── Skip-guard ─────────────────────────────────────────────────────────────────────
|
|
629
|
-
|
|
630
|
-
describe("skip-guard on marking done", () => {
|
|
631
|
-
test("warns when marking a later item done with earlier pending items", async () => {
|
|
632
|
-
const host = makeHost();
|
|
633
|
-
registerTodo(host.pi);
|
|
634
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
635
|
-
await run(host.execute, { action: "set", items: "a\nb\nc\nd" });
|
|
636
|
-
// Mark items 1 and 4 done, leaving 2 and 3 pending
|
|
637
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
638
|
-
const result = await run(host.execute, { action: "update", id: 4, status: "done" });
|
|
639
|
-
const out = text(result);
|
|
640
|
-
expect(out).toContain("\u26a0 Earlier items still incomplete");
|
|
641
|
-
expect(out).toContain("#2 (b)");
|
|
642
|
-
expect(out).toContain("#3 (c)");
|
|
643
|
-
expect(out).toContain("Mark each done or blocked before proceeding");
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
test("no warning when all earlier items are done", async () => {
|
|
647
|
-
const host = makeHost();
|
|
648
|
-
registerTodo(host.pi);
|
|
649
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
650
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
651
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
652
|
-
await run(host.execute, { action: "update", id: 2, status: "done" });
|
|
653
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
654
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
test("no warning when marking the first item done", async () => {
|
|
658
|
-
const host = makeHost();
|
|
659
|
-
registerTodo(host.pi);
|
|
660
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
661
|
-
await run(host.execute, { action: "set", items: "a\nb" });
|
|
662
|
-
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
663
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
test("no warning when earlier items are blocked (only pending/in_progress trigger)", async () => {
|
|
667
|
-
const host = makeHost();
|
|
668
|
-
registerTodo(host.pi);
|
|
669
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
670
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
671
|
-
await run(host.execute, { action: "update", id: 1, status: "blocked" });
|
|
672
|
-
await run(host.execute, { action: "update", id: 2, status: "done" });
|
|
673
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
674
|
-
// blocked is an explicit decision, not incomplete — no warning
|
|
675
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
676
|
-
});
|
|
677
|
-
|
|
678
|
-
test("warns about in_progress items too (not just pending)", async () => {
|
|
679
|
-
const host = makeHost();
|
|
680
|
-
registerTodo(host.pi);
|
|
681
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
682
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
683
|
-
await run(host.execute, { action: "update", id: 1, status: "in_progress" });
|
|
684
|
-
// Mark item 3 done while item 1 is still in_progress
|
|
685
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
686
|
-
const out = text(result);
|
|
687
|
-
expect(out).toContain("\u26a0");
|
|
688
|
-
expect(out).toContain("#1 (a)");
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
test("no skip-guard on in_progress (only on done)", async () => {
|
|
692
|
-
// in_progress uses cascade-close instead, which is different behavior
|
|
693
|
-
const host = makeHost();
|
|
694
|
-
registerTodo(host.pi);
|
|
695
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
696
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
697
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
698
|
-
// Should cascade-close, not warn
|
|
699
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
700
|
-
expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
|
|
701
|
-
expect(text(result)).toContain("\u25cf 2. b");
|
|
702
|
-
});
|
|
703
|
-
});
|
|
704
|
-
|
|
705
|
-
// ─── Unordered lists (ordered:false) ────────────────────────────────────
|
|
706
|
-
|
|
707
|
-
describe("unordered lists", () => {
|
|
708
|
-
test("ordered:false opening a later item does NOT cascade-close earlier ones", async () => {
|
|
709
|
-
const host = makeHost();
|
|
710
|
-
registerTodo(host.pi);
|
|
711
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
712
|
-
await run(host.execute, { action: "set", items: "a\nb\nc", ordered: false });
|
|
713
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
714
|
-
// Earlier items stay pending — no silent completion.
|
|
715
|
-
expect(text(result)).toContain("\u25cb 1. a");
|
|
716
|
-
expect(text(result)).toContain("\u25cb 2. b");
|
|
717
|
-
expect(text(result)).toContain("\u25d0 3. c");
|
|
718
|
-
});
|
|
719
|
-
|
|
720
|
-
test("ordered:false marking a later item done does NOT warn about earlier ones", async () => {
|
|
721
|
-
const host = makeHost();
|
|
722
|
-
registerTodo(host.pi);
|
|
723
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
724
|
-
await run(host.execute, { action: "set", items: "a\nb\nc", ordered: false });
|
|
725
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "done" });
|
|
726
|
-
expect(text(result)).not.toContain("\u26a0");
|
|
727
|
-
});
|
|
728
|
-
|
|
729
|
-
test("defaults to ordered (cascade-close) when ordered omitted", async () => {
|
|
730
|
-
const host = makeHost();
|
|
731
|
-
registerTodo(host.pi);
|
|
732
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
733
|
-
await run(host.execute, { action: "set", items: "a\nb\nc" });
|
|
734
|
-
const result = await run(host.execute, { action: "update", id: 3, status: "in_progress" });
|
|
735
|
-
expect(text(result)).toContain("\u25cf 1. a"); // cascade-closed to done
|
|
736
|
-
});
|
|
737
|
-
|
|
738
|
-
test("ordered flag persists and restores", async () => {
|
|
739
|
-
const host = makeHost();
|
|
740
|
-
registerTodo(host.pi);
|
|
741
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
742
|
-
host.appendCalls.length = 0;
|
|
743
|
-
await run(host.execute, { action: "set", items: "a\nb", ordered: false });
|
|
744
|
-
const data = host.appendCalls[0]?.data as { ordered?: boolean };
|
|
745
|
-
expect(data.ordered).toBe(false);
|
|
746
|
-
|
|
747
|
-
// Restore into a fresh host and confirm cascade stays disabled.
|
|
748
|
-
delete (globalThis as { __pixOnce?: WeakMap<object, Set<string>> }).__pixOnce;
|
|
749
|
-
const host2 = makeHost([
|
|
750
|
-
{
|
|
751
|
-
type: "custom",
|
|
752
|
-
customType: "todo-state",
|
|
753
|
-
data: {
|
|
754
|
-
todos: [
|
|
755
|
-
{ id: 1, text: "a", status: "pending" },
|
|
756
|
-
{ id: 2, text: "b", status: "pending" },
|
|
757
|
-
],
|
|
758
|
-
nextTodoId: 3,
|
|
759
|
-
ordered: false,
|
|
760
|
-
},
|
|
761
|
-
},
|
|
762
|
-
]);
|
|
763
|
-
registerTodo(host2.pi);
|
|
764
|
-
await host2.emit("session_start", {}, { sessionManager: host2.sessionManager });
|
|
765
|
-
const result = await run(host2.execute, { action: "update", id: 2, status: "in_progress" });
|
|
766
|
-
expect(text(result)).toContain("\u25cb 1. a"); // NOT cascade-closed
|
|
767
|
-
});
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
// ─── Turn-based reminder ────────────────────────────────────────────────────────────
|
|
771
|
-
|
|
772
|
-
describe("turn-based todo reminder", () => {
|
|
773
|
-
test("injects reminder every 10 turns when incomplete items exist", async () => {
|
|
774
|
-
const host = makeHost();
|
|
775
|
-
registerTodo(host.pi);
|
|
776
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
777
|
-
await run(host.execute, { action: "set", items: "a\nb" });
|
|
778
|
-
|
|
779
|
-
// Simulate 10 turns — only the 10th should inject
|
|
780
|
-
for (let i = 1; i <= 9; i++) {
|
|
781
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
782
|
-
// before_agent_start returns undefined when no injection
|
|
783
|
-
expect(result).toBeUndefined();
|
|
784
|
-
}
|
|
785
|
-
// 10th turn should inject
|
|
786
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
787
|
-
expect(result).toBeDefined();
|
|
788
|
-
const prompt = (result as { systemPrompt: string }).systemPrompt;
|
|
789
|
-
expect(prompt).toContain("base");
|
|
790
|
-
expect(prompt).toContain("Todo reminder");
|
|
791
|
-
expect(prompt).toContain("1. a");
|
|
792
|
-
expect(prompt).toContain("2. b");
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
test("does not inject when no todos exist", async () => {
|
|
796
|
-
const host = makeHost();
|
|
797
|
-
registerTodo(host.pi);
|
|
798
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
799
|
-
|
|
800
|
-
for (let i = 1; i <= 10; i++) {
|
|
801
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
802
|
-
expect(result).toBeUndefined();
|
|
803
|
-
}
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
test("does not inject when all items are done", async () => {
|
|
807
|
-
const host = makeHost();
|
|
808
|
-
registerTodo(host.pi);
|
|
809
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
810
|
-
await run(host.execute, { action: "set", items: "a" });
|
|
811
|
-
await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
812
|
-
|
|
813
|
-
for (let i = 1; i <= 10; i++) {
|
|
814
|
-
const result = await host.emit("before_agent_start", { systemPrompt: "base" });
|
|
815
|
-
expect(result).toBeUndefined();
|
|
816
|
-
}
|
|
817
|
-
});
|
|
818
|
-
});
|
|
819
|
-
|
|
820
|
-
describe("renderTodoLines (colored TUI render)", () => {
|
|
821
|
-
const items: TodoItem[] = [
|
|
822
|
-
{ id: 1, text: "alpha", status: "done" },
|
|
823
|
-
{ id: 2, text: "bravo", status: "in_progress" },
|
|
824
|
-
{ id: 3, text: "charlie", status: "pending" },
|
|
825
|
-
{ id: 4, text: "delta", status: "blocked" },
|
|
826
|
-
];
|
|
827
|
-
|
|
828
|
-
test("empty list renders muted placeholder", () => {
|
|
829
|
-
expect(renderTodoLines([], tagTheme)).toBe("[muted](no todos)[/]");
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
test("colors each card cell by status (glyph + body one unit)", () => {
|
|
833
|
-
const out = renderTodoLines(items, tagTheme);
|
|
834
|
-
// Cards are padded cells colored as a whole; assert the tint wraps each card
|
|
835
|
-
// body (glyph-agnostic — glyph codepoint comes from the icon catalog/mode).
|
|
836
|
-
expect(out).toMatch(/\[success\][^\n]*alpha/); // done card success
|
|
837
|
-
expect(out).toMatch(/<b>\[accent\][^\n]*bravo[^\n]*<\/b>/); // in_progress bold+accent
|
|
838
|
-
expect(out).toMatch(/\[text\][^\n]*charlie/); // pending card white (text)
|
|
839
|
-
expect(out).toMatch(/\[error\][^\n]*delta/); // blocked card error
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
test("renders a header row with one column per status", () => {
|
|
843
|
-
const out = renderTodoLines(items, tagTheme);
|
|
844
|
-
expect(out).toContain("To Do (1)");
|
|
845
|
-
expect(out).toContain("In Progress (1)");
|
|
846
|
-
expect(out).toContain("Blocked (1)");
|
|
847
|
-
expect(out).toContain("Done (1)");
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
describe("todo card layout", () => {
|
|
852
|
-
test("uses the self-rendered shell so the compact checkmark has no leading space", () => {
|
|
853
|
-
const host = makeHost();
|
|
854
|
-
registerTodo(host.pi);
|
|
855
|
-
expect(host.renderShell).toBe("self");
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
test("shows the todo <action> title while open, empty once collapsed", () => {
|
|
859
|
-
const host = makeHost();
|
|
860
|
-
registerTodo(host.pi);
|
|
861
|
-
// Open card: title + action visible.
|
|
862
|
-
const open = host.renderCall({ action: "set" }, tagTheme, { state: {}, expanded: false });
|
|
863
|
-
expect(open.render(80).join("\n")).toContain("todo");
|
|
864
|
-
expect(open.render(80).join("\n")).toContain("set");
|
|
865
|
-
// Collapsed card: call row hides so the summary row is the only line.
|
|
866
|
-
const closed = host.renderCall({ action: "set" }, tagTheme, {
|
|
867
|
-
state: { collapsed: true },
|
|
868
|
-
expanded: false,
|
|
869
|
-
});
|
|
870
|
-
expect(closed.render(80).join("\n")).toBe("");
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
test("expanded mode restores a collapsed checklist", async () => {
|
|
874
|
-
const host = makeHost();
|
|
875
|
-
registerTodo(host.pi);
|
|
876
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
877
|
-
const result = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
878
|
-
const rendered = host
|
|
879
|
-
.render(result, { expanded: true }, tagTheme, {
|
|
880
|
-
state: { collapsed: true },
|
|
881
|
-
invalidate: () => {},
|
|
882
|
-
})
|
|
883
|
-
.render(80)
|
|
884
|
-
.join("\n");
|
|
885
|
-
|
|
886
|
-
expect(rendered).toContain("alpha");
|
|
887
|
-
expect(rendered).toContain("[text]○ alpha"); // pending card, no id number
|
|
888
|
-
expect(rendered).not.toContain("[success]✓ [/] [toolTitle]<b>todo</b>[/]");
|
|
889
|
-
expect(rendered.split("\n")[0]).toBe(`[success]${"─".repeat(80)}[/]`);
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test("opening a new todo card collapses the previously open one", async () => {
|
|
893
|
-
const host = makeHost();
|
|
894
|
-
registerTodo(host.pi);
|
|
895
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
896
|
-
const r1 = await run(host.execute, { action: "set", items: "a" });
|
|
897
|
-
const r2 = await run(host.execute, { action: "add", items: "b" });
|
|
898
|
-
|
|
899
|
-
// Card 1 renders open (not yet collapsed).
|
|
900
|
-
let invalidated1 = false;
|
|
901
|
-
const state1 = { collapsed: false } as { collapsed?: boolean };
|
|
902
|
-
host.render(r1, { expanded: false }, tagTheme, {
|
|
903
|
-
state: state1,
|
|
904
|
-
invalidate: () => {
|
|
905
|
-
invalidated1 = true;
|
|
906
|
-
},
|
|
907
|
-
});
|
|
908
|
-
expect(state1.collapsed).toBe(false);
|
|
909
|
-
|
|
910
|
-
// Card 2 renders open → it claims focus and collapses card 1.
|
|
911
|
-
const state2 = { collapsed: false } as { collapsed?: boolean };
|
|
912
|
-
host.render(r2, { expanded: false }, tagTheme, {
|
|
913
|
-
state: state2,
|
|
914
|
-
invalidate: () => {},
|
|
915
|
-
});
|
|
916
|
-
expect(state1.collapsed).toBe(true); // previous card force-collapsed
|
|
917
|
-
expect(invalidated1).toBe(true); // and re-rendered
|
|
918
|
-
expect(state2.collapsed).toBe(false); // newest stays open
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
test("failed todo actions render their exact error", async () => {
|
|
922
|
-
const host = makeHost();
|
|
923
|
-
registerTodo(host.pi);
|
|
924
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
925
|
-
const result = await run(host.execute, { action: "update", id: 99, status: "done" });
|
|
926
|
-
const rendered = host
|
|
927
|
-
.render(result, { expanded: false }, tagTheme, {
|
|
928
|
-
state: { collapsed: true },
|
|
929
|
-
invalidate: () => {},
|
|
930
|
-
})
|
|
931
|
-
.render(80)
|
|
932
|
-
.join("\n")
|
|
933
|
-
.trimEnd();
|
|
934
|
-
|
|
935
|
-
expect(rendered.split("\n")[0]).toBe(`[error]${"─".repeat(80)}[/]`);
|
|
936
|
-
expect(rendered).toContain("No todo with id 99.");
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
test("a collapsed result is exactly one shared-style line", async () => {
|
|
940
|
-
const host = makeHost();
|
|
941
|
-
registerTodo(host.pi);
|
|
942
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
943
|
-
await run(host.execute, { action: "set", items: "foundation\ntodo renderer" });
|
|
944
|
-
const result = await run(host.execute, { action: "update", id: 1, status: "done" });
|
|
945
|
-
const activeResult = await run(host.execute, {
|
|
946
|
-
action: "update",
|
|
947
|
-
id: 2,
|
|
948
|
-
status: "in_progress",
|
|
949
|
-
});
|
|
950
|
-
const lines = host
|
|
951
|
-
.render(activeResult, { expanded: false }, tagTheme, {
|
|
952
|
-
state: { collapsed: true },
|
|
953
|
-
invalidate: () => {},
|
|
954
|
-
})
|
|
955
|
-
.render(120);
|
|
956
|
-
|
|
957
|
-
expect(result.details).toBeDefined();
|
|
958
|
-
expect(lines).toHaveLength(1);
|
|
959
|
-
expect(lines[0]).not.toContain("────");
|
|
960
|
-
expect(lines[0]?.trimEnd()).toBe(
|
|
961
|
-
"[success]✓ [/] [toolTitle]<b>todo</b>[/] [dim]#2 todo renderer[/] [muted]·[/] [muted]1/2 done[/]",
|
|
962
|
-
);
|
|
963
|
-
});
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
describe("renderResult snapshot isolation", () => {
|
|
967
|
-
test("successful results carry immutable snapshots", async () => {
|
|
968
|
-
const host = makeHost();
|
|
969
|
-
registerTodo(host.pi);
|
|
970
|
-
await host.emit("session_start", {}, { sessionManager: host.sessionManager });
|
|
971
|
-
const original = await run(host.execute, { action: "set", items: "alpha\nbravo" });
|
|
972
|
-
await run(host.execute, { action: "set", items: "changed" });
|
|
973
|
-
|
|
974
|
-
const details = original.details as { snapshot: TodoItem[] };
|
|
975
|
-
expect(details.snapshot.map((item) => item.text)).toEqual(["alpha", "bravo"]);
|
|
976
|
-
const rendered = host
|
|
977
|
-
.render(original, { expanded: true }, tagTheme, {
|
|
978
|
-
state: {},
|
|
979
|
-
invalidate: () => {},
|
|
980
|
-
})
|
|
981
|
-
.render(80)
|
|
982
|
-
.join("\n");
|
|
983
|
-
expect(rendered).toContain("alpha");
|
|
984
|
-
expect(rendered).toContain("bravo");
|
|
985
|
-
expect(rendered).not.toContain("changed");
|
|
986
|
-
});
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
describe("renderTodoSummaryLine (collapsed one-liner)", () => {
|
|
990
|
-
test("empty list renders a compact tool row", () => {
|
|
991
|
-
expect(renderTodoSummaryLine([], tagTheme)).toBe(
|
|
992
|
-
"[success]✓ [/] [toolTitle]<b>todo</b>[/] [dim]empty[/]",
|
|
993
|
-
);
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("uses warning status when work is blocked", () => {
|
|
997
|
-
const items: TodoItem[] = [{ id: 1, text: "Need approval", status: "blocked" }];
|
|
998
|
-
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
999
|
-
"[warning]⚠ [/] [toolTitle]<b>todo</b>[/] [dim]checklist[/] [muted]·[/] [muted]0/1 done · 1 blocked[/]",
|
|
1000
|
-
);
|
|
1001
|
-
});
|
|
1002
|
-
|
|
1003
|
-
test("renders active work and progress in one row", () => {
|
|
1004
|
-
const items: TodoItem[] = [
|
|
1005
|
-
{ id: 1, text: "a", status: "done" },
|
|
1006
|
-
{ id: 2, text: "b", status: "in_progress" },
|
|
1007
|
-
];
|
|
1008
|
-
expect(renderTodoSummaryLine(items, tagTheme)).toBe(
|
|
1009
|
-
"[success]✓ [/] [toolTitle]<b>todo</b>[/] [dim]#2 b[/] [muted]·[/] [muted]1/2 done[/]",
|
|
1010
|
-
);
|
|
1011
|
-
});
|
|
1012
|
-
});
|