@fyeeme/pi-todo 1.0.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/index.ts +222 -0
- package/package.json +61 -0
- package/src/commands.ts +457 -0
- package/src/prompts/todo.md +44 -0
- package/src/render.ts +295 -0
- package/src/restore.ts +33 -0
- package/src/state.ts +842 -0
- package/src/tool.ts +153 -0
- package/test/omp-alignment.test.ts +369 -0
- package/test/render.test.ts +213 -0
- package/test/state.test.ts +373 -0
- package/test/tool.test.ts +653 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import type { Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { renderTodoCall, renderTodoResult } from "../src/render.ts";
|
|
5
|
+
import type { TodoPhase, TodoToolDetails } from "../src/state.ts";
|
|
6
|
+
|
|
7
|
+
// Identity theme: assertions see omp's plain strings. Colors are structurally
|
|
8
|
+
// exercised by the renderer code (fg calls carry omp's color names).
|
|
9
|
+
const identityTheme = {
|
|
10
|
+
fg: (_name: string, text: string) => text,
|
|
11
|
+
bold: (text: string) => text,
|
|
12
|
+
strikethrough: (text: string) => text,
|
|
13
|
+
italic: (text: string) => text,
|
|
14
|
+
} as unknown as Theme;
|
|
15
|
+
|
|
16
|
+
function result(
|
|
17
|
+
op: TodoToolDetails["op"],
|
|
18
|
+
phases: TodoPhase[],
|
|
19
|
+
completedTasks?: TodoToolDetails["completedTasks"],
|
|
20
|
+
): AgentToolResult<TodoToolDetails> {
|
|
21
|
+
return { content: [], details: { op, phases, storage: "memory", completedTasks } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function options(isPartial = false, expanded = false): ToolRenderResultOptions {
|
|
25
|
+
return { expanded, isPartial };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function text(component: { render(width: number): string[] }): string {
|
|
29
|
+
return component
|
|
30
|
+
.render(200)
|
|
31
|
+
.map(line => line.trimEnd())
|
|
32
|
+
.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function phase(name: string, statuses: TodoPhase["tasks"][number]["status"][]): TodoPhase {
|
|
36
|
+
return { name, tasks: statuses.map((status, i) => ({ content: `${name.toLowerCase()}-${i + 1}`, status })) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Call line — omp renderCall: ⏳ Todo · <op> <task> <phase> N items
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
describe("todo render: call line (omp renderStatusLine shape)", () => {
|
|
44
|
+
it("renders the pending header with op meta", () => {
|
|
45
|
+
const out = text(renderTodoCall({ op: "done", task: "Wire workspace" }, identityTheme));
|
|
46
|
+
expect(out).toBe("⏳ Todo · done Wire workspace");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("joins multiple meta fragments and counts items", () => {
|
|
50
|
+
const out = text(
|
|
51
|
+
renderTodoCall(
|
|
52
|
+
{ op: "init", phase: "Auth", items: ["a", "b", "c"] } as never,
|
|
53
|
+
identityTheme,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
// omp joins a single op's parts with spaces (multi-op entries join " · ").
|
|
57
|
+
expect(out).toBe("⏳ Todo · init Auth 3 items");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("falls back to update when no op is present", () => {
|
|
61
|
+
const out = text(renderTodoCall({}, identityTheme));
|
|
62
|
+
expect(out).toBe("⏳ Todo · update");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Result — omp renderResult: ☑ Todo · N tasks header + phase tree
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe("todo render: result (omp renderer)", () => {
|
|
71
|
+
it("renders the omp header and a touched phase with tree glyphs and omp colors", () => {
|
|
72
|
+
const out = text(
|
|
73
|
+
renderTodoResult(
|
|
74
|
+
result("start", [phase("Foundation", ["completed", "in_progress", "pending"])]),
|
|
75
|
+
options(),
|
|
76
|
+
identityTheme,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
// omp header: tool.todo glyph accent + title + dim "· N tasks".
|
|
80
|
+
expect(out).toContain("☑ Todo · 3 tasks");
|
|
81
|
+
// Single-phase render has no phase header line and no tree indent.
|
|
82
|
+
expect(out).toContain("☑ foundation-1");
|
|
83
|
+
expect(out).toContain("☐ foundation-2");
|
|
84
|
+
expect(out).toContain("☐ foundation-3");
|
|
85
|
+
// No frame labels: collapsed selection is identity at 3 of cap 8.
|
|
86
|
+
expect(out).not.toContain("more todos");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("collapsed viewport shows the last closed row plus open work (omp #5873)", () => {
|
|
90
|
+
const out = text(
|
|
91
|
+
renderTodoResult(result("done", [phase("A", ["completed", "abandoned", "blocked"])]), options(), identityTheme),
|
|
92
|
+
);
|
|
93
|
+
// Closed lead = LAST closed task only (COLLAPSED_CLOSED_CONTEXT 1);
|
|
94
|
+
// open work (blocked) always renders.
|
|
95
|
+
expect(out).not.toContain("a-1");
|
|
96
|
+
expect(out).toContain("☐ a-2");
|
|
97
|
+
expect(out).toContain("☐ a-3 (blocked)");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders blocker notes", () => {
|
|
101
|
+
const phases: TodoPhase[] = [
|
|
102
|
+
{
|
|
103
|
+
name: "A",
|
|
104
|
+
tasks: [{ content: "a-1", status: "blocked", blocker: "ci down" }],
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
const out = text(renderTodoResult(result("block", phases), options(), identityTheme));
|
|
108
|
+
expect(out).toContain("a-1 (blocked: ci down)");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("collapses untouched phases to the omp one-line summary with roman numerals", () => {
|
|
112
|
+
const completed: NonNullable<TodoToolDetails["completedTasks"]> = [
|
|
113
|
+
{ phase: "Foundation", content: "foundation-1" },
|
|
114
|
+
];
|
|
115
|
+
const out = text(
|
|
116
|
+
renderTodoResult(
|
|
117
|
+
result("done", [phase("Foundation", ["completed", "in_progress"]), phase("Verify", ["pending"])], completed),
|
|
118
|
+
options(),
|
|
119
|
+
identityTheme,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
// Foundation is touched (completion transition + in_progress) → expanded.
|
|
123
|
+
expect(out).toContain("I. Foundation 1/2");
|
|
124
|
+
expect(out).toContain("└─ ☐ foundation-2");
|
|
125
|
+
// Verify untouched → one-line dim summary, tasks hidden.
|
|
126
|
+
expect(out).toContain("II. Verify 0/1");
|
|
127
|
+
expect(out).not.toContain("verify-1");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("expands every phase when the view is toggled expanded", () => {
|
|
131
|
+
const out = text(
|
|
132
|
+
renderTodoResult(
|
|
133
|
+
result("view", [phase("A", ["pending"]), phase("B", ["pending"])]),
|
|
134
|
+
options(false, true),
|
|
135
|
+
identityTheme,
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
expect(out).toContain("a-1");
|
|
139
|
+
expect(out).toContain("b-1");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("computeTouchedPhases consumes the call args via the render context (omp args fold)", () => {
|
|
143
|
+
// done on phase Two: One is untouched → one-line summary; Two expands.
|
|
144
|
+
const phases = [phase("One", ["pending"]), phase("Two", ["completed", "pending"])];
|
|
145
|
+
const completed: NonNullable<TodoToolDetails["completedTasks"]> = [{ phase: "Two", content: "two-1" }];
|
|
146
|
+
const out = text(
|
|
147
|
+
renderTodoResult(
|
|
148
|
+
result("done", phases, completed),
|
|
149
|
+
options(),
|
|
150
|
+
identityTheme,
|
|
151
|
+
{ op: "done", phase: "Two" },
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
expect(out).toContain("I. One 0/1");
|
|
155
|
+
expect(out).not.toContain("one-1");
|
|
156
|
+
expect(out).toContain("II. Two 1/2");
|
|
157
|
+
expect(out).toContain("two-2");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("multi-phase render indents tree lines under roman-numeral headers", () => {
|
|
161
|
+
const out = text(
|
|
162
|
+
renderTodoResult(
|
|
163
|
+
result("init", [phase("Foundation", ["in_progress"]), phase("Verify", ["pending"])]),
|
|
164
|
+
options(),
|
|
165
|
+
identityTheme,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
expect(out).toContain("I. Foundation 0/1");
|
|
169
|
+
expect(out).toContain(" └─ ☐ foundation-1");
|
|
170
|
+
expect(out).toContain("II. Verify 0/1");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("collapsed viewport caps at 8 rows with the omp more-todos summary", () => {
|
|
174
|
+
const tasks = Array.from({ length: 10 }, (_, i) => ({
|
|
175
|
+
content: `t-${i + 1}`,
|
|
176
|
+
status: (i === 0 ? "in_progress" : "pending") as never,
|
|
177
|
+
}));
|
|
178
|
+
const out = text(renderTodoResult(result("init", [{ name: "A", tasks }]), options(), identityTheme));
|
|
179
|
+
expect(out).toContain("… 2 more todos");
|
|
180
|
+
expect(out).not.toContain("t-10");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("keeps the last closed row visible above the open window (omp #5873 lead)", () => {
|
|
184
|
+
const tasks = [
|
|
185
|
+
{ content: "done-early", status: "completed" as const },
|
|
186
|
+
...Array.from({ length: 9 }, (_, i) => ({ content: `t-${i + 1}`, status: "pending" as const })),
|
|
187
|
+
];
|
|
188
|
+
const out = text(renderTodoResult(result("init", [{ name: "A", tasks }]), options(), identityTheme));
|
|
189
|
+
expect(out.indexOf("done-early")).toBeGreaterThanOrEqual(0);
|
|
190
|
+
expect(out).toContain("… 1 more todo");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("renders the omp empty-list fallback under the header", () => {
|
|
194
|
+
const out = text(
|
|
195
|
+
renderTodoResult(
|
|
196
|
+
{
|
|
197
|
+
content: [{ type: "text", text: "Todo list cleared." }],
|
|
198
|
+
details: { op: "rm", phases: [], storage: "memory" },
|
|
199
|
+
},
|
|
200
|
+
options(),
|
|
201
|
+
identityTheme,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
expect(out).toBe("☑ Todo · 0 tasks\n Todo list cleared.");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("renders the full tree while streaming — isPartial only flips the omp block state", () => {
|
|
208
|
+
const out = text(
|
|
209
|
+
renderTodoResult(result("init", [phase("A", ["pending"])]), options(true), identityTheme),
|
|
210
|
+
);
|
|
211
|
+
expect(out).toBe("☑ Todo · 1 tasks\n└─ ☐ a-1");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
applyOpsToPhases,
|
|
4
|
+
applyParams,
|
|
5
|
+
clonePhases,
|
|
6
|
+
formatSummary,
|
|
7
|
+
inferTodoOp,
|
|
8
|
+
isTodoPhase,
|
|
9
|
+
isTodoPhaseSnapshot,
|
|
10
|
+
normalizeInProgressTask,
|
|
11
|
+
openTasks,
|
|
12
|
+
type TodoOpEntry,
|
|
13
|
+
type TodoPhase,
|
|
14
|
+
} from "../src/state.ts";
|
|
15
|
+
|
|
16
|
+
function phasesWithTasks(...entries: Array<[string, ...string[]]>): TodoPhase[] {
|
|
17
|
+
return entries.map(([name, ...tasks]) => ({ name, tasks: tasks.map(content => ({ content, status: "pending" as const })) }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("todo state: init", () => {
|
|
21
|
+
it("installs a phased list, all tasks pending, in order", () => {
|
|
22
|
+
const { phases, errors } = applyParams(
|
|
23
|
+
[],
|
|
24
|
+
{
|
|
25
|
+
op: "init",
|
|
26
|
+
list: [
|
|
27
|
+
{ phase: "Foundation", items: ["Scaffold", "Wire"] },
|
|
28
|
+
{ phase: "Verify", items: ["Run tests"] },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
expect(errors).toEqual([]);
|
|
33
|
+
expect(phases.map(p => p.name)).toEqual(["Foundation", "Verify"]);
|
|
34
|
+
// normalizeInProgressTask auto-promotes the earliest pending task.
|
|
35
|
+
expect(phases[0].tasks[0]).toEqual({ content: "Scaffold", status: "in_progress" });
|
|
36
|
+
expect(phases[0].tasks[1].status).toBe("pending");
|
|
37
|
+
expect(phases[1].tasks[0].status).toBe("pending");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects duplicate task content atomically, leaving the previous list untouched", () => {
|
|
41
|
+
const previous = phasesWithTasks(["Foundation", "Scaffold"]);
|
|
42
|
+
const { phases, errors } = applyParams(clonePhases(previous), {
|
|
43
|
+
op: "init",
|
|
44
|
+
list: [
|
|
45
|
+
{ phase: "A", items: ["Novel task", "Novel task"] },
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
expect(errors).toEqual(['Duplicate task "Novel task" in init list']);
|
|
49
|
+
// init replaces the list; the untouched previous snapshot keeps its state.
|
|
50
|
+
expect(previous[0].tasks[0].status).toBe("pending");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rejects duplicate phase names", () => {
|
|
54
|
+
const { errors } = applyParams([], {
|
|
55
|
+
op: "init",
|
|
56
|
+
list: [
|
|
57
|
+
{ phase: "A", items: ["one"] },
|
|
58
|
+
{ phase: "A", items: ["two"] },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
expect(errors).toEqual(['Duplicate phase "A" in init list']);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects init without a list", () => {
|
|
65
|
+
const { errors } = applyParams([], { op: "init" });
|
|
66
|
+
expect(errors).toEqual(["Missing list for init operation"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("accepts the flattened single-phase shape via items", () => {
|
|
70
|
+
const { phases, errors } = applyParams([], { op: "init", items: ["one", "two"] });
|
|
71
|
+
expect(errors).toEqual([]);
|
|
72
|
+
expect(phases).toEqual([
|
|
73
|
+
{
|
|
74
|
+
name: "Tasks",
|
|
75
|
+
tasks: [
|
|
76
|
+
{ content: "one", status: "in_progress" },
|
|
77
|
+
{ content: "two", status: "pending" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("todo state: start", () => {
|
|
85
|
+
it("demotes every other in_progress task back to pending", () => {
|
|
86
|
+
const phases: TodoPhase[] = [
|
|
87
|
+
{ name: "A", tasks: [{ content: "a1", status: "in_progress" }] },
|
|
88
|
+
{ name: "B", tasks: [{ content: "b1", status: "pending" }] },
|
|
89
|
+
];
|
|
90
|
+
const { phases: updated, errors } = applyParams(phases, { op: "start", task: "b1" });
|
|
91
|
+
expect(errors).toEqual([]);
|
|
92
|
+
expect(updated[0].tasks[0].status).toBe("pending");
|
|
93
|
+
expect(updated[1].tasks[0].status).toBe("in_progress");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects unknown tasks without mutation", () => {
|
|
97
|
+
const phases = phasesWithTasks(["A", "a1"]);
|
|
98
|
+
const { errors } = applyParams(clonePhases(phases), { op: "start", task: "nope" });
|
|
99
|
+
expect(errors.join(" ")).toMatch(/not found/);
|
|
100
|
+
// The tool layer discards the batch on any error, so previous state wins.
|
|
101
|
+
expect(phases[0].tasks[0].status).toBe("pending");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("rejects start without task content", () => {
|
|
105
|
+
const { errors } = applyParams([], { op: "start" });
|
|
106
|
+
expect(errors).toEqual(["Missing task content"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("todo state: done and drop", () => {
|
|
111
|
+
it("marks a task completed without deleting it", () => {
|
|
112
|
+
const { phases, errors } = applyParams(phasesWithTasks(["A", "a1", "a2"]), { op: "done", task: "a1" });
|
|
113
|
+
expect(errors).toEqual([]);
|
|
114
|
+
expect(phases[0].tasks.find(t => t.content === "a1")?.status).toBe("completed");
|
|
115
|
+
expect(phases[0].tasks).toHaveLength(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("marks all tasks of a phase abandoned while keeping the phase", () => {
|
|
119
|
+
const { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"], ["B", "b1"]), { op: "drop", phase: "A" });
|
|
120
|
+
expect(phases[0].tasks.every(t => t.status === "abandoned")).toBe(true);
|
|
121
|
+
expect(phases).toHaveLength(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("rejects done for unknown phase", () => {
|
|
125
|
+
const { errors } = applyParams(phasesWithTasks(["A", "a1"]), { op: "done", phase: "Zed" });
|
|
126
|
+
expect(errors).toEqual(['Phase "Zed" not found']);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("todo state: block and unblock", () => {
|
|
131
|
+
it("blocks only actionable open work when targeting a phase", () => {
|
|
132
|
+
const phases: TodoPhase[] = [
|
|
133
|
+
{
|
|
134
|
+
name: "A",
|
|
135
|
+
tasks: [
|
|
136
|
+
{ content: "finished", status: "completed" },
|
|
137
|
+
{ content: "open", status: "pending" },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
const { phases: updated, errors } = applyParams(phases, { op: "block", phase: "A", reason: "waiting on CI" });
|
|
142
|
+
expect(errors).toEqual([]);
|
|
143
|
+
expect(updated[0].tasks.find(t => t.content === "finished")?.status).toBe("completed");
|
|
144
|
+
const blocked = updated[0].tasks.find(t => t.content === "open");
|
|
145
|
+
expect(blocked).toEqual({ content: "open", status: "blocked", blocker: "waiting on CI" });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("collapses whitespace runs in the reason to single spaces", () => {
|
|
149
|
+
const { phases } = applyParams(phasesWithTasks(["A", "a1"]), { op: "block", task: "a1", reason: "a\n\tb c" });
|
|
150
|
+
expect(phases[0].tasks[0].blocker).toBe("a b c");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("refines the blocker note when re-blocking", () => {
|
|
154
|
+
let phases = phasesWithTasks(["A", "a1"]);
|
|
155
|
+
({ phases } = applyParams(phases, { op: "block", task: "a1" }));
|
|
156
|
+
({ phases } = applyParams(phases, { op: "block", task: "a1", reason: "refined" }));
|
|
157
|
+
expect(phases[0].tasks[0]).toEqual({ content: "a1", status: "blocked", blocker: "refined" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("unblock returns a blocked task to pending and clears the note", () => {
|
|
161
|
+
const phases: TodoPhase[] = [
|
|
162
|
+
{
|
|
163
|
+
name: "A",
|
|
164
|
+
tasks: [
|
|
165
|
+
{ content: "a1", status: "in_progress" },
|
|
166
|
+
{ content: "a2", status: "blocked", blocker: "x" },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
const { phases: updated } = applyParams(phases, { op: "unblock", task: "a2" });
|
|
171
|
+
expect(updated[0].tasks[1]).toEqual({ content: "a2", status: "pending" });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects block and unblock without a target", () => {
|
|
175
|
+
expect(applyParams(phasesWithTasks(["A", "a1"]), { op: "block" }).errors).toEqual([
|
|
176
|
+
"block requires a task or phase target",
|
|
177
|
+
]);
|
|
178
|
+
expect(applyParams(phasesWithTasks(["A", "a1"]), { op: "unblock" }).errors).toEqual([
|
|
179
|
+
"unblock requires a task or phase target",
|
|
180
|
+
]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("todo state: rm", () => {
|
|
185
|
+
it("removes a single task", () => {
|
|
186
|
+
const { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"]), { op: "rm", task: "a1" });
|
|
187
|
+
expect(phases[0].tasks.map(t => t.content)).toEqual(["a2"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("clears one phase's tasks", () => {
|
|
191
|
+
const { phases } = applyParams(phasesWithTasks(["A", "a1"], ["B", "b1"]), { op: "rm", phase: "A" });
|
|
192
|
+
expect(phases[0].tasks).toEqual([]);
|
|
193
|
+
expect(phases[1].tasks).toHaveLength(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("clears all phases when no target is given", () => {
|
|
197
|
+
const { phases } = applyParams(phasesWithTasks(["A", "a1"], ["B", "b1"]), { op: "rm" });
|
|
198
|
+
expect(phases.every(p => p.tasks.length === 0)).toBe(true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("rejects unknown targets", () => {
|
|
202
|
+
const { errors } = applyParams(phasesWithTasks(["A", "a1"]), { op: "rm", task: "ghost" });
|
|
203
|
+
expect(errors.join(" ")).toMatch(/not found/);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("todo state: append", () => {
|
|
208
|
+
it("lazily creates the phase", () => {
|
|
209
|
+
const { phases, errors } = applyParams(phasesWithTasks(["A", "a1"]), { op: "append", phase: "New", items: ["n1"] });
|
|
210
|
+
expect(errors).toEqual([]);
|
|
211
|
+
expect(phases[1]).toEqual({ name: "New", tasks: [{ content: "n1", status: "pending" }] });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("rejects duplicates without half-applying", () => {
|
|
215
|
+
const previous = phasesWithTasks(["A", "a1"]);
|
|
216
|
+
const { phases, errors } = applyParams(clonePhases(previous), {
|
|
217
|
+
op: "append",
|
|
218
|
+
phase: "B",
|
|
219
|
+
items: ["novel", "a1"],
|
|
220
|
+
});
|
|
221
|
+
expect(errors).toEqual(['Task "a1" already exists']);
|
|
222
|
+
// The untouched original still holds only its own task (batch discarded).
|
|
223
|
+
expect(previous).toHaveLength(1);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("requires a phase and non-empty items", () => {
|
|
227
|
+
expect(applyParams([], { op: "append", items: ["x"] }).errors).toEqual(["Missing phase name for append operation"]);
|
|
228
|
+
expect(applyParams([], { op: "append", phase: "A", items: [] }).errors).toEqual([
|
|
229
|
+
"Missing items for append operation",
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("todo state: view is read-only", () => {
|
|
235
|
+
it("passes phases through and reports empty for fresh state", () => {
|
|
236
|
+
const previous = phasesWithTasks(["A", "a1"]);
|
|
237
|
+
const { phases, errors } = applyParams(previous, { op: "view" });
|
|
238
|
+
expect(errors).toEqual([]);
|
|
239
|
+
expect(phases[0].tasks[0].content).toBe("a1");
|
|
240
|
+
expect(formatSummary([], [], true)).toBe("Todo list is empty.");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("todo state: op inference", () => {
|
|
245
|
+
it("infers init from a non-empty list", () => {
|
|
246
|
+
expect(inferTodoOp({ list: [{ phase: "A", items: ["a"] }] }, false)).toBe("init");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("infers append from items plus phase", () => {
|
|
250
|
+
expect(inferTodoOp({ items: ["x"], phase: "A" }, true)).toBe("append");
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("infers init from bare items when nothing exists yet", () => {
|
|
254
|
+
expect(inferTodoOp({ items: ["x"] }, false)).toBe("init");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("does not infer from ambiguous targeting args", () => {
|
|
258
|
+
expect(inferTodoOp({ task: "a1" }, true)).toBeUndefined();
|
|
259
|
+
expect(inferTodoOp({ phase: "A" }, true)).toBeUndefined();
|
|
260
|
+
expect(inferTodoOp({ items: ["x"] }, true)).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("todo state: batch application", () => {
|
|
265
|
+
it("applies ops in sequence for /todo clear", () => {
|
|
266
|
+
const phases: TodoPhase[] = [
|
|
267
|
+
{
|
|
268
|
+
name: "A",
|
|
269
|
+
tasks: [
|
|
270
|
+
{ content: "a1", status: "completed" },
|
|
271
|
+
{ content: "a2", status: "in_progress" },
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
const { phases: updated, errors } = applyOpsToPhases(phases, [{ op: "rm", phase: "A" }, { op: "append", phase: "A", items: ["fresh"] }]);
|
|
276
|
+
expect(errors).toEqual([]);
|
|
277
|
+
expect(updated[0].tasks).toEqual([{ content: "fresh", status: "in_progress" }]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("todo state: normalization", () => {
|
|
282
|
+
it("demotes surplus in_progress tasks", () => {
|
|
283
|
+
const phases: TodoPhase[] = [
|
|
284
|
+
{
|
|
285
|
+
name: "A",
|
|
286
|
+
tasks: [
|
|
287
|
+
{ content: "a1", status: "in_progress" },
|
|
288
|
+
{ content: "a2", status: "in_progress" },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
normalizeInProgressTask(phases);
|
|
293
|
+
expect(phases[0].tasks.map(t => t.status)).toEqual(["in_progress", "pending"]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("auto-promotes the earliest pending task when none is in progress", () => {
|
|
297
|
+
const phases: TodoPhase[] = [
|
|
298
|
+
{ name: "A", tasks: [{ content: "a1", status: "completed" }] },
|
|
299
|
+
{ name: "B", tasks: [{ content: "b1", status: "pending" }] },
|
|
300
|
+
];
|
|
301
|
+
normalizeInProgressTask(phases);
|
|
302
|
+
expect(phases[1].tasks[0].status).toBe("in_progress");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("out-of-order completion can move the pointer back to an earlier phase", () => {
|
|
306
|
+
let { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"], ["B", "b1"]), { op: "done", task: "a1" });
|
|
307
|
+
({ phases } = applyParams(phases, { op: "done", task: "b1" }));
|
|
308
|
+
// b1 done; pointer returns to a2 (earliest open task).
|
|
309
|
+
expect(phases[0].tasks[1].status).toBe("in_progress");
|
|
310
|
+
expect(phases[1].tasks[0].status).toBe("completed");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("todo state: helpers", () => {
|
|
315
|
+
it("counts actionable open tasks, excluding settled and blocked work", () => {
|
|
316
|
+
const phases: TodoPhase[] = [
|
|
317
|
+
{
|
|
318
|
+
name: "A",
|
|
319
|
+
tasks: [
|
|
320
|
+
{ content: "a1", status: "completed" },
|
|
321
|
+
{ content: "a2", status: "abandoned" },
|
|
322
|
+
{ content: "a3", status: "pending" },
|
|
323
|
+
{ content: "a4", status: "in_progress" },
|
|
324
|
+
{ content: "a5", status: "blocked", blocker: "ci" },
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
];
|
|
328
|
+
expect(openTasks(phases).map(t => t.content)).toEqual(["a3", "a4"]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("validates phase snapshots for restore", () => {
|
|
332
|
+
expect(isTodoPhase({ name: "A", tasks: [{ content: "a", status: "pending" }] })).toBe(true);
|
|
333
|
+
expect(isTodoPhase({ name: "A", tasks: [{ content: "a", status: "bogus" }] })).toBe(false);
|
|
334
|
+
expect(isTodoPhase({ name: 3, tasks: [] })).toBe(false);
|
|
335
|
+
expect(isTodoPhaseSnapshot({ phases: [] })).toBe(true);
|
|
336
|
+
expect(isTodoPhaseSnapshot({ phases: [{ nope: true }] })).toBe(false);
|
|
337
|
+
expect(isTodoPhaseSnapshot(undefined)).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe("todo state: summary text", () => {
|
|
342
|
+
it("renders remaining items, overall counts, and the phase checklist", () => {
|
|
343
|
+
const phases: TodoPhase[] = [
|
|
344
|
+
{
|
|
345
|
+
name: "Foundation",
|
|
346
|
+
tasks: [
|
|
347
|
+
{ content: "scaffold", status: "completed" },
|
|
348
|
+
{ content: "wire", status: "in_progress" },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
{ name: "Verify", tasks: [{ content: "run tests", status: "blocked", blocker: "env" }] },
|
|
352
|
+
];
|
|
353
|
+
const text = formatSummary(phases, []);
|
|
354
|
+
expect(text).toContain("Remaining items (1):");
|
|
355
|
+
expect(text).toContain("- wire [in_progress] (Foundation)");
|
|
356
|
+
expect(text).toContain("Overall: 1/3 done, 1 open, 1 blocked.");
|
|
357
|
+
expect(text).toContain('Active phase 1/2 "Foundation" (1/2).');
|
|
358
|
+
expect(text).toContain("[X] scaffold");
|
|
359
|
+
expect(text).toContain("(blocked: env)");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("reports errors first and no-ops as cleared", () => {
|
|
363
|
+
expect(formatSummary([], ["boom"])).toBe("Errors: boom");
|
|
364
|
+
expect(formatSummary([], [])).toBe("Todo list cleared.");
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("todo state: params type", () => {
|
|
369
|
+
it("accepts every operation name in the entry type", () => {
|
|
370
|
+
const ops: TodoOpEntry["op"][] = ["init", "start", "done", "rm", "drop", "block", "unblock", "append", "view"];
|
|
371
|
+
expect(ops).toHaveLength(9);
|
|
372
|
+
});
|
|
373
|
+
});
|