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.
- package/LICENSE +202 -0
- package/README.md +91 -722
- package/docs/contract.md +92 -35
- package/package.json +13 -1
- package/src/api.ts +1 -1
- package/.codegraph/.gitignore +0 -5
- package/PRD.md +0 -144
- package/examples/bash-card.ts +0 -1694
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +0 -30
- package/odd/tasks/plugin-keymap.md +0 -32
- package/odd/tasks/plugin-surface-contract.md +0 -55
- package/test/api.test.ts +0 -395
- package/test/apply-on-move.test.ts +0 -477
- package/test/arm.test.ts +0 -220
- package/test/bridge-ready.test.ts +0 -309
- package/test/bus.test.ts +0 -165
- package/test/button-rows.test.ts +0 -409
- package/test/capture.test.ts +0 -356
- package/test/card-style-rows.test.ts +0 -510
- package/test/card-style-transform.test.ts +0 -379
- package/test/card-style-wiring.test.ts +0 -258
- package/test/card-styles-e2e.test.ts +0 -491
- package/test/card-styles.test.ts +0 -324
- package/test/card-transcript-path.test.ts +0 -178
- package/test/config.test.ts +0 -93
- package/test/e2e.test.ts +0 -443
- package/test/example-bash-identity.test.ts +0 -372
- package/test/example-bash-matrix.test.ts +0 -856
- package/test/example-designs.test.ts +0 -734
- package/test/example-neon-transform.test.ts +0 -283
- package/test/example-plain-style.test.ts +0 -1268
- package/test/extension.test.ts +0 -160
- package/test/fake-events.ts +0 -58
- package/test/fake-host.ts +0 -211
- package/test/fake-sidebar.ts +0 -516
- package/test/fake-transcript.ts +0 -486
- package/test/frame-telemetry.test.ts +0 -417
- package/test/hook.test.ts +0 -196
- package/test/manifest.test.ts +0 -105
- package/test/new-category.test.ts +0 -979
- package/test/notices.test.ts +0 -328
- package/test/plugin-integration-lines.test.ts +0 -979
- package/test/plugin-surface.test.ts +0 -152
- package/test/presence.test.ts +0 -459
- package/test/rail-surfaces.test.ts +0 -457
- package/test/rail-width.test.ts +0 -423
- package/test/regression.test.ts +0 -483
- package/test/repaint-api.test.ts +0 -200
- package/test/reserved-categories.test.ts +0 -361
- package/test/row-labels.test.ts +0 -284
- package/test/row-names.test.ts +0 -473
- package/test/row-origin.test.ts +0 -395
- package/test/section-interaction.test.ts +0 -364
- package/test/section-panel.test.ts +0 -403
- package/test/sections-rows.test.ts +0 -291
- package/test/sidebar-backend.test.ts +0 -471
- package/test/sidebar-e2e.test.ts +0 -331
- package/test/sidebar-hover.test.ts +0 -276
- package/test/sidebar-independent.test.ts +0 -349
- package/test/sidebar-render.test.ts +0 -281
- package/test/sidebar-sections.test.ts +0 -245
- package/test/status-card-styles.test.ts +0 -201
- package/test/status-section-frame.test.ts +0 -181
- package/test/status-sections.test.ts +0 -243
- package/test/status.test.ts +0 -209
- package/test/store.test.ts +0 -192
- package/test/surface-repaint-cost.test.ts +0 -520
- package/test/tool-design-chain.test.ts +0 -483
- package/test/tool-design-cost.test.ts +0 -473
- package/test/tool-design-rows.test.ts +0 -359
- package/test/tool-designs.test.ts +0 -745
- package/test/transform-pipeline.test.ts +0 -465
- package/test/transforms-bus.test.ts +0 -233
- package/test/verifier-width-regressions.test.ts +0 -164
- package/tsconfig.json +0 -18
|
@@ -1,477 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Move the cursor, press Enter to apply (A1–A4, L4).
|
|
3
|
-
*
|
|
4
|
-
* The user's report: a line the bridge added changes its value the moment ←/→ touches it — the `›`
|
|
5
|
-
* cursor and "Enter applies" never appear — while the host's own `Stats` line moves the cursor and
|
|
6
|
-
* applies on Enter. The host's rule is PER ROW, not per line, so the fix is a row field:
|
|
7
|
-
*
|
|
8
|
-
* - `lib/visual-customize-view.ts:552-566` steps the cursor inside one setting line and runs
|
|
9
|
-
* `rows[next].action()` only while `rows[next].choice?.applyOnMove !== false`;
|
|
10
|
-
* - `:409-417` lands a line on its current value, which is the cursor the user sees first;
|
|
11
|
-
* - `:590-592` marks the focused non-current value with `›`, and `:35-38,751` prints
|
|
12
|
-
* `←/→ choose · Enter apply` for a focused line whose row defers.
|
|
13
|
-
*
|
|
14
|
-
* `KeyDriver` below is that rule, modelled once and driven over the REAL rows the bridge emits —
|
|
15
|
-
* the `Sections` toggle, the card-style values in both modes (mixed with the host's own), and a
|
|
16
|
-
* plugin provider's own rows (a fixture with the shape the worked example used to demonstrate).
|
|
17
|
-
* It counts the actions it runs, so "a move applies
|
|
18
|
-
* nothing" and "Enter applies exactly once" are observations, not assumptions.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import assert from "node:assert/strict";
|
|
22
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
23
|
-
import { tmpdir } from "node:os";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import test from "node:test";
|
|
26
|
-
|
|
27
|
-
import { BRIDGE_API_KEY, publishBridgeApi, resetBridgeApiForTests } from "../src/api.ts";
|
|
28
|
-
import { armBridge } from "../src/arm.ts";
|
|
29
|
-
import {
|
|
30
|
-
registerCardStyle,
|
|
31
|
-
registerRows,
|
|
32
|
-
registerSection,
|
|
33
|
-
resetBusForTests,
|
|
34
|
-
resetCardStylesForTests,
|
|
35
|
-
resetSectionsForTests,
|
|
36
|
-
} from "../src/bus.ts";
|
|
37
|
-
import { installCardStyleRows, resetCardStyleRowStateForTests } from "../src/card-style-rows.ts";
|
|
38
|
-
import { HOST_CARD_STYLE_SETTING } from "../src/card-style-line.ts";
|
|
39
|
-
import { getCardStyleSelection } from "../src/card-styles.ts";
|
|
40
|
-
import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
|
|
41
|
-
import { resetHookStateForTests } from "../src/hook.ts";
|
|
42
|
-
import { getRepaintDiagnostics, resetRepaintForTests } from "../src/repaint.ts";
|
|
43
|
-
import { buildRows, resetRowBuildStateForTests } from "../src/rows.ts";
|
|
44
|
-
import { installSectionRows } from "../src/sections-rows.ts";
|
|
45
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
46
|
-
import type { CardStyleRegistration, CustomizeRow, InlineHelpers, RowContext, SidebarSection } from "../src/types.ts";
|
|
47
|
-
import { createFakeHost, type FakeRow } from "./fake-host.ts";
|
|
48
|
-
|
|
49
|
-
const inlineHelpers: InlineHelpers = {
|
|
50
|
-
input: async () => undefined,
|
|
51
|
-
confirm: async () => true,
|
|
52
|
-
capture: async () => undefined,
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const isVisible = (row: FakeRow): boolean => {
|
|
56
|
-
if (row.visible === undefined) return true;
|
|
57
|
-
try {
|
|
58
|
-
return row.visible();
|
|
59
|
-
} catch {
|
|
60
|
-
return false; // the renderer fails closed
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
interface KeyDriver {
|
|
65
|
-
/** Focus one setting line: the host lands on the value that is current (`:409-417`). */
|
|
66
|
-
focus: (setting: string) => { first: number; count: number; index: number };
|
|
67
|
-
/** ←/→ inside the focused line: move the cursor, then apply ONLY if the row allows it (`:565`). */
|
|
68
|
-
arrow: (direction: -1 | 1) => Promise<void>;
|
|
69
|
-
/** Enter on the focused value: always applies (`:575-577`). */
|
|
70
|
-
enter: () => Promise<void>;
|
|
71
|
-
/** How many row actions the host has run so far. */
|
|
72
|
-
actions: () => number;
|
|
73
|
-
/** The row the cursor sits on. */
|
|
74
|
-
focused: () => FakeRow;
|
|
75
|
-
/** The line the host would draw right now (`:583-595`): `●` current, `›` the cursor. */
|
|
76
|
-
line: () => string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The host's keyboard rule over whatever rows are live right now, taken row by row from
|
|
81
|
-
* `lib/visual-customize-view.ts` — including the per-row deferral the bridge rows now carry.
|
|
82
|
-
*/
|
|
83
|
-
function keyDriver(rowsNow: () => FakeRow[]): KeyDriver {
|
|
84
|
-
const rows = (): FakeRow[] => rowsNow().filter(isVisible);
|
|
85
|
-
let setting: string | undefined;
|
|
86
|
-
let index = 0;
|
|
87
|
-
let actions = 0;
|
|
88
|
-
|
|
89
|
-
const range = (name: string): { first: number; count: number } => {
|
|
90
|
-
const all = rows();
|
|
91
|
-
const first = all.findIndex((row) => row.choice?.setting === name);
|
|
92
|
-
if (first < 0) throw new Error(`keyDriver: no setting line ${JSON.stringify(name)}`);
|
|
93
|
-
let count = 1;
|
|
94
|
-
while (first + count < all.length && all[first + count]?.choice?.setting === name) count += 1;
|
|
95
|
-
return { first, count };
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const run = async (row: FakeRow): Promise<void> => {
|
|
99
|
-
actions += 1;
|
|
100
|
-
await row.action?.(inlineHelpers);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
return {
|
|
104
|
-
focus(name) {
|
|
105
|
-
const { first, count } = range(name);
|
|
106
|
-
setting = name;
|
|
107
|
-
index = first;
|
|
108
|
-
const all = rows();
|
|
109
|
-
for (let i = first; i < first + count; i += 1) {
|
|
110
|
-
try {
|
|
111
|
-
if (all[i]!.choice?.current() === true) {
|
|
112
|
-
index = i;
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
} catch {
|
|
116
|
-
// An unreadable value lands on the first choice, exactly as the host does.
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return { first, count, index };
|
|
120
|
-
},
|
|
121
|
-
async arrow(direction) {
|
|
122
|
-
if (setting === undefined) throw new Error("keyDriver: focus a line first");
|
|
123
|
-
const { first, count } = range(setting);
|
|
124
|
-
const next = index + direction;
|
|
125
|
-
// Stepping out of the value range does nothing; the host leaves the cursor where it is.
|
|
126
|
-
if (next < first || next >= first + count) return;
|
|
127
|
-
index = next;
|
|
128
|
-
const row = rows()[next]!;
|
|
129
|
-
// THE rule under test (`visual-customize-view.ts:565`): an explicit `false` defers to Enter.
|
|
130
|
-
if (row.choice?.applyOnMove !== false) await run(row);
|
|
131
|
-
},
|
|
132
|
-
async enter() {
|
|
133
|
-
if (setting === undefined) throw new Error("keyDriver: focus a line first");
|
|
134
|
-
await run(rows()[index]!);
|
|
135
|
-
},
|
|
136
|
-
actions: () => actions,
|
|
137
|
-
focused: () => rows()[index]!,
|
|
138
|
-
line() {
|
|
139
|
-
if (setting === undefined) throw new Error("keyDriver: focus a line first");
|
|
140
|
-
const { first, count } = range(setting);
|
|
141
|
-
const marks = rows()
|
|
142
|
-
.slice(first, first + count)
|
|
143
|
-
.map((row, offset) => {
|
|
144
|
-
const current = row.choice?.current() === true;
|
|
145
|
-
const cursor = !current && first + offset === index;
|
|
146
|
-
return `${current ? "●" : cursor ? "›" : "○"} ${row.choice?.value ?? ""}`;
|
|
147
|
-
});
|
|
148
|
-
return `${setting} ${marks.join(" ")}`;
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
let configHome: string;
|
|
154
|
-
let previousConfigHome: string | undefined;
|
|
155
|
-
|
|
156
|
-
test.before(() => {
|
|
157
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-apply-on-move-"));
|
|
158
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
159
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test.after(() => {
|
|
163
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
164
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
165
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test.beforeEach(() => {
|
|
169
|
-
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
170
|
-
delete (globalThis as unknown as Record<symbol, unknown>)[BRIDGE_API_KEY];
|
|
171
|
-
resetBridgeApiForTests();
|
|
172
|
-
resetBusForTests();
|
|
173
|
-
resetSectionsForTests();
|
|
174
|
-
resetCardStylesForTests();
|
|
175
|
-
resetCardStyleRowStateForTests();
|
|
176
|
-
resetCaptureStateForTests();
|
|
177
|
-
resetHookStateForTests();
|
|
178
|
-
resetRowBuildStateForTests();
|
|
179
|
-
resetRepaintForTests();
|
|
180
|
-
resetStoreStateForTests();
|
|
181
|
-
disableCaptureBackend();
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
const ctx = (): RowContext => ({ theme: undefined, configHome });
|
|
185
|
-
|
|
186
|
-
const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
|
|
187
|
-
id,
|
|
188
|
-
title: id.toUpperCase(),
|
|
189
|
-
lines: () => [`body ${id}`],
|
|
190
|
-
...extra,
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
const frame = () => ({
|
|
194
|
-
top: (given: { width: number }) => "▛".repeat(given.width),
|
|
195
|
-
line: (given: { text: string }) => given.text,
|
|
196
|
-
bottom: (given: { width: number }) => "▙".repeat(given.width),
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const style = (id: string, extra: Partial<CardStyleRegistration> = {}): CardStyleRegistration => ({
|
|
200
|
-
id,
|
|
201
|
-
label: id,
|
|
202
|
-
frame: frame(),
|
|
203
|
-
...extra,
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
/** The host's own `Card style` row, shaped like `extensions/gentle-shell.ts:3405-3419`. */
|
|
207
|
-
const hostStyleRow = (value: "neon" | "float", actions: string[]): FakeRow => ({
|
|
208
|
-
category: "Cards",
|
|
209
|
-
label: () => `Card style: ${value}`,
|
|
210
|
-
preview: () => ({ title: `Cards · ${value}`, sample: `${value} chrome + prompt` }),
|
|
211
|
-
action: async () => {
|
|
212
|
-
actions.push(value);
|
|
213
|
-
},
|
|
214
|
-
choice: { setting: HOST_CARD_STYLE_SETTING, value, current: () => value === "float" },
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
const rowsWithHostLine = (actions: string[]): FakeRow[] => [
|
|
218
|
-
{ category: "Cards", label: "Card bash: builtin", action: () => {} },
|
|
219
|
-
{ category: "Cards", label: "Card output: builtin", action: () => {} },
|
|
220
|
-
{ category: "Layout", label: "Layout: builtin", action: () => {} },
|
|
221
|
-
hostStyleRow("neon", actions),
|
|
222
|
-
hostStyleRow("float", actions),
|
|
223
|
-
];
|
|
224
|
-
|
|
225
|
-
const sectionStorePath = (): string => join(configHome, "gentle-shell-bridge", "bridge-sections.json");
|
|
226
|
-
|
|
227
|
-
const storedSections = (): unknown => {
|
|
228
|
-
try {
|
|
229
|
-
return JSON.parse(readFileSync(sectionStorePath(), "utf8"));
|
|
230
|
-
} catch {
|
|
231
|
-
return undefined;
|
|
232
|
-
}
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
// ---------------------------------------------------------------------------------------------
|
|
236
|
-
// The bridge's own `Sections` toggle
|
|
237
|
-
// ---------------------------------------------------------------------------------------------
|
|
238
|
-
|
|
239
|
-
test("the Sections toggle moves with ←/→ and applies only on Enter (A1, A4)", async () => {
|
|
240
|
-
registerSection(section("loon.music", { title: "MUSIC" }));
|
|
241
|
-
installSectionRows();
|
|
242
|
-
const host = createFakeHost();
|
|
243
|
-
armBridge(host.ui);
|
|
244
|
-
host.buildMenu();
|
|
245
|
-
|
|
246
|
-
const keys = keyDriver(host.liveRows);
|
|
247
|
-
keys.focus("music");
|
|
248
|
-
|
|
249
|
-
assert.equal(keys.focused().choice?.applyOnMove, false, "the toggle defers to Enter");
|
|
250
|
-
|
|
251
|
-
await keys.arrow(1);
|
|
252
|
-
|
|
253
|
-
assert.equal(keys.focused().choice?.value, "hidden", "the cursor moved to the other value");
|
|
254
|
-
assert.equal(keys.actions(), 0, "moving applied nothing");
|
|
255
|
-
assert.deepEqual(storedSections(), undefined, "and wrote no state");
|
|
256
|
-
assert.equal(getRepaintDiagnostics().lastTrigger, undefined, "nor asked for a frame");
|
|
257
|
-
assert.equal(keys.line(), "music ● shown › hidden", "the cursor is drawn on the value Enter would apply");
|
|
258
|
-
|
|
259
|
-
await keys.enter();
|
|
260
|
-
|
|
261
|
-
assert.equal(keys.actions(), 1, "Enter applied the choice exactly once");
|
|
262
|
-
assert.deepEqual(storedSections(), {
|
|
263
|
-
schemaVersion: 1,
|
|
264
|
-
values: { sections: { "loon.music": false } },
|
|
265
|
-
});
|
|
266
|
-
assert.equal(
|
|
267
|
-
getRepaintDiagnostics().lastTrigger?.cause,
|
|
268
|
-
'section "loon.music" → hidden',
|
|
269
|
-
"and the rail repaint still happens on the write, not on the move",
|
|
270
|
-
);
|
|
271
|
-
assert.equal(keys.line(), "music ○ shown ● hidden");
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
// ---------------------------------------------------------------------------------------------
|
|
275
|
-
// Card styles: the fallback line and the host's own line
|
|
276
|
-
// ---------------------------------------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
test("a registered card style defers to Enter on the fallback line (A1)", async () => {
|
|
279
|
-
const host = createFakeHost();
|
|
280
|
-
armBridge(host.ui);
|
|
281
|
-
installCardStyleRows();
|
|
282
|
-
registerCardStyle(style("loon.cute", { label: "cute" }));
|
|
283
|
-
host.buildMenu();
|
|
284
|
-
|
|
285
|
-
const keys = keyDriver(host.liveRows);
|
|
286
|
-
keys.focus("Bridge card style");
|
|
287
|
-
|
|
288
|
-
await keys.arrow(1);
|
|
289
|
-
|
|
290
|
-
assert.equal(keys.focused().choice?.value, "cute", "the cursor moved onto the registered style");
|
|
291
|
-
assert.equal(keys.actions(), 0, "moving applied nothing");
|
|
292
|
-
assert.equal(getCardStyleSelection(), "native", "the stored selection is untouched");
|
|
293
|
-
assert.equal(getRepaintDiagnostics().lastTrigger, undefined, "and no surface was repainted");
|
|
294
|
-
assert.equal(keys.line(), "Bridge card style ● native › cute");
|
|
295
|
-
|
|
296
|
-
await keys.enter();
|
|
297
|
-
|
|
298
|
-
assert.equal(keys.actions(), 1, "Enter applied exactly once");
|
|
299
|
-
assert.equal(getCardStyleSelection(), "loon.cute");
|
|
300
|
-
assert.equal(getRepaintDiagnostics().lastTrigger?.cause, "card style → loon.cute");
|
|
301
|
-
assert.deepEqual(getRepaintDiagnostics().lastTrigger?.surfaces, ["conversation", "rail"]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
test("the fallback line's native value defers too (A1)", async () => {
|
|
305
|
-
const host = createFakeHost();
|
|
306
|
-
armBridge(host.ui);
|
|
307
|
-
installCardStyleRows();
|
|
308
|
-
registerCardStyle(style("loon.cute", { label: "cute" }));
|
|
309
|
-
host.buildMenu();
|
|
310
|
-
|
|
311
|
-
const keys = keyDriver(host.liveRows);
|
|
312
|
-
keys.focus("Bridge card style");
|
|
313
|
-
await keys.arrow(1);
|
|
314
|
-
await keys.enter(); // select cute first, so native is no longer current
|
|
315
|
-
assert.equal(getCardStyleSelection(), "loon.cute");
|
|
316
|
-
const before = keys.actions();
|
|
317
|
-
|
|
318
|
-
keys.focus("Bridge card style");
|
|
319
|
-
await keys.arrow(-1);
|
|
320
|
-
|
|
321
|
-
assert.equal(keys.focused().choice?.value, "native", "the cursor moved back to native");
|
|
322
|
-
assert.equal(keys.actions(), before, "moving applied nothing");
|
|
323
|
-
assert.equal(getCardStyleSelection(), "loon.cute", "cute is still selected");
|
|
324
|
-
|
|
325
|
-
await keys.enter();
|
|
326
|
-
assert.equal(keys.actions(), before + 1, "Enter applied exactly once");
|
|
327
|
-
assert.equal(getCardStyleSelection(), "native");
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test("on the host's line the host's values keep their own field and the bridge's value defers (A3)", async () => {
|
|
331
|
-
const hostActions: string[] = [];
|
|
332
|
-
const host = createFakeHost({ rows: rowsWithHostLine(hostActions) });
|
|
333
|
-
armBridge(host.ui);
|
|
334
|
-
installCardStyleRows();
|
|
335
|
-
registerCardStyle(style("loon.cute", { label: "cute" }));
|
|
336
|
-
host.buildMenu();
|
|
337
|
-
|
|
338
|
-
const live = host.liveRows();
|
|
339
|
-
const hostRows = live.filter((row) => row.choice?.setting === HOST_CARD_STYLE_SETTING);
|
|
340
|
-
assert.equal(hostRows.length, 3, "neon, float and the spliced bridge value on ONE line");
|
|
341
|
-
assert.equal(hostRows[0]?.choice?.applyOnMove, undefined, "the host's neon row is the host's");
|
|
342
|
-
assert.equal(hostRows[1]?.choice?.applyOnMove, undefined, "and so is float");
|
|
343
|
-
assert.equal(hostRows[2]?.choice?.applyOnMove, false, "the bridge's value defers to Enter");
|
|
344
|
-
|
|
345
|
-
const keys = keyDriver(host.liveRows);
|
|
346
|
-
keys.focus(HOST_CARD_STYLE_SETTING);
|
|
347
|
-
|
|
348
|
-
// The host's own value applies on the move, exactly as before: the bridge did not rewrite it.
|
|
349
|
-
await keys.arrow(-1);
|
|
350
|
-
assert.deepEqual(hostActions, ["neon"], "the host's own action ran on the move");
|
|
351
|
-
keys.focus(HOST_CARD_STYLE_SETTING);
|
|
352
|
-
|
|
353
|
-
// The bridge's value on the SAME line only takes focus.
|
|
354
|
-
await keys.arrow(1);
|
|
355
|
-
await keys.arrow(1);
|
|
356
|
-
assert.equal(keys.focused().choice?.value, "cute", "the cursor reached the bridge value");
|
|
357
|
-
assert.equal(getCardStyleSelection(), "native", "moving onto it applied nothing");
|
|
358
|
-
assert.deepEqual(hostActions, ["neon"], "and ran no host action either");
|
|
359
|
-
|
|
360
|
-
await keys.enter();
|
|
361
|
-
assert.equal(getCardStyleSelection(), "loon.cute", "Enter applied the bridge value");
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
// ---------------------------------------------------------------------------------------------
|
|
365
|
-
// A plugin's own provider rows (a fixture, not the worked example)
|
|
366
|
-
// ---------------------------------------------------------------------------------------------
|
|
367
|
-
//
|
|
368
|
-
// The example contributes a tool DESIGN now, and the bridge owns the design line: its rows are not a
|
|
369
|
-
// provider's, so the provider path is driven by a fixture with the same shape and the same rules.
|
|
370
|
-
// Nothing here is specific to bash; the values are just two choices on one line.
|
|
371
|
-
|
|
372
|
-
test("a plugin's provider rows move without applying and apply on Enter (A1, A2)", async () => {
|
|
373
|
-
publishBridgeApi();
|
|
374
|
-
let stored = "native";
|
|
375
|
-
const values = ["native", "bash heading"];
|
|
376
|
-
registerRows({
|
|
377
|
-
id: "loon.apply-on-move",
|
|
378
|
-
category: "Cards",
|
|
379
|
-
name: "Loon example",
|
|
380
|
-
rows: () =>
|
|
381
|
-
values.map((value) => ({
|
|
382
|
-
category: "Cards",
|
|
383
|
-
label: "Bash design",
|
|
384
|
-
choice: {
|
|
385
|
-
setting: "Bash design",
|
|
386
|
-
value,
|
|
387
|
-
current: (): boolean => stored === value,
|
|
388
|
-
},
|
|
389
|
-
action: async (): Promise<void> => {
|
|
390
|
-
stored = value;
|
|
391
|
-
},
|
|
392
|
-
})),
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
const rows = buildRows(ctx()).rows;
|
|
396
|
-
const design = rows.filter((row) => row.choice?.setting === "Bash design");
|
|
397
|
-
assert.deepEqual(
|
|
398
|
-
design.map((row) => row.choice?.value),
|
|
399
|
-
["native", "bash heading"],
|
|
400
|
-
"the provider's two values are on one line",
|
|
401
|
-
);
|
|
402
|
-
|
|
403
|
-
const keys = keyDriver(() => rows);
|
|
404
|
-
keys.focus("Bash design");
|
|
405
|
-
|
|
406
|
-
await keys.arrow(1);
|
|
407
|
-
|
|
408
|
-
assert.equal(keys.focused().choice?.value, "bash heading", "the cursor moved onto the other value");
|
|
409
|
-
assert.equal(keys.actions(), 0, "moving applied nothing");
|
|
410
|
-
assert.equal(design[0]?.choice?.current(), true, "the stored value is still the first one");
|
|
411
|
-
assert.equal(design[1]?.choice?.current(), false);
|
|
412
|
-
assert.equal(keys.line(), "Bash design ● native › bash heading");
|
|
413
|
-
|
|
414
|
-
await keys.enter();
|
|
415
|
-
|
|
416
|
-
assert.equal(keys.actions(), 1, "Enter applied exactly once");
|
|
417
|
-
assert.equal(design[0]?.choice?.current(), false, "and the stored value became the other one");
|
|
418
|
-
assert.equal(design[1]?.choice?.current(), true);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
// ---------------------------------------------------------------------------------------------
|
|
422
|
-
// The build boundary: every provider choice defers, and the provider's objects are untouched
|
|
423
|
-
// ---------------------------------------------------------------------------------------------
|
|
424
|
-
|
|
425
|
-
test("the build defers every provider choice on a copy, never mutating the provider's row (A2)", () => {
|
|
426
|
-
const authored: CustomizeRow = {
|
|
427
|
-
category: "Cards",
|
|
428
|
-
label: "Loon mode",
|
|
429
|
-
choice: { setting: "Loon mode", value: "first", current: () => true },
|
|
430
|
-
action: () => {},
|
|
431
|
-
};
|
|
432
|
-
const alreadyDeferring: CustomizeRow = {
|
|
433
|
-
category: "Cards",
|
|
434
|
-
label: "Loon other",
|
|
435
|
-
choice: { setting: "Loon other", value: "one", current: () => true, applyOnMove: false },
|
|
436
|
-
action: () => {},
|
|
437
|
-
};
|
|
438
|
-
const explicitImmediate: CustomizeRow = {
|
|
439
|
-
category: "Cards",
|
|
440
|
-
label: "Loon third",
|
|
441
|
-
choice: { setting: "Loon third", value: "one", current: () => true, applyOnMove: true },
|
|
442
|
-
action: () => {},
|
|
443
|
-
};
|
|
444
|
-
const plain: CustomizeRow = { category: "Cards", label: "Loon action", action: () => {} };
|
|
445
|
-
registerRows({
|
|
446
|
-
id: "test.apply-on-move",
|
|
447
|
-
category: "Cards",
|
|
448
|
-
rows: () => [authored, alreadyDeferring, explicitImmediate, plain],
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
const built = buildRows(ctx()).rows;
|
|
452
|
-
|
|
453
|
-
assert.equal(built.length, 4, "no row was dropped");
|
|
454
|
-
for (const row of built) {
|
|
455
|
-
if (row.choice !== undefined) {
|
|
456
|
-
assert.equal(row.choice.applyOnMove, false, `${String(row.choice.setting)} defers to Enter`);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// The emitted copy is not the provider's row, and the provider's own objects are untouched.
|
|
461
|
-
assert.notEqual(built[0], authored, "the row reached the host as a copy");
|
|
462
|
-
assert.notEqual(built[0]?.choice, authored.choice, "and so did its choice");
|
|
463
|
-
assert.equal(authored.choice?.applyOnMove, undefined, "the provider's choice was not mutated");
|
|
464
|
-
assert.equal(built[0]?.label, authored.label, "every other field is carried as the same reference");
|
|
465
|
-
assert.equal(built[0]?.action, authored.action);
|
|
466
|
-
|
|
467
|
-
assert.equal(built[1], alreadyDeferring, "an authored false already conforms: no copy is needed");
|
|
468
|
-
assert.equal(explicitImmediate.choice?.applyOnMove, true, "an authored true is not mutated either");
|
|
469
|
-
assert.equal(built[2]?.choice?.applyOnMove, false, "but the emitted copy defers");
|
|
470
|
-
// A row without a choice is a BUTTON row (S16). The bridge hands the host a copy so it can contain
|
|
471
|
-
// a failing `action`; nothing else about it changes and the provider's object is never mutated.
|
|
472
|
-
assert.notEqual(built[3], plain, "a button row is a copy, so its action can be contained (S16)");
|
|
473
|
-
assert.equal(built[3]?.label, plain.label, "its label is carried as the same reference");
|
|
474
|
-
assert.notEqual(built[3]?.action, plain.action, "its action is the contained wrapper");
|
|
475
|
-
assert.equal(built[3]?.choice, undefined, "no choice is invented for a button");
|
|
476
|
-
assert.deepEqual(Object.keys(plain).sort(), ["action", "category", "label"], "the provider's row is not mutated");
|
|
477
|
-
});
|
package/test/arm.test.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import test from "node:test";
|
|
3
|
-
|
|
4
|
-
import { armBridge } from "../src/arm.ts";
|
|
5
|
-
import { registerRows, resetBusForTests } from "../src/bus.ts";
|
|
6
|
-
import { getCaptureDiagnostics, resetCaptureStateForTests } from "../src/capture.ts";
|
|
7
|
-
import { getHookDiagnostics, resetHookStateForTests } from "../src/hook.ts";
|
|
8
|
-
import type { CustomizeRow, RowProvider } from "../src/types.ts";
|
|
9
|
-
import { createFakeHost } from "./fake-host.ts";
|
|
10
|
-
|
|
11
|
-
const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
|
|
12
|
-
const CAPTURE_WRAPPED = Symbol.for("gentle-shell-bridge.capture.wrapped.v1");
|
|
13
|
-
|
|
14
|
-
const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
|
|
15
|
-
const clearHook = (): void => {
|
|
16
|
-
delete hookSlot()[HOOK_KEY];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
test.beforeEach(() => {
|
|
20
|
-
resetBusForTests();
|
|
21
|
-
resetCaptureStateForTests();
|
|
22
|
-
resetHookStateForTests();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test.afterEach(() => {
|
|
26
|
-
clearHook();
|
|
27
|
-
resetHookStateForTests();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
const labelsProvider = (id: string, category: string, labels: string[]): RowProvider => ({
|
|
31
|
-
id,
|
|
32
|
-
category,
|
|
33
|
-
rows: () => labels.map((label) => ({ label, action: () => {} })),
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
interface PushedProvider {
|
|
37
|
-
id: string;
|
|
38
|
-
rows: (ctx?: unknown) => CustomizeRow[];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
test("selects the hook backend and patches nothing on ui", () => {
|
|
42
|
-
const registry: PushedProvider[] = [];
|
|
43
|
-
hookSlot()[HOOK_KEY] = registry;
|
|
44
|
-
const host = createFakeHost();
|
|
45
|
-
registerRows(labelsProvider("p1", "Cards", ["Hooked row"]));
|
|
46
|
-
const customBefore = host.ui.custom;
|
|
47
|
-
|
|
48
|
-
const result = armBridge(host.ui);
|
|
49
|
-
|
|
50
|
-
assert.deepEqual(result, { outcome: "hooked", backend: "hook" });
|
|
51
|
-
assert.equal(host.ui.custom, customBefore, "ui.custom identity is unchanged");
|
|
52
|
-
assert.equal(Object.hasOwn(host.ui, CAPTURE_WRAPPED), false, "no marker on the instance");
|
|
53
|
-
const proto: object | null = Object.getPrototypeOf(host.ui) as object | null;
|
|
54
|
-
assert.equal(
|
|
55
|
-
proto === null ? false : Object.hasOwn(proto, CAPTURE_WRAPPED),
|
|
56
|
-
false,
|
|
57
|
-
"no marker on the prototype",
|
|
58
|
-
);
|
|
59
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 0, "the capture backend never armed");
|
|
60
|
-
assert.equal(registry.length, 1);
|
|
61
|
-
|
|
62
|
-
// Rows land through the registry's own call path.
|
|
63
|
-
assert.deepEqual(
|
|
64
|
-
registry[0].rows({}).map((row) => row.label),
|
|
65
|
-
["Hooked row"],
|
|
66
|
-
);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("an unusable hook shape falls back to capture and records why", () => {
|
|
70
|
-
hookSlot()[HOOK_KEY] = { register: 5 };
|
|
71
|
-
const host = createFakeHost();
|
|
72
|
-
registerRows(labelsProvider("p1", "Cards", ["Fallback row"]));
|
|
73
|
-
|
|
74
|
-
const result = armBridge(host.ui);
|
|
75
|
-
|
|
76
|
-
assert.deepEqual(result, {
|
|
77
|
-
outcome: "fallback-capture",
|
|
78
|
-
backend: "capture",
|
|
79
|
-
reason: "hook-unusable",
|
|
80
|
-
});
|
|
81
|
-
assert.equal(getHookDiagnostics().shape, "unusable");
|
|
82
|
-
|
|
83
|
-
host.buildMenu();
|
|
84
|
-
assert.deepEqual(host.rendered().slice(4), ["Fallback row"]);
|
|
85
|
-
assert.equal(host.invalidateCount(), 1);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test("arms capture when the host exposes no registry", () => {
|
|
89
|
-
const host = createFakeHost();
|
|
90
|
-
registerRows(labelsProvider("p1", "Cards", ["Captured row"]));
|
|
91
|
-
|
|
92
|
-
const result = armBridge(host.ui);
|
|
93
|
-
|
|
94
|
-
assert.deepEqual(result, { outcome: "capture-armed", backend: "capture" });
|
|
95
|
-
host.buildMenu();
|
|
96
|
-
assert.deepEqual(host.rendered().slice(4), ["Captured row"]);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("two session_start arms wrap once and inject once", () => {
|
|
100
|
-
const host = createFakeHost();
|
|
101
|
-
registerRows(labelsProvider("p1", "Cards", ["Once"]));
|
|
102
|
-
|
|
103
|
-
const first = armBridge(host.ui);
|
|
104
|
-
const second = armBridge(host.ui);
|
|
105
|
-
|
|
106
|
-
assert.deepEqual(first, { outcome: "capture-armed", backend: "capture" });
|
|
107
|
-
assert.deepEqual(second, { outcome: "already-armed", backend: "capture" });
|
|
108
|
-
assert.equal(getCaptureDiagnostics().armedTargets, 1, "one wrapper, not two");
|
|
109
|
-
|
|
110
|
-
host.buildMenu();
|
|
111
|
-
assert.deepEqual(
|
|
112
|
-
host.rendered().filter((label) => label === "Once"),
|
|
113
|
-
["Once"],
|
|
114
|
-
);
|
|
115
|
-
assert.equal(host.invalidateCount(), 1, "one invalidate per menu build");
|
|
116
|
-
|
|
117
|
-
host.buildMenu();
|
|
118
|
-
assert.deepEqual(
|
|
119
|
-
host.rendered().filter((label) => label === "Once"),
|
|
120
|
-
["Once"],
|
|
121
|
-
"a second build of the same menu still injects once",
|
|
122
|
-
);
|
|
123
|
-
assert.equal(host.invalidateCount(), 1);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("a prototype-only ui wraps where custom was found, and a second arm is a no-op", () => {
|
|
127
|
-
const host = createFakeHost({ customShape: "prototype" });
|
|
128
|
-
registerRows(labelsProvider("p1", "Cards", ["Proto row"]));
|
|
129
|
-
|
|
130
|
-
const first = armBridge(host.ui);
|
|
131
|
-
const second = armBridge(host.ui);
|
|
132
|
-
|
|
133
|
-
assert.deepEqual(first, { outcome: "capture-armed", backend: "capture" });
|
|
134
|
-
assert.deepEqual(second, { outcome: "already-armed", backend: "capture" });
|
|
135
|
-
assert.equal(Object.hasOwn(host.ui, "custom"), false, "the instance gained no own custom");
|
|
136
|
-
const proto: object | null = Object.getPrototypeOf(host.ui) as object | null;
|
|
137
|
-
assert.equal(
|
|
138
|
-
proto === null ? false : Object.hasOwn(proto, CAPTURE_WRAPPED),
|
|
139
|
-
true,
|
|
140
|
-
"the prototype carries the wrapper",
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
host.buildMenu();
|
|
144
|
-
assert.deepEqual(
|
|
145
|
-
host.rendered().filter((label) => label === "Proto row"),
|
|
146
|
-
["Proto row"],
|
|
147
|
-
);
|
|
148
|
-
assert.equal(host.invalidateCount(), 1);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("a no-UI context is a reported no-op, not a throw", () => {
|
|
152
|
-
assert.deepEqual(armBridge(undefined), {
|
|
153
|
-
outcome: "no-ui",
|
|
154
|
-
backend: "none",
|
|
155
|
-
reason: "ui-missing",
|
|
156
|
-
});
|
|
157
|
-
assert.deepEqual(armBridge(null), {
|
|
158
|
-
outcome: "no-ui",
|
|
159
|
-
backend: "none",
|
|
160
|
-
reason: "ui-missing",
|
|
161
|
-
});
|
|
162
|
-
assert.deepEqual(armBridge({}), {
|
|
163
|
-
outcome: "no-ui",
|
|
164
|
-
backend: "none",
|
|
165
|
-
reason: "custom-missing",
|
|
166
|
-
});
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("importing the arm module has no side effects", async () => {
|
|
170
|
-
const armedBefore = getCaptureDiagnostics().armedTargets;
|
|
171
|
-
const mapBefore = Array.prototype.map;
|
|
172
|
-
|
|
173
|
-
// The query string is what makes a second module instance, so the specifier is computed: `tsc`
|
|
174
|
-
// types a non-literal dynamic import as a value import, not as a path to resolve on disk.
|
|
175
|
-
const freshSpecifier = "../src/arm.ts?fresh=1";
|
|
176
|
-
const fresh: typeof import("../src/arm.ts") = await import(freshSpecifier);
|
|
177
|
-
|
|
178
|
-
assert.equal(getCaptureDiagnostics().armedTargets, armedBefore, "import arms nothing");
|
|
179
|
-
assert.equal(Array.prototype.map, mapBefore, "import patches nothing");
|
|
180
|
-
assert.equal(typeof fresh.armBridge, "function");
|
|
181
|
-
|
|
182
|
-
// A menu built without arming must show no provider rows: nothing armed itself on import.
|
|
183
|
-
const host = createFakeHost();
|
|
184
|
-
registerRows(labelsProvider("p1", "Cards", ["Must not appear"]));
|
|
185
|
-
host.buildMenu();
|
|
186
|
-
assert.deepEqual(host.rendered().slice(4), []);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("the same providers produce the same rows through either backend", () => {
|
|
190
|
-
const providers = (): void => {
|
|
191
|
-
registerRows(labelsProvider("first", "Cards", ["First a"]));
|
|
192
|
-
registerRows({
|
|
193
|
-
id: "boom",
|
|
194
|
-
category: "Banner",
|
|
195
|
-
rows: () => {
|
|
196
|
-
throw new Error("shared provider exploded");
|
|
197
|
-
},
|
|
198
|
-
});
|
|
199
|
-
registerRows(labelsProvider("second", "Layout", ["Second b"]));
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
// Capture backend.
|
|
203
|
-
providers();
|
|
204
|
-
const host = createFakeHost();
|
|
205
|
-
armBridge(host.ui);
|
|
206
|
-
host.buildMenu();
|
|
207
|
-
const capturedLabels = host.rendered().slice(4);
|
|
208
|
-
|
|
209
|
-
// Hook backend, fresh bus with the same providers.
|
|
210
|
-
resetBusForTests();
|
|
211
|
-
resetCaptureStateForTests();
|
|
212
|
-
providers();
|
|
213
|
-
const registry: PushedProvider[] = [];
|
|
214
|
-
hookSlot()[HOOK_KEY] = registry;
|
|
215
|
-
armBridge(createFakeHost().ui);
|
|
216
|
-
const hookedLabels = registry[0].rows({}).map((row) => String(row.label));
|
|
217
|
-
|
|
218
|
-
assert.deepEqual(capturedLabels, ["First a", "Second b"]);
|
|
219
|
-
assert.deepEqual(hookedLabels, capturedLabels, "same providers, same rows, same order");
|
|
220
|
-
});
|