pi-plans 0.1.2 → 0.2.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.
@@ -1,234 +0,0 @@
1
- import * as assert from "node:assert/strict";
2
- import { spawnSync } from "node:child_process";
3
- import * as fs from "node:fs";
4
- import * as os from "node:os";
5
- import * as path from "node:path";
6
- import { after, before, describe, it } from "node:test";
7
- import {
8
- captureRepoSnapshot,
9
- completeCompletedItems,
10
- createExecutionPanelState,
11
- refreshExecutionPanel,
12
- restorePanelState,
13
- subtractSnapshots,
14
- toggleExpanded,
15
- truncateAnsi,
16
- visibleWidth,
17
- } from "../src/execution-panel.ts";
18
-
19
- function initGitRepo(dir: string): void {
20
- fs.mkdirSync(dir, { recursive: true });
21
- const git = (args: string[]): void => {
22
- const result = spawnSync("git", args, { cwd: dir, stdio: "ignore" });
23
- if (result.status !== 0) {
24
- throw new Error(`git ${args.join(" ")} failed`);
25
- }
26
- };
27
- git(["init"]);
28
- git(["config", "user.name", "Pi Plans Test"]);
29
- git(["config", "user.email", "test@example.com"]);
30
- fs.writeFileSync(path.join(dir, "tracked.txt"), "line 1\n");
31
- git(["add", "tracked.txt"]);
32
- git(["commit", "-m", "baseline"]);
33
- }
34
-
35
- describe("execution panel helpers", () => {
36
- let tmpRoot: string;
37
- let repo: string;
38
-
39
- before(() => {
40
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-panel-"));
41
- repo = path.join(tmpRoot, "repo");
42
- initGitRepo(repo);
43
- });
44
-
45
- after(() => {
46
- fs.rmSync(tmpRoot, { recursive: true, force: true });
47
- });
48
-
49
- it("captures diff snapshots and restores panel state", () => {
50
- const baseline = captureRepoSnapshot(repo);
51
- fs.appendFileSync(path.join(repo, "tracked.txt"), "line 2\n");
52
- fs.writeFileSync(path.join(repo, "new.txt"), "new file\n");
53
- const current = captureRepoSnapshot(repo);
54
- const delta = subtractSnapshots(baseline, current);
55
- assert.ok(delta.added >= 1);
56
- assert.ok(delta.files >= 1);
57
-
58
- const panel = createExecutionPanelState();
59
- panel.expanded = true;
60
- panel.baseline = baseline;
61
- panel.lastSnapshot = current;
62
- panel.touchedPaths = ["tracked.txt", "new.txt"];
63
- panel.itemSummaries = {
64
- "VC-001": {
65
- summary: {
66
- added: 1,
67
- removed: 0,
68
- files: 2,
69
- paths: ["tracked.txt", "new.txt"],
70
- },
71
- },
72
- };
73
- const restored = restorePanelState(panel);
74
- assert.ok(restored);
75
- assert.equal(restored?.expanded, true);
76
- assert.equal(restored?.baseline?.added, baseline.added);
77
- assert.equal(restored?.itemSummaries["VC-001"]?.summary?.files, 2);
78
- assert.deepEqual(restored?.touchedPaths, ["tracked.txt", "new.txt"]);
79
- });
80
-
81
- it("records completed-item summaries with touched paths", () => {
82
- fs.writeFileSync(path.join(repo, "tracked.txt"), "line 1\n");
83
- fs.rmSync(path.join(repo, "new.txt"), { force: true });
84
- const baseline = captureRepoSnapshot(repo);
85
- fs.appendFileSync(path.join(repo, "tracked.txt"), "line 2\n");
86
- fs.writeFileSync(path.join(repo, "new.txt"), "new file\n");
87
-
88
- const execution = {
89
- planPath: path.join(repo, "PLAN_v1.md"),
90
- items: [{ id: "VC-001", text: "`VC-001` demo item", done: true }],
91
- panel: createExecutionPanelState(),
92
- };
93
- execution.panel.baseline = baseline;
94
- execution.panel.lastSnapshot = baseline;
95
- execution.panel.touchedPaths = ["tracked.txt", "new.txt"];
96
-
97
- const summary = completeCompletedItems(execution as any, repo, ["VC-001"]);
98
- assert.ok(summary);
99
- assert.ok((summary?.added ?? 0) >= 1);
100
- assert.ok((summary?.files ?? 0) >= 1);
101
- assert.deepEqual(execution.panel.itemSummaries["VC-001"]?.summary?.paths, ["tracked.txt", "new.txt"]);
102
- });
103
-
104
- it("toggles expanded state", () => {
105
- const execution = { planPath: path.join(repo, "PLAN_v1.md"), items: [] as any[], panel: createExecutionPanelState() };
106
- assert.equal(toggleExpanded(execution), true);
107
- assert.equal(toggleExpanded(execution), false);
108
- });
109
-
110
- it("reuses one registered widget factory across refreshes", () => {
111
- const execution: any = {
112
- planPath: "/tmp/PLAN_v1.md",
113
- items: [{ id: "VC-001", text: "item", done: false }],
114
- startedAt: "2026-08-29T00:00:00Z",
115
- usage: { inToks: 0, outToks: 0 },
116
- panel: createExecutionPanelState(),
117
- };
118
- const factories: unknown[] = [];
119
- const statuses: Array<string | undefined> = [];
120
- let renderRequests = 0;
121
- const theme = { fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`, strikethrough: (text: string) => text };
122
- const ctx = {
123
- ui: {
124
- setWidget: (_key: string, factory: any) => void factories.push(factory),
125
- setStatus: (_key: string, value: string | undefined) => void statuses.push(value),
126
- theme: { fg: (_color: string, text: string) => text },
127
- },
128
- } as any;
129
-
130
- // Collapsed execution renders no widget — the footer owns the ⌛ line.
131
- refreshExecutionPanel(ctx, execution);
132
- refreshExecutionPanel(ctx, execution); // idempotent while collapsed
133
- assert.equal(factories.length, 0);
134
- assert.match(String(statuses.at(-1)), /⌛ plans 0\/1/);
135
-
136
- // Expanding registers the detail widget exactly once and clears the
137
- // footer ⌛ line (the panel renders its own header).
138
- toggleExpanded(execution);
139
- refreshExecutionPanel(ctx, execution);
140
- assert.equal(factories.length, 1);
141
- assert.equal(statuses.at(-1), undefined, "expanded must clear the footer ⌛ line");
142
- const widget = (factories[0] as any)({ requestRender: () => { renderRequests += 1; } }, theme);
143
- const expandedLines = widget.render(80);
144
- assert.match(expandedLines[0] ?? "", /⌛ plans 0\/1/, "panel header must be the first line");
145
- assert.ok(expandedLines.length >= 1, "expanded render should list items");
146
-
147
- refreshExecutionPanel(ctx, execution); // same host: reuse, no re-register
148
- assert.equal(factories.length, 1);
149
- assert.equal(renderRequests, 1, "same-host refresh must request a TUI render");
150
- execution.items[0]!.done = true;
151
- refreshExecutionPanel(ctx, execution);
152
- assert.equal(factories.length, 1);
153
- assert.equal(renderRequests, 2, "expanded progress refresh must request a TUI render");
154
- assert.match(widget.render(80)[0] ?? "", /⌛ plans 1\/1/, "refresh must invalidate the live widget cache");
155
-
156
- // Clearing releases the slot so a future run registers afresh.
157
- refreshExecutionPanel(ctx, null);
158
- refreshExecutionPanel(ctx, execution);
159
- assert.equal(factories.length, 3); // #2 was the explicit clear (undefined)
160
- });
161
- });
162
-
163
- describe("execution panel width safety", () => {
164
- const styledTheme = {
165
- fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`,
166
- strikethrough: (text: string) => text,
167
- };
168
-
169
- it("measures CJK code points as double width and zero-width runs as none", () => {
170
- assert.equal(visibleWidth("新的"), 4);
171
- assert.equal(visibleWidth("菜单配置"), 8);
172
- assert.equal(visibleWidth("\uFF46\uFF55\uFF4C\uFF4C"), 8); // fullwidth “full”
173
- assert.equal(visibleWidth("e\u0301"), 1); // combining acute
174
- assert.equal(visibleWidth("\u2764\uFE0F"), 1); // variation selector
175
- assert.equal(visibleWidth("a\u001b[31mbc\u001b[0m"), 3); // ANSI ignored
176
- });
177
-
178
- it("truncates styled CJK lines within the requested width", () => {
179
- const cjk = "新的 Nemotron AIME 配置和批量入口能验证 seed vector、model pin、arm set 和 500 budget;" +
180
- "evidence: pr… 后续还有很长的中文描述需要被安全截断。".repeat(6);
181
- for (const width of [20, 40, 138]) {
182
- const line = truncateAnsi(styledTheme.fg("accent", cjk), width);
183
- assert.ok(
184
- visibleWidth(line) <= width,
185
- `width ${width}: rendered ${visibleWidth(line)} columns`,
186
- );
187
- }
188
- });
189
-
190
- it("renders every expanded checklist line inside the terminal width", () => {
191
- const execution: any = {
192
- planPath: "/tmp/PLAN_v1.md",
193
- startedAt: "2026-08-29T00:00:00Z",
194
- usage: { inToks: 0, outToks: 0 },
195
- items: [
196
- {
197
- id: "VC-001",
198
- // Mirrors the crash-log line that rendered at 155 columns in a 138-column terminal.
199
- text: "`VC-001` covers `I-001` 和 `I-003`;pass condition: 新的 Nemotron AIME 配置和批量入口能验证 seed vector、model pin、arm set 和 500 budget;evidence: pr…",
200
- done: false,
201
- },
202
- { id: "VC-002", text: "`VC-002` plain ascii item that is also far too long to fit one line ".repeat(4), done: false },
203
- ],
204
- panel: createExecutionPanelState(),
205
- };
206
- toggleExpanded(execution);
207
-
208
- let factory: ((tui: unknown, theme: unknown) => any) | undefined;
209
- const statuses: Array<string | undefined> = [];
210
- refreshExecutionPanel(
211
- {
212
- ui: {
213
- setWidget: (_key: string, f: any) => void (factory = f),
214
- setStatus: (_key: string, value: string | undefined) => void statuses.push(value),
215
- theme: { fg: (_color: string, text: string) => text },
216
- },
217
- } as any,
218
- execution,
219
- );
220
- const widget = factory!({}, styledTheme);
221
-
222
- let sawEllipsis = false;
223
- for (const width of [20, 40, 80, 138]) {
224
- for (const line of widget.render(width)) {
225
- if (line.endsWith("…") || line.includes("…\u001b[0m")) sawEllipsis = true;
226
- assert.ok(
227
- visibleWidth(line) <= width,
228
- `width ${width}: rendered ${visibleWidth(line)} columns: ${JSON.stringify(line.slice(0, 60))}`,
229
- );
230
- }
231
- }
232
- assert.ok(sawEllipsis, "expected at least one truncated line with an ellipsis");
233
- });
234
- });