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,361 +0,0 @@
1
- /**
2
- * T26 / S15: `Keymaps` is the host's own interaction and is closed to plugins.
3
- *
4
- * The host's `Keymaps` page is not a list of settings: its rows capture the next key press, write
5
- * `keymaps.json` and carry a category footer that promises `Enter assign`
6
- * (`lib/keymaps-customize.ts:49-68`, `lib/visual-customize-view.ts:40`). A plugin row there would be
7
- * advertised as an assignable binding and could never honour it, so the category is reserved —
8
- * compared trimmed and case-insensitively, because ` Keymaps ` is the same page as `Keymaps`.
9
- *
10
- * The refusal reuses the bridge's ONE rejection channel, at the two boundaries that can see a
11
- * category:
12
- *
13
- * - registration (`./bus.ts`): the provider is refused BEFORE anything is stored, so the throw lands
14
- * in `attributeRegistration` and the plugin's INTEGRATIONS line counts it and reads `degraded`
15
- * (proved here with a real plugin module in its own temporary package, like every attribution
16
- * test);
17
- * - menu build (`./rows.ts`): a single row whose OWN category is reserved is dropped as it is
18
- * staged and recorded for `/bridge-status`, exactly like a row-name rejection. Its siblings still
19
- * reach the menu — one bad row never costs the provider the rest.
20
- *
21
- * Nothing else changes: `Keymap`, `Key maps` and `Cards` are ordinary categories.
22
- */
23
-
24
- import assert from "node:assert/strict";
25
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
26
- import { tmpdir } from "node:os";
27
- import { join } from "node:path";
28
- import test from "node:test";
29
- import { pathToFileURL } from "node:url";
30
-
31
- import { publishBridgeApi, resetBridgeApiForTests } from "../src/api.ts";
32
- import { armBridge } from "../src/arm.ts";
33
- import {
34
- registerRows,
35
- resetBusForTests,
36
- resetCardStylesForTests,
37
- resetSectionsForTests,
38
- resetTransformsForTests,
39
- snapshotProviders,
40
- } from "../src/bus.ts";
41
- import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
42
- import { resetHookStateForTests } from "../src/hook.ts";
43
- import { resetNoticesForTests } from "../src/notices.ts";
44
- import {
45
- getPluginLineReport,
46
- installPluginStatus,
47
- resetPluginLinesForTests,
48
- } from "../src/plugin-lines.ts";
49
- import { getRowNameRejections, resetRowNameRejectionsForTests } from "../src/row-names.ts";
50
- import { getLastRowBuild, resetRowBuildStateForTests } from "../src/rows.ts";
51
- import { collectStatus, formatStatus } from "../src/status.ts";
52
- import { resetStoreStateForTests } from "../src/store.ts";
53
- import type { CustomizeRow, RowLabel } from "../src/types.ts";
54
- import { createFakeHost, type FakeHost } from "./fake-host.ts";
55
-
56
- /** The one sentence the refusal quotes, so the reason stands on its own in both places. */
57
- const RULE =
58
- "works differently in the host (its own key capture and footer); not open to plugins yet";
59
-
60
- const labelText = (label: RowLabel | undefined): string => {
61
- if (typeof label === "function") return label();
62
- return label ?? "";
63
- };
64
-
65
- const rowsOfLastBuild = (providerId: string): CustomizeRow[] =>
66
- getLastRowBuild()?.find((record) => record.providerId === providerId)?.rows ?? [];
67
-
68
- const loonLabels = (host: FakeHost): string[] =>
69
- host
70
- .liveRows()
71
- .map((row) => labelText(row.label))
72
- .filter((label) => label.startsWith("Loon"));
73
-
74
- let configHome: string;
75
- let previousConfigHome: string | undefined;
76
-
77
- test.before(() => {
78
- configHome = mkdtempSync(join(tmpdir(), "gsb-reserved-categories-"));
79
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
80
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
81
- });
82
-
83
- test.after(() => {
84
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
85
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
86
- rmSync(configHome, { recursive: true, force: true });
87
- for (const dir of scratchDirs) rmSync(dir, { recursive: true, force: true });
88
- });
89
-
90
- test.beforeEach(() => {
91
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
92
- resetBridgeApiForTests();
93
- resetBusForTests();
94
- resetSectionsForTests();
95
- resetCardStylesForTests();
96
- resetTransformsForTests();
97
- resetRowNameRejectionsForTests();
98
- resetCaptureStateForTests();
99
- resetHookStateForTests();
100
- resetRowBuildStateForTests();
101
- resetNoticesForTests();
102
- resetStoreStateForTests();
103
- resetPluginLinesForTests();
104
- disableCaptureBackend();
105
- });
106
-
107
- const menuHost = (): FakeHost => {
108
- const host = createFakeHost();
109
- armBridge(host.ui);
110
- return host;
111
- };
112
-
113
- const button = (category: string, label: string): CustomizeRow => ({
114
- category,
115
- label,
116
- action: () => {},
117
- });
118
-
119
- // ---------------------------------------------------------------------------------------------
120
- // Registration (the throw the plugin's own call sees, and how it is reported)
121
- // ---------------------------------------------------------------------------------------------
122
-
123
- const registrationError = (category: string, id = "loon.keymaps"): string =>
124
- `gentle-shell-bridge: cannot register row provider ${JSON.stringify(id)}: its category ` +
125
- `${JSON.stringify(category)} is the reserved category "Keymaps". Keymaps ${RULE}. ` +
126
- `Place the rows in one of the host's other categories, e.g. "Cards".`;
127
-
128
- test("a provider registered in Keymaps is refused, and nothing is stored", () => {
129
- const api = publishBridgeApi();
130
-
131
- assert.throws(
132
- () => api.registerRows({ id: "loon.keymaps", category: "Keymaps", rows: () => [] }),
133
- { message: registrationError("Keymaps") },
134
- "the error names the category and says why it is closed",
135
- );
136
-
137
- assert.deepEqual(snapshotProviders(), [], "a refused provider never reaches the bus");
138
- const host = menuHost();
139
- host.buildMenu();
140
- assert.deepEqual(loonLabels(host), [], "and therefore never reaches the menu");
141
- assert.deepEqual(getLastRowBuild(), [], "no build record exists for it");
142
- });
143
-
144
- test("every spelling of the same category is refused: trimmed and case-insensitive", () => {
145
- const api = publishBridgeApi();
146
- const spellings = ["keymaps", " Keymaps ", "KEYMAPS", "\tKeyMaps\n"];
147
-
148
- for (const [index, category] of spellings.entries()) {
149
- const id = `loon.keymaps-${String(index)}`;
150
- assert.throws(
151
- () => api.registerRows({ id, category, rows: () => [] }),
152
- { message: registrationError(category, id) },
153
- `${JSON.stringify(category)} is the same page as Keymaps`,
154
- );
155
- }
156
- assert.deepEqual(snapshotProviders(), []);
157
- });
158
-
159
- test("lookalikes are ordinary categories: Keymap, Key maps and Cards are still allowed", () => {
160
- const api = publishBridgeApi();
161
- const categories = ["Keymap", "Key maps", "Cards"];
162
-
163
- for (const [index, category] of categories.entries()) {
164
- api.registerRows({
165
- id: `loon.lookalike-${String(index)}`,
166
- category,
167
- rows: () => [button(category, `Loon choice ${String(index)}`)],
168
- });
169
- }
170
-
171
- assert.deepEqual(
172
- snapshotProviders().map((provider) => provider.category),
173
- categories,
174
- "the comparison is exact after trimming: a near-miss is a different category",
175
- );
176
-
177
- const host = menuHost();
178
- host.buildMenu();
179
- assert.deepEqual(loonLabels(host), [
180
- "Loon choice 0",
181
- "Loon choice 1",
182
- "Loon choice 2",
183
- ]);
184
- });
185
-
186
- // ---------------------------------------------------------------------------------------------
187
- // The reported rejection: the plugin's INTEGRATIONS line and /bridge-status
188
- // ---------------------------------------------------------------------------------------------
189
-
190
- const scratchDirs: string[] = [];
191
- let fixtureCount = 0;
192
-
193
- /** A real plugin module in its own temporary package, so attribution runs as it does in Pi. */
194
- const writePlugin = (packageName: string, body: string): string => {
195
- fixtureCount += 1;
196
- const root = mkdtempSync(join(tmpdir(), "gsb-reserved-plugin-"));
197
- scratchDirs.push(root);
198
- writeFileSync(join(root, "package.json"), JSON.stringify({ name: packageName, type: "module" }));
199
- const dir = join(root, `plugin-${String(fixtureCount)}`);
200
- mkdirSync(dir, { recursive: true });
201
- const file = join(dir, `plugin-${String(fixtureCount)}.mjs`);
202
- writeFileSync(
203
- file,
204
- `const api = globalThis[Symbol.for("gentle-shell-bridge/api/v1")];\n${body}\n`,
205
- );
206
- return file;
207
- };
208
-
209
- const loadPlugin = async (file: string): Promise<void> => {
210
- await import(`${pathToFileURL(file).href}?load=${String(Date.now())}-${String(fixtureCount)}`);
211
- };
212
-
213
- test("the refused registration is counted in the plugin's INTEGRATIONS line", async () => {
214
- const statuses = new Map<string, string>();
215
- installPluginStatus({
216
- setStatus(key: string, text?: string) {
217
- if (text === undefined) statuses.delete(key);
218
- else statuses.set(key, text);
219
- },
220
- notify() {},
221
- });
222
- publishBridgeApi();
223
-
224
- const file = writePlugin(
225
- "@acme/loon-kit",
226
- `api.registerRows({ id: "loon.keymaps", category: "Keymaps", rows: () => [] });`,
227
- );
228
- await assert.rejects(loadPlugin(file), {
229
- message: registrationError("Keymaps"),
230
- });
231
-
232
- assert.deepEqual([...statuses.values()], ["Loon kit · degraded"], "one line, and it says so");
233
- const [plugin] = getPluginLineReport().plugins;
234
- assert.equal(plugin?.accepted, 0, "the registration was not accepted");
235
- assert.equal(plugin?.rejected.length, 1, "it is the plugin's one reported rejection");
236
- assert.equal(plugin?.rejected[0]?.surface, "rows");
237
- assert.equal(plugin?.rejected[0]?.id, "loon.keymaps");
238
- assert.equal(plugin?.rejected[0]?.reason, registrationError("Keymaps"));
239
-
240
- const report = formatStatus(collectStatus());
241
- const text = report.join("\n");
242
- assert.ok(
243
- report.includes(` - rows "loon.keymaps": ${registrationError("Keymaps")}`),
244
- text,
245
- );
246
- assert.ok(text.includes("0 accepted, 1 rejected"), text);
247
- assert.deepEqual(snapshotProviders(), [], "and nothing is registered");
248
- });
249
-
250
- // ---------------------------------------------------------------------------------------------
251
- // A single row whose own category is reserved (the build boundary)
252
- // ---------------------------------------------------------------------------------------------
253
-
254
- const rowRejection = (value: string, id = "loon.mixed"): unknown => ({
255
- surface: "rows",
256
- id,
257
- field: "category",
258
- value,
259
- rule: "reserved category",
260
- reason: `it is the reserved category "Keymaps", which ${RULE}`,
261
- });
262
-
263
- test("a row in Keymaps is dropped, its siblings still render, and /bridge-status lists it", () => {
264
- registerRows({
265
- id: "loon.mixed",
266
- category: "Cards",
267
- rows: () => [
268
- button("Cards", "Loon first"),
269
- { category: "Keymaps", label: "Loon keymap", action: () => {} },
270
- { category: " keymaps ", label: "Loon other keymap", action: () => {} },
271
- button("Cards", "Loon third"),
272
- ],
273
- });
274
-
275
- const host = menuHost();
276
- host.buildMenu();
277
-
278
- assert.deepEqual(
279
- loonLabels(host),
280
- ["Loon first", "Loon third"],
281
- "the refused rows are absent; every sibling is injected",
282
- );
283
- assert.equal(
284
- host.liveRows().some((row) => row.category === "Keymaps"),
285
- false,
286
- "nothing reached the host's array under the reserved category",
287
- );
288
- assert.deepEqual(
289
- rowsOfLastBuild("loon.mixed").map((row) => labelText(row.label)),
290
- ["Loon first", "Loon third"],
291
- "and the effective set /bridge-status reads holds the siblings only",
292
- );
293
- assert.deepEqual(
294
- getRowNameRejections(),
295
- [rowRejection("Keymaps"), rowRejection(" keymaps ")],
296
- "one record per offending string, recorded verbatim",
297
- );
298
- assert.deepEqual(
299
- collectStatus().failures,
300
- [],
301
- "a refused row is a rejection, never a build failure: the provider keeps its rows",
302
- );
303
-
304
- const report = formatStatus(collectStatus());
305
- assert.ok(
306
- report.includes(
307
- ' - rows "loon.mixed" category "Keymaps": it is the reserved category "Keymaps", ' +
308
- `which ${RULE} (rejected, never rewritten)`,
309
- ),
310
- report.join("\n"),
311
- );
312
- });
313
-
314
- test("a rebuild keeps one record, and a corrected provider clears it", () => {
315
- const dispose = registerRows({
316
- id: "loon.mixed",
317
- category: "Cards",
318
- rows: () => [button("Cards", "Loon first"), { category: "Keymaps", label: "Loon keymap", action: () => {} }],
319
- });
320
-
321
- menuHost().buildMenu();
322
- menuHost().buildMenu();
323
- assert.equal(getRowNameRejections().length, 1, "a repeated build cannot grow the report");
324
-
325
- dispose();
326
- registerRows({
327
- id: "loon.mixed",
328
- category: "Cards",
329
- rows: () => [button("Cards", "Loon first"), button("Cards", "Loon second")],
330
- });
331
- const host = menuHost();
332
- host.buildMenu();
333
-
334
- assert.deepEqual(
335
- getRowNameRejections(),
336
- [],
337
- "the build that no longer contains the refused row clears the stale record",
338
- );
339
- assert.deepEqual(loonLabels(host), ["Loon first", "Loon second"]);
340
- });
341
-
342
- test("a provider the bus already holds in Keymaps still cannot inject rows", () => {
343
- // A copy of this package that predates the registration check can leave such a provider in the
344
- // process-global bus; the build boundary is the second, independent check. The bus holds the
345
- // provider object itself, so re-pointing its category is exactly the state an older copy writes.
346
- const provider = {
347
- id: "loon.legacy",
348
- category: "Cards",
349
- rows: () => [button("Cards", "Loon legacy")],
350
- };
351
- registerRows(provider);
352
- provider.category = "Keymaps";
353
-
354
- const host = menuHost();
355
- host.buildMenu();
356
-
357
- assert.deepEqual(loonLabels(host), [], "not one row of it reaches the menu");
358
- assert.deepEqual(getRowNameRejections(), [rowRejection("Keymaps", "loon.legacy")]);
359
- assert.equal(getRowNameRejections()[0]?.id, "loon.legacy");
360
- assert.deepEqual(collectStatus().failures, [], "and it is a rejection, not a build failure");
361
- });
@@ -1,284 +0,0 @@
1
- /**
2
- * Row naming (N1–N3): a row is a LABEL, not an id.
3
- *
4
- * The host prints `choice.setting` as a toggle line's name
5
- * (`lib/visual-customize-view.ts:583-595`), so a bridge-derived setting that contains an id puts
6
- * that id in front of the user: `Section: bash-card.status`. These tests pin the replacement rule —
7
- * lowercase words derived from the visible text, an explicit `rowLabel` winning, the id staying
8
- * internal, and collisions between two equal labels being disambiguated deterministically and
9
- * reported by `/bridge-status`.
10
- */
11
-
12
- import assert from "node:assert/strict";
13
- import { mkdtempSync, rmSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { join } from "node:path";
16
- import test from "node:test";
17
-
18
- import { armBridge } from "../src/arm.ts";
19
- import {
20
- registerCardStyle,
21
- registerSection,
22
- resetBusForTests,
23
- resetCardStylesForTests,
24
- resetSectionsForTests,
25
- } from "../src/bus.ts";
26
- import { installCardStyleRows, resetCardStyleRowStateForTests } from "../src/card-style-rows.ts";
27
- import { HOST_CARD_STYLE_SETTING } from "../src/card-style-line.ts";
28
- import {
29
- disableCaptureBackend,
30
- resetCaptureStateForTests,
31
- } from "../src/capture.ts";
32
- import { resetHookStateForTests } from "../src/hook.ts";
33
- import { resetRowBuildStateForTests } from "../src/rows.ts";
34
- import { installSectionRows } from "../src/sections-rows.ts";
35
- import { resetStoreStateForTests } from "../src/store.ts";
36
- import { collectStatus, formatStatus } from "../src/status.ts";
37
- import type {
38
- CardFrame,
39
- CardStyleRegistration,
40
- CustomizeRow,
41
- SidebarSection,
42
- } from "../src/types.ts";
43
- import { createFakeHost, type FakeHost } from "./fake-host.ts";
44
-
45
- let configHome: string;
46
- let previousConfigHome: string | undefined;
47
-
48
- test.before(() => {
49
- configHome = mkdtempSync(join(tmpdir(), "gsb-row-labels-"));
50
- previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
51
- process.env.GENTLE_PI_CONFIG_HOME = configHome;
52
- });
53
-
54
- test.after(() => {
55
- if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
56
- else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
57
- rmSync(configHome, { recursive: true, force: true });
58
- });
59
-
60
- test.beforeEach(() => {
61
- rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
62
- resetBusForTests();
63
- resetSectionsForTests();
64
- resetCardStylesForTests();
65
- resetCardStyleRowStateForTests();
66
- resetCaptureStateForTests();
67
- resetHookStateForTests();
68
- resetRowBuildStateForTests();
69
- resetStoreStateForTests();
70
- disableCaptureBackend();
71
- });
72
-
73
- const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
74
- id,
75
- title: id.toUpperCase(),
76
- lines: () => [`body ${id}`],
77
- ...extra,
78
- });
79
-
80
- const frame = (): CardFrame => ({
81
- top: (ctx) => "▛".repeat(ctx.width),
82
- line: (ctx) => ctx.text,
83
- bottom: (ctx) => "▙".repeat(ctx.width),
84
- });
85
-
86
- const style = (id: string, extra: Partial<CardStyleRegistration> = {}): CardStyleRegistration => ({
87
- id,
88
- label: id,
89
- frame: frame(),
90
- ...extra,
91
- });
92
-
93
- const textOf = (row: CustomizeRow): string => {
94
- const label = row.label;
95
- return typeof label === "function" ? label() : (label ?? "");
96
- };
97
-
98
- /** The bridge's own `Sections` provider, then a menu host that captures it. */
99
- const sectionsHost = (): FakeHost => {
100
- installSectionRows();
101
- const host = createFakeHost();
102
- armBridge(host.ui);
103
- return host;
104
- };
105
-
106
- /** The host's own `Card style` row, shaped like `extensions/gentle-shell.ts:3405-3419`. */
107
- const hostStyleRow = (value: "neon" | "float"): CustomizeRow => ({
108
- category: "Cards",
109
- label: () => `Card style: ${value}`,
110
- choice: {
111
- setting: HOST_CARD_STYLE_SETTING,
112
- value,
113
- current: () => value === "float",
114
- },
115
- action: async () => {},
116
- });
117
-
118
- /** A host menu long enough to be captured, with the host's own `Card style` line in `Cards`. */
119
- const rowsWithHostLine = (): CustomizeRow[] => [
120
- { category: "Cards", label: "Card bash: builtin", action: () => {} },
121
- { category: "Cards", label: "Card output: builtin", action: () => {} },
122
- hostStyleRow("neon"),
123
- hostStyleRow("float"),
124
- ];
125
-
126
- /** The capture backend, then the card-style surface, against a host that already has the line. */
127
- const cardStyleHost = (): FakeHost => {
128
- const host = createFakeHost({ rows: rowsWithHostLine() });
129
- armBridge(host.ui);
130
- installCardStyleRows();
131
- return host;
132
- };
133
-
134
- /** Every string the user could read in this menu: row labels, toggle lines and previews. */
135
- const visibleText = (host: FakeHost): string[] => [
136
- ...host.rendered(),
137
- ...host.toggleLines(),
138
- ...host.liveRows().flatMap((row) => {
139
- const preview = row.preview?.();
140
- return preview === undefined ? [] : [preview.title, preview.sample];
141
- }),
142
- ];
143
-
144
- // ---------------------------------------------------------------------------------------------
145
- // Sections (N1, N2)
146
- // ---------------------------------------------------------------------------------------------
147
-
148
- test("a section's row reads its title as a label: no prefix, no id", () => {
149
- registerSection(section("bash-card.status", { title: "BASH CARD" }));
150
- const host = sectionsHost();
151
-
152
- host.buildMenu();
153
-
154
- assert.deepEqual(host.toggleLines(), ["bash card ● shown ○ hidden"]);
155
- const injected = host.liveRows().filter((row) => row.category === "Sections");
156
- assert.equal(injected.length, 2);
157
- assert.deepEqual(
158
- injected.map(textOf),
159
- ["bash card", "bash card"],
160
- "the row label is the derived label, not the title as typed and not the id",
161
- );
162
- assert.equal(injected[0]?.choice?.setting, "bash card", "the line's name is the same label");
163
- assert.equal(
164
- visibleText(host).some((text) => text.includes("bash-card.status")),
165
- false,
166
- "the id never reaches a row",
167
- );
168
- assert.equal(
169
- visibleText(host).some((text) => text.startsWith("Section:")),
170
- false,
171
- "no prefix anywhere",
172
- );
173
- });
174
-
175
- test("the derivation lowercases and joins words, and drops dots and punctuation", () => {
176
- registerSection(section("internal.one", { title: "usageCost" }));
177
- registerSection(section("internal.two", { title: "bash-card.status" }));
178
- registerSection(section("internal.three", { title: "MUSIC 2" }));
179
- const host = sectionsHost();
180
-
181
- host.buildMenu();
182
-
183
- assert.deepEqual(host.toggleLines(), [
184
- "usage cost ● shown ○ hidden",
185
- "bash card status ● shown ○ hidden",
186
- "music 2 ● shown ○ hidden",
187
- ]);
188
- });
189
-
190
- test("an explicit rowLabel wins over the derived label", () => {
191
- registerSection(
192
- section("loon.music", { title: "MUSIC", rowLabel: "Music now playing" }),
193
- );
194
- const host = sectionsHost();
195
-
196
- host.buildMenu();
197
-
198
- assert.deepEqual(host.toggleLines(), ["Music now playing ● shown ○ hidden"]);
199
- assert.deepEqual(
200
- host.liveRows().filter((row) => row.category === "Sections").map(textOf),
201
- ["Music now playing", "Music now playing"],
202
- "the author's label is used verbatim",
203
- );
204
- });
205
-
206
- test("two sections with equal labels are disambiguated deterministically, never by id", () => {
207
- registerSection(section("loon.one", { title: "MUSIC" }));
208
- registerSection(section("loon.two", { title: "MUSIC" }));
209
- const first = sectionsHost();
210
- first.buildMenu();
211
-
212
- assert.deepEqual(first.toggleLines(), [
213
- "music ● shown ○ hidden",
214
- "music 2 ● shown ○ hidden",
215
- ]);
216
- assert.equal(
217
- first.toggleLines().some((line) => line.includes("loon.one") || line.includes("loon.two")),
218
- false,
219
- "the disambiguation is a readable suffix, never the raw id",
220
- );
221
-
222
- // Same registrations, a second menu build: the assignment cannot drift.
223
- const second = sectionsHost();
224
- second.buildMenu();
225
- assert.deepEqual(second.toggleLines(), first.toggleLines());
226
- });
227
-
228
- test("the collision is reported by /bridge-status, with ids and labels", () => {
229
- registerSection(section("loon.one", { title: "MUSIC" }));
230
- registerSection(section("loon.two", { title: "MUSIC" }));
231
-
232
- const snapshot = collectStatus();
233
- assert.deepEqual(
234
- snapshot.sections?.map((entry) => [entry.id, entry.rowLabel, entry.rowLabelCollision]),
235
- [
236
- ["loon.one", "music", undefined],
237
- ["loon.two", "music 2", "music"],
238
- ],
239
- );
240
-
241
- const lines = formatStatus(snapshot);
242
- assert.ok(lines.includes(' - loon.one "MUSIC": visible, registration order, row "music"'), lines.join("\n"));
243
- assert.ok(
244
- lines.includes(
245
- ' - loon.two "MUSIC": visible, registration order, row "music 2" ' +
246
- '(label collision with "music")',
247
- ),
248
- lines.join("\n"),
249
- );
250
- });
251
-
252
- // ---------------------------------------------------------------------------------------------
253
- // Card styles (N1, N3)
254
- // ---------------------------------------------------------------------------------------------
255
-
256
- test("a card-style row is a label derived from its label; the id stays internal", () => {
257
- registerCardStyle(style("loon.cute", { label: "Cute Card" }));
258
- const host = cardStyleHost();
259
-
260
- host.buildMenu();
261
-
262
- assert.deepEqual(host.toggleLines(), ["Card style ○ neon ● float ○ Cute Card"]);
263
- const registered = host.liveRows().filter((row) => row.choice?.value === "Cute Card");
264
- assert.equal(registered.length, 1);
265
- assert.equal(textOf(registered[0] as CustomizeRow), "cute card", "the row reads as a label");
266
- assert.equal(
267
- visibleText(host).some((text) => text.includes("loon.cute")),
268
- false,
269
- "the id never reaches a row, a line or a preview",
270
- );
271
- });
272
-
273
- test("an explicit card-style rowLabel wins, and the value keeps the style's own label", () => {
274
- registerCardStyle(style("loon.cute", { label: "Cute Card", rowLabel: "Plain card" }));
275
- const host = cardStyleHost();
276
-
277
- host.buildMenu();
278
-
279
- const registered = host.liveRows().filter((row) => row.choice?.setting === HOST_CARD_STYLE_SETTING);
280
- const bridgeRow = registered.find((row) => row.choice?.value === "Cute Card");
281
- assert.ok(bridgeRow !== undefined);
282
- assert.equal(textOf(bridgeRow), "Plain card", "the author's row label is used verbatim");
283
- assert.equal(bridgeRow.choice?.value, "Cute Card", "the line's value is still the style's label");
284
- });