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,364 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Section interaction (N12, N13, N14), proven against the fake host.
|
|
3
|
-
*
|
|
4
|
-
* The host's click model is positional: it maps a pointer event into the part's frame and calls the
|
|
5
|
-
* part's `handleMouse` (`lib/shell-sidebar-layout.ts:245-259`), and a part answers only the physical
|
|
6
|
-
* rows it declared. `test/fake-sidebar.ts` owns the rail map and the frame, so these tests dispatch
|
|
7
|
-
* events exactly the way the layout would: `y` counts the PART's rows, which is why the native
|
|
8
|
-
* footer's three rows come first.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
13
|
-
import { tmpdir } from "node:os";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
import test from "node:test";
|
|
16
|
-
|
|
17
|
-
import { registerSection, resetBusForTests, resetSectionsForTests } from "../src/bus.ts";
|
|
18
|
-
import { setRepaintHost, resetRepaintForTests } from "../src/repaint.ts";
|
|
19
|
-
import { setSectionTheme, resetSectionPanelForTests } from "../src/section-panel.ts";
|
|
20
|
-
import { resetSectionPointerForTests } from "../src/section-pointer.ts";
|
|
21
|
-
import {
|
|
22
|
-
SIDEBAR_RAIL_KEY,
|
|
23
|
-
disableSidebarBackend,
|
|
24
|
-
installSidebarBackend,
|
|
25
|
-
resetSidebarStateForTests,
|
|
26
|
-
} from "../src/sidebar.ts";
|
|
27
|
-
import {
|
|
28
|
-
getSectionFailures,
|
|
29
|
-
installSectionRail,
|
|
30
|
-
resetSectionFailuresForTests,
|
|
31
|
-
} from "../src/sidebar-render.ts";
|
|
32
|
-
import { resetStoreStateForTests } from "../src/store.ts";
|
|
33
|
-
import type { SectionRow, SidebarSection } from "../src/types.ts";
|
|
34
|
-
import { getSectionCollapsed, isSectionCollapsed } from "../src/visibility.ts";
|
|
35
|
-
import {
|
|
36
|
-
cardBody,
|
|
37
|
-
cacheRevision,
|
|
38
|
-
fakeState,
|
|
39
|
-
fakeTerminal,
|
|
40
|
-
fakeTui,
|
|
41
|
-
registerNativeFooter,
|
|
42
|
-
renderSidebar,
|
|
43
|
-
resetSidebar,
|
|
44
|
-
} from "./fake-sidebar.ts";
|
|
45
|
-
|
|
46
|
-
let configHome: string;
|
|
47
|
-
let previousConfigHome: string | undefined;
|
|
48
|
-
|
|
49
|
-
test.before(() => {
|
|
50
|
-
configHome = mkdtempSync(join(tmpdir(), "gsb-interaction-"));
|
|
51
|
-
previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME;
|
|
52
|
-
process.env.GENTLE_PI_CONFIG_HOME = configHome;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test.after(() => {
|
|
56
|
-
if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME;
|
|
57
|
-
else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome;
|
|
58
|
-
rmSync(configHome, { recursive: true, force: true });
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test.beforeEach(() => {
|
|
62
|
-
rmSync(join(configHome, "gentle-shell-bridge"), { recursive: true, force: true });
|
|
63
|
-
resetBusForTests();
|
|
64
|
-
resetSectionsForTests();
|
|
65
|
-
resetSidebar();
|
|
66
|
-
resetSidebarStateForTests();
|
|
67
|
-
resetSectionFailuresForTests();
|
|
68
|
-
resetSectionPanelForTests();
|
|
69
|
-
resetSectionPointerForTests();
|
|
70
|
-
resetRepaintForTests();
|
|
71
|
-
resetStoreStateForTests();
|
|
72
|
-
disableSidebarBackend();
|
|
73
|
-
renders = 0;
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const section = (id: string, extra: Partial<SidebarSection> = {}): SidebarSection => ({
|
|
77
|
-
id,
|
|
78
|
-
title: id.toUpperCase(),
|
|
79
|
-
lines: () => [`body ${id}`],
|
|
80
|
-
...extra,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
let renders = 0;
|
|
84
|
-
|
|
85
|
-
/** Arm the rail with a `tui` whose `requestRender` and cache revision a test can count. */
|
|
86
|
-
function arm(): void {
|
|
87
|
-
const tui = {
|
|
88
|
-
terminal: fakeTerminal(),
|
|
89
|
-
requestRender: () => {
|
|
90
|
-
renders += 1;
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
setRepaintHost(tui);
|
|
94
|
-
registerNativeFooter();
|
|
95
|
-
installSidebarBackend(() => fakeTui());
|
|
96
|
-
installSectionRail();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const rail = () => fakeState().parts.get(SIDEBAR_RAIL_KEY);
|
|
100
|
-
const rowOf = (text: string, width = 40): string | undefined =>
|
|
101
|
-
(rail()?.render(width) ?? []).find((row) => row.includes(text));
|
|
102
|
-
|
|
103
|
-
/** The native footer renders three rows, so the contribution's first row is 3. */
|
|
104
|
-
const NATIVE_ROWS = 3;
|
|
105
|
-
|
|
106
|
-
const move = (y: number): unknown =>
|
|
107
|
-
rail()?.handleMouse?.({ type: "move", button: "none", x: 4, y, width: 40, height: 40 });
|
|
108
|
-
const click = (y: number): unknown =>
|
|
109
|
-
rail()?.handleMouse?.({ type: "click", button: "left", x: 4, y, width: 40, height: 40 });
|
|
110
|
-
|
|
111
|
-
const ROLE_SGR: Record<string, number> = {
|
|
112
|
-
text: 37,
|
|
113
|
-
muted: 90,
|
|
114
|
-
dim: 2,
|
|
115
|
-
accent: 95,
|
|
116
|
-
success: 32,
|
|
117
|
-
error: 31,
|
|
118
|
-
warning: 33,
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
function fakeTheme(): { fg(role: string, text: string): string; bold(text: string): string } {
|
|
122
|
-
return {
|
|
123
|
-
fg: (role, text) => `\u001b[${ROLE_SGR[role] ?? 0}m${text}\u001b[39m`,
|
|
124
|
-
bold: (text) => `\u001b[1m${text}\u001b[22m`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
test("only a row that carries onClick highlights and answers the pointer", () => {
|
|
129
|
-
setSectionTheme(fakeTheme());
|
|
130
|
-
const calls: string[] = [];
|
|
131
|
-
registerSection(
|
|
132
|
-
section("music", {
|
|
133
|
-
title: "MUSIC",
|
|
134
|
-
lines: () => [
|
|
135
|
-
{ label: "play", onClick: () => calls.push("play") },
|
|
136
|
-
{ label: "inert" },
|
|
137
|
-
],
|
|
138
|
-
}),
|
|
139
|
-
);
|
|
140
|
-
arm();
|
|
141
|
-
renderSidebar();
|
|
142
|
-
|
|
143
|
-
const inertRow = NATIVE_ROWS + 2;
|
|
144
|
-
// An inert row never highlights and never asks for a frame. The move is still CONSUMED, by the
|
|
145
|
-
// host's own part region: it answers every move inside its frame
|
|
146
|
-
// (`lib/native-pointer-region.ts:80-88`, `extensions/gentle-shell.ts:2732-2741`), so `handled` is
|
|
147
|
-
// the honest answer even where the bridge has nothing to do.
|
|
148
|
-
assert.deepEqual(move(inertRow), { handled: true }, "an inert row does not answer a hover");
|
|
149
|
-
assert.equal(renders, 0, "and nothing repaints");
|
|
150
|
-
assert.ok(!(rowOf("inert") ?? "").includes("\u001b[33m"), "an inert row never highlights");
|
|
151
|
-
|
|
152
|
-
const playRow = NATIVE_ROWS + 1;
|
|
153
|
-
assert.deepEqual(move(playRow), { handled: true, render: true }, "the clickable row answers");
|
|
154
|
-
assert.equal(renders, 1, "and asks for one frame");
|
|
155
|
-
assert.ok((rowOf("play") ?? "").includes("\u001b[33m"), "the hovered row takes the hover role");
|
|
156
|
-
|
|
157
|
-
assert.deepEqual(move(playRow), { handled: true }, "the same row again is not a new hover");
|
|
158
|
-
assert.equal(renders, 1, "so it asks for no frame");
|
|
159
|
-
|
|
160
|
-
assert.deepEqual(move(inertRow), { handled: true, render: true }, "leaving the row clears it");
|
|
161
|
-
assert.equal(renders, 2);
|
|
162
|
-
assert.ok(!(rowOf("play") ?? "").includes("\u001b[33m"), "the row is painted idle again");
|
|
163
|
-
assert.deepEqual(calls, [], "hovering never runs a row's work");
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test("one click calls exactly one onClick, and only on the row that has one", () => {
|
|
167
|
-
const calls: string[] = [];
|
|
168
|
-
registerSection(
|
|
169
|
-
section("music", {
|
|
170
|
-
title: "MUSIC",
|
|
171
|
-
lines: () => [
|
|
172
|
-
{ label: "play", onClick: () => calls.push("play") },
|
|
173
|
-
{ label: "inert" },
|
|
174
|
-
],
|
|
175
|
-
}),
|
|
176
|
-
);
|
|
177
|
-
arm();
|
|
178
|
-
renderSidebar();
|
|
179
|
-
|
|
180
|
-
assert.deepEqual(click(NATIVE_ROWS + 1), { handled: true, render: true });
|
|
181
|
-
assert.deepEqual(calls, ["play"], "one click, one call");
|
|
182
|
-
|
|
183
|
-
renderSidebar();
|
|
184
|
-
assert.deepEqual(click(NATIVE_ROWS + 1), { handled: true, render: true });
|
|
185
|
-
assert.deepEqual(calls, ["play", "play"], "and one more click, one more call");
|
|
186
|
-
|
|
187
|
-
assert.equal(click(NATIVE_ROWS + 2), undefined, "an inert row declines the click");
|
|
188
|
-
assert.deepEqual(calls, ["play", "play"], "and calls nothing");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("the header click collapses the section, and a collapsed body is not built", () => {
|
|
192
|
-
let linesCalls = 0;
|
|
193
|
-
let metaCalls = 0;
|
|
194
|
-
registerSection(
|
|
195
|
-
section("music", {
|
|
196
|
-
title: "MUSIC",
|
|
197
|
-
meta: () => {
|
|
198
|
-
metaCalls += 1;
|
|
199
|
-
return "1 playing";
|
|
200
|
-
},
|
|
201
|
-
lines: () => {
|
|
202
|
-
linesCalls += 1;
|
|
203
|
-
return ["track Blue in Green"];
|
|
204
|
-
},
|
|
205
|
-
}),
|
|
206
|
-
);
|
|
207
|
-
arm();
|
|
208
|
-
renderSidebar();
|
|
209
|
-
assert.equal(linesCalls, 1);
|
|
210
|
-
|
|
211
|
-
assert.deepEqual(click(NATIVE_ROWS), { handled: true, render: true }, "the header is the control");
|
|
212
|
-
|
|
213
|
-
assert.ok(isSectionCollapsed(getSectionCollapsed(), { id: "music" }), "the click folded it");
|
|
214
|
-
assert.ok(readFileSync(join(configHome, "gentle-shell-bridge", "bridge-sections.json"), "utf8").includes('"music": true'), "and the state is stored");
|
|
215
|
-
|
|
216
|
-
linesCalls = 0;
|
|
217
|
-
metaCalls = 0;
|
|
218
|
-
const rows = (rail()?.render(40) ?? []).slice(NATIVE_ROWS);
|
|
219
|
-
assert.equal(rows.length, 1, "a collapsed section is its header alone");
|
|
220
|
-
assert.equal(linesCalls, 0, "its body is NOT BUILT, not built and hidden");
|
|
221
|
-
assert.equal(metaCalls, 1, "the header's count is still read, because the header is drawn");
|
|
222
|
-
|
|
223
|
-
click(NATIVE_ROWS);
|
|
224
|
-
assert.equal(isSectionCollapsed(getSectionCollapsed(), { id: "music" }), false);
|
|
225
|
-
assert.deepEqual(cardBody("MUSIC"), ["track Blue in Green"], "expanding builds the body again");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("the collapse repaints in the same frame, through the host's own two steps", () => {
|
|
229
|
-
registerSection(section("music", { title: "MUSIC" }));
|
|
230
|
-
arm();
|
|
231
|
-
renderSidebar();
|
|
232
|
-
const before = cacheRevision();
|
|
233
|
-
renders = 0;
|
|
234
|
-
|
|
235
|
-
click(NATIVE_ROWS);
|
|
236
|
-
|
|
237
|
-
assert.equal(renders, 1, "exactly one frame is asked for");
|
|
238
|
-
assert.equal(cacheRevision(), before + 1, "and the terminal's sidebar revision moved");
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("the collapsed state survives a reload: it is bridge store state, like visibility", () => {
|
|
242
|
-
registerSection(section("music", { title: "MUSIC" }));
|
|
243
|
-
arm();
|
|
244
|
-
renderSidebar();
|
|
245
|
-
click(NATIVE_ROWS);
|
|
246
|
-
|
|
247
|
-
// A reload loses every in-memory frame but not the store.
|
|
248
|
-
resetSectionFailuresForTests();
|
|
249
|
-
resetSectionPointerForTests();
|
|
250
|
-
assert.deepEqual(
|
|
251
|
-
(rail()?.render(40) ?? []).slice(NATIVE_ROWS).length,
|
|
252
|
-
1,
|
|
253
|
-
"the card is still folded",
|
|
254
|
-
);
|
|
255
|
-
assert.equal(isSectionCollapsed(getSectionCollapsed(), { id: "music" }), true);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("a meta that moves is drawn on the next frame without re-registering, and moves the digest", () => {
|
|
259
|
-
let meta = "default";
|
|
260
|
-
registerSection(
|
|
261
|
-
section("music", {
|
|
262
|
-
title: "MUSIC",
|
|
263
|
-
meta: () => meta,
|
|
264
|
-
lines: () => [`rendering ${meta}`],
|
|
265
|
-
}),
|
|
266
|
-
);
|
|
267
|
-
arm();
|
|
268
|
-
renderSidebar();
|
|
269
|
-
|
|
270
|
-
const before = rail()?.digest?.();
|
|
271
|
-
assert.equal(typeof before, "string");
|
|
272
|
-
assert.ok((rowOf("default") ?? "").includes("default"), "the header carries the count");
|
|
273
|
-
|
|
274
|
-
meta = "tool";
|
|
275
|
-
|
|
276
|
-
const after = rail()?.digest?.();
|
|
277
|
-
assert.notEqual(after, before, "the digest notices the change, so the host re-renders");
|
|
278
|
-
assert.ok((rowOf("tool") ?? "").includes("tool"), "and the field is live, with no re-registration");
|
|
279
|
-
assert.ok((rowOf("rendering tool") ?? "").length > 0, "a plain row is rebuilt from the same call");
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("the hover claim is the host's own pointer slot, and the host can take it back", () => {
|
|
283
|
-
setSectionTheme(fakeTheme());
|
|
284
|
-
registerSection(
|
|
285
|
-
section("music", {
|
|
286
|
-
title: "MUSIC",
|
|
287
|
-
lines: () => [{ label: "play", onClick: () => {} }],
|
|
288
|
-
}),
|
|
289
|
-
);
|
|
290
|
-
arm();
|
|
291
|
-
renderSidebar();
|
|
292
|
-
|
|
293
|
-
move(NATIVE_ROWS + 1);
|
|
294
|
-
const hover = (fakeState() as unknown as { hover?: { owner: object; clear: () => void } }).hover;
|
|
295
|
-
assert.ok(hover !== undefined, "the shared pointer slot carries our claim");
|
|
296
|
-
assert.equal(typeof hover.clear, "function");
|
|
297
|
-
|
|
298
|
-
// What the host's fallback mouse handler does when the pointer leaves the rail
|
|
299
|
-
// (`lib/shell-sidebar-layout.ts:249-252`): `setSidebarHover(tui)` with no owner.
|
|
300
|
-
renders = 0;
|
|
301
|
-
delete (fakeState() as unknown as { hover?: unknown }).hover;
|
|
302
|
-
hover.clear();
|
|
303
|
-
|
|
304
|
-
assert.equal(renders, 1, "the clear repaints once");
|
|
305
|
-
assert.ok(!(rowOf("play") ?? "").includes("\u001b[33m"), "and the row is idle again");
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test("a pointer event for a row we did not draw is declined, never guessed", () => {
|
|
309
|
-
const calls: string[] = [];
|
|
310
|
-
registerSection(
|
|
311
|
-
section("music", {
|
|
312
|
-
title: "MUSIC",
|
|
313
|
-
lines: () => [{ label: "play", onClick: () => calls.push("play") }],
|
|
314
|
-
}),
|
|
315
|
-
);
|
|
316
|
-
arm();
|
|
317
|
-
renderSidebar();
|
|
318
|
-
|
|
319
|
-
assert.equal(click(1), undefined, "a native row is not ours");
|
|
320
|
-
assert.equal(click(NATIVE_ROWS + 9), undefined, "a row past the contribution is not ours");
|
|
321
|
-
assert.equal(
|
|
322
|
-
rail()?.handleMouse?.({ type: "click", button: "left", x: 4, y: NATIVE_ROWS + 1 }),
|
|
323
|
-
undefined,
|
|
324
|
-
"without a frame width the offset cannot be mapped",
|
|
325
|
-
);
|
|
326
|
-
assert.deepEqual(calls, []);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
test("a throwing onClick is recorded, not thrown into the host's dispatch", () => {
|
|
330
|
-
registerSection(
|
|
331
|
-
section("music", {
|
|
332
|
-
title: "MUSIC",
|
|
333
|
-
lines: () => [
|
|
334
|
-
{
|
|
335
|
-
label: "boom",
|
|
336
|
-
onClick: () => {
|
|
337
|
-
throw new Error("onClick exploded");
|
|
338
|
-
},
|
|
339
|
-
},
|
|
340
|
-
],
|
|
341
|
-
}),
|
|
342
|
-
);
|
|
343
|
-
arm();
|
|
344
|
-
renderSidebar();
|
|
345
|
-
|
|
346
|
-
assert.deepEqual(click(NATIVE_ROWS + 1), { handled: true, render: true });
|
|
347
|
-
const failure = getSectionFailures().find((entry) => entry.sectionId === "music");
|
|
348
|
-
assert.equal(failure?.phase, "onClick");
|
|
349
|
-
assert.equal(failure?.message, "onClick exploded");
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
test("a structured row is still capped and elided like a plain one", () => {
|
|
353
|
-
const rows: SectionRow[] = Array.from({ length: 12 }, (_, index) => ({
|
|
354
|
-
label: `row ${index + 1}`,
|
|
355
|
-
meta: `${index}`,
|
|
356
|
-
}));
|
|
357
|
-
registerSection(section("many", { title: "MANY", lines: () => rows }));
|
|
358
|
-
arm();
|
|
359
|
-
renderSidebar();
|
|
360
|
-
|
|
361
|
-
const body = cardBody("MANY");
|
|
362
|
-
assert.equal(body.length, 8);
|
|
363
|
-
assert.equal(body.at(-1), "… +5 more");
|
|
364
|
-
});
|