@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,322 +0,0 @@
1
- /**
2
- * paint.test.ts — the two painting seams: role → host token, and the degrade-to-plain contract.
3
- *
4
- * Tests enter at createRowPaint / createContentPaint only, and every theme is a plain object, so no
5
- * host package is imported (AC-4).
6
- */
7
-
8
- // shape: table-driven data + closures — trigger #4, two host-shape stand-ins parameterized by an
9
- // escape table; a class or a fixture framework would be ceremony over one fg lookup.
10
- import { describe, expect, it } from "vitest";
11
- import { createContentPaint, createRowPaint } from "./paint.ts";
12
- import type { HostTheme, MarkdownTheme } from "./types/host.ts";
13
-
14
- // Fixture provenance — host theme shape: recorded-from @earendil-works/pi-coding-agent@1.0.4,
15
- // dist/modes/interactive/theme/theme.js:201-216 (read 2026-10-06): fg() returns `<open>text\x1b[39m`,
16
- // and throws `Unknown theme color: <token>` when the active theme lacks the token.
17
- // Fixture provenance — dark palette: recorded-from themes/dracula-soft.json (2026-10-06), vars resolved
18
- // to truecolor RGB — toolTitle #f6f6f4, toolOutput/muted/searchMatchText/mdHr/mdQuote/mdQuoteBorder/
19
- // mdCodeBlockBorder #7b7f8b, accent #bf9eee, error #ee6666, warning #ffb86c, mdCode #62e884.
20
- // Fixture provenance — light + system: invented — the indexed and terminal-default escape forms the
21
- // host emits in those modes, each token distinct so a leaked escape names itself; pi-render reads no
22
- // palette, so only the escape form is contract-relevant.
23
-
24
- const TEXT = "bash";
25
- const FG_CLOSE = "\x1b[39m";
26
-
27
- /** One escape per host token; typed so a fixture cannot silently omit a token paint reads. */
28
- type EscapeTable = {
29
- toolTitle: string;
30
- toolOutput: string;
31
- muted: string;
32
- accent: string;
33
- error: string;
34
- warning: string;
35
- searchMatchText: string;
36
- hr: string;
37
- quote: string;
38
- quoteBorder: string;
39
- code: string;
40
- codeBlockBorder: string;
41
- };
42
-
43
- const DARK_OPEN: EscapeTable = {
44
- toolTitle: "\x1b[38;2;246;246;244m",
45
- toolOutput: "\x1b[38;2;123;127;139m",
46
- muted: "\x1b[38;2;123;127;139m",
47
- accent: "\x1b[38;2;191;158;238m",
48
- error: "\x1b[38;2;238;102;102m",
49
- warning: "\x1b[38;2;255;184;108m",
50
- searchMatchText: "\x1b[38;2;123;127;139m",
51
- hr: "\x1b[38;2;123;127;139m",
52
- quote: "\x1b[38;2;123;127;139m",
53
- quoteBorder: "\x1b[38;2;123;127;139m",
54
- code: "\x1b[38;2;98;232;132m",
55
- codeBlockBorder: "\x1b[38;2;123;127;139m",
56
- };
57
-
58
- const LIGHT_OPEN: EscapeTable = {
59
- toolTitle: "\x1b[38;5;61m",
60
- toolOutput: "\x1b[38;5;240m",
61
- muted: "\x1b[38;5;240m",
62
- accent: "\x1b[38;5;134m",
63
- error: "\x1b[38;5;167m",
64
- warning: "\x1b[38;5;179m",
65
- searchMatchText: "\x1b[38;5;135m",
66
- hr: "\x1b[38;5;240m",
67
- quote: "\x1b[38;5;242m",
68
- quoteBorder: "\x1b[38;5;241m",
69
- code: "\x1b[38;5;71m",
70
- codeBlockBorder: "\x1b[38;5;239m",
71
- };
72
-
73
- /** An empty theme token renders the terminal default — the host uses `\x1b[39m` for every such token. */
74
- const DEFAULT_OPEN: EscapeTable = Object.fromEntries(
75
- Object.keys(DARK_OPEN).map((token) => [token, "\x1b[39m"]),
76
- ) as EscapeTable;
77
-
78
- const FORMS: ReadonlyArray<[string, EscapeTable]> = [
79
- ["truecolor (dark)", DARK_OPEN],
80
- ["indexed (light)", LIGHT_OPEN],
81
- ["terminal default (system)", DEFAULT_OPEN],
82
- ];
83
-
84
- /** Mirrors Theme.fg: `<open>text\x1b[39m`, throwing on a token the fixture theme does not carry. */
85
- function hostFg(open: Record<string, string>): (key: string, text: string) => string {
86
- return (key, text) => {
87
- const sequence = open[key];
88
- if (sequence === undefined) throw new Error(`Unknown theme color: ${key}`);
89
- return `${sequence}${text}${FG_CLOSE}`;
90
- };
91
- }
92
-
93
- function themeWith(open: Record<string, string>): HostTheme {
94
- return { fg: hostFg(open), bold: (text) => text };
95
- }
96
-
97
- /** Mirrors getMarkdownTheme(): each member closes over the live theme's fg for its md* token. */
98
- function markdownThemeWith(open: Record<string, string>): MarkdownTheme {
99
- const fg = hostFg(open);
100
- return {
101
- hr: (text) => fg("hr", text),
102
- quote: (text) => fg("quote", text),
103
- quoteBorder: (text) => fg("quoteBorder", text),
104
- code: (text) => fg("code", text),
105
- codeBlockBorder: (text) => fg("codeBlockBorder", text),
106
- };
107
- }
108
-
109
- /** Marker stand-in: no escapes at all, so the recorded token list is the only signal paint can leak. */
110
- function recorder(): { fg: (key: string, text: string) => string; tokens: string[] } {
111
- const tokens: string[] = [];
112
- return {
113
- tokens,
114
- fg: (key, text) => {
115
- tokens.push(key);
116
- return `«${key}»${text}«/${key}»`;
117
- },
118
- };
119
- }
120
-
121
- // biome-ignore lint/suspicious/noControlCharactersInRegex: the ESC introducer is exactly what we scan for
122
- const ESCAPE_SEQUENCE = /\x1b\[[0-9;]*m/g;
123
- const escapesIn = (text: string): string[] => text.match(ESCAPE_SEQUENCE) ?? [];
124
-
125
- describe("createRowPaint — region 1", () => {
126
- it("AC-1: every row role wraps through its own host token", () => {
127
- const marker = recorder();
128
- const paint = createRowPaint({ fg: marker.fg, bold: (text) => text });
129
- const wrapped = [
130
- paint.title(TEXT),
131
- paint.output(TEXT),
132
- paint.muted(TEXT),
133
- paint.accent(TEXT),
134
- paint.error(TEXT),
135
- paint.warning(TEXT),
136
- paint.gutter(TEXT),
137
- paint.match(TEXT),
138
- ];
139
- // fails_when: a role reads the wrong token, or returns its text unwrapped
140
- expect(marker.tokens).toEqual([
141
- "toolTitle",
142
- "toolOutput",
143
- "muted",
144
- "accent",
145
- "error",
146
- "warning",
147
- "muted",
148
- "searchMatchText",
149
- ]);
150
- expect(wrapped).toEqual([
151
- "«toolTitle»bash«/toolTitle»",
152
- "«toolOutput»bash«/toolOutput»",
153
- "«muted»bash«/muted»",
154
- "«accent»bash«/accent»",
155
- "«error»bash«/error»",
156
- "«warning»bash«/warning»",
157
- "«muted»bash«/muted»",
158
- "«searchMatchText»bash«/searchMatchText»",
159
- ]);
160
- });
161
-
162
- it.each(FORMS)("AC-1: %s escapes reach every role byte-exact", (_form, open) => {
163
- const paint = createRowPaint(themeWith(open));
164
- const outputs = [
165
- paint.title(TEXT),
166
- paint.output(TEXT),
167
- paint.muted(TEXT),
168
- paint.accent(TEXT),
169
- paint.error(TEXT),
170
- paint.warning(TEXT),
171
- paint.gutter(TEXT),
172
- paint.match(TEXT),
173
- ];
174
- // fails_when: paint rewrites, drops, or double-wraps the theme's own escape
175
- expect(outputs).toEqual([
176
- `${open.toolTitle}${TEXT}${FG_CLOSE}`,
177
- `${open.toolOutput}${TEXT}${FG_CLOSE}`,
178
- `${open.muted}${TEXT}${FG_CLOSE}`,
179
- `${open.accent}${TEXT}${FG_CLOSE}`,
180
- `${open.error}${TEXT}${FG_CLOSE}`,
181
- `${open.warning}${TEXT}${FG_CLOSE}`,
182
- `${open.muted}${TEXT}${FG_CLOSE}`,
183
- `${open.searchMatchText}${TEXT}${FG_CLOSE}`,
184
- ]);
185
- });
186
-
187
- it("AC-1: a token the active theme lacks degrades that role, not the frame", () => {
188
- const paint = createRowPaint(themeWith({ toolTitle: "\x1b[38;2;246;246;244m" }));
189
- // fails_when: an exotic theme crashes paint (host fg throws `Unknown theme color`)
190
- expect(paint.title(TEXT)).toBe("\x1b[38;2;246;246;244mbash\x1b[39m");
191
- expect([paint.output(TEXT), paint.muted(TEXT), paint.error(TEXT), paint.match(TEXT)]).toEqual([
192
- "bash",
193
- "bash",
194
- "bash",
195
- "bash",
196
- ]);
197
- });
198
-
199
- it("AC-4: a plain two-member object satisfies the seam with no host package", () => {
200
- const bolded: string[] = [];
201
- const theme = {
202
- fg: (key: string, text: string): string => `«${key}»${text}«/${key}»`,
203
- bold: (text: string): string => {
204
- bolded.push(text);
205
- return `«bold»${text}«/bold»`;
206
- },
207
- };
208
- const paint = createRowPaint(theme);
209
- // fails_when: paint requires the host Theme instance rather than the structural shape
210
- expect(paint.title(TEXT)).toBe("«toolTitle»bash«/toolTitle»");
211
- expect(bolded).toEqual([]);
212
- });
213
-
214
- it("reads the theme live on every call — no cache to invalidate", () => {
215
- const theme = themeWith({ toolTitle: "\x1b[38;2;1;2;3m" });
216
- const paint = createRowPaint(theme);
217
- expect(paint.title(TEXT)).toBe("\x1b[38;2;1;2;3mbash\x1b[39m");
218
- theme.fg = hostFg({ toolTitle: "\x1b[38;2;4;5;6m" }); // a theme switch swaps the host proxy in place
219
- // fails_when: the factory memoizes the escape, so the next frame paints the old theme
220
- expect(paint.title(TEXT)).toBe("\x1b[38;2;4;5;6mbash\x1b[39m");
221
- });
222
- });
223
-
224
- describe("createContentPaint — region 2", () => {
225
- it("AC-2: rule uses hr when the theme carries it", () => {
226
- const paint = createContentPaint(markdownThemeWith(LIGHT_OPEN));
227
- // fails_when: rule prefers the quoteBorder fallback while hr is available
228
- expect(paint.rule(TEXT)).toBe("\x1b[38;5;240mbash\x1b[39m");
229
- });
230
-
231
- it("AC-2: rule falls back to quoteBorder when the theme lacks hr", () => {
232
- const paint = createContentPaint({ quoteBorder: (text) => `«quoteBorder»${text}«/quoteBorder»` });
233
- // fails_when: a missing hr throws or returns the text unstyled instead of using the fallback
234
- expect(paint.rule(TEXT)).toBe("«quoteBorder»bash«/quoteBorder»");
235
- });
236
-
237
- it("AC-2: rule degrades to plain text with zero escapes when both tokens are missing", () => {
238
- const rule = createContentPaint({}).rule(TEXT);
239
- // fails_when: a missing token throws, or paint substitutes a literal fallback color
240
- expect(rule).toBe("bash");
241
- expect(rule).not.toContain("\x1b");
242
- });
243
-
244
- it("AC-2: a throwing hr falls through to quoteBorder", () => {
245
- const paint = createContentPaint({
246
- hr: () => {
247
- throw new Error("Unknown theme color: mdHr");
248
- },
249
- quoteBorder: (text) => `«quoteBorder»${text}«/quoteBorder»`,
250
- });
251
- // fails_when: a live-proxy throw during a theme switch takes the frame down
252
- expect(paint.rule(TEXT)).toBe("«quoteBorder»bash«/quoteBorder»");
253
- });
254
-
255
- it("AC-1: quote, code and codeBlockBorder use their own token, and degrade to plain when absent", () => {
256
- const styled = createContentPaint(markdownThemeWith(LIGHT_OPEN));
257
- expect([styled.quote(TEXT), styled.code(TEXT), styled.codeBlockBorder(TEXT)]).toEqual([
258
- "\x1b[38;5;242mbash\x1b[39m",
259
- "\x1b[38;5;71mbash\x1b[39m",
260
- "\x1b[38;5;239mbash\x1b[39m",
261
- ]);
262
- const bare = createContentPaint({});
263
- // fails_when: a missing md token throws or paints a literal color
264
- expect([bare.rule(TEXT), bare.quote(TEXT), bare.code(TEXT), bare.codeBlockBorder(TEXT)]).toEqual([
265
- "bash",
266
- "bash",
267
- "bash",
268
- "bash",
269
- ]);
270
- });
271
-
272
- it("AC-1: a markdown theme whose members all throw degrades every role to plain", () => {
273
- const boom = (): string => {
274
- throw new Error("Unknown theme color: mdHr");
275
- };
276
- const paint = createContentPaint({ hr: boom, quote: boom, quoteBorder: boom, code: boom, codeBlockBorder: boom });
277
- // fails_when: an exotic markdown theme crashes paint
278
- expect([paint.rule(TEXT), paint.quote(TEXT), paint.code(TEXT), paint.codeBlockBorder(TEXT)]).toEqual([
279
- "bash",
280
- "bash",
281
- "bash",
282
- "bash",
283
- ]);
284
- });
285
-
286
- it("reads the markdown closures live on every call — no cache to invalidate", () => {
287
- const mdTheme: MarkdownTheme = { hr: (text) => `«hr:1»${text}` };
288
- const paint = createContentPaint(mdTheme);
289
- expect(paint.rule(TEXT)).toBe("«hr:1»bash");
290
- mdTheme.hr = (text) => `«hr:2»${text}`;
291
- // fails_when: the factory captures the closure once, so a theme switch does not repaint
292
- expect(paint.rule(TEXT)).toBe("«hr:2»bash");
293
- });
294
- });
295
-
296
- describe("createRowPaint / createContentPaint — the escape boundary", () => {
297
- it("AC-3: every escape in the output is one the theme itself emitted", () => {
298
- const rows = createRowPaint(themeWith(LIGHT_OPEN));
299
- const content = createContentPaint(markdownThemeWith(LIGHT_OPEN));
300
- const outputs = [
301
- rows.title(TEXT),
302
- rows.output(TEXT),
303
- rows.muted(TEXT),
304
- rows.accent(TEXT),
305
- rows.error(TEXT),
306
- rows.warning(TEXT),
307
- rows.gutter(TEXT),
308
- rows.match(TEXT),
309
- content.rule(TEXT),
310
- content.quote(TEXT),
311
- content.code(TEXT),
312
- content.codeBlockBorder(TEXT),
313
- ];
314
- const allowed = new Set([...Object.values(LIGHT_OPEN), FG_CLOSE]);
315
- const emitted = outputs.flatMap(escapesIn);
316
- // 12 roles × (token open + fg close): keeps the leak scan below from passing on an empty set
317
- expect(emitted).toHaveLength(24);
318
- const leaks = emitted.filter((sequence) => !allowed.has(sequence));
319
- // fails_when: paint adds an escape of its own — a hardcoded color or a reset
320
- expect(leaks).toEqual([]);
321
- });
322
- });
@@ -1,307 +0,0 @@
1
- /**
2
- * registry through its seam — createRegistry(store, log), driven with a fake store and fake logger.
3
- *
4
- * Every scenario's first meaningful action is a createRegistry call; nothing below the seam (the Map,
5
- * the validators, readEntry) is imported.
6
- *
7
- * invented: the two doubles stand in for T-07 core/settings and T-06 core/log, which land in the same
8
- * wave; they satisfy the structural contracts and record every call in order.
9
- * invented: settings documents are hand-built to the SA §3 literal (version + modules map) — T-07 owns
10
- * the on-disk file, so there is no recorded payload to reuse here.
11
- */
12
- import { describe, expect, test } from "vitest";
13
- import { createRegistry } from "./registry.ts";
14
- import type { LogEntry, Logger } from "./types/log.ts";
15
- import type { ModuleDescriptor, SettingSpec } from "./types/registry.ts";
16
- import type { SettingsDoc, SettingsStore } from "./types/settings.ts";
17
-
18
- const EMPTY_DOC: SettingsDoc = { version: 1, modules: {} };
19
-
20
- const COLLAPSE_AT: SettingSpec = { name: "collapseAt", kind: "number", default: 5 };
21
- const COMPACT: SettingSpec = { name: "compact", kind: "toggle", default: false };
22
- const CODE_THEME: SettingSpec = {
23
- name: "codeTheme",
24
- kind: "enum",
25
- values: ["dracula-soft", "one-dark-pro"],
26
- default: "dracula-soft",
27
- };
28
-
29
- const BASH: ModuleDescriptor = { key: "row.bash", name: "Bash row", defaultEnabled: true, settings: [COLLAPSE_AT] };
30
- const TABLE: ModuleDescriptor = {
31
- key: "content.table",
32
- name: "Table",
33
- defaultEnabled: true,
34
- settings: [COLLAPSE_AT, COMPACT],
35
- };
36
- const CODETHEME: ModuleDescriptor = {
37
- key: "codetheme",
38
- name: "Code theme",
39
- defaultEnabled: true,
40
- settings: [CODE_THEME],
41
- };
42
-
43
- interface FakeStore extends SettingsStore {
44
- /** Every store interaction in call order — a read-path disk touch shows up here. Required. */
45
- readonly calls: string[];
46
- /** Documents handed to save(), in order. Required. */
47
- readonly saved: SettingsDoc[];
48
- }
49
-
50
- function fakeStore(initial: SettingsDoc): FakeStore {
51
- const calls: string[] = [];
52
- const saved: SettingsDoc[] = [];
53
- // The store owns its document (T-07 parses the file per instance), so a write cannot leak between tests.
54
- let doc = structuredClone(initial);
55
- return {
56
- calls,
57
- saved,
58
- load() {
59
- calls.push("load");
60
- return doc; // cached, like T-07: a re-read must come from an explicit load call, not implicitly
61
- },
62
- save(next) {
63
- calls.push("save");
64
- doc = next;
65
- saved.push(next);
66
- },
67
- };
68
- }
69
-
70
- interface FakeLog {
71
- readonly log: Logger;
72
- readonly entries: LogEntry[];
73
- }
74
-
75
- function fakeLog(): FakeLog {
76
- const entries: LogEntry[] = [];
77
- const seen = new Set<string>();
78
- const log: Logger = {
79
- logLine(scope, message) {
80
- entries.push({ scope, message, time: 0 });
81
- },
82
- logOnce(key, scope, message) {
83
- if (seen.has(key)) return;
84
- seen.add(key);
85
- entries.push({ scope, message, time: 0 });
86
- },
87
- drain() {
88
- return entries.splice(0, entries.length);
89
- },
90
- };
91
- return { log, entries };
92
- }
93
-
94
- describe("registry", () => {
95
- // spec: defineModule(row.bash, defaultEnabled true); setSettings("row.bash", { enabled: false }) ->
96
- // the NEXT isEnabled("row.bash") is false and the saved doc carries enabled: false.
97
- // fails_when: a disable never reaches the next read (the predecessor's toggle freeze).
98
- test("AC-1 a settings write lands on the next isEnabled read", () => {
99
- const store = fakeStore(EMPTY_DOC);
100
- const registry = createRegistry(store, fakeLog().log);
101
- registry.defineModule(BASH);
102
-
103
- expect(registry.isEnabled("row.bash")).toBe(true);
104
-
105
- registry.setSettings("row.bash", { enabled: false });
106
-
107
- expect(registry.isEnabled("row.bash")).toBe(false);
108
- expect(store.saved).toHaveLength(1);
109
- expect(store.saved[0]?.modules["row.bash"]?.enabled).toBe(false);
110
- });
111
-
112
- // spec: isEnabled("row.nope") -> false; getSettings("row.nope") -> {}; neither throws.
113
- // fails_when: a lookup for a key nothing registered throws.
114
- test("AC-2 an unknown key reads false and {} without throwing", () => {
115
- const registry = createRegistry(fakeStore(EMPTY_DOC), fakeLog().log);
116
-
117
- expect(registry.isEnabled("row.nope")).toBe(false);
118
- expect(registry.getSettings("row.nope")).toEqual({});
119
- });
120
-
121
- // spec: defineModule(BASH) then two more descriptors under "row.bash" -> the first name/default/
122
- // settings survive and exactly one log entry fires.
123
- // fails_when: a later descriptor overwrites the first, or each duplicate logs again.
124
- test("AC-3 a duplicate define keeps the first descriptor and logs exactly once", () => {
125
- const store = fakeStore(EMPTY_DOC);
126
- const { log, entries } = fakeLog();
127
- const registry = createRegistry(store, log);
128
-
129
- registry.defineModule(BASH);
130
- registry.defineModule({ ...BASH, name: "Impostor", defaultEnabled: false });
131
- registry.defineModule({
132
- ...BASH,
133
- name: "Impostor again",
134
- settings: [{ name: "other", kind: "toggle", default: false }],
135
- });
136
-
137
- expect(registry.isEnabled("row.bash")).toBe(true);
138
- expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
139
- expect(entries).toHaveLength(1);
140
- expect(entries[0]?.message).toContain("row.bash");
141
- });
142
-
143
- // spec: stored row.bash.settings = { collapseAt: "3" } (a string under a number spec) with
144
- // content.table.settings = { collapseAt: 7, ghost: 1 } -> row.bash reads the default 5, content.table
145
- // reads 7 with the unspec'd "ghost" dropped.
146
- // fails_when: one malformed key corrupts the others (or the malformed value is handed to a renderer).
147
- test("AC-4 a malformed stored value reads its default while other keys read stored values", () => {
148
- const store = fakeStore({
149
- version: 1,
150
- modules: {
151
- "row.bash": { enabled: true, settings: { collapseAt: "3" } },
152
- "content.table": { enabled: false, settings: { collapseAt: 7, ghost: 1 } },
153
- },
154
- });
155
- const { log, entries } = fakeLog();
156
- const registry = createRegistry(store, log);
157
- registry.defineModule(BASH);
158
- registry.defineModule(TABLE);
159
-
160
- expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
161
- expect(registry.getSettings("content.table")).toEqual({ collapseAt: 7, compact: false });
162
- expect(registry.isEnabled("content.table")).toBe(false);
163
- expect(entries).toHaveLength(1);
164
- });
165
-
166
- // spec: stored row.bash = 7 (not a slice) -> row.bash reads defaultEnabled true plus spec defaults
167
- // while content.table still reads its stored enabled: false and collapseAt: 7.
168
- // fails_when: a malformed module entry poisons its neighbours or throws at boot.
169
- test("AC-4 a malformed stored entry reads that module's defaults while other keys read stored values", () => {
170
- const incomplete = {
171
- version: 1,
172
- modules: { "row.bash": 7, "content.table": { enabled: false, settings: { collapseAt: 7 } } },
173
- };
174
- const store = fakeStore(incomplete as unknown as SettingsDoc); // boundary: the disk can hold any JSON
175
- const { log, entries } = fakeLog();
176
- const registry = createRegistry(store, log);
177
- registry.defineModule(BASH);
178
- registry.defineModule(TABLE);
179
-
180
- expect(registry.isEnabled("row.bash")).toBe(true);
181
- expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
182
- expect(registry.isEnabled("content.table")).toBe(false);
183
- expect(registry.getSettings("content.table")).toEqual({ collapseAt: 7, compact: false });
184
- expect(entries).toHaveLength(1);
185
- });
186
-
187
- // spec: one createRegistry (one store.load), 5 read invocations, one setSettings -> the store saw
188
- // exactly ["load", "save"]; the reads logged nothing.
189
- // fails_when: the registry reaches outside its seam — a per-render disk read or a pushed write.
190
- test("AC-5 every call stays inside the injected store and logger seam", () => {
191
- const store = fakeStore(EMPTY_DOC);
192
- const { log, entries } = fakeLog();
193
- const registry = createRegistry(store, log);
194
- registry.defineModule(CODETHEME);
195
-
196
- for (let i = 0; i < 5; i++) {
197
- registry.isEnabled("codetheme");
198
- registry.getSettings("codetheme");
199
- }
200
- expect(store.calls).toEqual(["load"]);
201
- expect(entries).toEqual([]);
202
-
203
- registry.setSettings("codetheme", { codeTheme: "one-dark-pro" });
204
-
205
- expect(store.calls).toEqual(["load", "save"]);
206
- expect(registry.getSettings("codetheme")).toEqual({ codeTheme: "one-dark-pro" });
207
- expect(store.calls).toEqual(["load", "save"]);
208
- });
209
-
210
- // spec: setSettings("content.table", { collapseAt: 9, compact: true }) -> both land on the next read
211
- // and both are in the saved doc.
212
- // fails_when: a patch is validated but never applied or never persisted.
213
- test("a valid patch merges into the stored settings and persists", () => {
214
- const store = fakeStore(EMPTY_DOC);
215
- const registry = createRegistry(store, fakeLog().log);
216
- registry.defineModule(TABLE);
217
-
218
- registry.setSettings("content.table", { collapseAt: 9, compact: true });
219
-
220
- expect(registry.getSettings("content.table")).toEqual({ collapseAt: 9, compact: true });
221
- expect(store.saved[0]?.modules["content.table"]?.settings).toEqual({ collapseAt: 9, compact: true });
222
- });
223
-
224
- // spec: setSettings("row.bash", { collapseAt: "3" }) -> no throw, the stored settings stay at the
225
- // default 5, enabled is untouched, exactly one log entry.
226
- // fails_when: an invalid patch value is stored, throws, or logs a line per frame it is seen.
227
- test("an invalid patch value is rejected with one log entry and never throws", () => {
228
- const store = fakeStore(EMPTY_DOC);
229
- const { log, entries } = fakeLog();
230
- const registry = createRegistry(store, log);
231
- registry.defineModule(BASH);
232
-
233
- registry.setSettings("row.bash", { collapseAt: "3" });
234
-
235
- expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
236
- expect(registry.isEnabled("row.bash")).toBe(true);
237
- expect(entries).toHaveLength(1);
238
- expect(entries[0]?.message).toBe("ignored invalid value for row.bash.collapseAt");
239
- });
240
-
241
- // spec: a patch naming a setting no spec declares -> rejected with one log entry and absent from the
242
- // effective settings.
243
- // fails_when: an unvalidatable key is written into the doc and handed back to a renderer.
244
- test("a patch naming an unknown setting is rejected with one log entry", () => {
245
- const store = fakeStore(EMPTY_DOC);
246
- const { log, entries } = fakeLog();
247
- const registry = createRegistry(store, log);
248
- registry.defineModule(BASH);
249
-
250
- registry.setSettings("row.bash", { nope: 1 });
251
-
252
- expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
253
- expect(entries).toHaveLength(1);
254
- expect(entries[0]?.message).toBe("ignored invalid value for row.bash.nope");
255
- });
256
-
257
- // spec: setSettings("codetheme", { codeTheme: "solarized" }) -> rejected, default kept, one log entry;
258
- // then { codeTheme: "one-dark-pro" } -> accepted.
259
- // fails_when: an enum value outside the spec's list reaches the code that uses it.
260
- test("an enum value outside the spec is rejected while an allowed value lands", () => {
261
- const store = fakeStore(EMPTY_DOC);
262
- const { log, entries } = fakeLog();
263
- const registry = createRegistry(store, log);
264
- registry.defineModule(CODETHEME);
265
-
266
- registry.setSettings("codetheme", { codeTheme: "solarized" });
267
- expect(registry.getSettings("codetheme")).toEqual({ codeTheme: "dracula-soft" });
268
- expect(entries).toHaveLength(1);
269
-
270
- registry.setSettings("codetheme", { codeTheme: "one-dark-pro" });
271
- expect(registry.getSettings("codetheme")).toEqual({ codeTheme: "one-dark-pro" });
272
- });
273
-
274
- // spec: setSettings("row.nope", { collapseAt: 9 }) and a non-object patch both no-op with one log
275
- // entry each and never throw or save.
276
- // fails_when: a stale panel target or an erased-type patch corrupts the doc.
277
- test("a write for an unknown key or a non-object patch is ignored, never throws", () => {
278
- const store = fakeStore(EMPTY_DOC);
279
- const { log, entries } = fakeLog();
280
- const registry = createRegistry(store, log);
281
- registry.defineModule(BASH);
282
-
283
- registry.setSettings("row.nope", { collapseAt: 9 });
284
- registry.setSettings("row.bash", "collapseAt" as unknown as object); // boundary: TS types are erased
285
-
286
- expect(registry.isEnabled("row.nope")).toBe(false);
287
- expect(registry.getSettings("row.bash")).toEqual({ collapseAt: 5 });
288
- expect(store.saved).toHaveLength(0);
289
- expect(entries).toHaveLength(2);
290
- });
291
-
292
- // spec: a stored enabled that is not a boolean ("yes") falls back to spec defaultEnabled (false) and
293
- // logs once, rather than being read as a truthy string.
294
- // fails_when: a malformed enabled flag is trusted and a module turns itself on.
295
- test("a malformed stored enabled falls back to the default and logs once", () => {
296
- const store = fakeStore({
297
- version: 1,
298
- modules: { "row.bash": { enabled: "yes", settings: {} } },
299
- } as unknown as SettingsDoc); // boundary: the disk can hold any JSON
300
- const { log, entries } = fakeLog();
301
- const registry = createRegistry(store, log);
302
- registry.defineModule({ ...BASH, defaultEnabled: false });
303
-
304
- expect(registry.isEnabled("row.bash")).toBe(false);
305
- expect(entries).toHaveLength(1);
306
- });
307
- });