@zhushanwen/pi-todo 0.6.0 → 0.7.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-todo",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "AI-driven todo list for Pi — stateful task management with session persistence and /todos command.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -25,7 +25,7 @@
25
25
  "vitest": "^4.1.8"
26
26
  },
27
27
  "dependencies": {
28
- "@xyz-agent/extension-protocol": "^0.3.1"
28
+ "@xyz-agent/extension-protocol": "^0.4.0"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@earendil-works/pi-coding-agent": "*",
@@ -3,18 +3,17 @@ import { describe, expect, it } from "vitest";
3
3
  import { buildGui, type Todo } from "../model";
4
4
 
5
5
  describe("buildGui", () => {
6
- it("maps 4 statuses to list-tree with correct icons", () => {
6
+ it("maps 3 statuses to list-tree with correct icons", () => {
7
7
  const todos: Todo[] = [
8
8
  { id: 1, text: "pending task", status: "pending" },
9
9
  { id: 2, text: "active task", status: "in_progress" },
10
10
  { id: 3, text: "done task", status: "completed" },
11
- { id: 4, text: "cancelled task", status: "cancelled" },
12
11
  ];
13
12
  const gui = buildGui(todos);
14
13
  expect(gui.v).toBe(1);
15
14
  expect(gui.component.type).toBe("list-tree");
16
15
  const items = gui.component.props.items;
17
- expect(items).toHaveLength(4);
16
+ expect(items).toHaveLength(3);
18
17
  // pending → dot, no status(guiResult 的 stripUndefined 删除 undefined 键)
19
18
  expect(items[0]).toMatchObject({ icon: "dot", label: "#1: pending task", depth: 0 });
20
19
  expect(items[0]).not.toHaveProperty("status");
@@ -22,8 +21,6 @@ describe("buildGui", () => {
22
21
  expect(items[1]).toMatchObject({ icon: "circle", label: "#2: active task", status: "running", depth: 0 });
23
22
  // completed → check, done
24
23
  expect(items[2]).toMatchObject({ icon: "check", label: "#3: done task", status: "done", depth: 0 });
25
- // cancelled → cross, failed
26
- expect(items[3]).toMatchObject({ icon: "cross", label: "#4: cancelled task", status: "failed", depth: 0 });
27
24
  });
28
25
 
29
26
  it("empty todos → empty list-tree", () => {
@@ -0,0 +1,76 @@
1
+ // Schema 强约束回归(T4/TC3/TC4):TodoParams 为 discriminated union(按 action),
2
+ // 每个分支只声明自己的参数且 additionalProperties:false。用 typebox 的 Value.Check
3
+ // 验证:缺失必填、多余字段、已删除的 action 都在 schema 层被拒绝,不依赖运行时 throw。
4
+ //
5
+ // 选 Value.Check 而非 ajv:typebox 自带 Value 校验器与其 schema 语义一致;
6
+ // spike 确认 Value.Check 与 plain ajv 对本 schema 的拒绝结论一致(ajv 的
7
+ // discriminator:true 选项会编译失败,故不依赖该选项)。
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import { Value } from "typebox/value";
12
+
13
+ import { TodoParams } from "../tool";
14
+
15
+ describe("TodoParams discriminated union schema", () => {
16
+ describe("合法 payload 通过", () => {
17
+ it("list(无参)", () => {
18
+ expect(Value.Check(TodoParams, { action: "list" })).toBe(true);
19
+ });
20
+ it("add + texts", () => {
21
+ expect(Value.Check(TodoParams, { action: "add", texts: ["write spec"] })).toBe(true);
22
+ });
23
+ it("update 单条 + id + status", () => {
24
+ expect(Value.Check(TodoParams, { action: "update", id: 1, status: "in_progress" })).toBe(true);
25
+ });
26
+ it("update 批量 + updates", () => {
27
+ expect(Value.Check(TodoParams, { action: "update", updates: [{ id: 1, status: "completed" }] })).toBe(true);
28
+ });
29
+ it("delete + ids", () => {
30
+ expect(Value.Check(TodoParams, { action: "delete", ids: [1, 2] })).toBe(true);
31
+ });
32
+ });
33
+
34
+ describe("TC4: 缺失必填被 schema 拒绝", () => {
35
+ it("add 缺 texts", () => {
36
+ expect(Value.Check(TodoParams, { action: "add" })).toBe(false);
37
+ });
38
+ it("update 缺 id 且缺 updates", () => {
39
+ expect(Value.Check(TodoParams, { action: "update" })).toBe(false);
40
+ });
41
+ it("delete 缺 ids", () => {
42
+ expect(Value.Check(TodoParams, { action: "delete" })).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe("TC3: 已删除的 clear action 被拒绝", () => {
47
+ it("clear 不在 action 枚举内", () => {
48
+ expect(Value.Check(TodoParams, { action: "clear" })).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe("额外属性被拒绝(additionalProperties:false)", () => {
53
+ it("TC7: add 同时传 text+texts(text 是多余字段)→ 拒绝", () => {
54
+ // schema 层拒绝双形陷阱;handler 层另有 defense-in-depth throw(见 tool-detectors)
55
+ expect(Value.Check(TodoParams, { action: "add", texts: ["y"], text: "x" })).toBe(false);
56
+ });
57
+ it("list 携带多余 texts → 拒绝", () => {
58
+ expect(Value.Check(TodoParams, { action: "list", texts: ["x"] })).toBe(false);
59
+ });
60
+ it("update 单条携带多余 ids → 拒绝", () => {
61
+ expect(Value.Check(TodoParams, { action: "update", id: 1, ids: [1] })).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("status 枚举强约束", () => {
66
+ it("合法三态 status 通过", () => {
67
+ expect(Value.Check(TodoParams, { action: "update", id: 1, status: "completed" })).toBe(true);
68
+ });
69
+ it("TC2: cancelled 不再合法", () => {
70
+ expect(Value.Check(TodoParams, { action: "update", id: 1, status: "cancelled" })).toBe(false);
71
+ });
72
+ it("非法 status 被拒绝", () => {
73
+ expect(Value.Check(TodoParams, { action: "update", id: 1, status: "banana" })).toBe(false);
74
+ });
75
+ });
76
+ });
@@ -2,11 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  buildBeforeAgentStartMessage,
5
- buildMinimalReminder,
6
5
  handleAutoClear,
7
6
  handleCompletionSteer,
8
- handleReminder,
9
- handleStallDetection,
10
7
  reconstructState,
11
8
  } from "../handlers";
12
9
  import type { Todo } from "../model";
@@ -101,95 +98,7 @@ describe("handleAutoClear", () => {
101
98
  });
102
99
  });
103
100
 
104
- // ── stall detection ─────────────────────────────────
105
-
106
- describe("handleStallDetection", () => {
107
- it("fires once when idle exceeds STALL_THRESHOLD (5)", () => {
108
- const s = makeState([{ id: 1, text: "task", status: "pending" }], {
109
- userMessageCount: 10, lastTodoCallCount: 5,
110
- });
111
- expect(handleStallDetection(s)).toBe(true);
112
- expect(s.stallNotified).toBe(true);
113
- expect(s.pendingSteerMessage).toContain("#1");
114
- });
115
-
116
- it("does not fire twice (single-shot lock)", () => {
117
- const s = makeState([{ id: 1, text: "task", status: "pending" }], {
118
- userMessageCount: 10, lastTodoCallCount: 5, stallNotified: true,
119
- });
120
- expect(handleStallDetection(s)).toBe(false);
121
- expect(s.pendingSteerMessage).toBeNull();
122
- });
123
-
124
- it("does not fire below threshold", () => {
125
- const s = makeState([{ id: 1, text: "task", status: "pending" }], {
126
- userMessageCount: 8, lastTodoCallCount: 5,
127
- });
128
- expect(handleStallDetection(s)).toBe(false);
129
- });
130
- });
131
-
132
- // ── reminder ────────────────────────────────────────
133
-
134
- describe("handleReminder", () => {
135
- it("fires when idle exceeds REMINDER_INTERVAL (2)", () => {
136
- const s = makeState([{ id: 1, text: "task", status: "pending" }], {
137
- userMessageCount: 5, lastTodoCallCount: 3,
138
- });
139
- expect(handleReminder(s)).toBe(true);
140
- expect(s.pendingSteerMessage).toContain("#1");
141
- });
142
-
143
- it("does not fire within interval", () => {
144
- const s = makeState([{ id: 1, text: "task", status: "pending" }], {
145
- userMessageCount: 4, lastTodoCallCount: 3,
146
- });
147
- expect(handleReminder(s)).toBe(false);
148
- });
149
- });
150
-
151
- // ── reminder / context builders ─────────────────────
152
-
153
- describe("buildMinimalReminder", () => {
154
- it("mentions the next pending task with action directive", () => {
155
- const s = makeState([
156
- { id: 1, text: "done", status: "completed" },
157
- { id: 2, text: "next", status: "pending" },
158
- ]);
159
- const out = buildMinimalReminder(s);
160
- expect(out).toContain("#2 next");
161
- expect(out).toContain("必须处理");
162
- expect(out).toContain("todo update");
163
- });
164
-
165
- it("includes in_progress todos as pending", () => {
166
- const s = makeState([
167
- { id: 1, text: "working", status: "in_progress" },
168
- { id: 2, text: "next", status: "pending" },
169
- ]);
170
- expect(buildMinimalReminder(s)).toContain("#1 working");
171
- });
172
-
173
- it("excludes cancelled todos from reminder", () => {
174
- const s = makeState([
175
- { id: 1, text: "cancelled task", status: "cancelled" },
176
- { id: 2, text: "active", status: "in_progress" },
177
- ]);
178
- const out = buildMinimalReminder(s);
179
- expect(out).toContain("#2 active");
180
- expect(out).not.toContain("#1");
181
- });
182
-
183
- it("returns empty string when only cancelled remain", () => {
184
- expect(
185
- buildMinimalReminder(makeState([{ id: 1, text: "x", status: "cancelled" }])),
186
- ).toBe("");
187
- });
188
-
189
- it("returns empty string when no pending", () => {
190
- expect(buildMinimalReminder(makeState([{ id: 1, text: "done", status: "completed" }]))).toBe("");
191
- });
192
- });
101
+ // ── before_agent_start context injection ────────────
193
102
 
194
103
  describe("buildBeforeAgentStartMessage", () => {
195
104
  it("injects hidden context for pending tasks only", () => {
@@ -213,21 +122,12 @@ describe("buildBeforeAgentStartMessage", () => {
213
122
  expect(m!.message.content).toContain("搁置不等于完成");
214
123
  });
215
124
 
216
- it("excludes cancelled todos from context injection", () => {
125
+ it("includes in_progress todos as pending", () => {
217
126
  const s = makeState([
218
- { id: 1, text: "cancelled", status: "cancelled" },
219
- { id: 2, text: "active", status: "pending" },
127
+ { id: 1, text: "working", status: "in_progress" },
128
+ { id: 2, text: "next", status: "pending" },
220
129
  ]);
221
- const m = buildBeforeAgentStartMessage(s);
222
- expect(m).toBeDefined();
223
- expect(m!.message.content).toContain("#2: active");
224
- expect(m!.message.content).not.toContain("#1");
225
- });
226
-
227
- it("returns undefined when only cancelled remain", () => {
228
- expect(
229
- buildBeforeAgentStartMessage(makeState([{ id: 1, text: "x", status: "cancelled" }])),
230
- ).toBeUndefined();
130
+ expect(buildBeforeAgentStartMessage(s)!.message.content).toContain("#1: working");
231
131
  });
232
132
 
233
133
  it("returns undefined when list empty", () => {
@@ -260,7 +160,7 @@ describe("reconstructState", () => {
260
160
  expect(s.nextId).toBe(2);
261
161
  });
262
162
 
263
- it("uses the last todo entry and GCs older ones (splice from tail)", () => {
163
+ it("uses the last todo entry (不再 splice GC 旧条目)", () => {
264
164
  const entries = [
265
165
  todoEntry([{ id: 1, text: "old", status: "pending" }], 2),
266
166
  todoEntry([{ id: 5, text: "new", status: "completed" }], 6),
@@ -269,7 +169,19 @@ describe("reconstructState", () => {
269
169
  reconstructState(s, makeCtx(entries));
270
170
  expect(s.todos[0].id).toBe(5);
271
171
  expect(s.nextId).toBe(6);
272
- expect(entries).toHaveLength(1);
172
+ });
173
+
174
+ it("TC10: 不修改传入的 entries(纯读,不再 splice)", () => {
175
+ const entries = [
176
+ todoEntry([{ id: 1, text: "old", status: "pending" }], 2),
177
+ todoEntry([{ id: 5, text: "new", status: "completed" }], 6),
178
+ ];
179
+ const ctx = makeCtx(entries);
180
+ const seen = ctx.sessionManager.getEntries();
181
+ const lenBefore = seen.length;
182
+ const s = createTodoSessionState();
183
+ reconstructState(s, ctx);
184
+ expect(seen).toHaveLength(lenBefore); // reconstructState 未 splice 任何条目
273
185
  });
274
186
 
275
187
  it("migrates legacy status on replay", () => {
@@ -280,6 +192,14 @@ describe("reconstructState", () => {
280
192
  expect(s.todos[0].status).toBe("pending");
281
193
  });
282
194
 
195
+ it("migrates legacy cancelled → completed on replay (TC1)", () => {
196
+ const legacy = [{ id: 1, text: "a", status: "cancelled" }] as unknown as Todo[];
197
+ const entries = [todoEntry(legacy, 2)];
198
+ const s = createTodoSessionState();
199
+ reconstructState(s, makeCtx(entries));
200
+ expect(s.todos[0].status).toBe("completed");
201
+ });
202
+
283
203
  it("skips dirty (null/primitive) elements without throwing", () => {
284
204
  const entries = [
285
205
  todoEntry([null, { id: 1, text: "ok", status: "pending" }] as unknown as Todo[], 3),
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  addTodos,
6
6
  formatTodoLine,
7
+ formatTodoList,
7
8
  migrateTodo,
8
9
  type Todo,
9
10
  updateTodos,
@@ -25,8 +26,8 @@ describe("Todo data model", () => {
25
26
  expect(migrated.id).toBe(1);
26
27
  });
27
28
 
28
- it("should include exactly four valid statuses", () => {
29
- expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed", "cancelled"]);
29
+ it("VALID_STATUSES 仅三态(pending/in_progress/completed)", () => {
30
+ expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed"]);
30
31
  });
31
32
 
32
33
  it("should migrate verifying → in_progress", () => {
@@ -53,10 +54,12 @@ describe("Todo data model", () => {
53
54
  expect(migrated.status).toBe("pending");
54
55
  });
55
56
 
56
- it("should preserve cancelled status (FR-1 four-state)", () => {
57
+ it("TC1: 历史 cancelled completed(三态化降级,不丢数据)", () => {
57
58
  const todo = { id: 1, text: "dropped", status: "cancelled" } as unknown as Todo;
58
59
  const migrated = migrateTodo(todo);
59
- expect(migrated.status).toBe("cancelled");
60
+ expect(migrated.status).toBe("completed");
61
+ expect(migrated.text).toBe("dropped");
62
+ expect(migrated.id).toBe(1);
60
63
  });
61
64
 
62
65
  it("should throw on null/primitive input (dirty data guard)", () => {
@@ -71,7 +74,6 @@ describe("Todo data model", () => {
71
74
  describe("todo add", () => {
72
75
  it("should add todos with sequential IDs", () => {
73
76
  const result = addTodos([], 1, ["A", "B"]);
74
- expect(result.error).toBeUndefined();
75
77
  expect(result.newTodos).toHaveLength(2);
76
78
  expect(result.newTodos[0].id).toBe(1);
77
79
  expect(result.newTodos[1].id).toBe(2);
@@ -81,26 +83,24 @@ describe("todo add", () => {
81
83
  it("should append to existing todos", () => {
82
84
  const existing: Todo[] = [{ id: 1, text: "existing", status: "pending" }];
83
85
  const result = addTodos(existing, 2, ["new task"]);
84
- expect(result.error).toBeUndefined();
85
86
  expect(result.newTodos).toHaveLength(2);
86
87
  expect(result.newTodos[1].id).toBe(2);
87
88
  expect(result.newTodos[1].text).toBe("new task");
88
89
  expect(result.newNextId).toBe(3);
89
90
  });
90
91
 
91
- it("should return error when texts is empty", () => {
92
- const result = addTodos([], 1, []);
93
- expect(result.error).toBe("texts required");
92
+ it("TC6: should throw when texts is empty array", () => {
93
+ expect(() => addTodos([], 1, [])).toThrow(/requires texts/);
94
94
  });
95
95
 
96
- it("should return error when all texts are empty after trim", () => {
97
- const result = addTodos([], 1, [" ", " "]);
98
- expect(result.error).toBe("all texts empty");
96
+ it("TC6: should throw when any text is empty after trim (不再静默 filter)", () => {
97
+ // C1 决策:任一项 trim 后空串 throw,不再 filter 静默丢弃
98
+ expect(() => addTodos([], 1, [" ", "valid"])).toThrow(/empty or whitespace-only/);
99
+ expect(() => addTodos([], 1, [" ", " "])).toThrow(/empty or whitespace-only/);
99
100
  });
100
101
 
101
102
  it("should trim texts", () => {
102
103
  const result = addTodos([], 1, [" new task "]);
103
- expect(result.error).toBeUndefined();
104
104
  expect(result.newTodos[0].text).toBe("new task");
105
105
  });
106
106
  });
@@ -130,6 +130,18 @@ describe("todo update batch", () => {
130
130
  expect(result.updatedTodos[2].text).toBe("C done");
131
131
  });
132
132
 
133
+ it("TC6: trims text on apply(批量路径)", () => {
134
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
135
+ const result = updateTodos(todos, [{ id: 1, text: " B updated " }]);
136
+ expect(result.error).toBeUndefined();
137
+ expect(result.updatedTodos[0].text).toBe("B updated");
138
+ });
139
+
140
+ it("TC6: should throw when any batch text is empty after trim (不再静默跳过)", () => {
141
+ const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
142
+ expect(() => updateTodos(todos, [{ id: 1, text: " " }])).toThrow(/empty or whitespace-only/);
143
+ });
144
+
133
145
  it("should reject duplicate ids in updates[]", () => {
134
146
  const todos: Todo[] = [{ id: 1, text: "A", status: "pending" }];
135
147
  const result = updateTodos(todos, [
@@ -157,24 +169,31 @@ describe("todo update batch", () => {
157
169
  const result = updateTodos(todos, [{ id: 1, status: "banana" }]);
158
170
  expect(result.error).toContain("invalid status");
159
171
  });
160
-
161
- it("cancelled todo 不可恢复(status 更新拒绝)", () => {
162
- const todos: Todo[] = [{ id: 1, text: "dropped", status: "cancelled" }];
163
- const result = updateTodos(todos, [{ id: 1, status: "pending" }]);
164
- expect(result.error).toBe("id 1 is cancelled");
165
- expect(result.resultText).toContain("cannot be restored");
166
- expect(result.updatedTodos).toEqual(todos);
167
- });
168
172
  });
169
173
 
170
174
  // ── handleSingleUpdate 守卫(tool 单条路径)────
171
175
 
172
176
  describe("handleSingleUpdate guards (tool single path)", () => {
173
- it("cancelled todo + status cannot restore", () => {
177
+ it("TC6: text=' ' (纯空格)throw (trim 后空串拒绝,不只判 ===)", () => {
174
178
  const state = createTodoSessionState();
175
- state.todos = [{ id: 1, text: "dropped", status: "cancelled" }];
176
- expect(() => handleSingleUpdate(state, { action: "update", id: 1, status: "pending" }))
177
- .toThrow("#1 is cancelled (cannot restore)");
179
+ state.todos = [{ id: 1, text: "x", status: "pending" }];
180
+ expect(() => handleSingleUpdate(state, { action: "update", id: 1, text: " " }))
181
+ .toThrow(/empty or whitespace-only/);
182
+ });
183
+
184
+ it("trims text on apply", () => {
185
+ const state = createTodoSessionState();
186
+ state.todos = [{ id: 1, text: "x", status: "pending" }];
187
+ handleSingleUpdate(state, { action: "update", id: 1, text: " hello " });
188
+ expect(state.todos[0].text).toBe("hello");
189
+ });
190
+
191
+ it("TC11: 最后一个 completed 时无 'All todos completed' 收尾文案", () => {
192
+ const state = createTodoSessionState();
193
+ state.todos = [{ id: 1, text: "only", status: "in_progress" }];
194
+ const out = handleSingleUpdate(state, { action: "update", id: 1, status: "completed" });
195
+ expect(out).not.toContain("All todos completed");
196
+ expect(out).toContain("Updated todo #1");
178
197
  });
179
198
  });
180
199
 
@@ -210,7 +229,7 @@ describe("completed without interception", () => {
210
229
  });
211
230
  });
212
231
 
213
- // ── formatTodoLine ──────────────────────────────────
232
+ // ── formatTodoLine / formatTodoList ──────────────────
214
233
 
215
234
  describe("formatTodoLine", () => {
216
235
  it("should format pending todo", () => {
@@ -227,10 +246,20 @@ describe("formatTodoLine", () => {
227
246
  const todo: Todo = { id: 3, text: "task C", status: "completed" };
228
247
  expect(formatTodoLine(todo)).toBe("[x] #3: task C");
229
248
  });
249
+ });
250
+
251
+ describe("formatTodoList (TC3/TC5)", () => {
252
+ it("TC5: formats the full list by reusing formatTodoLine, joined by newline", () => {
253
+ const todos: Todo[] = [
254
+ { id: 1, text: "A", status: "pending" },
255
+ { id: 2, text: "B", status: "in_progress" },
256
+ { id: 3, text: "C", status: "completed" },
257
+ ];
258
+ expect(formatTodoList(todos)).toBe("[ ] #1: A\n[~] #2: B\n[x] #3: C");
259
+ });
230
260
 
231
- it("should format cancelled todo", () => {
232
- const todo: Todo = { id: 4, text: "task D", status: "cancelled" };
233
- expect(formatTodoLine(todo)).toBe("[-] #4: task D");
261
+ it("empty list empty string", () => {
262
+ expect(formatTodoList([])).toBe("");
234
263
  });
235
264
  });
236
265
 
@@ -314,68 +343,3 @@ describe("widget rendering", () => {
314
343
  expect(lines.length).toBe(11); // 1 header + ceil(19/2)=10 rows; Pi truncates at 10
315
344
  });
316
345
  });
317
-
318
- // ── agent_end logic (pure data) ─────────────────────
319
-
320
- describe("agent_end logic", () => {
321
- it("should detect stall when no todo activity for threshold rounds", () => {
322
- const STALL_THRESHOLD = 5;
323
- const userMessageCount = 10;
324
- const lastTodoCallCount = 3;
325
- const todos: Todo[] = [{ id: 1, text: "pending task", status: "pending" }];
326
-
327
- const isStalled =
328
- todos.length > 0 &&
329
- userMessageCount - lastTodoCallCount >= STALL_THRESHOLD;
330
-
331
- expect(isStalled).toBe(true);
332
- });
333
-
334
- it("should detect reminder when interval elapsed", () => {
335
- const REMINDER_INTERVAL = 2;
336
- const userMessageCount = 5;
337
- const lastTodoCallCount = 3;
338
- const todos: Todo[] = [{ id: 1, text: "task", status: "pending" }];
339
-
340
- const needsReminder =
341
- todos.length > 0 &&
342
- userMessageCount - lastTodoCallCount >= REMINDER_INTERVAL;
343
-
344
- expect(needsReminder).toBe(true);
345
- });
346
-
347
- it("should auto-clear when all completed and delay rounds elapsed", () => {
348
- const AUTO_CLEAR_DELAY_ROUNDS = 2;
349
- const userMessageCount = 7;
350
- const allCompletedAtCount = 4;
351
-
352
- const shouldClear =
353
- allCompletedAtCount !== null &&
354
- userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
355
-
356
- expect(shouldClear).toBe(true);
357
- });
358
-
359
- it("should not auto-clear when delay rounds not yet elapsed", () => {
360
- const AUTO_CLEAR_DELAY_ROUNDS = 2;
361
- const userMessageCount = 5;
362
- const allCompletedAtCount = 4;
363
-
364
- const shouldClear =
365
- allCompletedAtCount !== null &&
366
- userMessageCount - allCompletedAtCount >= AUTO_CLEAR_DELAY_ROUNDS;
367
-
368
- expect(shouldClear).toBe(false);
369
- });
370
-
371
- it("should pick first pending todo as next recommended", () => {
372
- const todos: Todo[] = [
373
- { id: 1, text: "A", status: "completed" },
374
- { id: 2, text: "B", status: "pending" },
375
- { id: 3, text: "C", status: "pending" },
376
- ];
377
- const next = todos.find((t) => t.status !== "completed");
378
- expect(next!.id).toBe(2);
379
- expect(next!.text).toBe("B");
380
- });
381
- });
@@ -4,6 +4,11 @@
4
4
  // verify the Correct/error STRINGS exist; these exercise the actual throw logic of
5
5
  // handleAdd/handleDelete, so a refactor cannot silently drop the dual-form detection.
6
6
  //
7
+ // Note: the schema layer (TodoParams, additionalProperties:false) already rejects
8
+ // dual-form payloads before they reach the handler in production. These handler-level
9
+ // tests are defense-in-depth — they ensure the handler ALSO throws clearly if called
10
+ // directly (e.g. by another extension bypassing schema validation).
11
+ //
7
12
  // handleAdd/handleDelete were exported specifically to enable these tests.
8
13
 
9
14
  import { describe, expect, it } from "vitest";
@@ -29,6 +34,13 @@ describe("handleAdd — text/texts dual-form detection", () => {
29
34
  expect(() => handleAdd(state, { action: "add", texts: [] })).toThrow(/requires texts/);
30
35
  });
31
36
 
37
+ it("TC7: 同时传 text 和 texts → throw(明确提示 add 只接受 texts)", () => {
38
+ const state = createTodoSessionState();
39
+ expect(() => handleAdd(state, { action: "add", text: "x", texts: ["y"] })).toThrow(
40
+ /only accepts texts array/,
41
+ );
42
+ });
43
+
32
44
  it("does NOT throw when correct 'texts' array provided", () => {
33
45
  const state = createTodoSessionState();
34
46
  expect(() => handleAdd(state, { action: "add", texts: ["write spec"] })).not.toThrow();