gentle-shell-bridge 0.0.0-stage → 0.3.0
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/.codegraph/.gitignore +5 -0
- package/PRD.md +144 -0
- package/README.md +745 -2
- package/docs/architecture.md +981 -0
- package/docs/contract.md +1266 -0
- package/docs/state.md +251 -0
- package/examples/bash-card.ts +1694 -0
- package/gentle-shell-bridge-0.3.0.tgz +0 -0
- package/odd/tasks/palette-sections.md +30 -0
- package/odd/tasks/plugin-keymap.md +32 -0
- package/odd/tasks/plugin-surface-contract.md +55 -0
- package/package.json +35 -4
- package/src/api.ts +492 -0
- package/src/arm.ts +83 -0
- package/src/bus.ts +558 -0
- package/src/capture.ts +886 -0
- package/src/card-style-line.ts +112 -0
- package/src/card-style-rows.ts +390 -0
- package/src/card-style-transform.ts +862 -0
- package/src/card-style-wiring.ts +472 -0
- package/src/card-styles.ts +159 -0
- package/src/config.ts +71 -0
- package/src/extension.ts +271 -0
- package/src/hook.ts +310 -0
- package/src/index.ts +71 -0
- package/src/notices.ts +137 -0
- package/src/plugin-lines.ts +865 -0
- package/src/plugin-surface.ts +218 -0
- package/src/presence.ts +259 -0
- package/src/repaint.ts +351 -0
- package/src/row-labels.ts +146 -0
- package/src/row-names.ts +273 -0
- package/src/row-origin.ts +144 -0
- package/src/rows.ts +248 -0
- package/src/section-panel.ts +378 -0
- package/src/section-pointer.ts +164 -0
- package/src/sections-rows.ts +128 -0
- package/src/sidebar-render.ts +535 -0
- package/src/sidebar.ts +919 -0
- package/src/status.ts +1161 -0
- package/src/store.ts +279 -0
- package/src/tool-design-adoption.ts +263 -0
- package/src/tool-design-rows.ts +246 -0
- package/src/tool-design-wiring.ts +428 -0
- package/src/tool-designs.ts +402 -0
- package/src/transform-pipeline.ts +351 -0
- package/src/types.ts +442 -0
- package/src/visibility.ts +154 -0
- package/test/api.test.ts +395 -0
- package/test/apply-on-move.test.ts +477 -0
- package/test/arm.test.ts +220 -0
- package/test/bridge-ready.test.ts +309 -0
- package/test/bus.test.ts +165 -0
- package/test/button-rows.test.ts +409 -0
- package/test/capture.test.ts +356 -0
- package/test/card-style-rows.test.ts +510 -0
- package/test/card-style-transform.test.ts +379 -0
- package/test/card-style-wiring.test.ts +258 -0
- package/test/card-styles-e2e.test.ts +491 -0
- package/test/card-styles.test.ts +324 -0
- package/test/card-transcript-path.test.ts +178 -0
- package/test/config.test.ts +93 -0
- package/test/e2e.test.ts +443 -0
- package/test/example-bash-identity.test.ts +372 -0
- package/test/example-bash-matrix.test.ts +856 -0
- package/test/example-designs.test.ts +734 -0
- package/test/example-neon-transform.test.ts +283 -0
- package/test/example-plain-style.test.ts +1268 -0
- package/test/extension.test.ts +160 -0
- package/test/fake-events.ts +58 -0
- package/test/fake-host.ts +211 -0
- package/test/fake-sidebar.ts +516 -0
- package/test/fake-transcript.ts +486 -0
- package/test/frame-telemetry.test.ts +417 -0
- package/test/hook.test.ts +196 -0
- package/test/manifest.test.ts +105 -0
- package/test/new-category.test.ts +979 -0
- package/test/notices.test.ts +328 -0
- package/test/plugin-integration-lines.test.ts +979 -0
- package/test/plugin-surface.test.ts +152 -0
- package/test/presence.test.ts +459 -0
- package/test/rail-surfaces.test.ts +457 -0
- package/test/rail-width.test.ts +423 -0
- package/test/regression.test.ts +483 -0
- package/test/repaint-api.test.ts +200 -0
- package/test/reserved-categories.test.ts +361 -0
- package/test/row-labels.test.ts +284 -0
- package/test/row-names.test.ts +473 -0
- package/test/row-origin.test.ts +395 -0
- package/test/section-interaction.test.ts +364 -0
- package/test/section-panel.test.ts +403 -0
- package/test/sections-rows.test.ts +291 -0
- package/test/sidebar-backend.test.ts +471 -0
- package/test/sidebar-e2e.test.ts +331 -0
- package/test/sidebar-hover.test.ts +276 -0
- package/test/sidebar-independent.test.ts +349 -0
- package/test/sidebar-render.test.ts +281 -0
- package/test/sidebar-sections.test.ts +245 -0
- package/test/status-card-styles.test.ts +201 -0
- package/test/status-section-frame.test.ts +181 -0
- package/test/status-sections.test.ts +243 -0
- package/test/status.test.ts +209 -0
- package/test/store.test.ts +192 -0
- package/test/surface-repaint-cost.test.ts +520 -0
- package/test/tool-design-chain.test.ts +483 -0
- package/test/tool-design-cost.test.ts +473 -0
- package/test/tool-design-rows.test.ts +359 -0
- package/test/tool-designs.test.ts +745 -0
- package/test/transform-pipeline.test.ts +465 -0
- package/test/transforms-bus.test.ts +233 -0
- package/test/verifier-width-regressions.test.ts +164 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T17–T19 / S11: the bash DESIGN under every card style and every card content.
|
|
3
|
+
*
|
|
4
|
+
* The user's requirement, verbatim (L4):
|
|
5
|
+
*
|
|
6
|
+
* > tambien las tools redisenada (en este caso tool bash) personalizadas deberia ser compatibles con
|
|
7
|
+
* > card style float/neon (o agregados) y card content default/minimal
|
|
8
|
+
*
|
|
9
|
+
* The previous iterations shipped green suites while the feature did nothing in the real TUI,
|
|
10
|
+
* because the fixtures described one card content mode and one frame. So every fixture this suite
|
|
11
|
+
* drives is derived from the INSTALLED host and from pi, and each one says where: see the derivation
|
|
12
|
+
* block in `test/fake-transcript.ts` (`BASH_MINIMAL_*`, `BASH_DEFAULT_*`).
|
|
13
|
+
*
|
|
14
|
+
* The matrix, and what the host really draws in each cell:
|
|
15
|
+
*
|
|
16
|
+
* | card style | card content | the card's rows |
|
|
17
|
+
* | -------------- | ------------ | ------------------------------------------------------ |
|
|
18
|
+
* | `float` | `minimal` | host `floatRows`: padding, `▎ $ cmd … ctrl+o…` |
|
|
19
|
+
* | `neon` | `minimal` | host `cardTop`: `╭─ $ cmd ─── ctrl+o… ╮` |
|
|
20
|
+
* | `float` | `default` | pi native `Text` in a `Box`: ` $ cmd …` padded to width |
|
|
21
|
+
* | `neon` | `default` | identical: nothing on pi's path reads `cardStyle()` |
|
|
22
|
+
* | a REGISTERED | `minimal` | the float rows above, repainted by `src/card-style-…` |
|
|
23
|
+
* | style (added) | `default` | unreachable: the repaint needs the float ` ▎ ` grammar |
|
|
24
|
+
*
|
|
25
|
+
* Everything here is driven through the REAL bridge: the example registers through the published
|
|
26
|
+
* API, the bridge's one resolver wraps the host's renderers, and the design is selected by pressing
|
|
27
|
+
* its `Cards` row. Nothing calls `applyBashHeading` directly, because a helper that works is exactly
|
|
28
|
+
* what the earlier iterations had.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import assert from "node:assert/strict";
|
|
32
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
33
|
+
import { tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import test from "node:test";
|
|
36
|
+
|
|
37
|
+
import { publishBridgeApi, resetBridgeApiForTests } from "../src/api.ts";
|
|
38
|
+
import {
|
|
39
|
+
registerCardStyle,
|
|
40
|
+
resetBusForTests,
|
|
41
|
+
resetCardStylesForTests,
|
|
42
|
+
resetSectionsForTests,
|
|
43
|
+
snapshotSections,
|
|
44
|
+
} from "../src/bus.ts";
|
|
45
|
+
import { resetCardStyleTransformForTests, transformCardLines } from "../src/card-style-transform.ts";
|
|
46
|
+
import {
|
|
47
|
+
disableCardStyleTransform,
|
|
48
|
+
resetCardStyleWiringForTests,
|
|
49
|
+
} from "../src/card-style-wiring.ts";
|
|
50
|
+
import { setCardStyleSelection } from "../src/card-styles.ts";
|
|
51
|
+
import { disableCaptureBackend, resetCaptureStateForTests } from "../src/capture.ts";
|
|
52
|
+
import gentleShellBridge, { type BridgeExtensionApi } from "../src/extension.ts";
|
|
53
|
+
import { resetHookStateForTests } from "../src/hook.ts";
|
|
54
|
+
import { resetNoticesForTests } from "../src/notices.ts";
|
|
55
|
+
import { resetPluginLinesForTests } from "../src/plugin-lines.ts";
|
|
56
|
+
import { resetRepaintForTests } from "../src/repaint.ts";
|
|
57
|
+
import { buildRows, resetRowBuildStateForTests } from "../src/rows.ts";
|
|
58
|
+
import { disableSidebarBackend, resetSidebarStateForTests } from "../src/sidebar.ts";
|
|
59
|
+
import { resetSectionFailuresForTests } from "../src/sidebar-render.ts";
|
|
60
|
+
import { resetStoreStateForTests } from "../src/store.ts";
|
|
61
|
+
import { resetToolDesignsForTests, snapshotToolDesigns } from "../src/tool-designs.ts";
|
|
62
|
+
import { resetToolDesignRowStateForTests } from "../src/tool-design-rows.ts";
|
|
63
|
+
import { resetToolDesignWiringForTests } from "../src/tool-design-wiring.ts";
|
|
64
|
+
import type { CustomizeRow, InlineHelpers, SidebarSection, ToolDesignContext } from "../src/types.ts";
|
|
65
|
+
import { createFakeHost } from "./fake-host.ts";
|
|
66
|
+
import {
|
|
67
|
+
BASH_DEFAULT_CALL,
|
|
68
|
+
BASH_DEFAULT_CALL_NO_TIMEOUT,
|
|
69
|
+
BASH_DEFAULT_CALL_TIGHT,
|
|
70
|
+
BASH_DEFAULT_RESULT,
|
|
71
|
+
BASH_DEFAULT_RESULT_EXPANDED,
|
|
72
|
+
BASH_DEFAULT_RESULT_PROMPT,
|
|
73
|
+
BASH_MINIMAL_FLOAT_CALL,
|
|
74
|
+
BASH_MINIMAL_FLOAT_CALL_LONG,
|
|
75
|
+
BASH_MINIMAL_FLOAT_CALL_RUNNING,
|
|
76
|
+
BASH_MINIMAL_FLOAT_RESULT,
|
|
77
|
+
BASH_MINIMAL_FLOAT_RESULT_EMPTY,
|
|
78
|
+
BASH_MINIMAL_FLOAT_RESULT_PROMPT,
|
|
79
|
+
BASH_MINIMAL_NEON_CALL,
|
|
80
|
+
BASH_MINIMAL_NEON_CALL_LONG,
|
|
81
|
+
BASH_MINIMAL_NEON_CALL_RUNNING,
|
|
82
|
+
BASH_MINIMAL_NEON_RESULT,
|
|
83
|
+
BASH_MINIMAL_NEON_RESULT_EMPTY,
|
|
84
|
+
BASH_MINIMAL_NEON_RESULT_PROMPT,
|
|
85
|
+
stripAnsi,
|
|
86
|
+
} from "./fake-transcript.ts";
|
|
87
|
+
|
|
88
|
+
let configHome: string;
|
|
89
|
+
let previousConfigHome: string | undefined;
|
|
90
|
+
|
|
91
|
+
test.before(() => {
|
|
92
|
+
configHome = mkdtempSync(join(tmpdir(), "gsb-example-matrix-"));
|
|
93
|
+
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
94
|
+
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test.after(() => {
|
|
98
|
+
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
99
|
+
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
100
|
+
rmSync(configHome, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test.beforeEach(() => {
|
|
104
|
+
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
105
|
+
resetBridgeApiForTests();
|
|
106
|
+
resetBusForTests();
|
|
107
|
+
resetSectionsForTests();
|
|
108
|
+
resetCardStylesForTests();
|
|
109
|
+
resetCardStyleTransformForTests();
|
|
110
|
+
resetCardStyleWiringForTests();
|
|
111
|
+
resetCaptureStateForTests();
|
|
112
|
+
resetHookStateForTests();
|
|
113
|
+
resetRowBuildStateForTests();
|
|
114
|
+
resetSidebarStateForTests();
|
|
115
|
+
resetSectionFailuresForTests();
|
|
116
|
+
resetNoticesForTests();
|
|
117
|
+
resetPluginLinesForTests();
|
|
118
|
+
resetRepaintForTests();
|
|
119
|
+
resetStoreStateForTests();
|
|
120
|
+
resetToolDesignsForTests();
|
|
121
|
+
resetToolDesignRowStateForTests();
|
|
122
|
+
resetToolDesignWiringForTests();
|
|
123
|
+
disableCaptureBackend();
|
|
124
|
+
disableSidebarBackend();
|
|
125
|
+
disableCardStyleTransform();
|
|
126
|
+
setCardStyleSelection("native");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
type SessionHandler = (event: unknown, ctx: { hasUI?: boolean; ui?: unknown }) => void;
|
|
130
|
+
|
|
131
|
+
interface FakePi {
|
|
132
|
+
on?: (event: string, handler: SessionHandler) => void;
|
|
133
|
+
registerCommand?: () => void;
|
|
134
|
+
registerToolRenderer?: (resolver: unknown) => void;
|
|
135
|
+
getCommands?: () => Array<{ name: string }>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ExampleInit = (pi: FakePi) => () => void;
|
|
139
|
+
|
|
140
|
+
const loadExample = async (tag: string): Promise<ExampleInit> => {
|
|
141
|
+
const specifier = new URL(`../examples/bash-card.ts?${tag}`, import.meta.url).href;
|
|
142
|
+
return ((await import(specifier)) as { default: ExampleInit }).default;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const inlineHelpers: InlineHelpers = {
|
|
146
|
+
input: async () => undefined,
|
|
147
|
+
confirm: async () => true,
|
|
148
|
+
capture: async () => undefined,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
interface FakeComponent {
|
|
152
|
+
render(width: number): string[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface FakeRenderers {
|
|
156
|
+
renderShell?: unknown;
|
|
157
|
+
renderCall?: unknown;
|
|
158
|
+
renderResult?: unknown;
|
|
159
|
+
[member: string]: unknown;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fakeComponent(lines: readonly string[]): FakeComponent {
|
|
163
|
+
const rows = [...lines];
|
|
164
|
+
return { render: (): string[] => rows };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The renderers the host's call site hands Pi: a call component and a result component. */
|
|
168
|
+
function fakeRenderers(call: readonly string[], result: readonly string[]): FakeRenderers {
|
|
169
|
+
const callComponent = fakeComponent(call);
|
|
170
|
+
const resultComponent = fakeComponent(result);
|
|
171
|
+
return {
|
|
172
|
+
renderShell: "default",
|
|
173
|
+
renderCall: () => callComponent,
|
|
174
|
+
renderResult: () => resultComponent,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const THEME = { name: "fake-theme" };
|
|
179
|
+
|
|
180
|
+
function mount(renderers: FakeRenderers, member: "renderCall" | "renderResult"): FakeComponent {
|
|
181
|
+
const renderer = renderers[member];
|
|
182
|
+
assert.equal(typeof renderer, "function");
|
|
183
|
+
return member === "renderCall"
|
|
184
|
+
? (renderer as (args: unknown, theme: unknown, ctx: unknown) => FakeComponent)(
|
|
185
|
+
{ command: "npm test" },
|
|
186
|
+
THEME,
|
|
187
|
+
{},
|
|
188
|
+
)
|
|
189
|
+
: (renderer as (result: unknown, options: unknown, theme: unknown, ctx: unknown) => FakeComponent)(
|
|
190
|
+
{ content: [] },
|
|
191
|
+
{ expanded: false },
|
|
192
|
+
THEME,
|
|
193
|
+
{},
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type Resolver = (tool: string, next: () => FakeRenderers) => FakeRenderers | undefined;
|
|
198
|
+
|
|
199
|
+
interface World {
|
|
200
|
+
readonly resolver: Resolver;
|
|
201
|
+
rows(): CustomizeRow[];
|
|
202
|
+
pick(value: string): Promise<void>;
|
|
203
|
+
/** The example's CARD BASH section, live. */
|
|
204
|
+
section(): SidebarSection;
|
|
205
|
+
/** What the example asked Pi's TUI to tell the user, in order. */
|
|
206
|
+
notices(): string[];
|
|
207
|
+
/** A later `session_start`: the channel the example delivers buffered notices on. */
|
|
208
|
+
sessionStart(): void;
|
|
209
|
+
dispose(): void;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function startWorld(tag: string): Promise<World> {
|
|
213
|
+
publishBridgeApi();
|
|
214
|
+
const host = createFakeHost();
|
|
215
|
+
const resolvers: unknown[] = [];
|
|
216
|
+
const sessionHandlers: SessionHandler[] = [];
|
|
217
|
+
const notices: string[] = [];
|
|
218
|
+
const ui = {
|
|
219
|
+
custom: (factory: unknown, ...rest: unknown[]): unknown =>
|
|
220
|
+
(host.ui.custom as ((f: unknown, ...r: unknown[]) => unknown) | undefined)?.(factory, ...rest),
|
|
221
|
+
notify: (message: string): void => {
|
|
222
|
+
notices.push(message);
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
const pi: FakePi = {
|
|
226
|
+
// `gentle-pi` is present, so the bridge's `session_start` arms (S9/T12).
|
|
227
|
+
getCommands: () => [{ name: "gentle:customize" }],
|
|
228
|
+
on(_event, handler) {
|
|
229
|
+
sessionHandlers.push(handler);
|
|
230
|
+
},
|
|
231
|
+
registerCommand() {},
|
|
232
|
+
registerToolRenderer(resolver) {
|
|
233
|
+
resolvers.push(resolver);
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const sessionStart = (): void => {
|
|
238
|
+
// The bridge and the example both subscribe; each gets the same context, as on Pi.
|
|
239
|
+
for (const handler of sessionHandlers) handler({}, { hasUI: true, ui });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
gentleShellBridge(pi as BridgeExtensionApi);
|
|
243
|
+
const dispose = (await loadExample(tag))(pi);
|
|
244
|
+
sessionStart();
|
|
245
|
+
|
|
246
|
+
assert.equal(resolvers.length, 1, "the bridge registers exactly one resolver");
|
|
247
|
+
const resolver = resolvers[0] as Resolver;
|
|
248
|
+
assert.equal(typeof resolver, "function");
|
|
249
|
+
|
|
250
|
+
const rows = (): CustomizeRow[] => buildRows({ theme: undefined, configHome }).rows;
|
|
251
|
+
const section = (): SidebarSection => {
|
|
252
|
+
const found = snapshotSections().find((candidate) => candidate.id === "bash-card.status");
|
|
253
|
+
assert.ok(found !== undefined, "the example registers its CARD BASH section");
|
|
254
|
+
return found;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
resolver,
|
|
259
|
+
rows,
|
|
260
|
+
pick: async (value) => {
|
|
261
|
+
const row = rows().find(
|
|
262
|
+
(candidate) =>
|
|
263
|
+
/bash/iu.test(candidate.choice?.setting ?? "") && candidate.choice?.value === value,
|
|
264
|
+
);
|
|
265
|
+
assert.ok(row?.action !== undefined, `the Cards line carries the value ${value}`);
|
|
266
|
+
await row.action(inlineHelpers);
|
|
267
|
+
},
|
|
268
|
+
section,
|
|
269
|
+
notices: () => [...notices],
|
|
270
|
+
sessionStart,
|
|
271
|
+
dispose,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Visible width of one row, ANSI ignored. */
|
|
276
|
+
function widthOf(line: string): number {
|
|
277
|
+
return [...stripAnsi(line)].length;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Every row keeps the width the host gave it. */
|
|
281
|
+
function assertWidths(rows: readonly string[], width: number, label: string): void {
|
|
282
|
+
for (const [index, row] of rows.entries()) {
|
|
283
|
+
assert.equal(widthOf(row), width, `${label}: row ${index} keeps the host's width`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------------------------
|
|
288
|
+
// The matrix cells, with the exact heading each one draws and the exact row the design must emit
|
|
289
|
+
// ---------------------------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* `minimal` x `float`: `▎ $ npm test (timeout 30s) <12 spaces> ctrl+o to expand ` becomes
|
|
293
|
+
* `▎ bash npm test (timeout 30s) <9 spaces> ctrl+o to expand ` — the word costs three columns and
|
|
294
|
+
* the host's own space fill gives them back, so the rule still ends where it did.
|
|
295
|
+
*/
|
|
296
|
+
const DESIGNED_MINIMAL_FLOAT_HEADING =
|
|
297
|
+
" \u001b[48;5;236m\u001b[95m▎\u001b[39m\u001b[95m \u001b[39m\u001b[95mbash \u001b[95m\u001b[1mnpm test\u001b[22m\u001b[39m" +
|
|
298
|
+
"\u001b[37m (timeout 30s)\u001b[39m\u001b[39m\u001b[95m \u001b[39m \u001b[2mctrl+o to expand\u001b[39m" +
|
|
299
|
+
" \u001b[95m \u001b[39m\u001b[49m ";
|
|
300
|
+
|
|
301
|
+
/** `minimal` x `neon`: the same swap, taken out of the rule's `─` fill. */
|
|
302
|
+
const DESIGNED_MINIMAL_NEON_TOP =
|
|
303
|
+
"\u001b[95m╭\u001b[39m\u001b[95m─ \u001b[39m\u001b[95mbash \u001b[95m\u001b[1mnpm test\u001b[22m\u001b[39m" +
|
|
304
|
+
"\u001b[37m (timeout 30s)\u001b[39m\u001b[39m\u001b[95m ──────────\u001b[39m \u001b[2mctrl+o to expand\u001b[39m" +
|
|
305
|
+
" \u001b[95m╮\u001b[39m";
|
|
306
|
+
|
|
307
|
+
/** `default`: the mark is the row's first column and the row's padding gives the columns back. */
|
|
308
|
+
const DESIGNED_DEFAULT_HEADING =
|
|
309
|
+
"\u001b[95m\u001b[1mbash npm test\u001b[22m\u001b[39m\u001b[37m (timeout 30s)\u001b[39m" +
|
|
310
|
+
" ";
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* `minimal` x `float`, a command the HOST already collapsed: the word costs three columns, and the
|
|
314
|
+
* row has only its minimum rule fill, so the cost comes out of the COMMAND'S OWN ABBREVIATION — the
|
|
315
|
+
* three characters before the host's ellipsis — never out of a character the host did not
|
|
316
|
+
* abbreviate, and the fill is left exactly as the host drew it.
|
|
317
|
+
*/
|
|
318
|
+
const DESIGNED_MINIMAL_FLOAT_LONG_HEADING =
|
|
319
|
+
" \u001b[48;5;236m\u001b[95m▎\u001b[39m\u001b[95m \u001b[39m\u001b[95mbash \u001b[95m\u001b[1mnpm run typech…\u001b[22m\u001b[39m" +
|
|
320
|
+
"\u001b[37m (timeout 30s)\u001b[39m\u001b[39m\u001b[95m \u001b[39m \u001b[2mctrl+o to expand\u001b[39m" +
|
|
321
|
+
" \u001b[95m \u001b[39m\u001b[49m ";
|
|
322
|
+
|
|
323
|
+
/** `minimal` x `neon`, the same rule in the outline: the command's abbreviated tail pays. */
|
|
324
|
+
const DESIGNED_MINIMAL_NEON_LONG_TOP =
|
|
325
|
+
"\u001b[95m╭\u001b[39m\u001b[95m─ \u001b[39m\u001b[95mbash \u001b[95m\u001b[1mnpm run typechec…\u001b[22m\u001b[39m" +
|
|
326
|
+
"\u001b[37m (timeout 30s)\u001b[39m\u001b[39m\u001b[95m ─\u001b[39m \u001b[2mctrl+o to expand\u001b[39m" +
|
|
327
|
+
" \u001b[95m╮\u001b[39m";
|
|
328
|
+
|
|
329
|
+
interface Cell {
|
|
330
|
+
/** The user's own vocabulary: `<card style> × <card content>`. */
|
|
331
|
+
readonly id: string;
|
|
332
|
+
/** The width the design is handed: the component's, so the native cell loses the Box's padding. */
|
|
333
|
+
readonly width: number;
|
|
334
|
+
readonly call: readonly string[];
|
|
335
|
+
readonly result: readonly string[];
|
|
336
|
+
/** The call component's row that heads the card, and what it must read after the design. */
|
|
337
|
+
readonly headingRow: number;
|
|
338
|
+
readonly designedHeading: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const CELLS: readonly Cell[] = [
|
|
342
|
+
{
|
|
343
|
+
id: "float × minimal",
|
|
344
|
+
width: 60,
|
|
345
|
+
call: BASH_MINIMAL_FLOAT_CALL,
|
|
346
|
+
result: BASH_MINIMAL_FLOAT_RESULT,
|
|
347
|
+
headingRow: 1,
|
|
348
|
+
designedHeading: DESIGNED_MINIMAL_FLOAT_HEADING,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
id: "neon × minimal",
|
|
352
|
+
width: 60,
|
|
353
|
+
call: BASH_MINIMAL_NEON_CALL,
|
|
354
|
+
result: BASH_MINIMAL_NEON_RESULT,
|
|
355
|
+
headingRow: 0,
|
|
356
|
+
designedHeading: DESIGNED_MINIMAL_NEON_TOP,
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: "float × default",
|
|
360
|
+
width: 58,
|
|
361
|
+
call: BASH_DEFAULT_CALL,
|
|
362
|
+
result: BASH_DEFAULT_RESULT,
|
|
363
|
+
headingRow: 0,
|
|
364
|
+
designedHeading: DESIGNED_DEFAULT_HEADING,
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
// The same rows as the cell above, and that IS the finding: with content `default` the host's
|
|
368
|
+
// resolver calls `next()` and pi's native renderer draws, where nothing reads `cardStyle()`.
|
|
369
|
+
id: "neon × default",
|
|
370
|
+
width: 58,
|
|
371
|
+
call: BASH_DEFAULT_CALL,
|
|
372
|
+
result: BASH_DEFAULT_RESULT,
|
|
373
|
+
headingRow: 0,
|
|
374
|
+
designedHeading: DESIGNED_DEFAULT_HEADING,
|
|
375
|
+
},
|
|
376
|
+
];
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------------------------
|
|
379
|
+
// T17: the fixtures are the shapes the host draws, cell by cell
|
|
380
|
+
// ---------------------------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
test("T17: every cell's fixture is the host's own rows, at the host's own width", () => {
|
|
383
|
+
// `minimal` x `float`: 60 wide, heading on row 1 after the padding row.
|
|
384
|
+
assertWidths(BASH_MINIMAL_FLOAT_CALL, 60, "minimal/float call");
|
|
385
|
+
assert.equal(BASH_MINIMAL_FLOAT_CALL.length, 2, "floatRows: padding + heading, nothing else");
|
|
386
|
+
assert.equal(stripAnsi(BASH_MINIMAL_FLOAT_CALL[1] ?? "").includes("$ npm test"), true);
|
|
387
|
+
assert.equal(stripAnsi(BASH_MINIMAL_FLOAT_CALL[1] ?? "").startsWith(" ▎ $ npm test"), true);
|
|
388
|
+
|
|
389
|
+
// `minimal` x `neon`: one row, the outlined top rule.
|
|
390
|
+
assertWidths(BASH_MINIMAL_NEON_CALL, 60, "minimal/neon call");
|
|
391
|
+
assert.equal(BASH_MINIMAL_NEON_CALL.length, 1);
|
|
392
|
+
assert.equal(stripAnsi(BASH_MINIMAL_NEON_CALL[0] ?? "").startsWith("╭─ $ npm test"), true);
|
|
393
|
+
|
|
394
|
+
// `default`: pi's native row, 58 wide because the `Box` spends the card's two padding columns.
|
|
395
|
+
assertWidths(BASH_DEFAULT_CALL, 58, "default call");
|
|
396
|
+
assert.equal(BASH_DEFAULT_CALL.length, 1);
|
|
397
|
+
assert.equal(stripAnsi(BASH_DEFAULT_CALL[0] ?? "").startsWith("$ npm test"), true);
|
|
398
|
+
|
|
399
|
+
// The result components, in the shapes each content mode draws.
|
|
400
|
+
assertWidths(BASH_MINIMAL_FLOAT_RESULT, 60, "minimal/float result");
|
|
401
|
+
assertWidths(BASH_MINIMAL_NEON_RESULT, 60, "minimal/neon result");
|
|
402
|
+
// pi's result opens with a `Spacer` row, which renders as the empty string: the `Box` above it is
|
|
403
|
+
// what pads it, so the design is handed a zero-width row there and must leave it alone.
|
|
404
|
+
assert.equal(stripAnsi(BASH_DEFAULT_RESULT[0] ?? ""), "", "pi's result starts with a Spacer");
|
|
405
|
+
assertWidths(BASH_DEFAULT_RESULT.slice(1), 58, "default result");
|
|
406
|
+
assert.equal(BASH_MINIMAL_FLOAT_RESULT[0], BASH_MINIMAL_FLOAT_RESULT[3], "separator === closer");
|
|
407
|
+
assert.equal(stripAnsi(BASH_MINIMAL_FLOAT_RESULT_EMPTY[0] ?? "").startsWith(" ▎ "), true);
|
|
408
|
+
assert.equal(stripAnsi(BASH_MINIMAL_NEON_RESULT_EMPTY[0] ?? "").startsWith("╰"), true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------------------------
|
|
412
|
+
// T18: the design over the whole matrix
|
|
413
|
+
// ---------------------------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
test("T18: with the host's card selected, every cell draws the host's rows byte for byte", async () => {
|
|
416
|
+
const world = await startWorld("matrix-native");
|
|
417
|
+
|
|
418
|
+
for (const cell of CELLS) {
|
|
419
|
+
const renderers = fakeRenderers(cell.call, cell.result);
|
|
420
|
+
const resolved = world.resolver("bash", () => renderers);
|
|
421
|
+
assert.ok(resolved !== undefined, `${cell.id}: the bridge's wrapper is what the card uses`);
|
|
422
|
+
const call = mount(resolved, "renderCall");
|
|
423
|
+
|
|
424
|
+
assert.deepEqual(call.render(cell.width), [...cell.call], `${cell.id}: the host's card`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
world.dispose();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("T18: the design heads every cell with bash and changes no other byte", async () => {
|
|
431
|
+
const world = await startWorld("matrix-designed");
|
|
432
|
+
await world.pick("bash heading");
|
|
433
|
+
|
|
434
|
+
for (const cell of CELLS) {
|
|
435
|
+
const renderers = fakeRenderers(cell.call, cell.result);
|
|
436
|
+
const resolved = world.resolver("bash", () => renderers);
|
|
437
|
+
assert.ok(resolved !== undefined);
|
|
438
|
+
const call = mount(resolved, "renderCall");
|
|
439
|
+
const result = mount(resolved, "renderResult");
|
|
440
|
+
|
|
441
|
+
const output = call.render(cell.width);
|
|
442
|
+
|
|
443
|
+
assert.equal(output.length, cell.call.length, `${cell.id}: the frame keeps its row count`);
|
|
444
|
+
for (const [row, line] of output.entries()) {
|
|
445
|
+
if (row === cell.headingRow) {
|
|
446
|
+
assert.equal(line, cell.designedHeading, `${cell.id}: the heading reads bash`);
|
|
447
|
+
assert.equal(
|
|
448
|
+
stripAnsi(line).includes("$"),
|
|
449
|
+
false,
|
|
450
|
+
`${cell.id}: the host's mark is gone from the heading`,
|
|
451
|
+
);
|
|
452
|
+
} else {
|
|
453
|
+
assert.equal(line, cell.call[row], `${cell.id}: row ${row} is the host's byte for byte`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
assertWidths(output, cell.width, cell.id);
|
|
457
|
+
|
|
458
|
+
assert.deepEqual(
|
|
459
|
+
result.render(cell.width),
|
|
460
|
+
[...cell.result],
|
|
461
|
+
`${cell.id}: the result component is the host's, row for row`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
world.dispose();
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("T18: the heading is found with no timeout suffix and no hint on the row", async () => {
|
|
469
|
+
const world = await startWorld("matrix-plain-heading");
|
|
470
|
+
await world.pick("bash heading");
|
|
471
|
+
const resolved = world.resolver("bash", () => fakeRenderers(BASH_DEFAULT_CALL_NO_TIMEOUT, []));
|
|
472
|
+
assert.ok(resolved !== undefined);
|
|
473
|
+
|
|
474
|
+
const output = mount(resolved, "renderCall").render(58);
|
|
475
|
+
|
|
476
|
+
assert.deepEqual(output, [
|
|
477
|
+
"\u001b[95m\u001b[1mbash npm test\u001b[22m\u001b[39m" + " ".repeat(45),
|
|
478
|
+
]);
|
|
479
|
+
assertWidths(output, 58, "plain heading");
|
|
480
|
+
|
|
481
|
+
world.dispose();
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("T18: a card whose command is still running is headed too, in both minimal styles", async () => {
|
|
485
|
+
const world = await startWorld("matrix-running");
|
|
486
|
+
await world.pick("bash heading");
|
|
487
|
+
|
|
488
|
+
// While the result is missing the host closes the frame itself (`cardAwaitingResult`), so the call
|
|
489
|
+
// component carries `running…` rows after the heading — and the heading must be headed anyway.
|
|
490
|
+
const runningCells = [
|
|
491
|
+
{
|
|
492
|
+
label: "minimal/float running",
|
|
493
|
+
fixture: BASH_MINIMAL_FLOAT_CALL_RUNNING,
|
|
494
|
+
heading: 1,
|
|
495
|
+
designed: DESIGNED_MINIMAL_FLOAT_HEADING,
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
label: "minimal/neon running",
|
|
499
|
+
fixture: BASH_MINIMAL_NEON_CALL_RUNNING,
|
|
500
|
+
heading: 0,
|
|
501
|
+
designed: DESIGNED_MINIMAL_NEON_TOP,
|
|
502
|
+
},
|
|
503
|
+
] as const;
|
|
504
|
+
|
|
505
|
+
for (const cell of runningCells) {
|
|
506
|
+
const resolved = world.resolver("bash", () => fakeRenderers(cell.fixture, []));
|
|
507
|
+
assert.ok(resolved !== undefined);
|
|
508
|
+
const output = mount(resolved, "renderCall").render(60);
|
|
509
|
+
|
|
510
|
+
assert.equal(output.length, cell.fixture.length, `${cell.label}: every row the host drew`);
|
|
511
|
+
for (const [row, line] of output.entries()) {
|
|
512
|
+
if (row === cell.heading) {
|
|
513
|
+
assert.equal(line, cell.designed, `${cell.label}: the heading reads bash`);
|
|
514
|
+
} else {
|
|
515
|
+
assert.equal(line, cell.fixture[row], `${cell.label}: row ${row} is the host's`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
assertWidths(output, 60, cell.label);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
world.dispose();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ---------------------------------------------------------------------------------------------
|
|
525
|
+
// T24–T25: a command the host already collapsed pays the word's three columns from its own
|
|
526
|
+
// abbreviation; a command shown in full is never shortened (S14)
|
|
527
|
+
// ---------------------------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
test("T24/T25: a long collapsed command is headed bash by shortening its own ellipsis", async () => {
|
|
530
|
+
const world = await startWorld("matrix-long-collapsed");
|
|
531
|
+
await world.pick("bash heading");
|
|
532
|
+
|
|
533
|
+
const longCells = [
|
|
534
|
+
{
|
|
535
|
+
label: "float × minimal",
|
|
536
|
+
fixture: BASH_MINIMAL_FLOAT_CALL_LONG,
|
|
537
|
+
heading: 1,
|
|
538
|
+
designed: DESIGNED_MINIMAL_FLOAT_LONG_HEADING,
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
label: "neon × minimal",
|
|
542
|
+
fixture: BASH_MINIMAL_NEON_CALL_LONG,
|
|
543
|
+
heading: 0,
|
|
544
|
+
designed: DESIGNED_MINIMAL_NEON_LONG_TOP,
|
|
545
|
+
},
|
|
546
|
+
] as const;
|
|
547
|
+
|
|
548
|
+
for (const cell of longCells) {
|
|
549
|
+
const resolved = world.resolver("bash", () => fakeRenderers(cell.fixture, []));
|
|
550
|
+
assert.ok(resolved !== undefined);
|
|
551
|
+
const output = mount(resolved, "renderCall").render(60);
|
|
552
|
+
|
|
553
|
+
assert.equal(output.length, cell.fixture.length, `${cell.label}: the frame keeps its row count`);
|
|
554
|
+
for (const [row, line] of output.entries()) {
|
|
555
|
+
if (row === cell.heading) assert.equal(line, cell.designed, `${cell.label}: the heading reads bash`);
|
|
556
|
+
else assert.equal(line, cell.fixture[row], `${cell.label}: row ${row} is the host's byte for byte`);
|
|
557
|
+
}
|
|
558
|
+
assertWidths(output, 60, cell.label);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// The design handled both cards, so the section must not claim a refusal for them.
|
|
562
|
+
const report = world.section().lines({ theme: undefined, configHome });
|
|
563
|
+
assert.equal(
|
|
564
|
+
report.some((line) => typeof line === "string" && /^unrecognised\s+none$/u.test(line)),
|
|
565
|
+
true,
|
|
566
|
+
"the section reports no refusal for a collapsed command the design headed",
|
|
567
|
+
);
|
|
568
|
+
assert.equal(
|
|
569
|
+
report.some((line) => typeof line === "string" && /no fill/u.test(line)),
|
|
570
|
+
false,
|
|
571
|
+
"and stops claiming no fill once the design can pay the word's columns",
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
world.dispose();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("T25/S14: a command shown in full is never shortened, even with no fill", async () => {
|
|
578
|
+
const world = await startWorld("matrix-long-full");
|
|
579
|
+
await world.pick("bash heading");
|
|
580
|
+
const resolved = world.resolver("bash", () => fakeRenderers(BASH_DEFAULT_CALL_TIGHT, []));
|
|
581
|
+
assert.ok(resolved !== undefined);
|
|
582
|
+
|
|
583
|
+
// The row is full and its command is NOT abbreviated: a design must not hide what a command
|
|
584
|
+
// does by cutting three columns out of it, so the host's bytes stand and the refusal is reported.
|
|
585
|
+
assert.deepEqual(
|
|
586
|
+
mount(resolved, "renderCall").render(58),
|
|
587
|
+
[...BASH_DEFAULT_CALL_TIGHT],
|
|
588
|
+
"an unabbreviated command is the host's, byte for byte",
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
const lines = world.section().lines({ theme: undefined, configHome });
|
|
592
|
+
assert.equal(
|
|
593
|
+
lines.some((line) => typeof line === "string" && /no fill/u.test(line)),
|
|
594
|
+
true,
|
|
595
|
+
"a genuine refusal is still reported",
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
world.dispose();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// ---------------------------------------------------------------------------------------------
|
|
602
|
+
// T19: a frame the design cannot recognise comes back byte-identical, and is reported once
|
|
603
|
+
// ---------------------------------------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
test("T19: a result component is never rewritten, in every cell the shapes allow", async () => {
|
|
606
|
+
const world = await startWorld("matrix-results");
|
|
607
|
+
await world.pick("bash heading");
|
|
608
|
+
|
|
609
|
+
// `neon` and `default`: the mark sits on a row the design can prove is not a heading — a `│` side
|
|
610
|
+
// row, or a row after pi's blank Spacer — so it is the host's row.
|
|
611
|
+
for (const [label, width, fixture] of [
|
|
612
|
+
["neon", 60, BASH_MINIMAL_NEON_RESULT_PROMPT],
|
|
613
|
+
["default", 58, BASH_DEFAULT_RESULT_PROMPT],
|
|
614
|
+
["default expanded", 58, BASH_DEFAULT_RESULT_EXPANDED],
|
|
615
|
+
] as const) {
|
|
616
|
+
const resolved = world.resolver("bash", () => fakeRenderers(fixture, fixture));
|
|
617
|
+
assert.ok(resolved !== undefined);
|
|
618
|
+
assert.deepEqual(
|
|
619
|
+
mount(resolved, "renderResult").render(width),
|
|
620
|
+
[...fixture],
|
|
621
|
+
`${label}: output is never rewritten as if it were the heading`,
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
world.dispose();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("T21/S12: in the float cell an output row that starts with the mark passes through byte for byte", async () => {
|
|
629
|
+
const world = await startWorld("matrix-float-result-prompt");
|
|
630
|
+
await world.pick("bash heading");
|
|
631
|
+
const resolved = world.resolver("bash", () =>
|
|
632
|
+
fakeRenderers(BASH_MINIMAL_FLOAT_RESULT_PROMPT, BASH_MINIMAL_FLOAT_RESULT_PROMPT),
|
|
633
|
+
);
|
|
634
|
+
assert.ok(resolved !== undefined);
|
|
635
|
+
|
|
636
|
+
// The call component and the result component of a float card are drawn with the SAME chrome and
|
|
637
|
+
// the same row positions (padding row, then a `▎ ` row), so `cat script.sh` printing a line that
|
|
638
|
+
// begins with `$ ` sits exactly where a heading sits. The bridge now says WHICH part it is drawing
|
|
639
|
+
// (`ctx.part`), so the design acts on the call alone and the host's output is what is drawn: no
|
|
640
|
+
// data is lost, no row is widened, and every byte — ANSI, padding, the echoed command — comes
|
|
641
|
+
// back as the host drew it.
|
|
642
|
+
const output = mount(resolved, "renderResult").render(60);
|
|
643
|
+
|
|
644
|
+
assert.deepEqual(output, [...BASH_MINIMAL_FLOAT_RESULT_PROMPT], "the result is the host's, row for row");
|
|
645
|
+
assert.equal(stripAnsi(output[1] ?? "").includes("$ echo hi"), true, "the echoed prompt survives");
|
|
646
|
+
assertWidths(output, 60, "float prompt echo");
|
|
647
|
+
|
|
648
|
+
// The SAME rows handed to the CALL renderer are still a heading: `part` decides, never the row's
|
|
649
|
+
// shape, so the design still does its job where a heading really is.
|
|
650
|
+
const call = mount(resolved, "renderCall").render(60);
|
|
651
|
+
assert.equal(call.length, BASH_MINIMAL_FLOAT_RESULT_PROMPT.length, "the frame keeps its row count");
|
|
652
|
+
assert.equal(stripAnsi(call[1] ?? "").includes("bash echo hi"), true, "the call is still headed");
|
|
653
|
+
assert.equal(stripAnsi(call[1] ?? "").includes("$ echo hi"), false, "and its mark is gone");
|
|
654
|
+
assertWidths(call, 60, "float prompt echo call");
|
|
655
|
+
|
|
656
|
+
world.dispose();
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("T21/S12: a context with no `part` (an older bridge) keeps the previous behaviour", async () => {
|
|
660
|
+
const world = await startWorld("matrix-no-part");
|
|
661
|
+
await world.pick("bash heading");
|
|
662
|
+
|
|
663
|
+
// A bridge published BEFORE this field hands a design `{ width, tool, theme }` and no `part` at all.
|
|
664
|
+
// This build's wrapper always sets it, so an older context cannot be produced through the bus: this
|
|
665
|
+
// is the one place the registered design is driven directly, and what it pins is the fallback — the
|
|
666
|
+
// part is UNKNOWN, never `result`, so the design keeps the behaviour it had before the field
|
|
667
|
+
// existed (the row grammar decides) instead of refusing every frame an older bridge hands it.
|
|
668
|
+
const design = snapshotToolDesigns("bash").find((entry) => entry.id === "bash-card.bash-heading");
|
|
669
|
+
assert.ok(design !== undefined, "the example's design is the one on the bus");
|
|
670
|
+
// SAFETY: the older bridge's context has no `part`; the published type says it is always set, so the
|
|
671
|
+
// older shape is spelled on purpose here — that IS the case this test exists for.
|
|
672
|
+
const legacyContext = { width: 60, tool: "bash" } as ToolDesignContext;
|
|
673
|
+
const legacy = design.apply(BASH_MINIMAL_FLOAT_RESULT_PROMPT, legacyContext);
|
|
674
|
+
|
|
675
|
+
assert.equal(
|
|
676
|
+
stripAnsi(legacy[1] ?? "").includes("bash echo hi"),
|
|
677
|
+
true,
|
|
678
|
+
"with no part the row grammar still decides, exactly as before",
|
|
679
|
+
);
|
|
680
|
+
assertWidths(legacy, 60, "no part");
|
|
681
|
+
|
|
682
|
+
world.dispose();
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("T19: a heading with no fill to take the word's three columns is left alone and reported", async () => {
|
|
686
|
+
const world = await startWorld("matrix-no-room");
|
|
687
|
+
await world.pick("bash heading");
|
|
688
|
+
const resolved = world.resolver("bash", () => fakeRenderers(BASH_DEFAULT_CALL_TIGHT, []));
|
|
689
|
+
assert.ok(resolved !== undefined);
|
|
690
|
+
const call = mount(resolved, "renderCall");
|
|
691
|
+
|
|
692
|
+
assert.deepEqual(call.render(58), [...BASH_DEFAULT_CALL_TIGHT], "the frame, unchanged");
|
|
693
|
+
|
|
694
|
+
const section = world.section();
|
|
695
|
+
const lines = section.lines({ theme: undefined, configHome });
|
|
696
|
+
assert.equal(
|
|
697
|
+
lines.some((line) => typeof line === "string" && /^unrecognised\s/u.test(line)),
|
|
698
|
+
true,
|
|
699
|
+
"the section says the frame was not recognised",
|
|
700
|
+
);
|
|
701
|
+
assert.equal(
|
|
702
|
+
lines.some((line) => typeof line === "string" && /no fill/u.test(line)),
|
|
703
|
+
true,
|
|
704
|
+
"and names the reason",
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
// The rest of the frame is drawn again and again on every render pass; the report does not grow.
|
|
708
|
+
call.render(58);
|
|
709
|
+
call.render(58);
|
|
710
|
+
world.sessionStart();
|
|
711
|
+
world.sessionStart();
|
|
712
|
+
const notices = world.notices();
|
|
713
|
+
assert.equal(notices.length, 1, "one notice, delivered exactly once");
|
|
714
|
+
assert.match(notices[0] ?? "", /\[bash-card\]/u);
|
|
715
|
+
assert.match(notices[0] ?? "", /no padding or rule fill/u);
|
|
716
|
+
assert.match(notices[0] ?? "", /CARD BASH/u, "it points at the section that reports it live");
|
|
717
|
+
|
|
718
|
+
world.dispose();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------------------------
|
|
722
|
+
// T18: an ADDED card style — the bridge repaints the frame AFTER the design
|
|
723
|
+
// ---------------------------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/** A registered style whose chrome is readable at a glance and whose rows fit the width. */
|
|
726
|
+
function addedStyle(): { id: string; label: string; tops: string[] } {
|
|
727
|
+
const tops: string[] = [];
|
|
728
|
+
const fit = (text: string, width: number): string => {
|
|
729
|
+
const clipped = [...text].slice(0, width).join("");
|
|
730
|
+
return clipped.padEnd(width, "-");
|
|
731
|
+
};
|
|
732
|
+
registerCardStyle({
|
|
733
|
+
id: "matrix.added",
|
|
734
|
+
label: "added",
|
|
735
|
+
frame: {
|
|
736
|
+
top: (ctx) => {
|
|
737
|
+
const line = fit(`+${ctx.title ?? ""}${ctx.hint === undefined ? "" : `|${ctx.hint}`}`, ctx.width);
|
|
738
|
+
tops.push(line);
|
|
739
|
+
return line;
|
|
740
|
+
},
|
|
741
|
+
line: (ctx) => fit(`> ${ctx.text}`, ctx.width),
|
|
742
|
+
bottom: (ctx) => "-".repeat(ctx.width),
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
setCardStyleSelection("matrix.added");
|
|
746
|
+
return { id: "matrix.added", label: "added", tops };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
test("T18: the design's heading survives a style ADDED through registerCardStyle", async () => {
|
|
750
|
+
const world = await startWorld("matrix-added");
|
|
751
|
+
await world.pick("bash heading");
|
|
752
|
+
const style = addedStyle();
|
|
753
|
+
|
|
754
|
+
const renderers = fakeRenderers(BASH_MINIMAL_FLOAT_CALL, BASH_MINIMAL_FLOAT_RESULT);
|
|
755
|
+
const resolved = world.resolver("bash", () => renderers);
|
|
756
|
+
assert.ok(resolved !== undefined);
|
|
757
|
+
const designed = [
|
|
758
|
+
...mount(resolved, "renderCall").render(60),
|
|
759
|
+
...mount(resolved, "renderResult").render(60),
|
|
760
|
+
];
|
|
761
|
+
|
|
762
|
+
const repainted = transformCardLines([...designed], 60);
|
|
763
|
+
|
|
764
|
+
assert.notEqual(repainted, designed, "the added style repainted the card");
|
|
765
|
+
assert.equal(repainted.length > 0, true);
|
|
766
|
+
assert.equal(
|
|
767
|
+
repainted.some((row) => stripAnsi(row).startsWith("+")),
|
|
768
|
+
true,
|
|
769
|
+
"the added style's own frame is what is drawn",
|
|
770
|
+
);
|
|
771
|
+
assert.equal(
|
|
772
|
+
stripAnsi(repainted[0] ?? "").includes("bash npm test"),
|
|
773
|
+
true,
|
|
774
|
+
"the word the design put in the heading survives the repaint",
|
|
775
|
+
);
|
|
776
|
+
assert.equal(
|
|
777
|
+
stripAnsi(repainted[0] ?? "").includes("$ npm test"),
|
|
778
|
+
false,
|
|
779
|
+
"and the host's mark does not come back",
|
|
780
|
+
);
|
|
781
|
+
assert.equal(
|
|
782
|
+
style.tops.some((top) => top.includes("bash npm test")),
|
|
783
|
+
true,
|
|
784
|
+
"the style was handed the designed heading, not the host's",
|
|
785
|
+
);
|
|
786
|
+
for (const row of repainted) {
|
|
787
|
+
assert.equal(widthOf(row), 60, "every repainted row is the card's width");
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// The difference comes from the design, not from the style: with the host's card selected the
|
|
791
|
+
// very same transform draws the host's heading. The bridge drops the leading glyph before it hands
|
|
792
|
+
// the title to a style (`stripGlyph`, `src/card-style-transform.ts:301-305`), which is why the
|
|
793
|
+
// host's `$` never reaches a registered style and the design's whole word does.
|
|
794
|
+
await world.pick("native");
|
|
795
|
+
style.tops.length = 0;
|
|
796
|
+
const hostCard = [
|
|
797
|
+
...mount(resolved, "renderCall").render(60),
|
|
798
|
+
...mount(resolved, "renderResult").render(60),
|
|
799
|
+
];
|
|
800
|
+
const repaintedHost = transformCardLines([...hostCard], 60);
|
|
801
|
+
assert.equal(style.tops.length > 0, true, "the style still repainted");
|
|
802
|
+
assert.equal(
|
|
803
|
+
style.tops.every((top) => top.includes("npm test")),
|
|
804
|
+
true,
|
|
805
|
+
"the heading is the host's own command",
|
|
806
|
+
);
|
|
807
|
+
assert.equal(
|
|
808
|
+
style.tops.some((top) => top.includes("bash")),
|
|
809
|
+
false,
|
|
810
|
+
"and the design's word is not in it",
|
|
811
|
+
);
|
|
812
|
+
assert.equal(
|
|
813
|
+
repaintedHost.length,
|
|
814
|
+
4,
|
|
815
|
+
"the added style owns the frame: one top row, the card's two body rows, one bottom row",
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
world.dispose();
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test("T18: an added style cannot reach the default cell, and the design's rows pass through", async () => {
|
|
822
|
+
const world = await startWorld("matrix-added-default");
|
|
823
|
+
await world.pick("bash heading");
|
|
824
|
+
addedStyle();
|
|
825
|
+
|
|
826
|
+
const resolved = world.resolver("bash", () => fakeRenderers(BASH_DEFAULT_CALL, BASH_DEFAULT_RESULT));
|
|
827
|
+
assert.ok(resolved !== undefined);
|
|
828
|
+
const designed = [
|
|
829
|
+
...mount(resolved, "renderCall").render(58),
|
|
830
|
+
...mount(resolved, "renderResult").render(58),
|
|
831
|
+
];
|
|
832
|
+
assert.equal(designsBash(designed), true, "the design did its job first");
|
|
833
|
+
|
|
834
|
+
const lines = [...designed];
|
|
835
|
+
assert.equal(
|
|
836
|
+
transformCardLines(lines, 58),
|
|
837
|
+
lines,
|
|
838
|
+
"the repaint only knows the float grammar, so pi's native rows are the same array instance",
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
// The outlined `minimal` card is not repaintable either: the bridge's grammar is float's.
|
|
842
|
+
const neon = world.resolver("bash", () => fakeRenderers(BASH_MINIMAL_NEON_CALL, BASH_MINIMAL_NEON_RESULT));
|
|
843
|
+
assert.ok(neon !== undefined);
|
|
844
|
+
const neonRows = [
|
|
845
|
+
...mount(neon, "renderCall").render(60),
|
|
846
|
+
...mount(neon, "renderResult").render(60),
|
|
847
|
+
];
|
|
848
|
+
assert.equal(transformCardLines(neonRows, 60), neonRows, "neon rows are passed through");
|
|
849
|
+
|
|
850
|
+
world.dispose();
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
/** Whether the card's heading carries the design's word (used only to prove ordering). */
|
|
854
|
+
function designsBash(rows: readonly string[]): boolean {
|
|
855
|
+
return rows.some((row) => stripAnsi(row).startsWith("bash npm test"));
|
|
856
|
+
}
|