@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.
- package/README.md +20 -6
- package/index.ts +29 -33
- package/package.json +6 -2
- package/src/core/paint.ts +17 -2
- package/src/core/types/host.ts +15 -18
- package/src/core/types.ts +0 -1
- package/src/renderers/content/artifacts/cards.ts +13 -6
- package/src/renderers/content/code-panel.ts +53 -31
- package/src/renderers/content/index.ts +70 -21
- package/src/renderers/content/json-panel.ts +9 -17
- package/src/renderers/content/link-paragraph.ts +153 -0
- package/src/renderers/content/table.ts +4 -1
- package/src/renderers/tool/runtime.ts +44 -5
- package/src/renderers/tool/specs/search.ts +18 -10
- package/src/renderers/tool/types.ts +6 -1
- package/src/commands/canvas.test.ts +0 -150
- package/src/commands/canvas.ts +0 -57
- package/src/core/code-theme.test.ts +0 -266
- package/src/core/log.test.ts +0 -127
- package/src/core/paint.test.ts +0 -322
- package/src/core/registry.test.ts +0 -307
- package/src/core/settings.test.ts +0 -183
- package/src/renderers/content/artifacts/artifacts.test.ts +0 -199
- package/src/renderers/content/artifacts/cards.test.ts +0 -216
- package/src/renderers/content/artifacts/engines-extra.test.ts +0 -556
- package/src/renderers/content/artifacts/local-binary.test.ts +0 -207
- package/src/renderers/content/image-card.test.ts +0 -170
- package/src/renderers/content/panels.test.ts +0 -188
- package/src/renderers/content/table.test.ts +0 -209
- package/src/renderers/content/transformer.test.ts +0 -254
- package/src/renderers/tool/resolver.test.ts +0 -257
- package/src/renderers/tool/runtime.test.ts +0 -313
- package/src/renderers/tool/specs/bash.test.ts +0 -110
- package/src/renderers/tool/specs/codemode.test.ts +0 -212
- package/src/renderers/tool/specs/edit.test.ts +0 -260
- package/src/renderers/tool/specs/ls.test.ts +0 -173
- package/src/renderers/tool/specs/read.test.ts +0 -340
- package/src/renderers/tool/specs/search.test.ts +0 -197
- package/src/renderers/tool/specs/write.test.ts +0 -145
- package/themes/themes.test.ts +0 -251
package/src/core/paint.test.ts
DELETED
|
@@ -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
|
-
});
|