gentle-shell-bridge 0.3.0 → 0.3.1

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 (75) hide show
  1. package/LICENSE +202 -0
  2. package/docs/contract.md +1 -1
  3. package/package.json +13 -1
  4. package/src/api.ts +1 -1
  5. package/.codegraph/.gitignore +0 -5
  6. package/PRD.md +0 -144
  7. package/examples/bash-card.ts +0 -1694
  8. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  9. package/odd/tasks/palette-sections.md +0 -30
  10. package/odd/tasks/plugin-keymap.md +0 -32
  11. package/odd/tasks/plugin-surface-contract.md +0 -55
  12. package/test/api.test.ts +0 -395
  13. package/test/apply-on-move.test.ts +0 -477
  14. package/test/arm.test.ts +0 -220
  15. package/test/bridge-ready.test.ts +0 -309
  16. package/test/bus.test.ts +0 -165
  17. package/test/button-rows.test.ts +0 -409
  18. package/test/capture.test.ts +0 -356
  19. package/test/card-style-rows.test.ts +0 -510
  20. package/test/card-style-transform.test.ts +0 -379
  21. package/test/card-style-wiring.test.ts +0 -258
  22. package/test/card-styles-e2e.test.ts +0 -491
  23. package/test/card-styles.test.ts +0 -324
  24. package/test/card-transcript-path.test.ts +0 -178
  25. package/test/config.test.ts +0 -93
  26. package/test/e2e.test.ts +0 -443
  27. package/test/example-bash-identity.test.ts +0 -372
  28. package/test/example-bash-matrix.test.ts +0 -856
  29. package/test/example-designs.test.ts +0 -734
  30. package/test/example-neon-transform.test.ts +0 -283
  31. package/test/example-plain-style.test.ts +0 -1268
  32. package/test/extension.test.ts +0 -160
  33. package/test/fake-events.ts +0 -58
  34. package/test/fake-host.ts +0 -211
  35. package/test/fake-sidebar.ts +0 -516
  36. package/test/fake-transcript.ts +0 -486
  37. package/test/frame-telemetry.test.ts +0 -417
  38. package/test/hook.test.ts +0 -196
  39. package/test/manifest.test.ts +0 -105
  40. package/test/new-category.test.ts +0 -979
  41. package/test/notices.test.ts +0 -328
  42. package/test/plugin-integration-lines.test.ts +0 -979
  43. package/test/plugin-surface.test.ts +0 -152
  44. package/test/presence.test.ts +0 -459
  45. package/test/rail-surfaces.test.ts +0 -457
  46. package/test/rail-width.test.ts +0 -423
  47. package/test/regression.test.ts +0 -483
  48. package/test/repaint-api.test.ts +0 -200
  49. package/test/reserved-categories.test.ts +0 -361
  50. package/test/row-labels.test.ts +0 -284
  51. package/test/row-names.test.ts +0 -473
  52. package/test/row-origin.test.ts +0 -395
  53. package/test/section-interaction.test.ts +0 -364
  54. package/test/section-panel.test.ts +0 -403
  55. package/test/sections-rows.test.ts +0 -291
  56. package/test/sidebar-backend.test.ts +0 -471
  57. package/test/sidebar-e2e.test.ts +0 -331
  58. package/test/sidebar-hover.test.ts +0 -276
  59. package/test/sidebar-independent.test.ts +0 -349
  60. package/test/sidebar-render.test.ts +0 -281
  61. package/test/sidebar-sections.test.ts +0 -245
  62. package/test/status-card-styles.test.ts +0 -201
  63. package/test/status-section-frame.test.ts +0 -181
  64. package/test/status-sections.test.ts +0 -243
  65. package/test/status.test.ts +0 -209
  66. package/test/store.test.ts +0 -192
  67. package/test/surface-repaint-cost.test.ts +0 -520
  68. package/test/tool-design-chain.test.ts +0 -483
  69. package/test/tool-design-cost.test.ts +0 -473
  70. package/test/tool-design-rows.test.ts +0 -359
  71. package/test/tool-designs.test.ts +0 -745
  72. package/test/transform-pipeline.test.ts +0 -465
  73. package/test/transforms-bus.test.ts +0 -233
  74. package/test/verifier-width-regressions.test.ts +0 -164
  75. package/tsconfig.json +0 -18
@@ -1,349 +0,0 @@
1
- /**
2
- * Regressions found by independent review, each pinned before it was fixed.
3
- *
4
- * B1 — a sparse `lines()` used to take down its healthy siblings (S8).
5
- * B2 — a prototype-named id could not round-trip through the visibility map (S5).
6
- * B3 — the status output grew lines for a row-only session (S12).
7
- *
8
- * Plus the review's smaller findings: prune records when the host deletes a part, never let a
9
- * throwing `setWidget` accessor escape the session handler, and document by behaviour that the
10
- * rail wrapper spreads own ENUMERABLE fields only.
11
- */
12
-
13
- import assert from "node:assert/strict";
14
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
- import test from "node:test";
18
-
19
- import {
20
- registerSection,
21
- resetBusForTests,
22
- resetSectionsForTests,
23
- snapshotSections,
24
- } from "../src/bus.ts";
25
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
26
- import gentleShellBridge, {
27
- STATUS_COMMAND,
28
- type BridgeCommandContext,
29
- type BridgeCommandHandler,
30
- type BridgeExtensionApi,
31
- type BridgeSessionHandler,
32
- } from "../src/extension.ts";
33
- import { resetHookStateForTests } from "../src/hook.ts";
34
- import { resetRowBuildStateForTests } from "../src/rows.ts";
35
- import {
36
- SIDEBAR_RAIL_KEY,
37
- disableSidebarBackend,
38
- getSidebarDiagnostics,
39
- installSidebarBackend,
40
- resetSidebarStateForTests,
41
- setRailContributor,
42
- } from "../src/sidebar.ts";
43
- import {
44
- getSectionFailures,
45
- installSectionRail,
46
- resetSectionFailuresForTests,
47
- } from "../src/sidebar-render.ts";
48
- import { collectStatus, formatStatus } from "../src/status.ts";
49
- import { resetStoreStateForTests } from "../src/store.ts";
50
- import type { SidebarSection } from "../src/types.ts";
51
- import { getSectionVisibility, isSectionVisible, setSectionVisible } from "../src/visibility.ts";
52
- import { createFakeHost, type FakeHost } from "./fake-host.ts";
53
- import {
54
- cardBody,
55
- cardTitles,
56
- fakeState,
57
- fakeTui,
58
- registerNativeFooter,
59
- renderSidebar,
60
- resetSidebar,
61
- type Rail,
62
- } from "./fake-sidebar.ts";
63
-
64
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
65
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
66
-
67
- let configHome: string;
68
- let previousConfigHome: string | undefined;
69
-
70
- test.before(() => {
71
- configHome = mkdtempSync(join(tmpdir(), "gsb-independent-"));
72
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
73
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
74
- });
75
-
76
- test.after(() => {
77
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
78
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
79
- rmSync(configHome, { recursive: true, force: true });
80
- });
81
-
82
- test.beforeEach(() => {
83
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
84
- resetBusForTests();
85
- resetSectionsForTests();
86
- resetSidebar();
87
- resetSidebarStateForTests();
88
- resetSectionFailuresForTests();
89
- resetStoreStateForTests();
90
- resetCaptureStateForTests();
91
- resetHookStateForTests();
92
- resetRowBuildStateForTests();
93
- disableCaptureBackend();
94
- disableSidebarBackend();
95
- setRailContributor(undefined);
96
- delete hookSlot()[HOOK_KEY];
97
- });
98
-
99
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
100
- id,
101
- title: id.toUpperCase(),
102
- lines: () => [`body ${id}`],
103
- ...extra,
104
- });
105
-
106
- const storePath = (): string => join(configHome, "gentle-shell-bridge", "bridge-sections.json");
107
-
108
- /** Native part first, then the interception and the section renderer. */
109
- function arm(): void {
110
- registerNativeFooter();
111
- installSidebarBackend(() => fakeTui());
112
- installSectionRail();
113
- }
114
-
115
- interface Entry {
116
- sessionHandlers: BridgeSessionHandler[];
117
- commands: Array<{ name: string; handler: BridgeCommandHandler }>;
118
- }
119
-
120
- const loadEntry = (): Entry => {
121
- const sessionHandlers: BridgeSessionHandler[] = [];
122
- const commands: Entry["commands"] = [];
123
- const pi: BridgeExtensionApi = {
124
- // `gentle-pi` is present, so `session_start` arms and reports it (S9/T12).
125
- getCommands: () => [{ name: "gentle:customize" }],
126
- on(_event, handler) {
127
- sessionHandlers.push(handler);
128
- },
129
- registerCommand(name, command) {
130
- commands.push({ name, handler: command.handler });
131
- },
132
- };
133
- gentleShellBridge(pi);
134
- return { sessionHandlers, commands };
135
- };
136
-
137
- const armThroughEntry = (host: FakeHost): Entry => {
138
- const entry = loadEntry();
139
- entry.sessionHandlers[0]({}, { hasUI: true, ui: host.ui });
140
- return entry;
141
- };
142
-
143
- const printStatus = (entry: Entry): string[] => {
144
- const printed: string[] = [];
145
- const ctx: BridgeCommandContext = { ui: { notify: (message: string) => printed.push(message) } };
146
- entry.commands.find((command) => command.name === STATUS_COMMAND)?.handler("", ctx);
147
- return printed.join("\n").split("\n");
148
- };
149
-
150
- // ---------------------------------------------------------------------------------------------
151
- // B1 — a broken section must never take down its siblings or the whole contribution.
152
- // ---------------------------------------------------------------------------------------------
153
-
154
- test("B1: a sparse lines() loses only its own body and leaves its siblings rendering", () => {
155
- registerSection(section("bad", { title: "BAD", lines: () => new Array(2) as unknown as string[] }));
156
- registerSection(section("good", { title: "GOOD", lines: () => ["good row"] }));
157
- arm();
158
-
159
- assert.deepEqual(cardTitles(), ["FOOTER", "BAD", "GOOD"], "the whole contribution still renders");
160
- assert.deepEqual(cardBody("BAD"), [], "the broken body is dropped");
161
- assert.deepEqual(cardBody("GOOD"), ["good row"], "the healthy sibling is untouched");
162
-
163
- const failure = getSectionFailures().find((entry) => entry.sectionId === "bad");
164
- assert.equal(failure?.phase, "lines");
165
- assert.match(failure?.message ?? "", /sparse array/u);
166
- assert.deepEqual(
167
- getSidebarDiagnostics().lastFailures,
168
- [],
169
- "the backend never saw a throwing contributor: the failure stayed inside the section",
170
- );
171
- });
172
-
173
- test("B1: a hole in the middle of the body is a per-section failure, not a crash", () => {
174
- const sparse: string[] = ["first"];
175
- sparse[2] = "third"; // length 3, hole at index 1
176
- registerSection(section("holey", { title: "HOLEY", lines: () => sparse }));
177
- registerSection(section("good", { title: "GOOD", lines: () => ["still here"] }));
178
- arm();
179
-
180
- assert.deepEqual(cardTitles(), ["FOOTER", "HOLEY", "GOOD"]);
181
- assert.deepEqual(cardBody("HOLEY"), []);
182
- assert.deepEqual(cardBody("GOOD"), ["still here"]);
183
- assert.match(getSectionFailures().find((e) => e.sectionId === "holey")?.message ?? "", /sparse/u);
184
- });
185
-
186
- test("B1: the backend's own recorded failures reach /bridge-status", () => {
187
- registerNativeFooter();
188
- installSidebarBackend(() => fakeTui());
189
- setRailContributor({
190
- render: () => {
191
- throw new Error("contributor exploded");
192
- },
193
- });
194
- renderSidebar(40);
195
-
196
- const snapshot = collectStatus();
197
- assert.deepEqual(snapshot.sidebarFailures, [
198
- { key: "footer", phase: "contribute", message: "contributor exploded" },
199
- ]);
200
- const lines = formatStatus(snapshot);
201
- const at = lines.indexOf("sidebar failures: 1");
202
- assert.ok(at !== -1, "the block is emitted");
203
- assert.deepEqual(lines.slice(at, at + 2), [
204
- "sidebar failures: 1",
205
- " - footer (contribute): contributor exploded",
206
- ]);
207
- });
208
-
209
- // ---------------------------------------------------------------------------------------------
210
- // B2 — prototype-shaped identities must be rejected at the door and survive the store.
211
- // ---------------------------------------------------------------------------------------------
212
-
213
- test("B2: a prototype-shaped or unusable section id is rejected and registers nothing", () => {
214
- for (const id of ["__proto__", "constructor", "prototype", "", " "]) {
215
- assert.throws(
216
- () => registerSection(section(id)),
217
- /invalid section id/u,
218
- `id ${JSON.stringify(id)} must be rejected`,
219
- );
220
- }
221
- assert.deepEqual(snapshotSections(), [], "a rejected id must not half-register");
222
-
223
- registerSection(section("ok"));
224
- assert.deepEqual(
225
- snapshotSections().map((entry) => entry.id),
226
- ["ok"],
227
- "a valid id still registers afterwards",
228
- );
229
- });
230
-
231
- test("B2: visibility survives a prototype-named key and one toggle never erases another", () => {
232
- setSectionVisible("__proto__", false);
233
- const map = getSectionVisibility();
234
- assert.equal(Object.getPrototypeOf(map), null, "no inherited setter can swallow a key");
235
- assert.equal(isSectionVisible(map, { id: "__proto__" }), false);
236
-
237
- setSectionVisible("other", true);
238
-
239
- const after = getSectionVisibility();
240
- assert.equal(isSectionVisible(after, { id: "__proto__" }), false, "the earlier key survived");
241
- assert.equal(isSectionVisible(after, { id: "other" }), true);
242
-
243
- const stored = JSON.parse(readFileSync(storePath(), "utf8"));
244
- assert.equal(Object.hasOwn(stored.values.sections, "__proto__"), true);
245
- assert.equal(stored.values.sections["__proto__"], false);
246
- assert.equal(stored.values.sections.other, true);
247
- });
248
-
249
- // ---------------------------------------------------------------------------------------------
250
- // B3 — a row-only session must keep the pre-feature output, through the real entry.
251
- // ---------------------------------------------------------------------------------------------
252
-
253
- test("B3: a row-only session through the entry keeps the pre-feature status output", () => {
254
- const host = createFakeHost(); // no setWidget: the sidebar cannot arm
255
- const entry = armThroughEntry(host);
256
- host.buildMenu();
257
-
258
- assert.deepEqual(printStatus(entry), [
259
- "gentle-shell-bridge status",
260
- "gentle-pi: present",
261
- "providers: none",
262
- "backend: capture",
263
- "last capture: injected 0 rows",
264
- "last failures: none",
265
- "store problems: none",
266
- "duplicate settings: none",
267
- ]);
268
- });
269
-
270
- test("B3: the internal Sections provider is never listed, and the rail state only when worth it", () => {
271
- registerSection(section("music", { title: "MUSIC" }));
272
- const host = createFakeHost();
273
- const entry = armThroughEntry(host);
274
- host.buildMenu();
275
-
276
- const printed = printStatus(entry);
277
- assert.equal(printed.includes("bridge.sections"), false, "bridge plumbing is not a plugin provider");
278
- assert.ok(printed.includes("providers: none"));
279
- assert.ok(printed.includes("sections: 1"));
280
- assert.ok(
281
- printed.some((line) => line.startsWith("sidebar: not armed")),
282
- "with a section registered, an unreachable rail must be reported",
283
- );
284
- });
285
-
286
- // ---------------------------------------------------------------------------------------------
287
- // The review's smaller findings.
288
- // ---------------------------------------------------------------------------------------------
289
-
290
- test("a part deleted or cleared by the host is no longer reported as wrapped", () => {
291
- registerNativeFooter();
292
- installSidebarBackend(() => fakeTui());
293
- assert.equal(getSidebarDiagnostics().wrappedRails, 1);
294
-
295
- fakeState().parts.delete(SIDEBAR_RAIL_KEY);
296
-
297
- assert.equal(getSidebarDiagnostics().wrappedRails, 0, "a deleted part is not a wrapped rail");
298
- assert.equal(collectStatus().sidebar?.rail, "pending");
299
-
300
- registerNativeFooter();
301
- installSidebarBackend(() => fakeTui());
302
- assert.equal(getSidebarDiagnostics().wrappedRails, 1);
303
-
304
- fakeState().parts.clear();
305
-
306
- assert.equal(getSidebarDiagnostics().wrappedRails, 0, "a cleared map wraps nothing");
307
- });
308
-
309
- test("a throwing setWidget accessor is reported and never escapes the session handler", () => {
310
- registerSection(section("music", { title: "MUSIC" }));
311
- const host = createFakeHost();
312
- Object.defineProperty(host.ui, "setWidget", {
313
- configurable: true,
314
- get() {
315
- throw new Error("ui exploded");
316
- },
317
- });
318
-
319
- const entry = armThroughEntry(host); // must not throw
320
- host.buildMenu();
321
-
322
- assert.ok(host.rendered().includes("music"), "the row side still works");
323
- const printed = printStatus(entry);
324
- assert.ok(
325
- printed.some((line) => line.includes("sidebar: not armed") && line.includes("ui exploded")),
326
- `expected an unarmed report with the reason, got: ${printed.join(" | ")}`,
327
- );
328
- });
329
-
330
- test("the wrapper carries own enumerable fields only", () => {
331
- const prototype = { inheritedMethod: () => "from the prototype" };
332
- const rail = Object.create(prototype) as Rail;
333
- rail.render = () => ["▾ NATIVE"];
334
- let backing = "first";
335
- Object.defineProperty(rail, "live", { get: () => backing, enumerable: true });
336
- Object.defineProperty(rail, "hiddenField", { value: "not enumerable", enumerable: false });
337
- registerNativeFooter(rail);
338
-
339
- installSidebarBackend(() => fakeTui());
340
-
341
- const wrapped = fakeState().parts.get(SIDEBAR_RAIL_KEY) as Record<string, unknown> | undefined;
342
- assert.ok(wrapped !== undefined);
343
- assert.equal(typeof wrapped.render, "function");
344
- assert.equal("inheritedMethod" in wrapped, false, "prototype methods are not carried");
345
- assert.equal("hiddenField" in wrapped, false, "non-enumerable fields are not carried");
346
- assert.equal(wrapped.live, "first", "a getter is snapshotted when the wrapper is built");
347
- backing = "second";
348
- assert.equal(wrapped.live, "first", "and it does not track the original afterwards");
349
- });
@@ -1,281 +0,0 @@
1
- /**
2
- * Section rendering on the host's rail (S3, S7, S8), against the fake host.
3
- *
4
- * Every assertion here is about FRAMED output: the bridge contributes rows and card groups, and the
5
- * host's own frame draws the card. A raw string in a rail's output is not a rendered section.
6
- */
7
-
8
- import assert from "node:assert/strict";
9
- import { mkdtempSync, rmSync } from "node:fs";
10
- import { tmpdir } from "node:os";
11
- import { join } from "node:path";
12
- import test from "node:test";
13
-
14
- import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
15
- import {
16
- SIDEBAR_RAIL_KEY,
17
- disableSidebarBackend,
18
- installSidebarBackend,
19
- resetSidebarStateForTests,
20
- } from "../src/sidebar.ts";
21
- import {
22
- MAX_SECTION_BODY_ROWS,
23
- getSectionFailures,
24
- installSectionRail,
25
- resetSectionFailuresForTests,
26
- } from "../src/sidebar-render.ts";
27
- import { resetStoreStateForTests } from "../src/store.ts";
28
- import type { SidebarSection } from "../src/types.ts";
29
- import { setSectionVisible } from "../src/visibility.ts";
30
- import {
31
- cardBody,
32
- cardTitles,
33
- fakeState,
34
- fakeTui,
35
- registerNativeFooter,
36
- renderSidebar,
37
- resetSidebar,
38
- } from "./fake-sidebar.ts";
39
-
40
- let configHome: string;
41
- let previousConfigHome: string | undefined;
42
-
43
- test.before(() => {
44
- configHome = mkdtempSync(join(tmpdir(), "gsb-sections-"));
45
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
46
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
47
- });
48
-
49
- test.after(() => {
50
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
51
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
52
- rmSync(configHome, { recursive: true, force: true });
53
- });
54
-
55
- test.beforeEach(() => {
56
- // The visibility store is a real file: each test starts from nothing on disk.
57
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
58
- resetBusForTests();
59
- resetSectionsForTests();
60
- resetSidebar();
61
- resetSidebarStateForTests();
62
- resetSectionFailuresForTests();
63
- resetStoreStateForTests();
64
- disableSidebarBackend();
65
- });
66
-
67
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
68
- id,
69
- title: id.toUpperCase(),
70
- lines: () => [`body ${id}`],
71
- ...extra,
72
- });
73
-
74
- /** Native host first, then the bridge's interception and the section renderer. */
75
- function arm(): void {
76
- registerNativeFooter();
77
- installSidebarBackend(() => fakeTui());
78
- installSectionRail();
79
- }
80
-
81
- const rail = () => fakeState().parts.get(SIDEBAR_RAIL_KEY);
82
-
83
- test("sections are framed after the native content, in the registered order", () => {
84
- // Registered out of order on purpose: the T2 snapshot decides, not insertion order.
85
- registerSection(section("weather", { title: "WEATHER", lines: () => ["18° clear"], order: 2 }));
86
- registerSection(
87
- section("music", {
88
- title: "MUSIC",
89
- meta: () => "1 playing",
90
- lines: () => ["track one", "album two"],
91
- order: 1,
92
- }),
93
- );
94
- arm();
95
-
96
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC", "WEATHER"], "native first, then ordered sections");
97
- assert.deepEqual(cardBody("MUSIC"), ["track one", "album two"]);
98
-
99
- const heading = renderSidebar().find((row) => row.includes("MUSIC"));
100
- assert.ok(heading !== undefined);
101
- assert.ok(heading.includes("▾ MUSIC"), "the host's frame carries the section title");
102
- assert.ok(heading.includes("1 playing"), "meta() is drawn as the right-aligned count");
103
- });
104
-
105
- test("no body row is ever wider than the width the host passes", () => {
106
- registerSection(
107
- section("wide", {
108
- title: "WIDE",
109
- meta: () => "a meta string that cannot possibly fit here",
110
- lines: () => ["a body row that is certainly much too long for a narrow rail"],
111
- }),
112
- );
113
- arm();
114
-
115
- const width = 24;
116
- const contribution = rail()?.render(width) ?? [];
117
- for (const row of contribution) {
118
- assert.ok(row.length <= width, `contribution row ${JSON.stringify(row)} exceeds ${width}`);
119
- }
120
- assert.ok(
121
- contribution.some((row) => row.endsWith("…")),
122
- "a clipped row is marked, not silently cut",
123
- );
124
- for (const row of renderSidebar(width)) {
125
- // The host leaves one blank row between two sections; every drawn row must fit the width.
126
- if (row === "") continue;
127
- assert.equal(row.length, width, `framed row ${JSON.stringify(row)} does not fit ${width}`);
128
- }
129
- assert.ok(cardTitles(width).includes("WIDE"), "the heading survives a width the meta does not fit");
130
- });
131
-
132
- test("the body is capped and says what it elided", () => {
133
- const rows = Array.from({ length: MAX_SECTION_BODY_ROWS + 4 }, (_, index) => `row ${index + 1}`);
134
- registerSection(section("many", { title: "MANY", lines: () => rows }));
135
- arm();
136
-
137
- const body = cardBody("MANY");
138
- assert.equal(body.length, MAX_SECTION_BODY_ROWS, "the cap is the contract, not a suggestion");
139
- assert.equal(body.at(-1), `… +${rows.length - (MAX_SECTION_BODY_ROWS - 1)} more`);
140
- });
141
-
142
- test("hidden means not built: no card and no lines()/meta() call", () => {
143
- let linesCalls = 0;
144
- let metaCalls = 0;
145
- registerSection(
146
- section("quiet", {
147
- title: "QUIET",
148
- meta: () => {
149
- metaCalls += 1;
150
- return "quiet";
151
- },
152
- lines: () => {
153
- linesCalls += 1;
154
- return ["never built"];
155
- },
156
- }),
157
- );
158
- arm();
159
-
160
- assert.deepEqual(cardTitles(), ["FOOTER", "QUIET"]);
161
- assert.equal(linesCalls, 1);
162
- assert.equal(metaCalls, 1);
163
-
164
- setSectionVisible("quiet", false);
165
- linesCalls = 0;
166
- metaCalls = 0;
167
-
168
- assert.deepEqual(cardTitles(), ["FOOTER"], "a hidden section is not drawn");
169
- assert.equal(linesCalls, 0, "and its body is not built at all");
170
- assert.equal(metaCalls, 0, "not even its meta");
171
- });
172
-
173
- test("a throwing lines() drops that body only and is recorded with the section id", () => {
174
- registerSection(
175
- section("broken", {
176
- title: "BROKEN",
177
- lines: () => {
178
- throw new Error("lines exploded");
179
- },
180
- }),
181
- );
182
- registerSection(section("fine", { title: "FINE", lines: () => ["fine row"] }));
183
- arm();
184
-
185
- assert.deepEqual(cardTitles(), ["FOOTER", "BROKEN", "FINE"], "both sections still have a heading");
186
- assert.deepEqual(cardBody("BROKEN"), [], "the broken body is dropped, not half-drawn");
187
- assert.deepEqual(cardBody("FINE"), ["fine row"], "the sibling section is untouched");
188
- assert.deepEqual(cardBody("FOOTER"), ["native row 1", "native row 2"]);
189
-
190
- const failure = getSectionFailures().find((entry) => entry.sectionId === "broken");
191
- assert.ok(failure !== undefined, "the failure is on record for /bridge-status");
192
- assert.equal(failure.phase, "lines");
193
- assert.equal(failure.message, "lines exploded");
194
- });
195
-
196
- test("a throwing meta() drops the body too, per the spec's failure rule", () => {
197
- registerSection(
198
- section("badm", {
199
- title: "BADM",
200
- meta: () => {
201
- throw new Error("meta exploded");
202
- },
203
- lines: () => ["body that must not be shown"],
204
- }),
205
- );
206
- registerSection(section("good", { title: "GOOD", lines: () => ["good row"] }));
207
- arm();
208
-
209
- assert.deepEqual(cardTitles(), ["FOOTER", "BADM", "GOOD"]);
210
- assert.deepEqual(cardBody("BADM"), []);
211
-
212
- const failure = getSectionFailures().find((entry) => entry.sectionId === "badm");
213
- assert.equal(failure?.phase, "meta");
214
- assert.equal(failure?.message, "meta exploded");
215
- });
216
-
217
- test("a non-string line is a failure, never coerced", () => {
218
- registerSection(
219
- section("bad", {
220
- title: "BAD",
221
- // The shape is wrong on purpose: a plugin can return anything at runtime.
222
- lines: () => ["fine", 42 as unknown as string],
223
- }),
224
- );
225
- arm();
226
-
227
- assert.deepEqual(cardTitles(), ["FOOTER", "BAD"]);
228
- assert.deepEqual(cardBody("BAD"), []);
229
- assert.equal(getSectionFailures().find((entry) => entry.sectionId === "bad")?.phase, "lines");
230
- });
231
-
232
- test("a non-array lines() return is a failure, never coerced", () => {
233
- registerSection(
234
- section("notarray", {
235
- title: "NOTARRAY",
236
- lines: () => "just a string" as unknown as string[],
237
- }),
238
- );
239
- arm();
240
-
241
- assert.deepEqual(cardBody("NOTARRAY"), []);
242
- assert.equal(getSectionFailures().find((entry) => entry.sectionId === "notarray")?.phase, "lines");
243
- });
244
-
245
- test("a section registered after the first frame appears on the next one", () => {
246
- registerSection(section("first", { title: "FIRST" }));
247
- arm();
248
- assert.deepEqual(cardTitles(), ["FOOTER", "FIRST"]);
249
-
250
- registerSection(section("later", { title: "LATER", order: 1 }));
251
-
252
- assert.deepEqual(cardTitles(), ["FOOTER", "LATER", "FIRST"], "no reinstall, next frame");
253
- });
254
-
255
- test("a disposed section leaves no card and no group behind", () => {
256
- const section$ = section("gone", { title: "GONE" });
257
- const dispose = registerSection(section$);
258
- registerSection(section("stays", { title: "STAYS" }));
259
- arm();
260
- assert.deepEqual(cardTitles(), ["FOOTER", "GONE", "STAYS"]);
261
-
262
- dispose();
263
-
264
- assert.deepEqual(cardTitles(), ["FOOTER", "STAYS"]);
265
- });
266
-
267
- test("no visible section contributes nothing, leaving the host's own rows untouched", () => {
268
- registerSection(section("off", { title: "OFF", defaultVisible: false }));
269
- arm();
270
-
271
- assert.deepEqual(rail()?.render(40), ["▾ FOOTER", "native row 1", "native row 2"]);
272
- assert.deepEqual(rail()?.cardGroups?.(40), { groups: [{ start: 0, end: 3 }] });
273
- assert.deepEqual(cardTitles(), ["FOOTER"]);
274
- });
275
-
276
- test("a non-positive width contributes nothing instead of returning a broken row", () => {
277
- registerSection(section("any", { title: "ANY" }));
278
- arm();
279
-
280
- assert.deepEqual(rail()?.render(0), ["▾ FOOTER", "native row 1", "native row 2"]);
281
- });