@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
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* table.test.ts — the table surface: banded geometry, theme-derived frame, contrast gate.
|
|
3
|
-
*
|
|
4
|
-
* Every test enters at the exported Surface (table.rewrite) with markdown in and markdown out, and
|
|
5
|
-
* reads the frame through the marker paint double, so a leaked real escape — the #22 defect class —
|
|
6
|
-
* is visible from the string alone. No host package is imported here.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// shape: none — dispatch object does not apply: one entry point and no discriminator, so the cases
|
|
10
|
-
// differ by fixture data and never by behavior.
|
|
11
|
-
|
|
12
|
-
import { describe, expect, it } from "vitest";
|
|
13
|
-
import { assertNoRealEscapes, markerPaint } from "../../../test/fakes/index.ts";
|
|
14
|
-
import { createContentPaint } from "../../core/paint.ts";
|
|
15
|
-
import type { TransformContext } from "../../core/types/host.ts";
|
|
16
|
-
import { table } from "./table.ts";
|
|
17
|
-
|
|
18
|
-
const ESC = String.fromCharCode(27);
|
|
19
|
-
const ESCAPE = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
|
|
20
|
-
|
|
21
|
-
type Rgb = readonly [number, number, number];
|
|
22
|
-
|
|
23
|
-
/** One sRGB channel of a WCAG relative-luminance computation. */
|
|
24
|
-
function channel(value: number): number {
|
|
25
|
-
const srgb = value / 255;
|
|
26
|
-
return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function luminance([red, green, blue]: Rgb): number {
|
|
30
|
-
return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** WCAG 2.x contrast ratio, lighter over darker. */
|
|
34
|
-
function contrastRatio(a: Rgb, b: Rgb): number {
|
|
35
|
-
const [lighter, darker]: [Rgb, Rgb] = luminance(a) >= luminance(b) ? [a, b] : [b, a];
|
|
36
|
-
return (luminance(lighter) + 0.05) / (luminance(darker) + 0.05);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const CTX: TransformContext = { messageType: "assistant", isStreaming: false, availableWidth: 60 };
|
|
40
|
-
|
|
41
|
-
/** Painted through a sentinel, so the assertions compare against the fake's own pair, not a copy. */
|
|
42
|
-
const SENTINEL = "\u0000";
|
|
43
|
-
const [RULE_OPEN = "", RULE_CLOSE = ""] = markerPaint().rule(SENTINEL).split(SENTINEL);
|
|
44
|
-
|
|
45
|
-
const plain = (text: string): string => text.replace(ESCAPE, "");
|
|
46
|
-
|
|
47
|
-
/** One entry per markdown code span (the fence stripped); markers are kept for the escape assertions. */
|
|
48
|
-
const splitRows = (output: string): string[] =>
|
|
49
|
-
output
|
|
50
|
-
.replace(/\n$/, "")
|
|
51
|
-
.split(" \n")
|
|
52
|
-
.map((row) => row.slice(1, -1));
|
|
53
|
-
|
|
54
|
-
const rowsOf = (output: string): string[] => splitRows(output).map(plain);
|
|
55
|
-
|
|
56
|
-
// invented: a canonical three-column GFM table with a wrapping cell; its plain rows were diffed
|
|
57
|
-
// against the predecessor's renderTable output on the same token (probe, 2026-10-06) — identical.
|
|
58
|
-
const CANONICAL = [
|
|
59
|
-
"| Tool | Purpose | Notes |",
|
|
60
|
-
"|---|---|---|",
|
|
61
|
-
"| bash | run a shell command in the working directory | collapses to a single line; ctrl+o expands the row |",
|
|
62
|
-
"| read | read a file | none |",
|
|
63
|
-
"",
|
|
64
|
-
].join("\n");
|
|
65
|
-
|
|
66
|
-
// recorded-from: pi-pretty-tui/src/features/canvas/table.test.ts (2026-10-06) — its long-cell case;
|
|
67
|
-
// the path is one unbroken word, so it only fits by wrapping mid-token.
|
|
68
|
-
const LONG_PATH =
|
|
69
|
-
"~/.pi/agent/pi-pretty-tui-cache/118fe853b8b6cf94b9300c0909baa93c9abadffb41e819fc52f60a1bd963d914.svg";
|
|
70
|
-
const WIDE = [
|
|
71
|
-
"| Gate | State |",
|
|
72
|
-
"|---|---|",
|
|
73
|
-
`| sa-validate | PASS — 16/16, 495 lines (first run caught a real gap: ${LONG_PATH}) |`,
|
|
74
|
-
"",
|
|
75
|
-
].join("\n");
|
|
76
|
-
|
|
77
|
-
// invented: one cell per inline shape the host tokenizer emits, so each role's handling is pinned.
|
|
78
|
-
const INLINE = ["| A | B |", "|---|---|", "| `bash` | **bold** and *em* and [link](http://x) |", ""].join("\n");
|
|
79
|
-
|
|
80
|
-
/** the golden: the predecessor's banded geometry, pinned as literals (AC-1). */
|
|
81
|
-
const GOLDEN_TOP = "┌────────┬────────────────────────┬────────────────────────┐";
|
|
82
|
-
const GOLDEN_ROWS = [
|
|
83
|
-
GOLDEN_TOP,
|
|
84
|
-
"│ Tool │ Purpose │ Notes │",
|
|
85
|
-
"├────────┼────────────────────────┼────────────────────────┤",
|
|
86
|
-
"│ bash │ run a shell command in │ collapses to a single │",
|
|
87
|
-
"│ │ the working directory │ line; ctrl+o expands │",
|
|
88
|
-
"│ │ │ the row │",
|
|
89
|
-
"├────────┼────────────────────────┼────────────────────────┤",
|
|
90
|
-
"│ read │ read a file │ none │",
|
|
91
|
-
"└────────┴────────────────────────┴────────────────────────┘",
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
describe("table.rewrite — geometry", () => {
|
|
95
|
-
it("AC-1: the canonical table matches the golden banded layout", () => {
|
|
96
|
-
const output = table.rewrite(CANONICAL, CTX, markerPaint());
|
|
97
|
-
// fails_when: a rule, a column width, or a wrap regresses against the predecessor's table
|
|
98
|
-
expect(rowsOf(output)).toEqual(GOLDEN_ROWS);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("AC-1: every row is emitted as one code span, so the host cannot reflow the box", () => {
|
|
102
|
-
const output = table.rewrite(CANONICAL, CTX, markerPaint());
|
|
103
|
-
// fails_when: raw box-drawing goes back into markdown, where the host reflows or recolors it
|
|
104
|
-
for (const row of plain(output).replace(/\n$/, "").split(" \n")) {
|
|
105
|
-
expect(row.startsWith("`")).toBe(true);
|
|
106
|
-
expect(row.endsWith("`")).toBe(true);
|
|
107
|
-
}
|
|
108
|
-
expect(plain(output).endsWith("`\n")).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("AC-1: nothing is elided — a wrapped cell stays reachable in full", () => {
|
|
112
|
-
const output = table.rewrite(WIDE, CTX, markerPaint());
|
|
113
|
-
// fails_when: a cell is truncated instead of wrapped (geometry only survives if it wraps)
|
|
114
|
-
expect(plain(output)).not.toContain("...");
|
|
115
|
-
expect(plain(output)).toContain("963d914.svg");
|
|
116
|
-
expect(rowsOf(output)).toHaveLength(9);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("AC-4: no row exceeds availableWidth", () => {
|
|
120
|
-
const wide = table.rewrite(WIDE, CTX, markerPaint());
|
|
121
|
-
const narrow = table.rewrite(CANONICAL, { ...CTX, availableWidth: 40 }, markerPaint());
|
|
122
|
-
// fails_when: an overflowing row reaches the terminal
|
|
123
|
-
for (const row of rowsOf(wide)) expect(row.length).toBeLessThanOrEqual(60);
|
|
124
|
-
for (const row of rowsOf(narrow)) expect(row.length).toBeLessThanOrEqual(40);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
it("returns markdown that holds no table byte-identically", () => {
|
|
128
|
-
const paragraph = "A paragraph with a | pipe but no table row.\n";
|
|
129
|
-
// recorded-from: executed probe 2026-10-06 — Marked normalizes \r\n to \n, so joining the
|
|
130
|
-
// tokens' own raw text is NOT byte-exact; only the input itself preserves the bytes.
|
|
131
|
-
const crlf = "line one\r\nline two with | pipe\r\n";
|
|
132
|
-
// fails_when: a pipe-bearing block with no table is rebuilt from lexed tokens
|
|
133
|
-
expect(table.rewrite(paragraph, CTX, markerPaint())).toBe(paragraph);
|
|
134
|
-
expect(table.rewrite(crlf, CTX, markerPaint())).toBe(crlf);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it("rewrites the table block and leaves the surrounding blocks alone", () => {
|
|
138
|
-
const output = table.rewrite(`Before the table.\n\n${CANONICAL}\nAfter the table.\n`, CTX, markerPaint());
|
|
139
|
-
// fails_when: the surrounding blocks are dropped or rewritten
|
|
140
|
-
expect(plain(output).startsWith("Before the table.\n\n")).toBe(true);
|
|
141
|
-
expect(plain(output).endsWith("\n\nAfter the table.\n")).toBe(true);
|
|
142
|
-
expect(plain(output).split(GOLDEN_TOP).length - 1).toBe(1);
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
describe("table.rewrite — the #22 fix", () => {
|
|
147
|
-
it("AC-2: the frame is painted by the rule role and nothing else", () => {
|
|
148
|
-
const output = table.rewrite(CANONICAL, CTX, markerPaint());
|
|
149
|
-
const marked = splitRows(output);
|
|
150
|
-
// fails_when: a hardcoded frame color (a real escape survives the marker-only detector)
|
|
151
|
-
assertNoRealEscapes(output);
|
|
152
|
-
// fails_when: any escape in the frame that is not a rule marker
|
|
153
|
-
expect([...new Set(output.match(ESCAPE) ?? [])].sort()).toEqual([RULE_OPEN, RULE_CLOSE].sort());
|
|
154
|
-
for (const row of marked) {
|
|
155
|
-
if (row.includes("│")) {
|
|
156
|
-
// fails_when: a bar drawn outside a rule pair
|
|
157
|
-
expect(row.split(`${RULE_OPEN}│${RULE_CLOSE}`)).toHaveLength(row.split("│").length);
|
|
158
|
-
} else {
|
|
159
|
-
// fails_when: a rule line drawn outside a rule pair
|
|
160
|
-
expect(row.startsWith(RULE_OPEN)).toBe(true);
|
|
161
|
-
expect(row.endsWith(RULE_CLOSE)).toBe(true);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("AC-2: every frame segment comes from the live theme's hr, never from a literal", () => {
|
|
167
|
-
const calls: string[] = [];
|
|
168
|
-
const paint = createContentPaint({
|
|
169
|
-
hr: (text) => {
|
|
170
|
-
calls.push(text);
|
|
171
|
-
return text;
|
|
172
|
-
},
|
|
173
|
-
});
|
|
174
|
-
const output = table.rewrite(CANONICAL, CTX, paint);
|
|
175
|
-
// fails_when: the frame carries a hardcoded color instead of the theme's own token
|
|
176
|
-
expect(rowsOf(output)).toEqual(GOLDEN_ROWS);
|
|
177
|
-
expect(output).not.toContain(ESC);
|
|
178
|
-
expect(calls).toContain("│");
|
|
179
|
-
expect(calls.filter((text) => text.includes("─"))).toHaveLength(4);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("AC-2: inline code goes through the code role; emphasis keeps its text", () => {
|
|
183
|
-
const output = table.rewrite(INLINE, CTX, markerPaint());
|
|
184
|
-
const codePair = markerPaint().code(SENTINEL).split(SENTINEL);
|
|
185
|
-
assertNoRealEscapes(output);
|
|
186
|
-
// fails_when: inline code renders as its plain text (the role is never called)
|
|
187
|
-
expect(output).toContain(`${codePair[0]}bash${codePair[1]}`);
|
|
188
|
-
// fails_when: an emphasis wrapper leaks as a literal escape, or its text is dropped
|
|
189
|
-
expect(rowsOf(output)).toContain("│ bash │ bold and em and link │");
|
|
190
|
-
});
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
describe("table.rewrite — the frame token's contrast (issue #22)", () => {
|
|
194
|
-
// recorded-from: sa-reviewer-delta-verdict.md point 7 (executed getResolvedThemeColors + WCAG
|
|
195
|
-
// computation, 2026-10-06) — mdHr resolves to #9da5a9 dark / #677176 light; the backgrounds are
|
|
196
|
-
// the pinned fixtures the design computes against (the terminal background is not reachable here).
|
|
197
|
-
const THEMES = ["dark", "light"] as const;
|
|
198
|
-
const RECORDED: Record<(typeof THEMES)[number], { mdHr: Rgb; background: Rgb; ratio: number }> = {
|
|
199
|
-
dark: { mdHr: [0x9d, 0xa5, 0xa9], background: [0x1e, 0x1e, 0x2e], ratio: 6.55 },
|
|
200
|
-
light: { mdHr: [0x67, 0x71, 0x76], background: [0xff, 0xff, 0xff], ratio: 5.0 },
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
it.each(THEMES)("AC-3: mdHr on the %s theme clears the 4.5:1 gate", (theme) => {
|
|
204
|
-
const { mdHr, background, ratio } = RECORDED[theme];
|
|
205
|
-
// fails_when: either theme measures below the gate — then the token, not the test, is wrong
|
|
206
|
-
expect(contrastRatio(mdHr, background)).toBeGreaterThanOrEqual(4.5);
|
|
207
|
-
expect(contrastRatio(mdHr, background)).toBeCloseTo(ratio, 1);
|
|
208
|
-
});
|
|
209
|
-
});
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* transformer through its seam — createContentTransformer(registry, surfaces, deps), driven with fake
|
|
3
|
-
* surfaces, a fake registry, the marker paint, and a recording logger.
|
|
4
|
-
*
|
|
5
|
-
* Every scenario's first meaningful action is a factory call; nothing below the seam is imported.
|
|
6
|
-
*
|
|
7
|
-
* invented: the fake surface / registry / logger doubles stand in for T-22..T-24, core/registry and
|
|
8
|
-
* core/log — they satisfy the structural contracts and record every call.
|
|
9
|
-
*/
|
|
10
|
-
import { describe, expect, test } from "vitest";
|
|
11
|
-
import { markerPaint } from "../../../test/fakes/index.ts";
|
|
12
|
-
import type { TransformContext } from "../../core/types/host.ts";
|
|
13
|
-
import type { LogEntry, Logger } from "../../core/types/log.ts";
|
|
14
|
-
import type { ContentPaint } from "../../core/types/paint.ts";
|
|
15
|
-
import type { Registry } from "../../core/types/registry.ts";
|
|
16
|
-
import { type ContentSurfaces, createContentTransformer } from "./index.ts";
|
|
17
|
-
import type { Surface } from "./types.ts";
|
|
18
|
-
|
|
19
|
-
const PAINT = markerPaint();
|
|
20
|
-
const ASSISTANT: TransformContext = { messageType: "assistant", isStreaming: false, availableWidth: 80 };
|
|
21
|
-
|
|
22
|
-
// invented: a two-row table and a one-line fence are the smallest inputs that name two different
|
|
23
|
-
// surfaces; T-22/T-23 own the real table and panel shapes, and this ticket pins dispatch, not rendering.
|
|
24
|
-
const TABLE = "| a | b |\n| - | - |\n| 1 | 2 |\n";
|
|
25
|
-
const FENCE = "```ts\nconst x = 1;\n```\n";
|
|
26
|
-
|
|
27
|
-
// recorded-from: @earendil-works/pi-coding-agent dist, createMermaidMarkdownTransformer({getMode: () =>
|
|
28
|
-
// "unicode"}), width 100, executed 2026-10-07 — its output for "```mermaid\ngraph TD; A-->B;\n```".
|
|
29
|
-
const HOST_MERMAID_ROWS = "` ┌───┐` \n` │ A │` \n` └─┬─┘` \n` │` \n` ▼` \n` ┌───┐` \n` │ B │` \n` └───┘`\n";
|
|
30
|
-
|
|
31
|
-
/** One recorded rewrite call. */
|
|
32
|
-
interface SurfaceCall {
|
|
33
|
-
markdown: string;
|
|
34
|
-
ctx: TransformContext;
|
|
35
|
-
paint: ContentPaint;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface FakeSurface extends Surface {
|
|
39
|
-
/** Every call this surface received, in order; a skipped surface leaves no entry. Required. */
|
|
40
|
-
readonly calls: SurfaceCall[];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function fakeSurface(transform: (markdown: string) => string): FakeSurface {
|
|
44
|
-
const calls: SurfaceCall[] = [];
|
|
45
|
-
return {
|
|
46
|
-
calls,
|
|
47
|
-
rewrite(markdown, ctx, paint) {
|
|
48
|
-
calls.push({ markdown, ctx, paint });
|
|
49
|
-
return transform(markdown);
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function fakeRegistry(disabled: readonly string[]): Registry {
|
|
55
|
-
return {
|
|
56
|
-
defineModule: () => {},
|
|
57
|
-
isEnabled: (key) => !disabled.includes(key),
|
|
58
|
-
getSettings: () => ({}),
|
|
59
|
-
setSettings: () => {},
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function fakeLog(): { log: Logger; entries: LogEntry[] } {
|
|
64
|
-
const entries: LogEntry[] = [];
|
|
65
|
-
const seen = new Set<string>();
|
|
66
|
-
return {
|
|
67
|
-
entries,
|
|
68
|
-
log: {
|
|
69
|
-
logLine(scope, message) {
|
|
70
|
-
entries.push({ scope, message, time: 0 });
|
|
71
|
-
},
|
|
72
|
-
logOnce(key, scope, message) {
|
|
73
|
-
if (seen.has(key)) return;
|
|
74
|
-
seen.add(key);
|
|
75
|
-
entries.push({ scope, message, time: 0 });
|
|
76
|
-
},
|
|
77
|
-
drain: () => entries.splice(0, entries.length),
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
type Slot = keyof ContentSurfaces;
|
|
83
|
-
|
|
84
|
-
interface Harness {
|
|
85
|
-
readonly transformer: (markdown: string, ctx: TransformContext) => string;
|
|
86
|
-
readonly table: FakeSurface;
|
|
87
|
-
readonly codePanel: FakeSurface;
|
|
88
|
-
readonly jsonPanel: FakeSurface;
|
|
89
|
-
readonly imageCard: FakeSurface;
|
|
90
|
-
readonly artifacts: FakeSurface | undefined;
|
|
91
|
-
readonly entries: LogEntry[];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function harness(
|
|
95
|
-
config: {
|
|
96
|
-
/** Registry keys to disable, e.g. "content.table". */
|
|
97
|
-
disabled?: readonly string[];
|
|
98
|
-
/** Per-slot rewrites; a slot named here but absent from `rewrite` passes its input through. */
|
|
99
|
-
rewrite?: Partial<Record<Slot, (markdown: string) => string>>;
|
|
100
|
-
/** Slots whose rewrite throws — the never-throw path. */
|
|
101
|
-
throwing?: readonly Slot[];
|
|
102
|
-
/** Inject the artifacts fence pass, as T-28 will once T-25/T-26 land. */
|
|
103
|
-
withArtifacts?: boolean;
|
|
104
|
-
} = {},
|
|
105
|
-
): Harness {
|
|
106
|
-
const { disabled = [], rewrite = {}, throwing = [], withArtifacts = false } = config;
|
|
107
|
-
const make = (slot: Slot): FakeSurface => {
|
|
108
|
-
if (throwing.includes(slot)) {
|
|
109
|
-
return fakeSurface(() => {
|
|
110
|
-
throw new Error(`${slot} exploded`);
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
return fakeSurface(rewrite[slot] ?? ((markdown) => markdown));
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const table = make("table");
|
|
117
|
-
const codePanel = make("codePanel");
|
|
118
|
-
const jsonPanel = make("jsonPanel");
|
|
119
|
-
const imageCard = make("imageCard");
|
|
120
|
-
const artifacts = withArtifacts ? make("artifacts") : undefined;
|
|
121
|
-
const { log, entries } = fakeLog();
|
|
122
|
-
const surfaces: ContentSurfaces =
|
|
123
|
-
artifacts === undefined
|
|
124
|
-
? { table, codePanel, jsonPanel, imageCard }
|
|
125
|
-
: { table, codePanel, jsonPanel, imageCard, artifacts };
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
table,
|
|
129
|
-
codePanel,
|
|
130
|
-
jsonPanel,
|
|
131
|
-
imageCard,
|
|
132
|
-
artifacts,
|
|
133
|
-
entries,
|
|
134
|
-
transformer: createContentTransformer(fakeRegistry(disabled), surfaces, { paint: PAINT, log }),
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Every rewrite the transformer dispatched, across all slots — a gate or skip leaves none. */
|
|
139
|
-
function allCalls(h: Harness): SurfaceCall[] {
|
|
140
|
-
return [h.table, h.codePanel, h.jsonPanel, h.imageCard, h.artifacts]
|
|
141
|
-
.filter((surface) => surface !== undefined)
|
|
142
|
-
.flatMap((surface) => surface.calls);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
describe("content transformer", () => {
|
|
146
|
-
// spec: transformer(markdown, {messageType:"user"|"assistant-thinking"}) -> markdown, zero surface
|
|
147
|
-
// calls; transformer(markdown, {isStreaming:true}) -> markdown, zero calls; transformer(markdown,
|
|
148
|
-
// settled assistant) -> the table surface's output.
|
|
149
|
-
// fails_when: streaming or user content is transformed.
|
|
150
|
-
test("AC-1 the gate keeps every non-final-assistant message by reference", () => {
|
|
151
|
-
const h = harness({ rewrite: { table: (markdown) => markdown.replace(TABLE, "TABLE\n") } });
|
|
152
|
-
const markdown = TABLE;
|
|
153
|
-
|
|
154
|
-
expect(h.transformer(markdown, { ...ASSISTANT, messageType: "user" })).toBe(markdown);
|
|
155
|
-
expect(h.transformer(markdown, { ...ASSISTANT, messageType: "assistant-thinking" })).toBe(markdown);
|
|
156
|
-
expect(h.transformer(markdown, { ...ASSISTANT, isStreaming: true })).toBe(markdown);
|
|
157
|
-
expect(allCalls(h)).toEqual([]);
|
|
158
|
-
|
|
159
|
-
// The gate assertion above is not vacuous: the same markdown on a settled assistant pass rewrites.
|
|
160
|
-
expect(h.transformer(markdown, ASSISTANT)).toBe("TABLE\n");
|
|
161
|
-
expect(h.table.calls).toHaveLength(1);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// spec: registry disables content.table -> the table text passes through unchanged while
|
|
165
|
-
// content.codePanel still rewrites the fence; enabling both rewrites both.
|
|
166
|
-
// fails_when: one disabled surface blocks the rest or transforms anyway.
|
|
167
|
-
test("AC-2 a disabled surface is skipped while the other surfaces still rewrite", () => {
|
|
168
|
-
const rewrite = {
|
|
169
|
-
table: (markdown: string) => markdown.replace(TABLE, "TABLE\n"),
|
|
170
|
-
codePanel: (markdown: string) => markdown.replace(FENCE, "PANEL\n"),
|
|
171
|
-
};
|
|
172
|
-
const markdown = TABLE + FENCE;
|
|
173
|
-
|
|
174
|
-
const disabled = harness({ disabled: ["content.table"], rewrite });
|
|
175
|
-
expect(disabled.transformer(markdown, ASSISTANT)).toBe(`${TABLE}PANEL\n`);
|
|
176
|
-
expect(disabled.table.calls).toEqual([]);
|
|
177
|
-
expect(disabled.codePanel.calls).toHaveLength(1);
|
|
178
|
-
expect(disabled.codePanel.calls[0]?.markdown).toBe(markdown);
|
|
179
|
-
|
|
180
|
-
// The enabled control is what makes the pass-through above meaningful.
|
|
181
|
-
const enabled = harness({ rewrite });
|
|
182
|
-
expect(enabled.transformer(markdown, ASSISTANT)).toBe("TABLE\nPANEL\n");
|
|
183
|
-
expect(enabled.table.calls[0]?.ctx).toBe(ASSISTANT);
|
|
184
|
-
expect(enabled.table.calls[0]?.paint).toBe(PAINT);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
// spec: all four surfaces enabled, each returning its input by reference -> prose matching no
|
|
188
|
-
// surface returns the same string while a table the table surface rewrites does not.
|
|
189
|
-
// fails_when: a no-op pass is turned into a rewrite (a changed string where nothing matched).
|
|
190
|
-
test("AC-3 markdown matching no surface returns the input unchanged", () => {
|
|
191
|
-
const h = harness({ rewrite: { table: (markdown) => markdown.replace(TABLE, "TABLE\n") } });
|
|
192
|
-
const prose = "Just prose: no table, no fence, no image reference.\n";
|
|
193
|
-
|
|
194
|
-
expect(h.transformer(prose, ASSISTANT)).toBe(prose);
|
|
195
|
-
// Every slot was dispatched: the identity survives a full pass, not a skipped one.
|
|
196
|
-
expect(allCalls(h).map((call) => call.markdown)).toEqual([prose, prose, prose, prose]);
|
|
197
|
-
|
|
198
|
-
expect(h.transformer(TABLE, ASSISTANT)).toBe("TABLE\n");
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
// spec: the content.codePanel surface throws -> the ORIGINAL input returns (the table surface's rewrite
|
|
202
|
-
// is rolled back) and exactly one entry with scope "content" and message
|
|
203
|
-
// "transform failed: codePanel exploded" is logged; a second failing pass logs nothing more.
|
|
204
|
-
// fails_when: a failing surface kills the answer render.
|
|
205
|
-
test("AC-4 a throwing surface returns the input unchanged with one keyed log line", () => {
|
|
206
|
-
const h = harness({
|
|
207
|
-
rewrite: { table: (markdown) => markdown.replace(TABLE, "TABLE\n") },
|
|
208
|
-
throwing: ["codePanel"],
|
|
209
|
-
});
|
|
210
|
-
const markdown = TABLE + FENCE;
|
|
211
|
-
|
|
212
|
-
expect(h.transformer(markdown, ASSISTANT)).toBe(markdown);
|
|
213
|
-
expect(h.entries).toEqual([{ scope: "content", message: "transform failed: codePanel exploded", time: 0 }]);
|
|
214
|
-
|
|
215
|
-
expect(h.transformer(markdown, ASSISTANT)).toBe(markdown);
|
|
216
|
-
expect(h.entries).toHaveLength(1);
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// spec: input carrying the host's already-transformed mermaid rows alone -> the same string returns;
|
|
220
|
-
// input carrying those rows plus a table -> the rows survive byte-for-byte and the table alone
|
|
221
|
-
// rewrites, with the rows reaching the surface verbatim.
|
|
222
|
-
// fails_when: double-transformation of host-owned content.
|
|
223
|
-
test("AC-5 already-transformed mermaid passes through untouched", () => {
|
|
224
|
-
const untouched = harness();
|
|
225
|
-
expect(untouched.transformer(HOST_MERMAID_ROWS, ASSISTANT)).toBe(HOST_MERMAID_ROWS);
|
|
226
|
-
|
|
227
|
-
const h = harness({ rewrite: { table: (markdown) => markdown.replace(TABLE, "TABLE\n") } });
|
|
228
|
-
const message = `Diagram:\n\n${HOST_MERMAID_ROWS}${TABLE}`;
|
|
229
|
-
|
|
230
|
-
expect(h.transformer(message, ASSISTANT)).toBe(`Diagram:\n\n${HOST_MERMAID_ROWS}TABLE\n`);
|
|
231
|
-
expect(h.table.calls[0]?.markdown).toBe(message);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
// spec: artifacts injected -> its rewrite runs AFTER the four surfaces (it receives their output) and
|
|
235
|
-
// its card replaces the fence; content.artifacts disabled -> the fence pass is skipped and no call
|
|
236
|
-
// lands.
|
|
237
|
-
// fails_when: the fence pass runs while disabled, or before the markdown surfaces.
|
|
238
|
-
test("the artifacts fence pass runs last, only while content.artifacts is enabled", () => {
|
|
239
|
-
const rewrite = {
|
|
240
|
-
table: (markdown: string) => markdown.replace(TABLE, "TABLE\n"),
|
|
241
|
-
artifacts: (markdown: string) => markdown.replace(FENCE, "CARD\n"),
|
|
242
|
-
};
|
|
243
|
-
const markdown = TABLE + FENCE;
|
|
244
|
-
|
|
245
|
-
const enabled = harness({ rewrite, withArtifacts: true });
|
|
246
|
-
expect(enabled.transformer(markdown, ASSISTANT)).toBe("TABLE\nCARD\n");
|
|
247
|
-
// Received the table surface's output, fence still raw: the pass runs last.
|
|
248
|
-
expect(enabled.artifacts?.calls[0]?.markdown).toBe(`TABLE\n${FENCE}`);
|
|
249
|
-
|
|
250
|
-
const disabled = harness({ disabled: ["content.artifacts"], rewrite, withArtifacts: true });
|
|
251
|
-
expect(disabled.transformer(markdown, ASSISTANT)).toBe(`TABLE\n${FENCE}`);
|
|
252
|
-
expect(disabled.artifacts?.calls).toEqual([]);
|
|
253
|
-
});
|
|
254
|
-
});
|