gentle-shell-bridge 0.3.0 → 0.3.2

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 (76) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +91 -722
  3. package/docs/contract.md +92 -35
  4. package/package.json +13 -1
  5. package/src/api.ts +1 -1
  6. package/.codegraph/.gitignore +0 -5
  7. package/PRD.md +0 -144
  8. package/examples/bash-card.ts +0 -1694
  9. package/gentle-shell-bridge-0.3.0.tgz +0 -0
  10. package/odd/tasks/palette-sections.md +0 -30
  11. package/odd/tasks/plugin-keymap.md +0 -32
  12. package/odd/tasks/plugin-surface-contract.md +0 -55
  13. package/test/api.test.ts +0 -395
  14. package/test/apply-on-move.test.ts +0 -477
  15. package/test/arm.test.ts +0 -220
  16. package/test/bridge-ready.test.ts +0 -309
  17. package/test/bus.test.ts +0 -165
  18. package/test/button-rows.test.ts +0 -409
  19. package/test/capture.test.ts +0 -356
  20. package/test/card-style-rows.test.ts +0 -510
  21. package/test/card-style-transform.test.ts +0 -379
  22. package/test/card-style-wiring.test.ts +0 -258
  23. package/test/card-styles-e2e.test.ts +0 -491
  24. package/test/card-styles.test.ts +0 -324
  25. package/test/card-transcript-path.test.ts +0 -178
  26. package/test/config.test.ts +0 -93
  27. package/test/e2e.test.ts +0 -443
  28. package/test/example-bash-identity.test.ts +0 -372
  29. package/test/example-bash-matrix.test.ts +0 -856
  30. package/test/example-designs.test.ts +0 -734
  31. package/test/example-neon-transform.test.ts +0 -283
  32. package/test/example-plain-style.test.ts +0 -1268
  33. package/test/extension.test.ts +0 -160
  34. package/test/fake-events.ts +0 -58
  35. package/test/fake-host.ts +0 -211
  36. package/test/fake-sidebar.ts +0 -516
  37. package/test/fake-transcript.ts +0 -486
  38. package/test/frame-telemetry.test.ts +0 -417
  39. package/test/hook.test.ts +0 -196
  40. package/test/manifest.test.ts +0 -105
  41. package/test/new-category.test.ts +0 -979
  42. package/test/notices.test.ts +0 -328
  43. package/test/plugin-integration-lines.test.ts +0 -979
  44. package/test/plugin-surface.test.ts +0 -152
  45. package/test/presence.test.ts +0 -459
  46. package/test/rail-surfaces.test.ts +0 -457
  47. package/test/rail-width.test.ts +0 -423
  48. package/test/regression.test.ts +0 -483
  49. package/test/repaint-api.test.ts +0 -200
  50. package/test/reserved-categories.test.ts +0 -361
  51. package/test/row-labels.test.ts +0 -284
  52. package/test/row-names.test.ts +0 -473
  53. package/test/row-origin.test.ts +0 -395
  54. package/test/section-interaction.test.ts +0 -364
  55. package/test/section-panel.test.ts +0 -403
  56. package/test/sections-rows.test.ts +0 -291
  57. package/test/sidebar-backend.test.ts +0 -471
  58. package/test/sidebar-e2e.test.ts +0 -331
  59. package/test/sidebar-hover.test.ts +0 -276
  60. package/test/sidebar-independent.test.ts +0 -349
  61. package/test/sidebar-render.test.ts +0 -281
  62. package/test/sidebar-sections.test.ts +0 -245
  63. package/test/status-card-styles.test.ts +0 -201
  64. package/test/status-section-frame.test.ts +0 -181
  65. package/test/status-sections.test.ts +0 -243
  66. package/test/status.test.ts +0 -209
  67. package/test/store.test.ts +0 -192
  68. package/test/surface-repaint-cost.test.ts +0 -520
  69. package/test/tool-design-chain.test.ts +0 -483
  70. package/test/tool-design-cost.test.ts +0 -473
  71. package/test/tool-design-rows.test.ts +0 -359
  72. package/test/tool-designs.test.ts +0 -745
  73. package/test/transform-pipeline.test.ts +0 -465
  74. package/test/transforms-bus.test.ts +0 -233
  75. package/test/verifier-width-regressions.test.ts +0 -164
  76. package/tsconfig.json +0 -18
@@ -1,403 +0,0 @@
1
- /**
2
- * The section's row model and its drawing (N10, N11): a section body is no longer bare text.
3
- *
4
- * Two things are proven here, and they are different claims:
5
- *
6
- * 1. **A plain string row is byte-identical.** The bytes the plugin wrote are the bytes the rail
7
- * frames — no padding, no indent, no ANSI — so no existing plugin changes appearance (N10).
8
- * 2. **The fallback drawing is the host's drawing.** `renderPanelHeader` / `renderPanelRow` /
9
- * `paintHoverable` / `clipPanelText` / `fitPanelLine` live in gentle-shell's
10
- * `lib/shell-panel.ts` and `lib/shell-hover.ts`, which this package cannot import (Pi loads each
11
- * extension in its own jiti module graph). The reference implementations at the bottom of this
12
- * file are transcribed from those sources, and every case below compares our output to theirs
13
- * byte for byte — that is what "byte-identical fallback" means, and it is a stronger claim than
14
- * "looks similar".
15
- */
16
-
17
- import assert from "node:assert/strict";
18
- import { mkdtempSync, rmSync } from "node:fs";
19
- import { tmpdir } from "node:os";
20
- import { join } from "node:path";
21
- import test from "node:test";
22
-
23
- import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
24
- import { setSectionTheme, resetSectionPanelForTests } from "../src/section-panel.ts";
25
- import {
26
- SIDEBAR_RAIL_KEY,
27
- disableSidebarBackend,
28
- installSidebarBackend,
29
- resetSidebarStateForTests,
30
- } from "../src/sidebar.ts";
31
- import {
32
- getSectionFailures,
33
- installSectionRail,
34
- resetSectionFailuresForTests,
35
- } from "../src/sidebar-render.ts";
36
- import { resetStoreStateForTests } from "../src/store.ts";
37
- import type { SectionRow, SidebarSection } from "../src/types.ts";
38
- import {
39
- cardBody,
40
- cardTitles,
41
- fakeState,
42
- fakeTui,
43
- registerNativeFooter,
44
- renderSidebar,
45
- resetSidebar,
46
- } from "./fake-sidebar.ts";
47
-
48
- let configHome: string;
49
- let previousConfigHome: string | undefined;
50
-
51
- test.before(() => {
52
- configHome = mkdtempSync(join(tmpdir(), "gsb-panel-"));
53
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
54
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
55
- });
56
-
57
- test.after(() => {
58
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
59
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
60
- rmSync(configHome, { recursive: true, force: true });
61
- });
62
-
63
- test.beforeEach(() => {
64
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
65
- resetBusForTests();
66
- resetSectionsForTests();
67
- resetSidebar();
68
- resetSidebarStateForTests();
69
- resetSectionFailuresForTests();
70
- resetStoreStateForTests();
71
- resetSectionPanelForTests();
72
- disableSidebarBackend();
73
- });
74
-
75
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
76
- id,
77
- title: id.toUpperCase(),
78
- lines: () => [`body ${id}`],
79
- ...extra,
80
- });
81
-
82
- /** Native host first, then the bridge's interception and the section renderer. */
83
- function arm(): void {
84
- registerNativeFooter();
85
- installSidebarBackend(() => fakeTui());
86
- installSectionRail();
87
- }
88
-
89
- const rail = () => fakeState().parts.get(SIDEBAR_RAIL_KEY);
90
- /** The rows the bridge contributes for one frame, native rows excluded. */
91
- const contributed = (width = 40): string[] => (rail()?.render(width) ?? []).slice(3);
92
-
93
- test("a plain string row reaches the rail byte for byte, even with a theme in force", () => {
94
- const theme = fakeTheme();
95
- setSectionTheme(theme);
96
- const written = "track Blue in Green";
97
- registerSection(section("music", { title: "MUSIC", lines: () => [written] }));
98
- arm();
99
-
100
- assert.deepEqual(contributed(), [
101
- referenceHeader(theme, 40, { title: "MUSIC", open: true }),
102
- written,
103
- ]);
104
- assert.ok(
105
- !contributed()[1].includes("\u001b"),
106
- "a string row carries no styling the plugin did not write",
107
- );
108
- assert.deepEqual(cardBody("MUSIC"), [written]);
109
- });
110
-
111
- test("a structured row renders glyph, label and meta in the host's roles", () => {
112
- const theme = fakeTheme();
113
- setSectionTheme(theme);
114
- const row: SectionRow = {
115
- glyph: "♪",
116
- label: "Blue in Green",
117
- meta: "5:37",
118
- tone: "accent",
119
- };
120
- registerSection(section("music", { title: "MUSIC", lines: () => [row] }));
121
- arm();
122
-
123
- const [header, body] = contributed();
124
- assert.deepEqual(theme.calls, [
125
- ["muted", "▾"],
126
- // The host paints the uppercased title through `theme.bold` first
127
- // (`lib/shell-panel.ts:71-72`), then the role.
128
- ["text", "\u001b[1mMUSIC\u001b[22m"],
129
- ["accent", "♪"],
130
- ["accent", "Blue in Green"],
131
- ["muted", "5:37"],
132
- ]);
133
- assert.equal(body, referenceRow(40, `${theme.fg("accent", "♪")} ${theme.fg("accent", "Blue in Green")}`, theme.fg("muted", "5:37")));
134
- assert.equal(header, referenceHeader(theme, 40, { title: "MUSIC", open: true }));
135
-
136
- // The host's own composition: the row is indented under the header and its secondary text is
137
- // dimmer than its label, which is the field the report said was missing.
138
- const plain = stripSgr(body);
139
- assert.ok(plain.startsWith(" ♪ Blue in Green"), `row is indented: ${JSON.stringify(plain)}`);
140
- assert.ok(plain.endsWith("5:37"), `meta is right-aligned: ${JSON.stringify(plain)}`);
141
- assert.equal(plain.length, 40, "the host's fitPanelLine pads the row to the width");
142
- assert.notEqual(
143
- roleOf(body, "5:37"),
144
- roleOf(body, "Blue in Green"),
145
- "the meta is painted in a different role than the label",
146
- );
147
- });
148
-
149
- test("the fallback drawing equals the host's builders in every case we can compare", () => {
150
- const theme = fakeTheme();
151
- setSectionTheme(theme);
152
- registerSection(
153
- section("wide", {
154
- title: "Wide Section",
155
- meta: () => "2/6",
156
- lines: () => [
157
- { glyph: "▸", label: "a plain row", meta: "1" },
158
- { label: "no glyph, no meta" },
159
- { glyph: "✗", label: "failed", meta: "3", tone: "error" },
160
- { label: "a row whose text is long enough that the host has to clip it", meta: "99" },
161
- ],
162
- }),
163
- );
164
- arm();
165
-
166
- const width = 40;
167
- const rows = contributed(width);
168
- assert.equal(
169
- rows[0],
170
- referenceHeader(theme, width, { title: "Wide Section", count: "2/6", open: true }),
171
- );
172
- const cases: Array<[SectionRow, string]> = [
173
- [{ glyph: "▸", label: "a plain row", meta: "1" }, referenceRow(width, `${theme.fg("muted", "▸")} ${theme.fg("text", "a plain row")}`, theme.fg("muted", "1"))],
174
- [{ label: "no glyph, no meta" }, referenceRow(width, theme.fg("text", "no glyph, no meta"))],
175
- [
176
- { glyph: "✗", label: "failed", meta: "3", tone: "error" },
177
- referenceRow(width, `${theme.fg("error", "✗")} ${theme.fg("error", "failed")}`, theme.fg("muted", "3")),
178
- ],
179
- [
180
- { label: "a row whose text is long enough that the host has to clip it", meta: "99" },
181
- referenceRow(
182
- width,
183
- theme.fg("text", "a row whose text is long enough that the host has to clip it"),
184
- theme.fg("muted", "99"),
185
- ),
186
- ],
187
- ];
188
- cases.forEach(([row, expected], index) => {
189
- assert.equal(
190
- rows[index + 1],
191
- expected,
192
- `row ${JSON.stringify(row.label)} matches the host's renderPanelRow`,
193
- );
194
- });
195
- assert.ok(
196
- stripSgr(rows[4]).includes("…"),
197
- "a row that cannot fit is clipped with the host's ellipsis, not silently cut",
198
- );
199
- });
200
-
201
- test("a hovered row paints with the host's hover role, and only for a row that acts", () => {
202
- const theme = fakeTheme();
203
- setSectionTheme(theme);
204
- const calls: string[] = [];
205
- registerSection(
206
- section("music", {
207
- title: "MUSIC",
208
- lines: () => [
209
- { label: "play", meta: "enter", onClick: () => calls.push("play") },
210
- { label: "inert" },
211
- ],
212
- }),
213
- );
214
- arm();
215
- contributed(40);
216
- theme.calls.length = 0;
217
-
218
- assert.deepEqual(
219
- rail()?.handleMouse?.({ type: "move", button: "none", x: 4, y: 4, width: 40, height: 20 }),
220
- { handled: true, render: true },
221
- "the clickable row answers a hover",
222
- );
223
- contributed(40);
224
-
225
- const hoveredRow = contributed(40)[1];
226
- assert.equal(roleOf(hoveredRow, "play"), "warning", "the label takes the host's hover role");
227
- assert.equal(
228
- hoveredRow,
229
- referenceRow(40, `${paintHoverable(theme, undefined, "play", true, "text")}`, paintHoverable(theme, undefined, "enter", true, "muted")),
230
- "and it is drawn exactly as the host draws a hovered panel row",
231
- );
232
- });
233
-
234
- test("a row whose tone is not one of the host's roles is rejected, never coerced", () => {
235
- registerSection(
236
- section("bad", {
237
- title: "BAD",
238
- lines: () => [{ label: "row", tone: "hotpink" as unknown as "text" }],
239
- }),
240
- );
241
- arm();
242
-
243
- assert.deepEqual(cardTitles(), ["FOOTER", "BAD"]);
244
- assert.deepEqual(cardBody("BAD"), [], "the body is dropped");
245
- const failure = getSectionFailures().find((entry) => entry.sectionId === "bad");
246
- assert.equal(failure?.phase, "lines");
247
- assert.match(failure?.message ?? "", /tone/u);
248
- });
249
-
250
- test("a row with no usable label is rejected, never coerced", () => {
251
- registerSection(
252
- section("nob", {
253
- title: "NOB",
254
- lines: () => [{ meta: "x" } as unknown as SectionRow],
255
- }),
256
- );
257
- arm();
258
-
259
- assert.deepEqual(cardBody("NOB"), []);
260
- assert.match(
261
- getSectionFailures().find((entry) => entry.sectionId === "nob")?.message ?? "",
262
- /label/u,
263
- );
264
- });
265
-
266
- test("a collapsed section still gets its header, from the section's own caret", () => {
267
- const theme = fakeTheme();
268
- setSectionTheme(theme);
269
- registerSection(section("music", { title: "MUSIC" }));
270
- arm();
271
-
272
- contributed(40);
273
- rail()?.handleMouse?.({ type: "click", button: "left", x: 4, y: 3, width: 40, height: 20 });
274
- const rows = contributed(40);
275
-
276
- assert.equal(rows.length, 1, "a collapsed section is its header alone");
277
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
278
- assert.equal(rows[0], referenceHeader(theme, 40, { title: "MUSIC", open: false }));
279
- assert.ok(stripSgr(rows[0]).startsWith("▸ MUSIC"), "the caret says it is collapsed");
280
- });
281
-
282
- // ---------------------------------------------------------------------------------------------
283
- // helpers
284
- // ---------------------------------------------------------------------------------------------
285
-
286
- const ROLE_SGR: Record<string, number> = {
287
- text: 37,
288
- muted: 90,
289
- dim: 2,
290
- accent: 95,
291
- success: 32,
292
- error: 31,
293
- warning: 33,
294
- };
295
-
296
- interface FakeTheme {
297
- calls: Array<[string, string]>;
298
- fg(role: string, text: string): string;
299
- bold(text: string): string;
300
- }
301
-
302
- function fakeTheme(): FakeTheme {
303
- const calls: Array<[string, string]> = [];
304
- return {
305
- calls,
306
- fg(role, text) {
307
- calls.push([role, text]);
308
- return `\u001b[${ROLE_SGR[role] ?? 0}m${text}\u001b[39m`;
309
- },
310
- bold(text) {
311
- return `\u001b[1m${text}\u001b[22m`;
312
- },
313
- };
314
- }
315
-
316
- /** The role a piece of text was painted in, read back out of the rendered bytes. */
317
- function roleOf(row: string, text: string): string {
318
- const pattern = new RegExp(`\\u001b\\[(\\d+)m${text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}`, "u");
319
- const match = pattern.exec(row);
320
- assert.ok(match !== null, `${JSON.stringify(text)} is painted in ${JSON.stringify(row)}`);
321
- const code = Number(match[1]);
322
- const role = Object.keys(ROLE_SGR).find((name) => ROLE_SGR[name] === code);
323
- assert.ok(role !== undefined, `code ${code} maps to a role`);
324
- return role;
325
- }
326
-
327
- function stripSgr(text: string): string {
328
- return text.replace(/\u001b\[[0-9;]*m/gu, "");
329
- }
330
-
331
- /** Visible columns of a plain-or-ANSI row, code point by code point (`visibleWidth`, ASCII here). */
332
- function plainWidth(text: string): number {
333
- return [...stripSgr(text)].length;
334
- }
335
-
336
- // The reference implementations below are transcribed from gentle-shell, read-only:
337
- // `lib/shell-panel.ts:44-80` (`clipPanelText`, `fitPanelLine`, `renderPanelHeader`,
338
- // `renderPanelRow`) and `lib/shell-hover.ts:33-38` (`HOVER_ROLE`, `paintHoverable`). They are
339
- // verbatim apart from the width function, which is pi-tui's `visibleWidth` in the host and a
340
- // code-point count here — identical for the ASCII rows these cases use.
341
-
342
- function referenceClip(text: string, width: number): string {
343
- if (width <= 0) return "";
344
- if (plainWidth(text) <= width) return text;
345
- let clipped = "";
346
- for (const unit of text.match(/\u001b\[[0-9;]*m|[\s\S]/gu) ?? []) {
347
- if (unit.startsWith("\u001b[")) {
348
- clipped += unit;
349
- continue;
350
- }
351
- if (plainWidth(clipped + unit) > width - 1) break;
352
- clipped += unit;
353
- }
354
- return `${clipped}…`;
355
- }
356
-
357
- function referenceFit(left: string, right: string, width: number): string {
358
- const target = Math.max(0, Math.floor(width));
359
- const rightWidth = plainWidth(right);
360
- const shownRight = rightWidth + 1 <= target ? right : "";
361
- const room = target - plainWidth(shownRight) - (shownRight ? 1 : 0);
362
- const shownLeft = referenceClip(left, room);
363
- return `${shownLeft}${" ".repeat(Math.max(0, target - plainWidth(shownLeft) - plainWidth(shownRight)))}${shownRight}`;
364
- }
365
-
366
- function paintHoverable(
367
- theme: FakeTheme | undefined,
368
- _: unknown,
369
- text: string,
370
- hovered: boolean,
371
- idleRole?: string,
372
- ): string {
373
- if (theme === undefined) return text;
374
- if (hovered) return theme.fg("warning", text);
375
- return idleRole === undefined ? text : theme.fg(idleRole, text);
376
- }
377
-
378
- function referenceHeader(
379
- theme: FakeTheme,
380
- width: number,
381
- header: { title: string; count?: string; open: boolean; hovered?: boolean },
382
- ): string {
383
- const glyph = paintHoverable(theme, undefined, header.open ? "▾" : "▸", header.hovered ?? false, "muted");
384
- const name = header.title.toUpperCase();
385
- const title = theme.fg(header.hovered === true ? "warning" : "text", theme.bold(name));
386
- const count = header.count === undefined ? "" : theme.fg("muted", header.count);
387
- return referenceFit(`${glyph} ${title}`, count, width);
388
- }
389
-
390
- function referenceRow(width: number, left: string, right = ""): string {
391
- return referenceFit(`${" ".repeat(2)}${left}`, right, width);
392
- }
393
-
394
- test("the reference drawing is itself usable: the fixtures above are the host's shape", () => {
395
- // A cheap guard on the transcription: if a fixture stops looking like `▾ NAME … count`, the
396
- // comparisons above would compare two wrong things and still pass.
397
- const theme = fakeTheme();
398
- const row = referenceHeader(theme, 20, { title: "music", count: "2", open: true });
399
- assert.equal(plainWidth(row), 20);
400
- assert.ok(stripSgr(row).startsWith("▾ MUSIC"));
401
- assert.ok(stripSgr(row).endsWith("2"));
402
- assert.equal(renderSidebar(40).length >= 0, true);
403
- });
@@ -1,291 +0,0 @@
1
- /**
2
- * The automatic `Sections` toggle rows (S5, S6), against the fake host and the fake sidebar.
3
- *
4
- * A plugin never writes a `setting`, a `value`, a `current()`, an `action()` or a `preview()`: the
5
- * bridge derives all of it from the section registration. These tests pin that surface, the
6
- * persistence file, and the one property that makes the toggle useful — hiding takes effect on the
7
- * sidebar's NEXT FRAME, not only on the next menu open.
8
- */
9
-
10
- import assert from "node:assert/strict";
11
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
12
- import { tmpdir } from "node:os";
13
- import { join } from "node:path";
14
- import test from "node:test";
15
-
16
- import { armBridge } from "../src/arm.ts";
17
- import { registerSection, resetBusForTests, resetSectionsForTests, snapshotProviders } from "../src/bus.ts";
18
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
19
- import { resetHookStateForTests } from "../src/hook.ts";
20
- import { resetRowBuildStateForTests } from "../src/rows.ts";
21
- import {
22
- SECTION_ROWS_PROVIDER_ID,
23
- installSectionRows,
24
- } from "../src/sections-rows.ts";
25
- import {
26
- disableSidebarBackend,
27
- installSidebarBackend,
28
- resetSidebarStateForTests,
29
- } from "../src/sidebar.ts";
30
- import { installSectionRail, resetSectionFailuresForTests } from "../src/sidebar-render.ts";
31
- import { resetStoreStateForTests } from "../src/store.ts";
32
- import type { CustomizeRow, RowContext, SidebarSection } from "../src/types.ts";
33
- import { createFakeHost, type FakeHost } from "./fake-host.ts";
34
- import { cardTitles, fakeTui, registerNativeFooter, resetSidebar } from "./fake-sidebar.ts";
35
-
36
- const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
37
- const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
38
-
39
- let configHome: string;
40
- let previousConfigHome: string | undefined;
41
-
42
- test.before(() => {
43
- configHome = mkdtempSync(join(tmpdir(), "gsb-rows-"));
44
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
45
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
46
- });
47
-
48
- test.after(() => {
49
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
50
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
51
- rmSync(configHome, { recursive: true, force: true });
52
- });
53
-
54
- test.beforeEach(() => {
55
- // The visibility store is a real file: each test starts from nothing on disk.
56
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
57
- resetBusForTests();
58
- resetSectionsForTests();
59
- resetSidebar();
60
- resetSidebarStateForTests();
61
- resetSectionFailuresForTests();
62
- resetStoreStateForTests();
63
- resetCaptureStateForTests();
64
- resetHookStateForTests();
65
- resetRowBuildStateForTests();
66
- delete hookSlot()[HOOK_KEY];
67
- });
68
-
69
- test.afterEach(() => {
70
- disableCaptureBackend();
71
- disableSidebarBackend();
72
- delete hookSlot()[HOOK_KEY];
73
- });
74
-
75
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
76
- id,
77
- title: id.toUpperCase(),
78
- lines: () => [`body ${id}`],
79
- ...extra,
80
- });
81
-
82
- const ctx = (): RowContext => ({ theme: undefined, configHome });
83
-
84
- /** The one internal provider, read straight from the bus so a build needs no menu. */
85
- const rowsNow = (): CustomizeRow[] => {
86
- const provider = snapshotProviders().find((entry) => entry.id === SECTION_ROWS_PROVIDER_ID);
87
- assert.ok(provider !== undefined, "the bridge registers its own Sections provider");
88
- return provider.rows(ctx());
89
- };
90
-
91
- const settingsOf = (rows: CustomizeRow[]): string[] =>
92
- rows.map((row) => row.choice?.setting ?? "");
93
-
94
- /** Native host + interception + renderer + the internal rows provider, then a menu host. */
95
- function arm(): FakeHost {
96
- registerNativeFooter();
97
- installSidebarBackend(() => fakeTui());
98
- installSectionRail();
99
- installSectionRows();
100
- const host = createFakeHost();
101
- armBridge(host.ui);
102
- return host;
103
- }
104
-
105
- const storePath = (): string => join(configHome, "gentle-shell-bridge", "bridge-sections.json");
106
-
107
- test("a section becomes two consecutive Sections rows carrying its title as a label, and both values", () => {
108
- registerSection(section("loon.music", { title: "MUSIC" }));
109
- const host = arm();
110
-
111
- host.buildMenu();
112
-
113
- const injected = host.liveRows().filter((row) => row.category === "Sections");
114
- assert.equal(injected.length, 2, "exactly one toggle: two values, two rows");
115
- assert.equal(
116
- injected[0].choice?.setting,
117
- "music",
118
- "the line's name is the row label, never the section id",
119
- );
120
- assert.equal(injected[0].choice?.setting, injected[1].choice?.setting, "one setting, two values");
121
- assert.deepEqual(
122
- injected.map((row) => row.choice?.value),
123
- ["shown", "hidden"],
124
- "consecutive rows, so the host draws one line",
125
- );
126
- assert.deepEqual(
127
- injected.map((row) => (typeof row.label === "function" ? row.label() : row.label)),
128
- ["music", "music"],
129
- "the label is the title as a human reads it, in the row label and in the setting",
130
- );
131
-
132
- const lines = host.toggleLines();
133
- assert.equal(lines.length, 1, "one rendered line per section");
134
- assert.ok(lines[0].startsWith("music "), "the rendered line reads as a label");
135
- assert.equal(lines[0].includes("Section:"), false, "no prefix is added to the label");
136
- assert.equal(lines[0].includes("loon.music"), false, "and the id never appears in the row");
137
- assert.ok(lines[0].includes("● shown"), "defaultVisible defaults to shown");
138
- assert.ok(lines[0].includes("○ hidden"));
139
- assert.ok(host.rendered().includes("music"), "the label is what the row reads as");
140
- });
141
-
142
- test("toggling persists to the bridge store first, then the host repaints", async () => {
143
- registerSection(section("loon.music", { title: "MUSIC" }));
144
- const host = arm();
145
- host.buildMenu();
146
- const invalidationsBefore = host.invalidateCount();
147
-
148
- await host.pressRow(
149
- (row) => row.choice?.setting === "music" && row.choice.value === "hidden",
150
- );
151
-
152
- assert.deepEqual(JSON.parse(readFileSync(storePath(), "utf8")), {
153
- schemaVersion: 1,
154
- values: { sections: { "loon.music": false } },
155
- });
156
- assert.equal(host.invalidateCount(), invalidationsBefore + 1, "the host repaints after the action");
157
-
158
- assert.ok(host.toggleLines()[0].includes("○ shown"), "the bullet left shown");
159
- assert.ok(host.toggleLines()[0].includes("● hidden"), "and landed on hidden");
160
- });
161
-
162
- test("the previews are literal in both states, never the host's default", () => {
163
- registerSection(section("loon.music", { title: "MUSIC" }));
164
- arm();
165
-
166
- const rows = rowsNow();
167
- const shown = rows.find((row) => row.choice?.value === "shown");
168
- const hidden = rows.find((row) => row.choice?.value === "hidden");
169
- assert.ok(shown?.preview !== undefined && hidden?.preview !== undefined);
170
-
171
- assert.deepEqual(shown.preview(), {
172
- title: "MUSIC — shown",
173
- sample: "▾ MUSIC\n(the card is drawn in the sidebar)",
174
- });
175
- assert.deepEqual(hidden.preview(), {
176
- title: "MUSIC — hidden",
177
- sample: "▾ MUSIC (not drawn)\n(the card does not render and its content is not built)",
178
- });
179
- });
180
-
181
- test("hiding takes effect on the sidebar's next frame, without building the section again", async () => {
182
- let linesCalls = 0;
183
- registerSection(
184
- section("loon.music", {
185
- title: "MUSIC",
186
- lines: () => {
187
- linesCalls += 1;
188
- return ["track one"];
189
- },
190
- }),
191
- );
192
- const host = arm();
193
- host.buildMenu();
194
-
195
- assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
196
- assert.equal(linesCalls, 1);
197
-
198
- await host.pressRow(
199
- (row) => row.choice?.setting === "music" && row.choice.value === "hidden",
200
- );
201
- linesCalls = 0;
202
-
203
- assert.deepEqual(cardTitles(), ["FOOTER"], "the next frame drops the card");
204
- assert.equal(linesCalls, 0, "and does not build the hidden section's content");
205
- });
206
-
207
- test("a section registered later gains rows without re-registering the provider", () => {
208
- registerSection(section("first", { title: "FIRST" }));
209
- installSectionRows();
210
-
211
- assert.equal(rowsNow().length, 2);
212
-
213
- registerSection(section("second", { title: "SECOND" }));
214
-
215
- assert.deepEqual(
216
- settingsOf(rowsNow()),
217
- ["first", "first", "second", "second"],
218
- "rows are built per menu build from the live registry, each named by its label",
219
- );
220
- });
221
-
222
- test("a disposed section loses its rows and its card", async () => {
223
- const dispose = registerSection(section("gone", { title: "GONE" }));
224
- registerSection(section("stays", { title: "STAYS" }));
225
- const host = arm();
226
- host.buildMenu();
227
- assert.equal(host.toggleLines().length, 2);
228
- assert.deepEqual(cardTitles(), ["FOOTER", "GONE", "STAYS"]);
229
-
230
- dispose();
231
-
232
- assert.deepEqual(settingsOf(rowsNow()), ["stays", "stays"]);
233
- assert.deepEqual(cardTitles(), ["FOOTER", "STAYS"]);
234
- });
235
-
236
- test("two sections with the same id replace each other instead of duplicating", () => {
237
- registerSection(section("dup", { title: "FIRST TITLE" }));
238
- registerSection(section("dup", { title: "SECOND TITLE" }));
239
- registerSection(section("other", { title: "OTHER" }));
240
- const host = arm();
241
- host.buildMenu();
242
-
243
- const dupRows = rowsNow().filter((row) => row.choice?.setting === "second title");
244
- assert.equal(dupRows.length, 2, "still one toggle for that id");
245
- assert.equal(
246
- host.toggleLines().filter((line) => line.startsWith("second title")).length,
247
- 1,
248
- "and one line, named by the live title's label",
249
- );
250
-
251
- assert.deepEqual(cardTitles(), ["FOOTER", "SECOND TITLE", "OTHER"], "one card, the live section");
252
-
253
- // One rendered label per toggle value, so the same label appears twice by design; what matters is
254
- // that the replaced label is gone.
255
- const rendered = [...new Set(host.rendered().filter((label) => label.includes("title")))];
256
- assert.deepEqual(rendered, ["second title"], "the replaced title is not left in the menu");
257
- });
258
-
259
- test("each section's line is independent: one toggle never writes another's key", () => {
260
- registerSection(section("a", { title: "AAA" }));
261
- registerSection(section("b", { title: "BBB" }));
262
- arm();
263
-
264
- const hideB = rowsNow().find(
265
- (row) => row.choice?.setting === "bbb" && row.choice.value === "hidden",
266
- );
267
- assert.ok(hideB?.action !== undefined);
268
- hideB.action({ input: async () => undefined, confirm: async () => true, capture: async () => undefined });
269
-
270
- assert.deepEqual(JSON.parse(readFileSync(storePath(), "utf8")), {
271
- schemaVersion: 1,
272
- values: { sections: { b: false } },
273
- });
274
- const rows = rowsNow();
275
- const currentFor = (setting: string, value: string): boolean | undefined =>
276
- rows.find((row) => row.choice?.setting === setting && row.choice.value === value)?.choice?.current();
277
- assert.equal(currentFor("aaa", "shown"), true, "the untouched section stays shown");
278
- assert.equal(currentFor("bbb", "shown"), false);
279
- assert.equal(currentFor("bbb", "hidden"), true);
280
- });
281
-
282
- test("defaultVisible false starts hidden, and the rows say so", () => {
283
- registerSection(section("quiet", { title: "QUIET", defaultVisible: false }));
284
- const host = arm();
285
- host.buildMenu();
286
-
287
- assert.deepEqual(cardTitles(), ["FOOTER"]);
288
- const line = host.toggleLines()[0];
289
- assert.ok(line.includes("○ shown"));
290
- assert.ok(line.includes("● hidden"));
291
- });