@zenodinh/pi-render 0.1.0 → 0.1.3

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.
Files changed (40) hide show
  1. package/README.md +20 -6
  2. package/index.ts +29 -33
  3. package/package.json +6 -2
  4. package/src/core/paint.ts +17 -2
  5. package/src/core/types/host.ts +15 -18
  6. package/src/core/types.ts +0 -1
  7. package/src/renderers/content/artifacts/cards.ts +13 -6
  8. package/src/renderers/content/code-panel.ts +53 -31
  9. package/src/renderers/content/index.ts +70 -21
  10. package/src/renderers/content/json-panel.ts +9 -17
  11. package/src/renderers/content/link-paragraph.ts +153 -0
  12. package/src/renderers/content/table.ts +4 -1
  13. package/src/renderers/tool/runtime.ts +44 -5
  14. package/src/renderers/tool/specs/search.ts +18 -10
  15. package/src/renderers/tool/types.ts +6 -1
  16. package/src/commands/canvas.test.ts +0 -150
  17. package/src/commands/canvas.ts +0 -57
  18. package/src/core/code-theme.test.ts +0 -266
  19. package/src/core/log.test.ts +0 -127
  20. package/src/core/paint.test.ts +0 -322
  21. package/src/core/registry.test.ts +0 -307
  22. package/src/core/settings.test.ts +0 -183
  23. package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
  24. package/src/renderers/content/artifacts/cards.test.ts +0 -216
  25. package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
  26. package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
  27. package/src/renderers/content/image-card.test.ts +0 -170
  28. package/src/renderers/content/panels.test.ts +0 -188
  29. package/src/renderers/content/table.test.ts +0 -209
  30. package/src/renderers/content/transformer.test.ts +0 -254
  31. package/src/renderers/tool/resolver.test.ts +0 -257
  32. package/src/renderers/tool/runtime.test.ts +0 -313
  33. package/src/renderers/tool/specs/bash.test.ts +0 -110
  34. package/src/renderers/tool/specs/codemode.test.ts +0 -212
  35. package/src/renderers/tool/specs/edit.test.ts +0 -260
  36. package/src/renderers/tool/specs/ls.test.ts +0 -173
  37. package/src/renderers/tool/specs/read.test.ts +0 -340
  38. package/src/renderers/tool/specs/search.test.ts +0 -197
  39. package/src/renderers/tool/specs/write.test.ts +0 -145
  40. package/themes/themes.test.ts +0 -251
@@ -1,266 +0,0 @@
1
- /**
2
- * code-theme.test.ts — the spec of createCodeTheme at its seam, with a counting engine double.
3
- *
4
- * Spec: createCodeTheme(bindEngine, {registry, log}) resolves `codeTheme` to a bundled name (unknown →
5
- * dracula-soft + one log line), highlights once per theme+lang+code, never hands the engine an empty or
6
- * over-80k block, and returns plain source lines when the engine or the bind throws.
7
- */
8
-
9
- import { describe, expect, it } from "vitest";
10
- import { type BindHighlightEngine, CODE_THEME_MODULE, type CodeThemeName, createCodeTheme } from "./code-theme.ts";
11
-
12
- // invented: the engine is faked, so the block only has to be two stable lines.
13
- const CODE = "const a = 1;\nlet b = 2;";
14
-
15
- /** One engine invocation: the theme it was bound to, plus the block it was asked for. */
16
- interface EngineCall {
17
- theme: CodeThemeName;
18
- code: string;
19
- lang: string;
20
- }
21
-
22
- /**
23
- * Engine double that counts: it records every call and stamps its bound theme into the lines, so the
24
- * resolved theme is visible in the output rather than only in the bind argument.
25
- */
26
- function countingEngine(options: { throwOnBind?: boolean; throwOnCall?: boolean } = {}): {
27
- bind: BindHighlightEngine;
28
- calls: EngineCall[];
29
- } {
30
- const calls: EngineCall[] = [];
31
- const bind: BindHighlightEngine = (theme) => {
32
- if (options.throwOnBind) throw new Error("bind failed");
33
- return (code, lang) => {
34
- calls.push({ theme, code, lang });
35
- if (options.throwOnCall) throw new Error("engine failed");
36
- return code.split("\n").map((line) => `«${theme}»${line}`);
37
- };
38
- };
39
- return { bind, calls };
40
- }
41
-
42
- /** Logger double: logOnce's once-per-key rule plus the readout, so "exactly one line" is observable. */
43
- function logDouble(): {
44
- logOnce(key: string, scope: string, message: string): void;
45
- entries: Array<{ key: string; scope: string; message: string }>;
46
- attempts: number;
47
- } {
48
- const entries: Array<{ key: string; scope: string; message: string }> = [];
49
- const seen = new Set<string>();
50
- const double = {
51
- entries,
52
- attempts: 0,
53
- logOnce(key: string, scope: string, message: string): void {
54
- double.attempts += 1;
55
- if (seen.has(key)) return;
56
- seen.add(key);
57
- entries.push({ key, scope, message });
58
- },
59
- };
60
- return double;
61
- }
62
-
63
- /**
64
- * Settings double over the injected contract. invented: the slice is contracted as
65
- * `Record<string, unknown>`, so any bag is a legal stored document; `set` models the registry's
66
- * "a write lands on the next read".
67
- */
68
- function registryDouble(initial: Record<string, unknown> = {}): {
69
- getSettings(key: string): Record<string, unknown>;
70
- set(patch: Record<string, unknown>): void;
71
- } {
72
- const slices = new Map<string, Record<string, unknown>>([["codetheme", { ...initial }]]);
73
- return {
74
- getSettings: (key) => slices.get(key) ?? {},
75
- set: (patch) => slices.set("codetheme", { ...(slices.get("codetheme") ?? {}), ...patch }),
76
- };
77
- }
78
-
79
- describe("createCodeTheme — theme resolution", () => {
80
- it("AC-1: an unknown codeTheme renders the default theme and logs exactly one line", async () => {
81
- const engine = countingEngine();
82
- const log = logDouble();
83
- // invented: the setting is persisted JSON, so an out-of-set name is the exact input AC-1 names.
84
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "nope" }), log });
85
-
86
- const first = await theme.highlight(CODE, "ts");
87
- const second = await theme.highlight(CODE, "ts");
88
- const joined = theme.highlightSync(CODE, "ts");
89
-
90
- // fails_when: a crash, empty output, or one log line per call.
91
- expect(first).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
92
- expect([second, joined]).toEqual([first, first.join("\n")]);
93
- expect(engine.calls.map((call) => call.theme)).toEqual(["dracula-soft"]);
94
- expect(log.entries.map((entry) => entry.key)).toEqual(["theme:nope"]);
95
- expect(log.entries[0]?.message).toContain("dracula-soft");
96
- expect(log.attempts).toBe(3);
97
- });
98
-
99
- it("AC-1: a bundled non-default theme is rendered without a log line", async () => {
100
- const engine = countingEngine();
101
- const log = logDouble();
102
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "one-dark-pro" }), log });
103
-
104
- // fails_when: the setting is ignored and the default theme always renders.
105
- expect(await theme.highlight(CODE, "ts")).toEqual(["«one-dark-pro»const a = 1;", "«one-dark-pro»let b = 2;"]);
106
- expect(log.entries).toEqual([]);
107
- });
108
-
109
- it("AC-1: an absent setting reads the default theme silently", async () => {
110
- const engine = countingEngine();
111
- const log = logDouble();
112
- const theme = createCodeTheme(engine.bind, { registry: registryDouble(), log });
113
-
114
- // fails_when: a fresh install logs a fallback line on every render.
115
- expect(await theme.highlight(CODE, "ts")).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
116
- expect(log.entries).toEqual([]);
117
- });
118
-
119
- it("AC-1: the descriptor is exported for T-28, and the injected registry has no write route", async () => {
120
- // The injected registry shape is read-only (getSettings only), so a registration here cannot compile.
121
- expect(CODE_THEME_MODULE).toEqual({
122
- key: "codetheme",
123
- name: "Code theme",
124
- defaultEnabled: true,
125
- settings: [
126
- { name: "codeTheme", kind: "enum", values: ["dracula-soft", "one-dark-pro"], default: "dracula-soft" },
127
- ],
128
- });
129
- });
130
- });
131
-
132
- describe("createCodeTheme — cache", () => {
133
- it("AC-2: the same theme+lang+code highlights once", async () => {
134
- const engine = countingEngine();
135
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
136
-
137
- const first = await theme.highlight(CODE, "ts");
138
- const second = await theme.highlight(CODE, "ts");
139
-
140
- // fails_when: every call re-highlights (the #21 defect class).
141
- expect(second).toEqual(first);
142
- expect(engine.calls).toHaveLength(1);
143
- });
144
-
145
- it("AC-2: lang and theme are part of the key", async () => {
146
- const engine = countingEngine();
147
- const registry = registryDouble({ codeTheme: "dracula-soft" });
148
- const theme = createCodeTheme(engine.bind, { registry });
149
-
150
- await theme.highlight(CODE, "ts");
151
- await theme.highlight(CODE, "py");
152
- registry.set({ codeTheme: "one-dark-pro" });
153
- await theme.highlight(CODE, "ts");
154
-
155
- // fails_when: the key ignores lang or theme and serves the previous theme's colours.
156
- expect(engine.calls.map((call) => `${call.theme}:${call.lang}`)).toEqual([
157
- "dracula-soft:ts",
158
- "dracula-soft:py",
159
- "one-dark-pro:ts",
160
- ]);
161
- });
162
-
163
- it("AC-2: highlightSync shares the cache with highlight", async () => {
164
- const engine = countingEngine();
165
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
166
-
167
- await theme.highlight(CODE, "ts");
168
- const joined = theme.highlightSync(CODE, "ts");
169
-
170
- // fails_when: the panel path keeps a second cache and highlights the same block again.
171
- expect(joined).toBe("«dracula-soft»const a = 1;\n«dracula-soft»let b = 2;");
172
- expect(engine.calls).toHaveLength(1);
173
- });
174
-
175
- it("AC-2: the cache is bounded at 128 entries and a hit counts as recent use", async () => {
176
- const engine = countingEngine();
177
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
178
-
179
- for (let i = 0; i < 128; i += 1) await theme.highlight(`line ${i}`, "ts");
180
- await theme.highlight("line 0", "ts");
181
- await theme.highlight("line 128", "ts");
182
- expect(engine.calls).toHaveLength(129); // the 129th entry evicted the oldest untouched one
183
-
184
- // fails_when: the cache grows without bound, or a hit does not count as recent use.
185
- expect(await theme.highlight("line 0", "ts")).toEqual(["«dracula-soft»line 0"]);
186
- expect(engine.calls).toHaveLength(129);
187
- expect(await theme.highlight("line 1", "ts")).toEqual(["«dracula-soft»line 1"]);
188
- expect(engine.calls).toHaveLength(130);
189
- });
190
- });
191
-
192
- describe("createCodeTheme — what never reaches the engine", () => {
193
- it("AC-3: a block past 80k characters returns its own plain lines", async () => {
194
- const engine = countingEngine();
195
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
196
- // invented: one character past the ported 80_000 gate (P render.ts:86).
197
- const over = `x${"y".repeat(80_000)}`;
198
-
199
- // fails_when: a blob this size enters shiki.
200
- expect(await theme.highlight(over, "ts")).toEqual([over]);
201
- expect(engine.calls).toEqual([]);
202
-
203
- // the gate is strict: exactly 80_000 characters reach the engine.
204
- await theme.highlight("y".repeat(80_000), "ts");
205
- expect(engine.calls).toHaveLength(1);
206
- });
207
-
208
- it("AC-3: empty code returns one empty line", async () => {
209
- const engine = countingEngine();
210
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
211
-
212
- // fails_when: an empty block enters shiki, or the panel path emits a stray newline.
213
- expect(await theme.highlight("", "ts")).toEqual([""]);
214
- expect(theme.highlightSync("", "ts")).toBe("");
215
- expect(engine.calls).toEqual([]);
216
- });
217
- });
218
-
219
- describe("createCodeTheme — degradation", () => {
220
- it("AC-4: a throwing engine returns plain lines and nothing escapes", async () => {
221
- const engine = countingEngine({ throwOnCall: true });
222
- const log = logDouble();
223
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }), log });
224
-
225
- // fails_when: a shiki failure kills a render.
226
- await expect(theme.highlight(CODE, "ts")).resolves.toEqual(["const a = 1;", "let b = 2;"]);
227
- expect(theme.highlightSync(CODE, "ts")).toBe("const a = 1;\nlet b = 2;");
228
- expect(log.entries.map((entry) => entry.key)).toEqual(["fail:ts"]);
229
- });
230
-
231
- it("AC-4: a throwing engine bind degrades the same way", async () => {
232
- const engine = countingEngine({ throwOnBind: true });
233
- const theme = createCodeTheme(engine.bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
234
-
235
- // fails_when: a bind failure (shiki not loaded yet, a bad theme) escapes into the render path.
236
- await expect(theme.highlight(CODE, "ts")).resolves.toEqual(["const a = 1;", "let b = 2;"]);
237
- });
238
-
239
- it("AC-4: an engine with no registry and no logger still highlights and still degrades", async () => {
240
- const engine = countingEngine();
241
- const theme = createCodeTheme(engine.bind);
242
-
243
- // fails_when: the optional collaborators are required, so a bare construction throws.
244
- expect(await theme.highlight(CODE, "ts")).toEqual(["«dracula-soft»const a = 1;", "«dracula-soft»let b = 2;"]);
245
- });
246
- });
247
-
248
- describe("createCodeTheme — contrast normalization (P render.ts:112-140)", () => {
249
- it("drops a foreground below the readability floor and keeps the rest", async () => {
250
- // invented: RGB triples chosen around the 72 luminance floor (20 below, 80 just above) and the
251
- // indexed-black form P treated as dark.
252
- const DIM = "\u001b[38;2;20;20;20m";
253
- const INDEXED = "\u001b[38;5;0m";
254
- const READABLE = "\u001b[38;2;80;80;80m";
255
- const RESET = "\u001b[0m";
256
- const bind: BindHighlightEngine = () => () => [
257
- `${DIM}dim${RESET}`,
258
- `${INDEXED}indexed${RESET}`,
259
- `${READABLE}readable${RESET}`,
260
- ];
261
- const theme = createCodeTheme(bind, { registry: registryDouble({ codeTheme: "dracula-soft" }) });
262
-
263
- // fails_when: an unreadable near-black token survives to the terminal.
264
- expect(await theme.highlight("x", "ts")).toEqual([`dim${RESET}`, `indexed${RESET}`, `${READABLE}readable${RESET}`]);
265
- });
266
- });
@@ -1,127 +0,0 @@
1
- /**
2
- * log.test.ts — createLogger's behavior through its seam: logLine / logOnce in, drain() out (T-06).
3
- *
4
- * No module internals appear here; every assertion reads the entries a test or the future /render
5
- * panel would read back.
6
- *
7
- * shape: none — test module, no runtime unit for DSG-1's trigger table to select a shape for.
8
- */
9
- // invented fixtures: the keys, scopes, and messages below are plain strings with no upstream payload to
10
- // record; "tool:call-42" is the key shape named in src/core/types/log.ts.
11
- import { describe, expect, test, vi } from "vitest";
12
- import { createLogger } from "./log.ts";
13
-
14
- describe("createLogger", () => {
15
- // spec: logOnce(key, …) appends one entry for a key; each distinct key appends its own.
16
- // fails_when: a repeated key adds a duplicate entry, or a second key collides onto the first.
17
- test("AC-1 logOnce keeps one entry per key, distinct keys do not collide", () => {
18
- const logger = createLogger();
19
-
20
- logger.logOnce("tool:call-42", "tool.bash", "first");
21
- logger.logOnce("tool:call-42", "tool.bash", "repeat of the same key");
22
- logger.logOnce("tool:call-43", "tool.bash", "a different key");
23
-
24
- expect(logger.drain().map((entry) => entry.message)).toEqual(["first", "a different key"]);
25
- });
26
-
27
- // spec: any sequence of logging calls returns without throwing, including drain() on a logger with
28
- // no entries; the render path calls these, so a throw is a permanently broken row.
29
- // fails_when: a logging call throws — the render path can be broken by its own diagnostics.
30
- test("AC-2 no logging call throws, even on hostile text", () => {
31
- const logger = createLogger();
32
- const longText = "x".repeat(200_000);
33
-
34
- expect(logger.drain()).toEqual([]);
35
-
36
- expect(() => {
37
- logger.logLine("", "");
38
- logger.logLine("tool.bash", "line\nwith newlines\tand a 🖼 surrogate pair");
39
- logger.logLine("tool.bash", longText);
40
- logger.logOnce("tool:call-42", "tool.bash", longText);
41
- logger.logOnce("tool:call-42", "tool.bash", "repeat");
42
- logger.drain();
43
- }).not.toThrow();
44
-
45
- expect(logger.drain()).toEqual([]);
46
- });
47
-
48
- // spec: a logging call writes nothing to process.stdout or process.stderr — pi owns the terminal
49
- // for the TUI, so a write lands inside the rendered frame.
50
- // fails_when: a diagnostic leaks to the terminal.
51
- test("AC-3 logging writes neither stdout nor stderr", () => {
52
- const logger = createLogger();
53
- const stdout = vi.spyOn(process.stdout, "write").mockReturnValue(true);
54
- const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
55
-
56
- try {
57
- logger.logLine("tool.bash", "line");
58
- logger.logOnce("tool:call-42", "tool.bash", "once");
59
- logger.logOnce("tool:call-42", "tool.bash", "once");
60
- expect(logger.drain()).toHaveLength(2);
61
- // Asserted before mockRestore(): restoring also discards the recorded calls.
62
- expect(stdout).not.toHaveBeenCalled();
63
- expect(stderr).not.toHaveBeenCalled();
64
- } finally {
65
- stdout.mockRestore();
66
- stderr.mockRestore();
67
- }
68
- });
69
-
70
- // spec: logLine(scope, message) leaves the entry attributable — that scope, that message, and a
71
- // capture time inside the call window.
72
- // fails_when: entries cannot be attributed to the module that logged them.
73
- test("AC-4 each entry keeps its scope, message, and capture time", () => {
74
- const logger = createLogger();
75
- const before = Date.now();
76
-
77
- logger.logLine("find", "no matches");
78
- logger.logOnce("tool:call-42", "tool.bash", "degraded row");
79
-
80
- const after = Date.now();
81
- const entries = logger.drain();
82
-
83
- expect(entries.map((entry) => entry.scope)).toEqual(["find", "tool.bash"]);
84
- expect(entries.map((entry) => entry.message)).toEqual(["no matches", "degraded row"]);
85
- for (const entry of entries) {
86
- expect(entry.time).toBeGreaterThanOrEqual(before);
87
- expect(entry.time).toBeLessThanOrEqual(after);
88
- }
89
- });
90
-
91
- // spec: past 256 entries the newest 256 survive in order and the oldest are dropped.
92
- // fails_when: the buffer grows unbounded, or it drops the newest instead of the oldest.
93
- test("AC-5 more than 256 entries keeps the newest 256 in order", () => {
94
- const logger = createLogger();
95
- for (let i = 0; i < 300; i += 1) logger.logLine("loop", `line-${i}`);
96
-
97
- const messages = logger.drain().map((entry) => entry.message);
98
-
99
- expect(messages).toHaveLength(256);
100
- expect(messages.at(0)).toBe("line-44");
101
- expect(messages.at(-1)).toBe("line-299");
102
- });
103
-
104
- // spec: drain() returns every captured entry in log order, and the buffer is empty afterwards.
105
- // fails_when: a drained buffer still holds entries, so a panel re-shows the same lines.
106
- test("drain() returns every captured entry and then empties the buffer", () => {
107
- const logger = createLogger();
108
- logger.logLine("tool.bash", "one");
109
- logger.logLine("find", "two");
110
-
111
- expect(logger.drain().map((entry) => entry.scope)).toEqual(["tool.bash", "find"]);
112
- expect(logger.drain()).toEqual([]);
113
- });
114
-
115
- // spec: "at most one entry per key" is a property of the logger, not of the current buffer — a
116
- // panel that drains every frame must not re-emit a key's line.
117
- // fails_when: drain() clears the dedup marks, so the same key logs again after a readout.
118
- test("logOnce keeps a key spent across drain()", () => {
119
- const logger = createLogger();
120
- logger.logOnce("tool:call-42", "tool.bash", "once");
121
- logger.drain();
122
-
123
- logger.logOnce("tool:call-42", "tool.bash", "again");
124
-
125
- expect(logger.drain()).toEqual([]);
126
- });
127
- });