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
package/test/sidebar-e2e.test.ts
DELETED
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* End-to-end over the PUBLIC API (S9, S12, S13).
|
|
3
|
-
*
|
|
4
|
-
* Everything a plugin may touch is imported from `src/index.ts`; the only other non-test import is
|
|
5
|
-
* the Pi entry Pi itself loads (`src/extension.ts`). Internal seams appear here for teardown only,
|
|
6
|
-
* exactly as `test/e2e.test.ts` uses them, and the API-surface test proves they are not public.
|
|
7
|
-
*
|
|
8
|
-
* The host is the fake: a menu host for the `Sections` rows and the fake sidebar for the rail. So
|
|
9
|
-
* these tests prove the wiring, the ordering and the shared state — not the real TUI, which is
|
|
10
|
-
* listed as unverified in PRD.md and README.md.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import assert from "node:assert/strict";
|
|
14
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import test from "node:test";
|
|
18
|
-
|
|
19
|
-
// The public surface, exactly as a plugin reaches it.
|
|
20
|
-
import {
|
|
21
|
-
VERSION,
|
|
22
|
-
registerRows,
|
|
23
|
-
registerSection,
|
|
24
|
-
type CustomizeRow,
|
|
25
|
-
type RowProvider,
|
|
26
|
-
type SidebarSection,
|
|
27
|
-
} from "../src/index.ts";
|
|
28
|
-
// The real Pi entry, armed and commanded exactly as Pi loads it.
|
|
29
|
-
import gentleShellBridge, {
|
|
30
|
-
STATUS_COMMAND,
|
|
31
|
-
type BridgeCommandContext,
|
|
32
|
-
type BridgeCommandHandler,
|
|
33
|
-
type BridgeExtensionApi,
|
|
34
|
-
type BridgeSessionHandler,
|
|
35
|
-
} from "../src/extension.ts";
|
|
36
|
-
// Teardown-only seams.
|
|
37
|
-
import { resetBusForTests } from "../src/bus.ts";
|
|
38
|
-
import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
|
|
39
|
-
import { resetHookStateForTests } from "../src/hook.ts";
|
|
40
|
-
import { resetRowBuildStateForTests } from "../src/rows.ts";
|
|
41
|
-
import {
|
|
42
|
-
disableSidebarBackend,
|
|
43
|
-
resetSidebarStateForTests,
|
|
44
|
-
} from "../src/sidebar.ts";
|
|
45
|
-
import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
|
|
46
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
47
|
-
import { createFakeHost, type FakeHost, type FakeUiTarget } from "./fake-host.ts";
|
|
48
|
-
import {
|
|
49
|
-
cardBody,
|
|
50
|
-
cardTitles,
|
|
51
|
-
fakeState,
|
|
52
|
-
fakeTui,
|
|
53
|
-
registerNativeFooter,
|
|
54
|
-
resetSidebar,
|
|
55
|
-
} from "./fake-sidebar.ts";
|
|
56
|
-
|
|
57
|
-
const HOOK_KEY = Symbol.for("gentle-pi.customize-row-providers");
|
|
58
|
-
const hookSlot = (): Record<symbol, unknown> => globalThis as unknown as Record<symbol, unknown>;
|
|
59
|
-
|
|
60
|
-
let configHome: string;
|
|
61
|
-
let previousConfigHome: string | undefined;
|
|
62
|
-
|
|
63
|
-
test.before(() => {
|
|
64
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-sidebar-e2e-"));
|
|
65
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
66
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test.after(() => {
|
|
70
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
71
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
72
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test.beforeEach(() => {
|
|
76
|
-
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
77
|
-
resetBusForTests();
|
|
78
|
-
resetSidebar();
|
|
79
|
-
resetSidebarStateForTests();
|
|
80
|
-
resetSectionFailuresForTests();
|
|
81
|
-
resetStoreStateForTests();
|
|
82
|
-
resetCaptureStateForTests();
|
|
83
|
-
resetHookStateForTests();
|
|
84
|
-
resetRowBuildStateForTests();
|
|
85
|
-
disableCaptureBackend();
|
|
86
|
-
disableSidebarBackend();
|
|
87
|
-
delete hookSlot()[HOOK_KEY];
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test.afterEach(() => {
|
|
91
|
-
disableCaptureBackend();
|
|
92
|
-
disableSidebarBackend();
|
|
93
|
-
delete hookSlot()[HOOK_KEY];
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
interface Entry {
|
|
97
|
-
sessionHandlers: BridgeSessionHandler[];
|
|
98
|
-
commands: Array<{ name: string; handler: BridgeCommandHandler }>;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const loadEntry = (): Entry => {
|
|
102
|
-
const sessionHandlers: BridgeSessionHandler[] = [];
|
|
103
|
-
const commands: Entry["commands"] = [];
|
|
104
|
-
const pi: BridgeExtensionApi = {
|
|
105
|
-
// `gentle-pi` is present, so `session_start` arms the row side and the rail (S9/T12).
|
|
106
|
-
getCommands: () => [{ name: "gentle:customize" }],
|
|
107
|
-
on(_event, handler) {
|
|
108
|
-
sessionHandlers.push(handler);
|
|
109
|
-
},
|
|
110
|
-
registerCommand(name, command) {
|
|
111
|
-
commands.push({ name, handler: command.handler });
|
|
112
|
-
},
|
|
113
|
-
};
|
|
114
|
-
gentleShellBridge(pi);
|
|
115
|
-
return { sessionHandlers, commands };
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
/** Pi fires `session_start`: the entry arms the row side and installs the sidebar widget. */
|
|
119
|
-
const armThroughEntry = (host: FakeHost): Entry => {
|
|
120
|
-
const entry = loadEntry();
|
|
121
|
-
entry.sessionHandlers[0]({}, { hasUI: true, ui: host.ui });
|
|
122
|
-
return entry;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const printStatus = (entry: Entry): string => {
|
|
126
|
-
const printed: string[] = [];
|
|
127
|
-
const ctx: BridgeCommandContext = { ui: { notify: (message: string) => printed.push(message) } };
|
|
128
|
-
entry.commands.find((command) => command.name === STATUS_COMMAND)?.handler("", ctx);
|
|
129
|
-
return printed.join("\n");
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
type UiWithWidget = FakeUiTarget & {
|
|
133
|
-
setWidget?: (key: string, factory: (tui: unknown, theme: unknown) => unknown) => void;
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
interface WidgetCall {
|
|
137
|
-
key: string;
|
|
138
|
-
component: unknown;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Model Pi's `ui.setWidget(key, factory)`: it runs the factory with the real `tui` immediately. */
|
|
142
|
-
const installWidgetSurface = (host: FakeHost): WidgetCall[] => {
|
|
143
|
-
const calls: WidgetCall[] = [];
|
|
144
|
-
const ui = host.ui as UiWithWidget;
|
|
145
|
-
ui.setWidget = (key, factory) => {
|
|
146
|
-
calls.push({ key, component: factory(fakeTui(), { name: "fake-theme" }) });
|
|
147
|
-
};
|
|
148
|
-
return calls;
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const musicSection = (overrides: Partial<SidebarSection> = {}): SidebarSection => ({
|
|
152
|
-
id: "loon.music",
|
|
153
|
-
title: "MUSIC",
|
|
154
|
-
meta: () => "1 playing",
|
|
155
|
-
lines: () => ["track one", "album two"],
|
|
156
|
-
...overrides,
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
const sectionLine = (label: string): CustomizeRow[] => [
|
|
160
|
-
{
|
|
161
|
-
category: "Cards",
|
|
162
|
-
label,
|
|
163
|
-
choice: { setting: "Loon banner source", value: "on", current: () => true },
|
|
164
|
-
action: () => {},
|
|
165
|
-
},
|
|
166
|
-
{
|
|
167
|
-
category: "Cards",
|
|
168
|
-
label,
|
|
169
|
-
choice: { setting: "Loon banner source", value: "off", current: () => false },
|
|
170
|
-
action: () => {},
|
|
171
|
-
},
|
|
172
|
-
];
|
|
173
|
-
|
|
174
|
-
test("one registerSection call reaches the rail and the Sections menu through the entry", () => {
|
|
175
|
-
const host = createFakeHost();
|
|
176
|
-
const widgets = installWidgetSurface(host);
|
|
177
|
-
registerNativeFooter(); // the host's own rail, registered BEFORE our arm
|
|
178
|
-
registerSection(musicSection());
|
|
179
|
-
|
|
180
|
-
const entry = armThroughEntry(host);
|
|
181
|
-
host.buildMenu();
|
|
182
|
-
|
|
183
|
-
assert.equal(widgets.length, 1, "the entry installed exactly one sidebar widget factory");
|
|
184
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"], "the host frames our section after its own");
|
|
185
|
-
assert.deepEqual(cardBody("MUSIC"), ["track one", "album two"]);
|
|
186
|
-
|
|
187
|
-
const line = host.toggleLines().find((candidate) => candidate.startsWith("music "));
|
|
188
|
-
assert.ok(line !== undefined, "the toggle exists without the plugin writing a row");
|
|
189
|
-
assert.ok(line.includes("● shown") && line.includes("○ hidden"), line);
|
|
190
|
-
assert.ok(host.rendered().includes("music"), "the row reads as the section label");
|
|
191
|
-
|
|
192
|
-
const printed = printStatus(entry);
|
|
193
|
-
assert.match(printed, /sections: 1/u);
|
|
194
|
-
assert.match(printed, /sidebar: intercepted \(rail wrapped\)/u);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
test("the same holds when the host registers its rail AFTER the arm", () => {
|
|
198
|
-
const host = createFakeHost();
|
|
199
|
-
installWidgetSurface(host);
|
|
200
|
-
// The host's rail STATE exists from startup; what arrives late is the footer PART. That is the
|
|
201
|
-
// ordering S4 is about, and the reason the interception patches the map instead of the part.
|
|
202
|
-
fakeState();
|
|
203
|
-
registerSection(musicSection());
|
|
204
|
-
|
|
205
|
-
armThroughEntry(host);
|
|
206
|
-
registerNativeFooter(); // the host's own rail, registered after our interception
|
|
207
|
-
|
|
208
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("the widget the bridge installs renders no rows", () => {
|
|
212
|
-
const host = createFakeHost();
|
|
213
|
-
const widgets = installWidgetSurface(host);
|
|
214
|
-
registerNativeFooter();
|
|
215
|
-
|
|
216
|
-
armThroughEntry(host);
|
|
217
|
-
|
|
218
|
-
const component = widgets[0]?.component as { render(width: number): string[] } | undefined;
|
|
219
|
-
assert.ok(component !== undefined);
|
|
220
|
-
assert.deepEqual(component.render(40), [], "the widget exists only to receive the tui");
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test("toggling through the menu hides the section on the next frame and stops building it", async () => {
|
|
224
|
-
const host = createFakeHost();
|
|
225
|
-
installWidgetSurface(host);
|
|
226
|
-
registerNativeFooter();
|
|
227
|
-
let linesCalls = 0;
|
|
228
|
-
registerSection(
|
|
229
|
-
musicSection({
|
|
230
|
-
lines: () => {
|
|
231
|
-
linesCalls += 1;
|
|
232
|
-
return ["track one", "album two"];
|
|
233
|
-
},
|
|
234
|
-
}),
|
|
235
|
-
);
|
|
236
|
-
|
|
237
|
-
armThroughEntry(host);
|
|
238
|
-
host.buildMenu();
|
|
239
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
|
|
240
|
-
|
|
241
|
-
await host.pressRow(
|
|
242
|
-
(row) => row.choice?.setting === "music" && row.choice.value === "hidden",
|
|
243
|
-
);
|
|
244
|
-
linesCalls = 0;
|
|
245
|
-
|
|
246
|
-
assert.deepEqual(cardTitles(), ["FOOTER"], "the next frame drops the card");
|
|
247
|
-
assert.equal(linesCalls, 0, "and the hidden section's content is not built");
|
|
248
|
-
assert.deepEqual(
|
|
249
|
-
JSON.parse(readFileSync(join(configHome, "gentle-shell-bridge", "bridge-sections.json"), "utf8")),
|
|
250
|
-
{ schemaVersion: 1, values: { sections: { "loon.music": false } } },
|
|
251
|
-
);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test("disposing the section removes the card and, on a fresh menu, its rows", () => {
|
|
255
|
-
const host = createFakeHost();
|
|
256
|
-
installWidgetSurface(host);
|
|
257
|
-
registerNativeFooter();
|
|
258
|
-
const dispose = registerSection(musicSection());
|
|
259
|
-
|
|
260
|
-
armThroughEntry(host);
|
|
261
|
-
host.buildMenu();
|
|
262
|
-
assert.deepEqual(cardTitles(), ["FOOTER", "MUSIC"]);
|
|
263
|
-
assert.ok(host.toggleLines().some((line) => line.startsWith("music ")));
|
|
264
|
-
|
|
265
|
-
dispose();
|
|
266
|
-
|
|
267
|
-
assert.deepEqual(cardTitles(), ["FOOTER"], "the rail drops it immediately");
|
|
268
|
-
|
|
269
|
-
// A menu that received rows once keeps them (the capture marks the array), so a fresh menu is the
|
|
270
|
-
// honest way to observe the next build.
|
|
271
|
-
const second = createFakeHost();
|
|
272
|
-
installWidgetSurface(second);
|
|
273
|
-
armThroughEntry(second);
|
|
274
|
-
second.buildMenu();
|
|
275
|
-
assert.deepEqual(second.toggleLines(), [], "no toggle left for a disposed section");
|
|
276
|
-
assert.equal(
|
|
277
|
-
second.rendered().some((label) => label.includes("MUSIC")),
|
|
278
|
-
false,
|
|
279
|
-
"and no row label either",
|
|
280
|
-
);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test("the row side still behaves in the same process, alongside the sections", () => {
|
|
284
|
-
const host = createFakeHost();
|
|
285
|
-
installWidgetSurface(host);
|
|
286
|
-
registerNativeFooter();
|
|
287
|
-
const provider: RowProvider = {
|
|
288
|
-
id: "loon.banner-source",
|
|
289
|
-
category: "Cards",
|
|
290
|
-
rows: () => sectionLine("Loon banner source"),
|
|
291
|
-
};
|
|
292
|
-
registerRows(provider);
|
|
293
|
-
registerSection(musicSection());
|
|
294
|
-
|
|
295
|
-
armThroughEntry(host);
|
|
296
|
-
host.buildMenu();
|
|
297
|
-
|
|
298
|
-
const injected = host.liveRows().slice(4);
|
|
299
|
-
assert.deepEqual(
|
|
300
|
-
injected.slice(0, 2).map((row) => row.category),
|
|
301
|
-
["Cards", "Cards"],
|
|
302
|
-
"provider rows keep their place and order",
|
|
303
|
-
);
|
|
304
|
-
assert.deepEqual(
|
|
305
|
-
injected.slice(2).map((row) => row.category),
|
|
306
|
-
["Sections", "Sections"],
|
|
307
|
-
"the bridge's own rows follow",
|
|
308
|
-
);
|
|
309
|
-
const lines = host.toggleLines();
|
|
310
|
-
assert.ok(lines.some((line) => line.startsWith("Loon banner source")));
|
|
311
|
-
assert.ok(lines.some((line) => line.startsWith("music ")));
|
|
312
|
-
});
|
|
313
|
-
|
|
314
|
-
test("a UI without setWidget is reported as unarmed and never throws", () => {
|
|
315
|
-
const host = createFakeHost(); // no setWidget on this UI: Pi always has one, the test does not
|
|
316
|
-
registerSection(musicSection());
|
|
317
|
-
|
|
318
|
-
const entry = armThroughEntry(host); // must not throw
|
|
319
|
-
host.buildMenu();
|
|
320
|
-
|
|
321
|
-
assert.deepEqual(cardTitles(), [], "no rail interception was possible");
|
|
322
|
-
assert.ok(host.rendered().includes("music"), "the row side still works: the toggle is visible");
|
|
323
|
-
|
|
324
|
-
const printed = printStatus(entry);
|
|
325
|
-
assert.match(printed, /sidebar: not armed/u);
|
|
326
|
-
assert.match(printed, /sections: 1/u);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("the public version is the released one", () => {
|
|
330
|
-
assert.equal(VERSION, "0.3.0");
|
|
331
|
-
});
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* S21/T40–T41: hover reaches the bridge's own rail rows.
|
|
3
|
-
*
|
|
4
|
-
* The fake now models the host's part-level mouse dispatch as it really is. The `footer` part is a
|
|
5
|
-
* `NativePointerRegion` (`extensions/gentle-shell.ts:2766`) whose `onHover` answers EVERY `move`
|
|
6
|
-
* inside the part's frame (`lib/native-pointer-region.ts:80-88`,
|
|
7
|
-
* `extensions/gentle-shell.ts:2732-2741`): it claims the host's shared pointer slot
|
|
8
|
-
* (`SidebarState.hover`, `lib/shell-sidebar.ts:31,72-78`) for a row it owns and clears it otherwise,
|
|
9
|
-
* returning `{ handled: true }` either way. A move over a row the bridge appended is therefore
|
|
10
|
-
* consumed by the host's region and never reaches the bridge's wrapper, which is why these tests
|
|
11
|
-
* failed before the fix: the wrapper consulted the host first and only asked the contributor when
|
|
12
|
-
* the host declined, and the host never declines a move.
|
|
13
|
-
*
|
|
14
|
-
* Every dispatch below is the event the layout really hands a part — `y` in the PART's frame, `width`
|
|
15
|
-
* the frame it rendered (`lib/shell-sidebar-layout.ts:245-259`) — so the offset arithmetic is the
|
|
16
|
-
* bridge's real code, not a test-only shortcut.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import assert from "node:assert/strict";
|
|
20
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
-
import { tmpdir } from "node:os";
|
|
22
|
-
import { join } from "node:path";
|
|
23
|
-
import test from "node:test";
|
|
24
|
-
|
|
25
|
-
import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
|
|
26
|
-
import { setRepaintHost, resetRepaintForTests } from "../src/repaint.ts";
|
|
27
|
-
import { resetSectionPanelForTests, setSectionTheme } from "../src/section-panel.ts";
|
|
28
|
-
import { getSectionHoverReport, resetSectionPointerForTests } from "../src/section-pointer.ts";
|
|
29
|
-
import {
|
|
30
|
-
SIDEBAR_RAIL_KEY,
|
|
31
|
-
disableSidebarBackend,
|
|
32
|
-
installSidebarBackend,
|
|
33
|
-
resetSidebarStateForTests,
|
|
34
|
-
} from "../src/sidebar.ts";
|
|
35
|
-
import {
|
|
36
|
-
installSectionRail,
|
|
37
|
-
resetSectionFailuresForTests,
|
|
38
|
-
} from "../src/sidebar-render.ts";
|
|
39
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
40
|
-
import type { SidebarSection } from "../src/types.ts";
|
|
41
|
-
import {
|
|
42
|
-
cacheRevision,
|
|
43
|
-
fakeState,
|
|
44
|
-
fakeTerminal,
|
|
45
|
-
fakeTui,
|
|
46
|
-
registerNativeFooter,
|
|
47
|
-
renderSidebar,
|
|
48
|
-
resetSidebar,
|
|
49
|
-
setHostHover,
|
|
50
|
-
} from "./fake-sidebar.ts";
|
|
51
|
-
|
|
52
|
-
let configHome: string;
|
|
53
|
-
let previousConfigHome: string | undefined;
|
|
54
|
-
|
|
55
|
-
test.before(() => {
|
|
56
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-sidebar-hover-"));
|
|
57
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
58
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test.after(() => {
|
|
62
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
63
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
64
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test.beforeEach(() => {
|
|
68
|
-
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
69
|
-
resetBusForTests();
|
|
70
|
-
resetSectionsForTests();
|
|
71
|
-
resetSidebar();
|
|
72
|
-
resetSidebarStateForTests();
|
|
73
|
-
resetSectionFailuresForTests();
|
|
74
|
-
resetSectionPanelForTests();
|
|
75
|
-
resetSectionPointerForTests();
|
|
76
|
-
resetRepaintForTests();
|
|
77
|
-
resetStoreStateForTests();
|
|
78
|
-
disableSidebarBackend();
|
|
79
|
-
renders = 0;
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
|
|
83
|
-
id,
|
|
84
|
-
title: id.toUpperCase(),
|
|
85
|
-
lines: () => [`body ${id}`],
|
|
86
|
-
...extra,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
let renders = 0;
|
|
90
|
-
|
|
91
|
-
/** Arm the rail with a `tui` whose `requestRender` and cache revision a test can count. */
|
|
92
|
-
function arm(): void {
|
|
93
|
-
const tui = {
|
|
94
|
-
terminal: fakeTerminal(),
|
|
95
|
-
requestRender: () => {
|
|
96
|
-
renders += 1;
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
setRepaintHost(tui);
|
|
100
|
-
registerNativeFooter();
|
|
101
|
-
installSidebarBackend(() => fakeTui());
|
|
102
|
-
installSectionRail();
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const rail = () => fakeState().parts.get(SIDEBAR_RAIL_KEY);
|
|
106
|
-
const rowOf = (text: string, width = 40): string | undefined =>
|
|
107
|
-
(rail()?.render(width) ?? []).find((row) => row.includes(text));
|
|
108
|
-
|
|
109
|
-
/** The native footer renders three rows, so the contribution's first row is 3. */
|
|
110
|
-
const NATIVE_ROWS = 3;
|
|
111
|
-
|
|
112
|
-
/** The section HEADER row, and the first inner row of a body, in the part's own frame. */
|
|
113
|
-
const HEADER_ROW = NATIVE_ROWS;
|
|
114
|
-
const FIRST_BODY_ROW = NATIVE_ROWS + 1;
|
|
115
|
-
|
|
116
|
-
const move = (y: number): unknown =>
|
|
117
|
-
rail()?.handleMouse?.({ type: "move", button: "none", x: 4, y, width: 40, height: 40 });
|
|
118
|
-
const click = (y: number): unknown =>
|
|
119
|
-
rail()?.handleMouse?.({ type: "click", button: "left", x: 4, y, width: 40, height: 40 });
|
|
120
|
-
|
|
121
|
-
/** The host's hover role (`HOVER_ROLE = "warning"`, `lib/shell-hover.ts:33`), as SGR 33. */
|
|
122
|
-
const HOVER_SGR = "\u001b[33m";
|
|
123
|
-
|
|
124
|
-
const ROLE_SGR: Record<string, number> = {
|
|
125
|
-
text: 37,
|
|
126
|
-
muted: 90,
|
|
127
|
-
dim: 2,
|
|
128
|
-
accent: 95,
|
|
129
|
-
success: 32,
|
|
130
|
-
error: 31,
|
|
131
|
-
warning: 33,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
function fakeTheme(): { fg(role: string, text: string): string; bold(text: string): string } {
|
|
135
|
-
return {
|
|
136
|
-
fg: (role, text) => `\u001b[${String(ROLE_SGR[role] ?? 0)}m${text}\u001b[39m`,
|
|
137
|
-
bold: (text) => `\u001b[1m${text}\u001b[22m`,
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
test("a move over the section HEADER paints it with the host's hover role", () => {
|
|
142
|
-
setSectionTheme(fakeTheme());
|
|
143
|
-
registerSection(section("music", { title: "MUSIC" }));
|
|
144
|
-
arm();
|
|
145
|
-
renderSidebar();
|
|
146
|
-
|
|
147
|
-
assert.ok(!(rowOf("MUSIC") ?? "").includes(HOVER_SGR), "the header starts idle");
|
|
148
|
-
const digestBefore = rail()?.digest?.();
|
|
149
|
-
const revisionBefore = cacheRevision();
|
|
150
|
-
|
|
151
|
-
assert.deepEqual(move(HEADER_ROW), { handled: true, render: true }, "the header answers the move");
|
|
152
|
-
assert.equal(renders, 1, "exactly one differential frame is asked for");
|
|
153
|
-
assert.equal(cacheRevision(), revisionBefore + 1, "and the host's sidebar revision moved");
|
|
154
|
-
assert.ok((rowOf("MUSIC") ?? "").includes(HOVER_SGR), "the header takes the host's hover role");
|
|
155
|
-
|
|
156
|
-
const hover = fakeState().hover;
|
|
157
|
-
assert.ok(hover !== undefined, "the shared pointer slot carries our claim");
|
|
158
|
-
assert.equal(getSectionHoverReport().mechanism, "host-state");
|
|
159
|
-
assert.notEqual(rail()?.digest?.(), digestBefore, "the hover moves the digest, so no stale frame");
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("a move over an inner CLICKABLE row paints it, and never runs its action", () => {
|
|
163
|
-
setSectionTheme(fakeTheme());
|
|
164
|
-
const calls: string[] = [];
|
|
165
|
-
registerSection(
|
|
166
|
-
section("music", {
|
|
167
|
-
title: "MUSIC",
|
|
168
|
-
lines: () => [
|
|
169
|
-
{ label: "play", meta: "enter", onClick: () => calls.push("play") },
|
|
170
|
-
{ label: "inert" },
|
|
171
|
-
],
|
|
172
|
-
}),
|
|
173
|
-
);
|
|
174
|
-
arm();
|
|
175
|
-
renderSidebar();
|
|
176
|
-
|
|
177
|
-
assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR), "the row starts idle");
|
|
178
|
-
|
|
179
|
-
assert.deepEqual(move(FIRST_BODY_ROW), { handled: true, render: true }, "the row answers the move");
|
|
180
|
-
assert.equal(renders, 1, "one frame");
|
|
181
|
-
assert.ok((rowOf("play") ?? "").includes(HOVER_SGR), "the row takes the host's hover role");
|
|
182
|
-
assert.equal(fakeState().hover !== undefined, true, "the shared pointer slot carries our claim");
|
|
183
|
-
assert.deepEqual(calls, [], "hovering never runs a row's work");
|
|
184
|
-
|
|
185
|
-
assert.deepEqual(move(FIRST_BODY_ROW), { handled: true }, "the same row again is not a new hover");
|
|
186
|
-
assert.equal(renders, 1, "so it asks for no frame");
|
|
187
|
-
|
|
188
|
-
assert.deepEqual(move(FIRST_BODY_ROW + 1), { handled: true, render: true }, "an inert row clears it");
|
|
189
|
-
assert.equal(renders, 2);
|
|
190
|
-
assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR), "the row is painted idle again");
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("the host's own row keeps the pointer: the bridge never steals it", () => {
|
|
194
|
-
setSectionTheme(fakeTheme());
|
|
195
|
-
registerSection(
|
|
196
|
-
section("music", {
|
|
197
|
-
title: "MUSIC",
|
|
198
|
-
lines: () => [{ label: "play", onClick: () => {} }],
|
|
199
|
-
}),
|
|
200
|
-
);
|
|
201
|
-
arm();
|
|
202
|
-
renderSidebar();
|
|
203
|
-
|
|
204
|
-
// Row 1 is the native footer's own body row: the host's region claims it, and a fix that asked the
|
|
205
|
-
// contributor first for EVERY row would take the pointer from the card the user says works today.
|
|
206
|
-
assert.deepEqual(
|
|
207
|
-
move(1),
|
|
208
|
-
{ handled: true, render: true },
|
|
209
|
-
"the host's region answered, exactly as it does for its own cards",
|
|
210
|
-
);
|
|
211
|
-
assert.equal(renders, 0, "no bridge frame is asked for");
|
|
212
|
-
assert.equal(getSectionHoverReport().mechanism, "not used", "the bridge never claimed the pointer");
|
|
213
|
-
assert.ok(fakeState().hover !== undefined, "the host's own claim holds the slot");
|
|
214
|
-
assert.ok(!(rowOf("MUSIC") ?? "").includes(HOVER_SGR), "and none of our rows is painted");
|
|
215
|
-
assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR));
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test("moving from our row to the host's row hands the pointer back, one surface at a time", () => {
|
|
219
|
-
setSectionTheme(fakeTheme());
|
|
220
|
-
registerSection(
|
|
221
|
-
section("music", {
|
|
222
|
-
title: "MUSIC",
|
|
223
|
-
lines: () => [{ label: "play", onClick: () => {} }],
|
|
224
|
-
}),
|
|
225
|
-
);
|
|
226
|
-
arm();
|
|
227
|
-
renderSidebar();
|
|
228
|
-
|
|
229
|
-
move(FIRST_BODY_ROW);
|
|
230
|
-
assert.ok((rowOf("play") ?? "").includes(HOVER_SGR), "our row is painted while it owns the pointer");
|
|
231
|
-
const claimed = fakeState().hover?.owner;
|
|
232
|
-
|
|
233
|
-
move(0);
|
|
234
|
-
assert.notEqual(fakeState().hover?.owner, claimed, "the host's own target took the pointer");
|
|
235
|
-
assert.ok(!(rowOf("play") ?? "").includes(HOVER_SGR), "and our row is released, not left painted");
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test("the host's own pointer-left step clears our row and repaints once", () => {
|
|
239
|
-
setSectionTheme(fakeTheme());
|
|
240
|
-
registerSection(section("music", { title: "MUSIC" }));
|
|
241
|
-
arm();
|
|
242
|
-
renderSidebar();
|
|
243
|
-
move(HEADER_ROW);
|
|
244
|
-
assert.equal(renders, 1);
|
|
245
|
-
|
|
246
|
-
// `setSidebarHover(tui)` with no owner: what the layout does when the pointer leaves the rail
|
|
247
|
-
// (`lib/shell-sidebar-layout.ts:280,447`). It runs the current owner's `clear()`.
|
|
248
|
-
setHostHover();
|
|
249
|
-
|
|
250
|
-
assert.equal(renders, 2, "the clear repaints once");
|
|
251
|
-
assert.equal(fakeState().hover, undefined, "the slot is empty");
|
|
252
|
-
assert.ok(!(rowOf("MUSIC") ?? "").includes(HOVER_SGR), "the header is idle again");
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
test("a click on an inner action row still runs once, and only on that row", () => {
|
|
256
|
-
const calls: string[] = [];
|
|
257
|
-
registerSection(
|
|
258
|
-
section("music", {
|
|
259
|
-
title: "MUSIC",
|
|
260
|
-
lines: () => [
|
|
261
|
-
{ label: "play", onClick: () => calls.push("play") },
|
|
262
|
-
{ label: "inert" },
|
|
263
|
-
],
|
|
264
|
-
}),
|
|
265
|
-
);
|
|
266
|
-
arm();
|
|
267
|
-
renderSidebar();
|
|
268
|
-
|
|
269
|
-
assert.deepEqual(click(FIRST_BODY_ROW), { handled: true, render: true });
|
|
270
|
-
assert.deepEqual(click(FIRST_BODY_ROW), { handled: true, render: true });
|
|
271
|
-
assert.deepEqual(calls, ["play", "play"], "one click, one call — never doubled by the host");
|
|
272
|
-
|
|
273
|
-
assert.equal(click(FIRST_BODY_ROW + 1), undefined, "an inert row declines");
|
|
274
|
-
assert.equal(click(HEADER_ROW + 9), undefined, "a row past the contribution is not ours");
|
|
275
|
-
assert.deepEqual(calls, ["play", "play"]);
|
|
276
|
-
});
|