@zhushanwen/pi-todo 0.1.4 → 0.1.6
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 +7 -3
- package/src/__tests__/todo.test.ts +760 -0
- package/src/commands.ts +50 -0
- package/src/component.ts +99 -0
- package/src/handlers.ts +255 -0
- package/src/index.ts +28 -721
- package/src/model.ts +324 -0
- package/src/render.ts +173 -0
- package/src/state.ts +31 -0
- package/src/tool.ts +494 -0
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { describe, expect,it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addTodos,
|
|
5
|
+
buildRender,
|
|
6
|
+
formatTodoLine,
|
|
7
|
+
migrateTodo,
|
|
8
|
+
type Todo,
|
|
9
|
+
updateTodos,
|
|
10
|
+
VALID_STATUSES,
|
|
11
|
+
} from "../model";
|
|
12
|
+
|
|
13
|
+
// ── Task 1: 数据模型增强 + 向后兼容 ──────────────────
|
|
14
|
+
|
|
15
|
+
describe("Todo data model - Task 1", () => {
|
|
16
|
+
it("should load old data without verifyText/verifyAttempts", () => {
|
|
17
|
+
// 旧格式: 没有 verifyText 和 verifyAttempts 字段
|
|
18
|
+
const oldTodo = { id: 1, text: "test", status: "completed" } as unknown as Todo;
|
|
19
|
+
const migrated = migrateTodo(oldTodo);
|
|
20
|
+
|
|
21
|
+
expect(migrated.verifyText).toBeUndefined();
|
|
22
|
+
expect(migrated.verifyAttempts).toBe(0);
|
|
23
|
+
expect(migrated.status).toBe("completed");
|
|
24
|
+
expect(migrated.text).toBe("test");
|
|
25
|
+
expect(migrated.id).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should accept failed status", () => {
|
|
29
|
+
const todo: Todo = {
|
|
30
|
+
id: 1,
|
|
31
|
+
text: "test",
|
|
32
|
+
status: "failed",
|
|
33
|
+
verifyAttempts: 2,
|
|
34
|
+
};
|
|
35
|
+
const migrated = migrateTodo(todo);
|
|
36
|
+
|
|
37
|
+
expect(migrated.status).toBe("failed");
|
|
38
|
+
expect(migrated.verifyAttempts).toBe(2);
|
|
39
|
+
expect(VALID_STATUSES).toContain("failed");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should set verifyAttempts default to 0 when migrating old data", () => {
|
|
43
|
+
const oldTodo = { id: 5, text: "old task", status: "pending" } as unknown as Todo;
|
|
44
|
+
const migrated = migrateTodo(oldTodo);
|
|
45
|
+
|
|
46
|
+
expect(migrated.verifyAttempts).toBe(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should preserve verifyText when present in old data", () => {
|
|
50
|
+
const todo = {
|
|
51
|
+
id: 2,
|
|
52
|
+
text: "task",
|
|
53
|
+
status: "completed",
|
|
54
|
+
verifyText: "check output",
|
|
55
|
+
verifyAttempts: 1,
|
|
56
|
+
} as unknown as Todo;
|
|
57
|
+
const migrated = migrateTodo(todo);
|
|
58
|
+
|
|
59
|
+
expect(migrated.verifyText).toBe("check output");
|
|
60
|
+
expect(migrated.verifyAttempts).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should migrate done:true to completed with default verify fields", () => {
|
|
64
|
+
// 极旧格式: done: boolean
|
|
65
|
+
const veryOldTodo = { id: 3, text: "ancient", done: true } as unknown as Todo;
|
|
66
|
+
const migrated = migrateTodo(veryOldTodo);
|
|
67
|
+
|
|
68
|
+
expect(migrated.status).toBe("completed");
|
|
69
|
+
expect(migrated.verifyText).toBeUndefined();
|
|
70
|
+
expect(migrated.verifyAttempts).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should migrate done:false to pending with default verify fields", () => {
|
|
74
|
+
const veryOldTodo = { id: 4, text: "ancient2", done: false } as unknown as Todo;
|
|
75
|
+
const migrated = migrateTodo(veryOldTodo);
|
|
76
|
+
|
|
77
|
+
expect(migrated.status).toBe("pending");
|
|
78
|
+
expect(migrated.verifyText).toBeUndefined();
|
|
79
|
+
expect(migrated.verifyAttempts).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should include all five valid statuses", () => {
|
|
83
|
+
expect(VALID_STATUSES).toEqual(["pending", "in_progress", "verifying", "completed", "failed"]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should preserve evidence when migrating old data", () => {
|
|
87
|
+
const todo = {
|
|
88
|
+
id: 1,
|
|
89
|
+
text: "task",
|
|
90
|
+
status: "verifying",
|
|
91
|
+
verifyText: "check X",
|
|
92
|
+
evidence: "grep confirmed no residual",
|
|
93
|
+
verifyAttempts: 0,
|
|
94
|
+
} as unknown as Todo;
|
|
95
|
+
const migrated = migrateTodo(todo);
|
|
96
|
+
expect(migrated.evidence).toBe("grep confirmed no residual");
|
|
97
|
+
expect(migrated.status).toBe("verifying");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should default evidence to undefined when absent", () => {
|
|
101
|
+
const oldTodo = { id: 1, text: "test", status: "pending" } as unknown as Todo;
|
|
102
|
+
const migrated = migrateTodo(oldTodo);
|
|
103
|
+
expect(migrated.evidence).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── Task 2: todo add 支持 verifyTexts 参数 ────────────
|
|
108
|
+
|
|
109
|
+
describe("todo add verifyTexts - Task 2", () => {
|
|
110
|
+
it("should map verifyTexts to todos at corresponding indices", () => {
|
|
111
|
+
const result = addTodos([], 1, ["A", "B"], ["V1"]);
|
|
112
|
+
|
|
113
|
+
expect(result.error).toBeUndefined();
|
|
114
|
+
expect(result.newTodos).toHaveLength(2);
|
|
115
|
+
expect(result.newTodos[0].verifyText).toBe("V1");
|
|
116
|
+
expect(result.newTodos[1].verifyText).toBeUndefined();
|
|
117
|
+
expect(result.newTodos[0].verifyAttempts).toBe(0);
|
|
118
|
+
expect(result.newTodos[1].verifyAttempts).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should reject verifyTexts longer than texts", () => {
|
|
122
|
+
const result = addTodos([], 1, ["A"], ["V1", "V2"]);
|
|
123
|
+
|
|
124
|
+
expect(result.error).toBe("verifyTexts too long");
|
|
125
|
+
expect(result.resultText).toContain("Error");
|
|
126
|
+
expect(result.newTodos).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should work without verifyTexts (backward compat)", () => {
|
|
130
|
+
const result = addTodos([], 1, ["A"]);
|
|
131
|
+
|
|
132
|
+
expect(result.error).toBeUndefined();
|
|
133
|
+
expect(result.newTodos).toHaveLength(1);
|
|
134
|
+
expect(result.newTodos[0].verifyText).toBeUndefined();
|
|
135
|
+
expect(result.newTodos[0].verifyAttempts).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("should map all verifyTexts when lengths match", () => {
|
|
139
|
+
const result = addTodos([], 1, ["A", "B", "C"], ["V1", "V2", "V3"]);
|
|
140
|
+
|
|
141
|
+
expect(result.error).toBeUndefined();
|
|
142
|
+
expect(result.newTodos).toHaveLength(3);
|
|
143
|
+
expect(result.newTodos[0].verifyText).toBe("V1");
|
|
144
|
+
expect(result.newTodos[1].verifyText).toBe("V2");
|
|
145
|
+
expect(result.newTodos[2].verifyText).toBe("V3");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should return error when texts is empty", () => {
|
|
149
|
+
const result = addTodos([], 1, []);
|
|
150
|
+
|
|
151
|
+
expect(result.error).toBe("texts required");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("should return error when all texts are empty after trim", () => {
|
|
155
|
+
const result = addTodos([], 1, [" ", " "]);
|
|
156
|
+
|
|
157
|
+
expect(result.error).toBe("all texts empty");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("should trim texts and assign correct IDs", () => {
|
|
161
|
+
const existing: Todo[] = [{ id: 1, text: "existing", status: "pending", verifyAttempts: 0 }];
|
|
162
|
+
const result = addTodos(existing, 2, [" new task "]);
|
|
163
|
+
|
|
164
|
+
expect(result.error).toBeUndefined();
|
|
165
|
+
expect(result.newTodos).toHaveLength(2);
|
|
166
|
+
expect(result.newTodos[1].id).toBe(2);
|
|
167
|
+
expect(result.newTodos[1].text).toBe("new task");
|
|
168
|
+
expect(result.newNextId).toBe(3);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── Task 3: todo update batch updates[] ──────────────
|
|
173
|
+
|
|
174
|
+
describe("todo update batch - Task 3", () => {
|
|
175
|
+
it("should update multiple todos with updates[]", () => {
|
|
176
|
+
const todos: Todo[] = [
|
|
177
|
+
{ id: 1, text: "A", status: "pending", verifyAttempts: 0 },
|
|
178
|
+
{ id: 2, text: "B", status: "in_progress", verifyAttempts: 0 },
|
|
179
|
+
{ id: 3, text: "C", status: "pending", verifyAttempts: 0 },
|
|
180
|
+
];
|
|
181
|
+
const result = updateTodos(todos, [
|
|
182
|
+
{ id: 1, status: "completed" },
|
|
183
|
+
{ id: 2, text: "B updated" },
|
|
184
|
+
{ id: 3, status: "failed", text: "C failed" },
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
expect(result.error).toBeUndefined();
|
|
188
|
+
expect(result.updatedTodos).toHaveLength(3);
|
|
189
|
+
expect(result.updatedTodos[0].status).toBe("completed");
|
|
190
|
+
expect(result.updatedTodos[0].text).toBe("A");
|
|
191
|
+
expect(result.updatedTodos[1].text).toBe("B updated");
|
|
192
|
+
expect(result.updatedTodos[1].status).toBe("in_progress"); // unchanged
|
|
193
|
+
expect(result.updatedTodos[2].status).toBe("failed");
|
|
194
|
+
expect(result.updatedTodos[2].text).toBe("C failed");
|
|
195
|
+
expect(result.resultText).toBe("Updated 3 todo(s)");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("should reject duplicate ids in updates[]", () => {
|
|
199
|
+
const todos: Todo[] = [
|
|
200
|
+
{ id: 1, text: "A", status: "pending", verifyAttempts: 0 },
|
|
201
|
+
];
|
|
202
|
+
const result = updateTodos(todos, [
|
|
203
|
+
{ id: 1, status: "completed" },
|
|
204
|
+
{ id: 1, status: "pending" },
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
expect(result.error).toBe("duplicate ids in updates");
|
|
208
|
+
expect(result.updatedTodos).toEqual(todos); // unchanged
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should reject non-existent ids in updates[] (all-or-nothing)", () => {
|
|
212
|
+
const todos: Todo[] = [
|
|
213
|
+
{ id: 1, text: "A", status: "pending", verifyAttempts: 0 },
|
|
214
|
+
];
|
|
215
|
+
const result = updateTodos(todos, [
|
|
216
|
+
{ id: 1, status: "completed" },
|
|
217
|
+
{ id: 999, status: "pending" },
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
expect(result.error).toBe("id 999 not found");
|
|
221
|
+
expect(result.updatedTodos[0].status).toBe("pending"); // #1 not modified
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("should reject updates[] item missing both status and text", () => {
|
|
225
|
+
const todos: Todo[] = [
|
|
226
|
+
{ id: 1, text: "A", status: "pending", verifyAttempts: 0 },
|
|
227
|
+
];
|
|
228
|
+
const result = updateTodos(todos, [{ id: 1 }]);
|
|
229
|
+
|
|
230
|
+
expect(result.error).toContain("neither status nor text");
|
|
231
|
+
expect(result.updatedTodos[0].status).toBe("pending"); // unchanged
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// ── Task 4: todo list verifyText ────────────────────
|
|
236
|
+
|
|
237
|
+
describe("todo list verifyText - Task 4", () => {
|
|
238
|
+
it("should include verifyText in list output when present", () => {
|
|
239
|
+
const todo: Todo = {
|
|
240
|
+
id: 1,
|
|
241
|
+
text: "check output",
|
|
242
|
+
status: "pending",
|
|
243
|
+
verifyText: "check X",
|
|
244
|
+
verifyAttempts: 0,
|
|
245
|
+
};
|
|
246
|
+
const line = formatTodoLine(todo);
|
|
247
|
+
|
|
248
|
+
expect(line).toContain(" | 验证: check X");
|
|
249
|
+
expect(line).toContain("#1");
|
|
250
|
+
expect(line).toContain("check output");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should not include verify suffix when verifyText is absent", () => {
|
|
254
|
+
const todo: Todo = {
|
|
255
|
+
id: 2,
|
|
256
|
+
text: "plain task",
|
|
257
|
+
status: "completed",
|
|
258
|
+
verifyAttempts: 0,
|
|
259
|
+
};
|
|
260
|
+
const line = formatTodoLine(todo);
|
|
261
|
+
|
|
262
|
+
expect(line).not.toContain("验证");
|
|
263
|
+
expect(line).toContain("#2");
|
|
264
|
+
expect(line).toContain("plain task");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("should show verifying status with evidence", () => {
|
|
268
|
+
const todo: Todo = {
|
|
269
|
+
id: 3,
|
|
270
|
+
text: "fix auth",
|
|
271
|
+
status: "verifying",
|
|
272
|
+
verifyText: "check status codes",
|
|
273
|
+
evidence: "grep confirmed no display:true residual",
|
|
274
|
+
verifyAttempts: 0,
|
|
275
|
+
};
|
|
276
|
+
const line = formatTodoLine(todo);
|
|
277
|
+
|
|
278
|
+
expect(line).toContain("[v]");
|
|
279
|
+
expect(line).toContain("验证中: grep confirmed no display:true residual");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("should show completed with evidence", () => {
|
|
283
|
+
const todo: Todo = {
|
|
284
|
+
id: 4,
|
|
285
|
+
text: "fix login",
|
|
286
|
+
status: "completed",
|
|
287
|
+
verifyText: "密码错误时返回正确错误码",
|
|
288
|
+
evidence: "42 测试全通过,typecheck 无错误",
|
|
289
|
+
verifyAttempts: 0,
|
|
290
|
+
};
|
|
291
|
+
const line = formatTodoLine(todo);
|
|
292
|
+
|
|
293
|
+
expect(line).toContain("[x]");
|
|
294
|
+
expect(line).toContain("已验证: 42 测试全通过");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Task 5: agent_end loop logic (pure data model tests) ──────────
|
|
299
|
+
|
|
300
|
+
describe("agent_end loop logic - Task 5", () => {
|
|
301
|
+
const MAX_VERIFY_ATTEMPTS = 2;
|
|
302
|
+
|
|
303
|
+
it("should detect completed tasks needing verification", () => {
|
|
304
|
+
const todos: Todo[] = [
|
|
305
|
+
{
|
|
306
|
+
id: 1,
|
|
307
|
+
text: "task A",
|
|
308
|
+
status: "completed",
|
|
309
|
+
verifyText: "check output",
|
|
310
|
+
verifyAttempts: 0,
|
|
311
|
+
},
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
const needsVerify = todos.find(
|
|
315
|
+
(t) =>
|
|
316
|
+
t.status === "completed" &&
|
|
317
|
+
t.verifyText &&
|
|
318
|
+
t.verifyAttempts < MAX_VERIFY_ATTEMPTS,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(needsVerify).toBeDefined();
|
|
322
|
+
expect(needsVerify!.id).toBe(1);
|
|
323
|
+
expect(needsVerify!.verifyText).toBe("check output");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should not trigger verify for tasks without verifyText", () => {
|
|
327
|
+
const todos: Todo[] = [
|
|
328
|
+
{
|
|
329
|
+
id: 1,
|
|
330
|
+
text: "task A",
|
|
331
|
+
status: "completed",
|
|
332
|
+
verifyAttempts: 0,
|
|
333
|
+
},
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
const needsVerify = todos.find(
|
|
337
|
+
(t) =>
|
|
338
|
+
t.status === "completed" &&
|
|
339
|
+
t.verifyText &&
|
|
340
|
+
t.verifyAttempts < MAX_VERIFY_ATTEMPTS,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(needsVerify).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("should detect verify failure when attempts exceed max (completed status)", () => {
|
|
347
|
+
const todos: Todo[] = [
|
|
348
|
+
{
|
|
349
|
+
id: 2,
|
|
350
|
+
text: "task B",
|
|
351
|
+
status: "completed",
|
|
352
|
+
verifyText: "check B",
|
|
353
|
+
verifyAttempts: MAX_VERIFY_ATTEMPTS,
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
const failed = todos.find(
|
|
358
|
+
(t) =>
|
|
359
|
+
t.status === "completed" &&
|
|
360
|
+
t.verifyText &&
|
|
361
|
+
t.verifyAttempts >= MAX_VERIFY_ATTEMPTS,
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
expect(failed).toBeDefined();
|
|
365
|
+
failed!.status = "failed";
|
|
366
|
+
expect(failed!.status).toBe("failed");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("should increment verifyAttempts when AI re-opens completed task with verifyText", () => {
|
|
370
|
+
const todos: Todo[] = [
|
|
371
|
+
{ id: 1, text: "task A", status: "completed", verifyText: "check A", verifyAttempts: 0 },
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
todos[0].status = "in_progress";
|
|
375
|
+
todos[0].verifyAttempts++;
|
|
376
|
+
expect(todos[0].verifyAttempts).toBe(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("should detect stall when no todo activity for threshold rounds", () => {
|
|
380
|
+
const STALL_THRESHOLD = 5;
|
|
381
|
+
const userMessageCount = 10;
|
|
382
|
+
const lastTodoCallCount = 3;
|
|
383
|
+
const todos: Todo[] = [
|
|
384
|
+
{ id: 1, text: "pending task", status: "pending", verifyAttempts: 0 },
|
|
385
|
+
];
|
|
386
|
+
const allCompletedAtCount = null;
|
|
387
|
+
|
|
388
|
+
const isStalled =
|
|
389
|
+
todos.length > 0 &&
|
|
390
|
+
allCompletedAtCount === null &&
|
|
391
|
+
userMessageCount - lastTodoCallCount >= STALL_THRESHOLD;
|
|
392
|
+
|
|
393
|
+
expect(isStalled).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("should detect reminder when interval elapsed", () => {
|
|
397
|
+
const REMINDER_INTERVAL = 3;
|
|
398
|
+
const userMessageCount = 8;
|
|
399
|
+
const lastTodoCallCount = 4;
|
|
400
|
+
const todos: Todo[] = [
|
|
401
|
+
{ id: 1, text: "task", status: "pending", verifyAttempts: 0 },
|
|
402
|
+
];
|
|
403
|
+
const allCompletedAtCount = null;
|
|
404
|
+
|
|
405
|
+
const needsReminder =
|
|
406
|
+
todos.length > 0 &&
|
|
407
|
+
allCompletedAtCount === null &&
|
|
408
|
+
userMessageCount - lastTodoCallCount >= REMINDER_INTERVAL;
|
|
409
|
+
|
|
410
|
+
expect(needsReminder).toBe(true);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("should auto-clear when all completed and delay rounds elapsed", () => {
|
|
414
|
+
const AUTO_CLEAR_DELAY_ROUNDS = 2;
|
|
415
|
+
const userMessageCount = 7;
|
|
416
|
+
const allCompletedAtCount = 4;
|
|
417
|
+
|
|
418
|
+
const shouldClear =
|
|
419
|
+
allCompletedAtCount !== null &&
|
|
420
|
+
userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
|
|
421
|
+
|
|
422
|
+
expect(shouldClear).toBe(true);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("should not auto-clear when delay rounds not yet elapsed", () => {
|
|
426
|
+
const AUTO_CLEAR_DELAY_ROUNDS = 2;
|
|
427
|
+
const userMessageCount = 5;
|
|
428
|
+
const allCompletedAtCount = 4;
|
|
429
|
+
|
|
430
|
+
const shouldClear =
|
|
431
|
+
allCompletedAtCount !== null &&
|
|
432
|
+
userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
|
|
433
|
+
|
|
434
|
+
expect(shouldClear).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("should format pending tasks with verifyText for context injection", () => {
|
|
438
|
+
const todos: Todo[] = [
|
|
439
|
+
{
|
|
440
|
+
id: 1,
|
|
441
|
+
text: "task A",
|
|
442
|
+
status: "pending",
|
|
443
|
+
verifyText: "check A",
|
|
444
|
+
verifyAttempts: 0,
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
id: 2,
|
|
448
|
+
text: "task B",
|
|
449
|
+
status: "in_progress",
|
|
450
|
+
verifyAttempts: 0,
|
|
451
|
+
},
|
|
452
|
+
];
|
|
453
|
+
|
|
454
|
+
const pendingTodos = todos.filter((t) => t.status !== "completed");
|
|
455
|
+
const lines = pendingTodos.map((t) => {
|
|
456
|
+
const verifyTag = t.verifyText
|
|
457
|
+
? ` [待验证: ${t.verifyText}]`
|
|
458
|
+
: "";
|
|
459
|
+
return `#${t.id}: ${t.text}${verifyTag}`;
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
expect(lines).toHaveLength(2);
|
|
463
|
+
expect(lines[0]).toBe("#1: task A [待验证: check A]");
|
|
464
|
+
expect(lines[1]).toBe("#2: task B");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("should include verifying tasks as pending (not completed)", () => {
|
|
468
|
+
const todos: Todo[] = [
|
|
469
|
+
{ id: 1, text: "A", status: "verifying", verifyText: "check A", verifyAttempts: 0, evidence: "testing..." },
|
|
470
|
+
{ id: 2, text: "B", status: "completed", verifyAttempts: 0 },
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
const pendingTodos = todos.filter((t) => t.status !== "completed");
|
|
474
|
+
expect(pendingTodos).toHaveLength(1);
|
|
475
|
+
expect(pendingTodos[0].status).toBe("verifying");
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ── batch update validation - Task 3b ─────────────────
|
|
480
|
+
|
|
481
|
+
describe("batch update validation - Task 3b", () => {
|
|
482
|
+
it("should reject invalid status values in updates[]", () => {
|
|
483
|
+
const todos: Todo[] = [{ id: 1, text: "A", status: "pending", verifyAttempts: 0 }];
|
|
484
|
+
const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
|
|
485
|
+
expect(result.error).toContain("invalid status");
|
|
486
|
+
expect(result.updatedTodos[0].status).toBe("pending"); // unchanged
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("should accept valid status values in updates[]", () => {
|
|
490
|
+
const todos: Todo[] = [{ id: 1, text: "A", status: "pending", verifyAttempts: 0 }];
|
|
491
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
|
|
492
|
+
expect(result.error).toBeUndefined();
|
|
493
|
+
expect(result.updatedTodos[0].status).toBe("completed");
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ── verifyAttempts increment on re-open ─────────────
|
|
498
|
+
|
|
499
|
+
describe("verifyAttempts increment on re-open", () => {
|
|
500
|
+
it("should increment verifyAttempts when status changes from completed to in_progress with verifyText", () => {
|
|
501
|
+
const todo: Todo = { id: 1, text: "fix auth", status: "completed", verifyText: "check status codes", verifyAttempts: 0 };
|
|
502
|
+
|
|
503
|
+
const oldStatus = todo.status;
|
|
504
|
+
todo.status = "in_progress";
|
|
505
|
+
if (oldStatus === "completed" && todo.verifyText && todo.verifyAttempts < 2) {
|
|
506
|
+
todo.verifyAttempts++;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
expect(todo.verifyAttempts).toBe(1);
|
|
510
|
+
expect(todo.status).toBe("in_progress");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("should NOT increment verifyAttempts when re-opening task without verifyText", () => {
|
|
514
|
+
const todo: Todo = { id: 1, text: "simple task", status: "completed", verifyAttempts: 0 };
|
|
515
|
+
|
|
516
|
+
const oldStatus = todo.status;
|
|
517
|
+
todo.status = "in_progress";
|
|
518
|
+
if (oldStatus === "completed" && todo.verifyText && todo.verifyAttempts < 2) {
|
|
519
|
+
todo.verifyAttempts++;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
expect(todo.verifyAttempts).toBe(0); // no verifyText → no increment
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// ── verifying state transitions ────────────────────
|
|
527
|
+
|
|
528
|
+
describe("verifying state transitions", () => {
|
|
529
|
+
it("should block verifying on task without verifyText", () => {
|
|
530
|
+
const todos: Todo[] = [
|
|
531
|
+
{ id: 1, text: "simple", status: "in_progress", verifyAttempts: 0 },
|
|
532
|
+
];
|
|
533
|
+
const result = updateTodos(todos, [{ id: 1, status: "verifying", evidence: "this should be blocked" }]);
|
|
534
|
+
|
|
535
|
+
expect(result.blocked).toBeDefined();
|
|
536
|
+
expect(result.blocked!.length).toBe(1);
|
|
537
|
+
expect(result.blocked![0].reason).toContain("verifyText");
|
|
538
|
+
expect(result.updatedTodos[0].status).toBe("in_progress"); // unchanged
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("should block verifying without evidence", () => {
|
|
542
|
+
const todos: Todo[] = [
|
|
543
|
+
{ id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
|
|
544
|
+
];
|
|
545
|
+
const result = updateTodos(todos, [{ id: 1, status: "verifying" }]);
|
|
546
|
+
|
|
547
|
+
expect(result.blocked).toBeDefined();
|
|
548
|
+
expect(result.blocked![0].reason).toContain("evidence");
|
|
549
|
+
expect(result.updatedTodos[0].status).toBe("in_progress");
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("should block verifying with evidence shorter than 10 chars", () => {
|
|
553
|
+
const todos: Todo[] = [
|
|
554
|
+
{ id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
|
|
555
|
+
];
|
|
556
|
+
const result = updateTodos(todos, [{ id: 1, status: "verifying", evidence: "short" }]);
|
|
557
|
+
|
|
558
|
+
expect(result.blocked).toBeDefined();
|
|
559
|
+
expect(result.blocked![0].reason).toContain("10");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("should allow verifying with valid evidence", () => {
|
|
563
|
+
const todos: Todo[] = [
|
|
564
|
+
{ id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
|
|
565
|
+
];
|
|
566
|
+
const result = updateTodos(todos, [{ id: 1, status: "verifying", evidence: "grep confirmed no residual code" }]);
|
|
567
|
+
|
|
568
|
+
expect(result.error).toBeUndefined();
|
|
569
|
+
expect(result.blocked).toBeUndefined();
|
|
570
|
+
expect(result.updatedTodos[0].status).toBe("verifying");
|
|
571
|
+
expect(result.updatedTodos[0].evidence).toBe("grep confirmed no residual code");
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("should block verifying → completed without evidence", () => {
|
|
575
|
+
const todos: Todo[] = [
|
|
576
|
+
{ id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "testing" },
|
|
577
|
+
];
|
|
578
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
|
|
579
|
+
|
|
580
|
+
expect(result.blocked).toBeDefined();
|
|
581
|
+
expect(result.blocked![0].reason).toContain("evidence");
|
|
582
|
+
expect(result.updatedTodos[0].status).toBe("verifying"); // unchanged
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("should allow verifying → completed with evidence", () => {
|
|
586
|
+
const todos: Todo[] = [
|
|
587
|
+
{ id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "testing in progress" },
|
|
588
|
+
];
|
|
589
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed", evidence: "all 42 tests passed, typecheck clean" }]);
|
|
590
|
+
|
|
591
|
+
expect(result.error).toBeUndefined();
|
|
592
|
+
expect(result.updatedTodos[0].status).toBe("completed");
|
|
593
|
+
expect(result.updatedTodos[0].evidence).toBe("all 42 tests passed, typecheck clean");
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("should allow in_progress → completed directly for tasks without verifyText", () => {
|
|
597
|
+
const todos: Todo[] = [
|
|
598
|
+
{ id: 1, text: "simple", status: "in_progress", verifyAttempts: 0 },
|
|
599
|
+
];
|
|
600
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
|
|
601
|
+
|
|
602
|
+
expect(result.error).toBeUndefined();
|
|
603
|
+
expect(result.blocked).toBeUndefined();
|
|
604
|
+
expect(result.updatedTodos[0].status).toBe("completed");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should block in_progress → completed on verifyText task without verified+evidence", () => {
|
|
608
|
+
const todos: Todo[] = [
|
|
609
|
+
{ id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
|
|
610
|
+
];
|
|
611
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed" }]);
|
|
612
|
+
|
|
613
|
+
expect(result.blocked).toBeDefined();
|
|
614
|
+
expect(result.blocked![0].reason).toContain("verifying");
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("should allow in_progress → completed with verified=true + evidence (skip verifying)", () => {
|
|
618
|
+
const todos: Todo[] = [
|
|
619
|
+
{ id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
|
|
620
|
+
];
|
|
621
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed", verified: true, evidence: "all tests passed confirmed" }]);
|
|
622
|
+
|
|
623
|
+
expect(result.error).toBeUndefined();
|
|
624
|
+
expect(result.updatedTodos[0].status).toBe("completed");
|
|
625
|
+
expect(result.updatedTodos[0].evidence).toBe("all tests passed confirmed");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("should block in_progress → completed with verified=true but no evidence", () => {
|
|
629
|
+
const todos: Todo[] = [
|
|
630
|
+
{ id: 1, text: "fix auth", status: "in_progress", verifyText: "check codes", verifyAttempts: 0 },
|
|
631
|
+
];
|
|
632
|
+
const result = updateTodos(todos, [{ id: 1, status: "completed", verified: true }]);
|
|
633
|
+
|
|
634
|
+
expect(result.blocked).toBeDefined();
|
|
635
|
+
expect(result.blocked![0].reason).toContain("evidence");
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("should block batch completed when mixed verifyText tasks lack evidence", () => {
|
|
639
|
+
const todos: Todo[] = [
|
|
640
|
+
{ id: 1, text: "A", status: "in_progress", verifyText: "check A", verifyAttempts: 0 },
|
|
641
|
+
{ id: 2, text: "B", status: "in_progress", verifyAttempts: 0 },
|
|
642
|
+
];
|
|
643
|
+
const result = updateTodos(todos, [
|
|
644
|
+
{ id: 1, status: "completed" },
|
|
645
|
+
{ id: 2, status: "completed" },
|
|
646
|
+
]);
|
|
647
|
+
|
|
648
|
+
expect(result.blocked).toBeDefined();
|
|
649
|
+
expect(result.blocked!.length).toBe(1); // only #1 blocked
|
|
650
|
+
expect(result.blocked![0].id).toBe(1);
|
|
651
|
+
// both unchanged (all-or-nothing)
|
|
652
|
+
expect(result.updatedTodos[0].status).toBe("in_progress");
|
|
653
|
+
expect(result.updatedTodos[1].status).toBe("in_progress");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("should allow batch when verifyText task has verified+evidence and non-verifyText task is plain", () => {
|
|
657
|
+
const todos: Todo[] = [
|
|
658
|
+
{ id: 1, text: "A", status: "in_progress", verifyText: "check A", verifyAttempts: 0 },
|
|
659
|
+
{ id: 2, text: "B", status: "in_progress", verifyAttempts: 0 },
|
|
660
|
+
];
|
|
661
|
+
const result = updateTodos(todos, [
|
|
662
|
+
{ id: 1, status: "completed", verified: true, evidence: "confirmed via grep" },
|
|
663
|
+
{ id: 2, status: "completed" },
|
|
664
|
+
]);
|
|
665
|
+
|
|
666
|
+
expect(result.error).toBeUndefined();
|
|
667
|
+
expect(result.updatedTodos[0].status).toBe("completed");
|
|
668
|
+
expect(result.updatedTodos[1].status).toBe("completed");
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("should allow non-completed status changes without evidence", () => {
|
|
672
|
+
const todos: Todo[] = [
|
|
673
|
+
{ id: 1, text: "A", status: "pending", verifyText: "check A", verifyAttempts: 0 },
|
|
674
|
+
];
|
|
675
|
+
const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
|
|
676
|
+
|
|
677
|
+
expect(result.error).toBeUndefined();
|
|
678
|
+
expect(result.blocked).toBeUndefined();
|
|
679
|
+
expect(result.updatedTodos[0].status).toBe("in_progress");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("should allow verifying → in_progress (verification failed, rework)", () => {
|
|
683
|
+
const todos: Todo[] = [
|
|
684
|
+
{ id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 0, evidence: "initial check" },
|
|
685
|
+
];
|
|
686
|
+
const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
|
|
687
|
+
|
|
688
|
+
expect(result.error).toBeUndefined();
|
|
689
|
+
expect(result.updatedTodos[0].status).toBe("in_progress");
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// ── buildRender ───────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
describe("buildRender", () => {
|
|
696
|
+
it("should calculate summary correctly", () => {
|
|
697
|
+
const todos: Todo[] = [
|
|
698
|
+
{ id: 1, text: "a", status: "completed", verifyAttempts: 0 },
|
|
699
|
+
{ id: 2, text: "b", status: "pending", verifyAttempts: 0 },
|
|
700
|
+
{ id: 3, text: "c", status: "failed", verifyAttempts: 2 },
|
|
701
|
+
];
|
|
702
|
+
const render = buildRender(todos);
|
|
703
|
+
|
|
704
|
+
expect(render).toBeDefined();
|
|
705
|
+
expect(render!.summary).toBe("1/3 completed");
|
|
706
|
+
expect(render!.data.items).toHaveLength(3);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("should handle empty list", () => {
|
|
710
|
+
const render = buildRender([]);
|
|
711
|
+
|
|
712
|
+
expect(render).toBeDefined();
|
|
713
|
+
expect(render!.summary).toBe("0/0 completed");
|
|
714
|
+
expect(render!.data.items).toHaveLength(0);
|
|
715
|
+
});
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
// ── batch update verifyAttempts increment ───────────
|
|
719
|
+
|
|
720
|
+
describe("batch update verifyAttempts increment", () => {
|
|
721
|
+
it("should increment verifyAttempts when batch reverts completed → in_progress with verifyText", () => {
|
|
722
|
+
const todos: Todo[] = [
|
|
723
|
+
{ id: 1, text: "fix auth", status: "completed", verifyText: "check codes", verifyAttempts: 0 },
|
|
724
|
+
];
|
|
725
|
+
const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
|
|
726
|
+
|
|
727
|
+
expect(result.error).toBeUndefined();
|
|
728
|
+
expect(result.updatedTodos[0].verifyAttempts).toBe(1);
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it("should increment verifyAttempts when batch reverts verifying → in_progress with verifyText", () => {
|
|
732
|
+
const todos: Todo[] = [
|
|
733
|
+
{ id: 1, text: "fix auth", status: "verifying", verifyText: "check codes", verifyAttempts: 1, evidence: "testing" },
|
|
734
|
+
];
|
|
735
|
+
const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
|
|
736
|
+
|
|
737
|
+
expect(result.error).toBeUndefined();
|
|
738
|
+
expect(result.updatedTodos[0].verifyAttempts).toBe(2);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("should NOT increment verifyAttempts for tasks without verifyText", () => {
|
|
742
|
+
const todos: Todo[] = [
|
|
743
|
+
{ id: 1, text: "simple", status: "completed", verifyAttempts: 0 },
|
|
744
|
+
];
|
|
745
|
+
const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
|
|
746
|
+
|
|
747
|
+
expect(result.error).toBeUndefined();
|
|
748
|
+
expect(result.updatedTodos[0].verifyAttempts).toBe(0);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("should NOT increment verifyAttempts when already at MAX (2)", () => {
|
|
752
|
+
const todos: Todo[] = [
|
|
753
|
+
{ id: 1, text: "fix auth", status: "completed", verifyText: "check codes", verifyAttempts: 2 },
|
|
754
|
+
];
|
|
755
|
+
const result = updateTodos(todos, [{ id: 1, status: "in_progress" }]);
|
|
756
|
+
|
|
757
|
+
expect(result.error).toBeUndefined();
|
|
758
|
+
expect(result.updatedTodos[0].verifyAttempts).toBe(2); // capped
|
|
759
|
+
});
|
|
760
|
+
});
|