@zhushanwen/pi-todo 0.6.1 → 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 +1 -1
- package/src/__tests__/gui.test.ts +2 -5
- package/src/__tests__/schema.test.ts +76 -0
- package/src/__tests__/steer.test.ts +27 -107
- package/src/__tests__/todo.test.ts +58 -94
- package/src/__tests__/tool-detectors.test.ts +12 -0
- package/src/__tests__/tool-prompt.test.ts +32 -52
- package/src/__tests__/tool-rpc.test.ts +3 -3
- package/src/handlers.ts +13 -64
- package/src/index.ts +2 -2
- package/src/model.ts +35 -42
- package/src/render.ts +5 -13
- package/src/state.ts +1 -5
- package/src/tool.ts +106 -85
package/package.json
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
// ──
|
|
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("
|
|
125
|
+
it("includes in_progress todos as pending", () => {
|
|
217
126
|
const s = makeState([
|
|
218
|
-
{ id: 1, text: "
|
|
219
|
-
{ id: 2, text: "
|
|
127
|
+
{ id: 1, text: "working", status: "in_progress" },
|
|
128
|
+
{ id: 2, text: "next", status: "pending" },
|
|
220
129
|
]);
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
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("
|
|
29
|
-
expect(VALID_STATUSES).toEqual(["pending", "in_progress", "completed"
|
|
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("
|
|
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("
|
|
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
|
|
92
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
expect(
|
|
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("
|
|
177
|
+
it("TC6: text=' ' (纯空格) → throw (trim 后空串拒绝,不只判 ===)", () => {
|
|
174
178
|
const state = createTodoSessionState();
|
|
175
|
-
state.todos = [{ id: 1, text: "
|
|
176
|
-
expect(() => handleSingleUpdate(state, { action: "update", id: 1,
|
|
177
|
-
.toThrow(
|
|
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("
|
|
232
|
-
|
|
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();
|