magus-cli 7.3.1 → 7.4.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.
Files changed (123) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Blind tests — contract C5 (requirements R5.1–R5.8, R4.6, R9.4, R10.6).
3
+ *
4
+ * Written from requirements.md and architecture.md only. The Screen list and
5
+ * the width set are taken from the contract, not from the module, so the
6
+ * generated test names exist even while the module does not.
7
+ */
8
+ import { describe, expect, test } from "bun:test";
9
+ import { readFileSync, readdirSync, statSync } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const loadKeymap = () => import("../../ui/keymap.js");
13
+ const loadTabBar = () => import("../../ui/components/TabBar.js");
14
+
15
+ const SCREENS = [
16
+ "plugins",
17
+ "mcp",
18
+ "mcp-registry",
19
+ "settings",
20
+ "cli-tools",
21
+ "profiles",
22
+ "skills",
23
+ "styles",
24
+ "gitignore",
25
+ "alias",
26
+ "models",
27
+ ] as const;
28
+ type Screen = (typeof SCREENS)[number];
29
+ const TAB_SCREENS = SCREENS.filter((s) => s !== "mcp-registry");
30
+ const WIDTHS = [40, 60, 80, 100, 200] as const;
31
+ /** Wide enough that nothing is dropped for lack of room. */
32
+ const UNLIMITED = 10_000;
33
+
34
+ interface Binding {
35
+ id: string;
36
+ keys: readonly string[];
37
+ label: string;
38
+ description: string;
39
+ priority: number;
40
+ }
41
+ type Keymap = Awaited<ReturnType<typeof loadKeymap>>;
42
+
43
+ const sig = (b: Binding) => JSON.stringify([b.id, [...b.keys], b.label]);
44
+ const show = (b: Binding) => `${b.keys.join("/")} ${b.label} (p${b.priority})`;
45
+
46
+ function helpBinding(km: Keymap): Binding {
47
+ const help = (km.GLOBAL_BINDINGS as readonly Binding[]).find((b) =>
48
+ b.keys.includes("?"),
49
+ );
50
+ if (!help) throw new Error('GLOBAL_BINDINGS has no binding with key "?"');
51
+ return help;
52
+ }
53
+
54
+ /** Normalise a displayed key so "PgUp", "PageUp", "Ctrl+U" and "^U" compare. */
55
+ function normKey(k: string): string {
56
+ const s = k.trim().toLowerCase().replace(/\s+/g, "");
57
+ const ctrl = s.replace(/^(ctrl\+|ctrl-|c-)/, "^");
58
+ if (ctrl === "pageup") return "pgup";
59
+ if (ctrl === "pagedown") return "pgdn";
60
+ return ctrl;
61
+ }
62
+ const splitKeys = (keys: readonly string[]) =>
63
+ keys.flatMap((k) => (k.length > 1 ? k.split("/") : [k])).map(normKey);
64
+ const SCROLL_KEY_NAMES = new Set(["pgup", "pgdn", "^u", "^d"]);
65
+
66
+ describe("C5 — the keymap lists every binding (R5.1)", () => {
67
+ test("R5.1 SCREEN_BINDINGS has exactly one entry per Screen (10 tabs + mcp-registry)", async () => {
68
+ const km = await loadKeymap();
69
+ expect(Object.keys(km.SCREEN_BINDINGS).sort()).toEqual([...SCREENS].sort());
70
+ for (const s of SCREENS) {
71
+ expect(Array.isArray(km.SCREEN_BINDINGS[s as Screen])).toBe(true);
72
+ }
73
+ });
74
+
75
+ test("R5.1 every binding is well-formed: id, displayed keys, label, description, finite priority", async () => {
76
+ const km = await loadKeymap();
77
+ const groups: [string, readonly Binding[]][] = [
78
+ ["GLOBAL", km.GLOBAL_BINDINGS],
79
+ ["SCROLL", km.SCROLL_BINDINGS],
80
+ ...SCREENS.map(
81
+ (s) =>
82
+ [
83
+ s,
84
+ (km.SCREEN_BINDINGS[s as Screen] ?? []) as readonly Binding[],
85
+ ] as [string, readonly Binding[]],
86
+ ),
87
+ ];
88
+ const bad: string[] = [];
89
+ for (const [group, bindings] of groups) {
90
+ for (const b of bindings) {
91
+ const ok =
92
+ typeof b.id === "string" &&
93
+ b.id.length > 0 &&
94
+ Array.isArray(b.keys) &&
95
+ b.keys.length > 0 &&
96
+ b.keys.every((k) => typeof k === "string" && k.length > 0) &&
97
+ typeof b.label === "string" &&
98
+ b.label.length > 0 &&
99
+ typeof b.description === "string" &&
100
+ b.description.length > 0 &&
101
+ Number.isFinite(b.priority);
102
+ if (!ok) bad.push(`${group}: ${JSON.stringify(b)}`);
103
+ }
104
+ }
105
+ expect(bad).toEqual([]);
106
+ });
107
+
108
+ test('R5.1 GLOBAL_BINDINGS includes a "?" binding and a "q" binding', async () => {
109
+ const km = await loadKeymap();
110
+ const all = (km.GLOBAL_BINDINGS as readonly Binding[]).flatMap((b) => [
111
+ ...b.keys,
112
+ ]);
113
+ expect(all).toContain("?");
114
+ expect(all).toContain("q");
115
+ });
116
+
117
+ test("R5.4 the global ? binding is the `help` hint", async () => {
118
+ const km = await loadKeymap();
119
+ expect(helpBinding(km).label.toLowerCase()).toContain("help");
120
+ });
121
+
122
+ test("R5.1 SCROLL_BINDINGS cover PgUp, PgDn, ^U and ^D", async () => {
123
+ const km = await loadKeymap();
124
+ const keys = new Set(
125
+ (km.SCROLL_BINDINGS as readonly Binding[]).flatMap((b) =>
126
+ splitKeys(b.keys),
127
+ ),
128
+ );
129
+ for (const k of SCROLL_KEY_NAMES) expect(keys.has(k)).toBe(true);
130
+ });
131
+
132
+ test("R5.1 binding ids are unique within GLOBAL_BINDINGS and within SCROLL_BINDINGS", async () => {
133
+ const km = await loadKeymap();
134
+ for (const group of [km.GLOBAL_BINDINGS, km.SCROLL_BINDINGS]) {
135
+ const ids = (group as readonly Binding[]).map((b) => b.id);
136
+ expect(new Set(ids).size).toBe(ids.length);
137
+ }
138
+ });
139
+ });
140
+
141
+ describe("C5 — tab keys come from TABS (R5.7)", () => {
142
+ test("R5.7 TABS lists the 10 tab screens exactly once each (mcp-registry has no tab)", async () => {
143
+ const { TABS } = await loadTabBar();
144
+ const screens = TABS.map((t: { screen: string }) => t.screen);
145
+ expect([...screens].sort()).toEqual([...TAB_SCREENS].sort());
146
+ });
147
+
148
+ test("R5.7 every tab key is a single digit, and no two tabs share one", async () => {
149
+ const { TABS } = await loadTabBar();
150
+ const keys = TABS.map((t: { key: string }) => t.key);
151
+ for (const k of keys) expect(k).toMatch(/^[0-9]$/);
152
+ expect(new Set(keys).size).toBe(keys.length);
153
+ });
154
+
155
+ for (const screen of TAB_SCREENS) {
156
+ test(`R5.7 tabKey("${screen}") is that screen's key from TABS`, async () => {
157
+ const km = await loadKeymap();
158
+ const { TABS } = await loadTabBar();
159
+ const tab = TABS.find((t: { screen: string }) => t.screen === screen);
160
+ expect(tab).toBeDefined();
161
+ expect(km.tabKey(screen)).toBe(tab?.key);
162
+ });
163
+ }
164
+
165
+ test('R5.7 tabKey("mcp-registry") is null (a sub-screen with no tab)', async () => {
166
+ const km = await loadKeymap();
167
+ expect(km.tabKey("mcp-registry")).toBeNull();
168
+ });
169
+
170
+ test("R5.7/R9.4 no UI text under src/ui types a tab number (e.g. `Press 5`)", () => {
171
+ // Static scan of source text. Comment lines are skipped; a template such
172
+ // as `Press ${tabKey("profiles")}` does not match.
173
+ const uiDir = path.resolve(import.meta.dir, "../../ui");
174
+ const offenders: string[] = [];
175
+ const pattern = /\b(press|hit|type)\s+[0-9]\b/i;
176
+ for (const file of walk(uiDir)) {
177
+ const lines = readFileSync(file, "utf8").split("\n");
178
+ lines.forEach((line, i) => {
179
+ if (isCommentLine(line)) return;
180
+ if (pattern.test(line)) {
181
+ offenders.push(
182
+ `${path.relative(uiDir, file)}:${i + 1}: ${line.trim()}`,
183
+ );
184
+ }
185
+ });
186
+ }
187
+ expect(offenders).toEqual([]);
188
+ });
189
+ });
190
+
191
+ for (const screen of SCREENS) {
192
+ describe(`C5 — ${screen} bindings (R5.3, R4.6)`, () => {
193
+ test(`R5.3 ${screen}: no two bindings claim the same key`, async () => {
194
+ const km = await loadKeymap();
195
+ const keys = (km.SCREEN_BINDINGS[screen] as readonly Binding[]).flatMap(
196
+ (b) => [...b.keys],
197
+ );
198
+ const dupes = keys.filter((k, i) => keys.indexOf(k) !== i);
199
+ expect(dupes).toEqual([]);
200
+ });
201
+
202
+ test(`R5.3 ${screen}: binding ids are unique`, async () => {
203
+ const km = await loadKeymap();
204
+ const ids = (km.SCREEN_BINDINGS[screen] as readonly Binding[]).map(
205
+ (b) => b.id,
206
+ );
207
+ expect(new Set(ids).size).toBe(ids.length);
208
+ });
209
+
210
+ test(`R5.3 ${screen}: no binding claims a digit, q or ?`, async () => {
211
+ const km = await loadKeymap();
212
+ const claimed = (
213
+ km.SCREEN_BINDINGS[screen] as readonly Binding[]
214
+ ).flatMap((b) =>
215
+ b.keys
216
+ .filter((k) => /^[0-9]$/.test(k) || k === "q" || k === "?")
217
+ .map((k) => `${b.id}: ${k}`),
218
+ );
219
+ expect(claimed).toEqual([]);
220
+ });
221
+
222
+ test(`R4.6 ${screen}: no binding claims a scroll key (PgUp, PgDn, ^U, ^D)`, async () => {
223
+ // The screen layer sits above scroll; a screen binding on a scroll key
224
+ // would stop the panel scrolling on this screen.
225
+ const km = await loadKeymap();
226
+ const claimed = (
227
+ km.SCREEN_BINDINGS[screen] as readonly Binding[]
228
+ ).flatMap((b) =>
229
+ splitKeys(b.keys)
230
+ .filter((k) => SCROLL_KEY_NAMES.has(k))
231
+ .map((k) => `${b.id}: ${k}`),
232
+ );
233
+ expect(claimed).toEqual([]);
234
+ });
235
+ });
236
+
237
+ describe(`C5 — footerHints("${screen}", width) (R5.2, R5.4, R5.5)`, () => {
238
+ for (const width of WIDTHS) {
239
+ test(`R5.5 ${screen} @${width}: footerWidth(hints) <= ${width}`, async () => {
240
+ const km = await loadKeymap();
241
+ const hints = km.footerHints(screen, width);
242
+ expect(km.footerWidth(hints)).toBeLessThanOrEqual(width);
243
+ });
244
+
245
+ test(`R5.4 ${screen} @${width}: the footer shows the "? help" hint`, async () => {
246
+ const km = await loadKeymap();
247
+ const help = helpBinding(km);
248
+ // The contract keeps "?" whenever the width allows it.
249
+ expect(km.footerWidth([help])).toBeLessThanOrEqual(width);
250
+ const hints = km.footerHints(screen, width) as Binding[];
251
+ expect(hints.map(sig)).toContain(sig(help));
252
+ });
253
+
254
+ test(`R5.2 ${screen} @${width}: every hint comes from the keymap (screen ∪ scroll ∪ global), once`, async () => {
255
+ const km = await loadKeymap();
256
+ const allowed = new Set(
257
+ [
258
+ ...(km.SCREEN_BINDINGS[screen] as readonly Binding[]),
259
+ ...(km.SCROLL_BINDINGS as readonly Binding[]),
260
+ ...(km.GLOBAL_BINDINGS as readonly Binding[]),
261
+ ].map(sig),
262
+ );
263
+ const hints = (km.footerHints(screen, width) as Binding[]).map(sig);
264
+ expect(hints.filter((h) => !allowed.has(h))).toEqual([]);
265
+ expect(new Set(hints).size).toBe(hints.length);
266
+ });
267
+
268
+ test(`R5.5 ${screen} @${width}: never keeps a lower-priority hint while dropping a higher one`, async () => {
269
+ const km = await loadKeymap();
270
+ const help = sig(helpBinding(km));
271
+ const pool = km.footerHints(screen, UNLIMITED) as Binding[];
272
+ const kept = km.footerHints(screen, width) as Binding[];
273
+ const keptSigs = new Set(kept.map(sig));
274
+ const dropped = pool.filter((b) => !keptSigs.has(sig(b)));
275
+ // "?" is pinned by R5.4, so it is exempt from the ordering.
276
+ const ranked = kept.filter((b) => sig(b) !== help);
277
+ const violations: string[] = [];
278
+ for (const d of dropped) {
279
+ for (const k of ranked) {
280
+ if (k.priority < d.priority) {
281
+ violations.push(`kept ${show(k)} but dropped ${show(d)}`);
282
+ }
283
+ }
284
+ }
285
+ expect(violations).toEqual([]);
286
+ });
287
+
288
+ test(`R5.5 ${screen} @${width}: shows as many hints as fit (the next-priority hint does not fit)`, async () => {
289
+ const km = await loadKeymap();
290
+ const pool = km.footerHints(screen, UNLIMITED) as Binding[];
291
+ const kept = km.footerHints(screen, width) as Binding[];
292
+ const keptSigs = new Set(kept.map(sig));
293
+ const dropped = pool.filter((b) => !keptSigs.has(sig(b)));
294
+ if (dropped.length === 0) return; // everything fits — nothing to check
295
+ const top = Math.max(...dropped.map((b) => b.priority));
296
+ const next = dropped.filter((b) => b.priority === top);
297
+ const fitting = next.filter(
298
+ (d) => km.footerWidth([...kept, d]) <= width,
299
+ );
300
+ // At least one of the next-highest candidates must be too wide,
301
+ // otherwise the footer dropped a hint it had room for.
302
+ expect(fitting.length).toBeLessThan(next.length);
303
+ });
304
+ }
305
+
306
+ test(`R5.5 ${screen}: a narrower footer keeps a subset of a wider one`, async () => {
307
+ const km = await loadKeymap();
308
+ const sets = WIDTHS.map(
309
+ (w) => new Set((km.footerHints(screen, w) as Binding[]).map(sig)),
310
+ );
311
+ const leaks: string[] = [];
312
+ for (let i = 0; i + 1 < sets.length; i++) {
313
+ for (const s of sets[i] as Set<string>) {
314
+ if (!(sets[i + 1] as Set<string>).has(s)) {
315
+ leaks.push(`@${WIDTHS[i]} has ${s}, @${WIDTHS[i + 1]} does not`);
316
+ }
317
+ }
318
+ }
319
+ expect(leaks).toEqual([]);
320
+ });
321
+ });
322
+
323
+ describe(`C5 — helpSections("${screen}") (R5.2, R5.6)`, () => {
324
+ test(`R5.6 helpSections("${screen}"): the first section is ${screen}`, async () => {
325
+ const km = await loadKeymap();
326
+ const sections = km.helpSections(screen);
327
+ expect(sections.length).toBeGreaterThan(0);
328
+ expect(sections[0]?.screen).toBe(screen);
329
+ });
330
+
331
+ test(`R5.6 helpSections("${screen}"): every Screen appears in exactly one section`, async () => {
332
+ const km = await loadKeymap();
333
+ const sections = km.helpSections(screen);
334
+ const counts = Object.fromEntries(
335
+ SCREENS.map((s) => [s, sections.filter((x) => x.screen === s).length]),
336
+ );
337
+ expect(counts).toEqual(Object.fromEntries(SCREENS.map((s) => [s, 1])));
338
+ });
339
+
340
+ test(`R5.6 helpSections("${screen}"): global and scroll keys have their own sections, after the screens`, async () => {
341
+ const km = await loadKeymap();
342
+ const sections = km.helpSections(screen);
343
+ const has = (
344
+ s: { bindings: readonly Binding[] },
345
+ group: readonly Binding[],
346
+ ) => {
347
+ const sigs = new Set(s.bindings.map(sig));
348
+ return group.length > 0 && group.every((b) => sigs.has(sig(b)));
349
+ };
350
+ const g = sections.findIndex(
351
+ (s) =>
352
+ s.screen === undefined &&
353
+ has(s, km.GLOBAL_BINDINGS as readonly Binding[]),
354
+ );
355
+ const sc = sections.findIndex(
356
+ (s) =>
357
+ s.screen === undefined &&
358
+ has(s, km.SCROLL_BINDINGS as readonly Binding[]),
359
+ );
360
+ expect(g).toBeGreaterThanOrEqual(0);
361
+ expect(sc).toBeGreaterThanOrEqual(0);
362
+ expect(g).not.toBe(sc);
363
+ const lastScreen = Math.max(
364
+ ...sections.map((s, i) => (s.screen !== undefined ? i : -1)),
365
+ );
366
+ expect(lastScreen).toBeLessThan(Math.min(g, sc));
367
+ });
368
+
369
+ test(`R5.2 helpSections("${screen}"): each screen section lists exactly that screen's keymap bindings`, async () => {
370
+ const km = await loadKeymap();
371
+ const mismatched: string[] = [];
372
+ for (const s of km.helpSections(screen)) {
373
+ if (s.screen === undefined) continue;
374
+ const want = (
375
+ km.SCREEN_BINDINGS[s.screen as Screen] as readonly Binding[]
376
+ )
377
+ .map(sig)
378
+ .sort();
379
+ const got = (s.bindings as readonly Binding[]).map(sig).sort();
380
+ if (JSON.stringify(want) !== JSON.stringify(got))
381
+ mismatched.push(s.screen);
382
+ }
383
+ expect(mismatched).toEqual([]);
384
+ });
385
+
386
+ test(`R5.6 helpSections("${screen}"): every section has a title`, async () => {
387
+ const km = await loadKeymap();
388
+ const untitled = km
389
+ .helpSections(screen)
390
+ .filter((s) => typeof s.title !== "string" || s.title.trim() === "");
391
+ expect(untitled).toEqual([]);
392
+ });
393
+ });
394
+ }
395
+
396
+ describe("C5 — help legend (R5.8, R10.6)", () => {
397
+ // Requirement R10.6 lists these symbols; each must be explained by the
398
+ // legend of whichever screen uses it.
399
+ const SYMBOLS = ["■", "□", "⚙", "[↓]", "[–]", "★", "☆", "●", "○"];
400
+
401
+ for (const symbol of SYMBOLS) {
402
+ test(`R10.6 the help legend explains ${symbol}`, async () => {
403
+ const km = await loadKeymap();
404
+ const entries = km
405
+ .helpSections("plugins")
406
+ .flatMap((s) => [...(s.legend ?? [])]);
407
+ const match = entries.find((e) => e.symbol.includes(symbol));
408
+ expect(match).toBeDefined();
409
+ expect(match?.meaning.trim().length ?? 0).toBeGreaterThan(0);
410
+ });
411
+ }
412
+
413
+ test("R5.8 legends are attached to screen sections, not to the global or scroll sections", async () => {
414
+ const km = await loadKeymap();
415
+ const stray = km
416
+ .helpSections("plugins")
417
+ .filter((s) => s.screen === undefined && (s.legend?.length ?? 0) > 0)
418
+ .map((s) => s.title);
419
+ expect(stray).toEqual([]);
420
+ });
421
+ });
422
+
423
+ describe("C5 — footerWidth", () => {
424
+ test("R5.5 footerWidth of one hint covers at least its key and label", async () => {
425
+ const km = await loadKeymap();
426
+ const help = helpBinding(km);
427
+ expect(km.footerWidth([help])).toBeGreaterThanOrEqual(
428
+ (help.keys[0] as string).length + 1 + help.label.length,
429
+ );
430
+ });
431
+
432
+ test("R5.5 footerWidth grows when a hint is added", async () => {
433
+ const km = await loadKeymap();
434
+ const [a, b] = km.GLOBAL_BINDINGS as readonly Binding[];
435
+ expect(a).toBeDefined();
436
+ expect(b).toBeDefined();
437
+ const both = km.footerWidth([a as Binding, b as Binding]);
438
+ expect(both).toBeGreaterThan(km.footerWidth([a as Binding]));
439
+ expect(both).toBeGreaterThan(km.footerWidth([b as Binding]));
440
+ });
441
+ });
442
+
443
+ function walk(dir: string): string[] {
444
+ const out: string[] = [];
445
+ for (const name of readdirSync(dir)) {
446
+ const full = path.join(dir, name);
447
+ if (statSync(full).isDirectory()) out.push(...walk(full));
448
+ else if (/\.(ts|tsx)$/.test(name)) out.push(full);
449
+ }
450
+ return out;
451
+ }
452
+
453
+ function isCommentLine(line: string): boolean {
454
+ const t = line.trim();
455
+ return (
456
+ t.startsWith("//") ||
457
+ t.startsWith("*") ||
458
+ t.startsWith("/*") ||
459
+ t.startsWith("{/*")
460
+ );
461
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Blind tests — contract C6 (requirement R6.1).
3
+ *
4
+ * Written from requirements.md and architecture.md only. The race scenarios
5
+ * use hand-resolved promises, not timers, so the finishing order is exact.
6
+ */
7
+ import { describe, expect, test } from "bun:test";
8
+
9
+ const load = () => import("../../utils/request-gate.js");
10
+
11
+ function deferred<T>() {
12
+ let resolve!: (v: T) => void;
13
+ const promise = new Promise<T>((r) => {
14
+ resolve = r;
15
+ });
16
+ return { promise, resolve };
17
+ }
18
+
19
+ describe("C6 — createRequestGate tokens", () => {
20
+ test("R6.1 a load that is the only one started is current", async () => {
21
+ const { createRequestGate } = await load();
22
+ const gate = createRequestGate();
23
+ const t = gate.begin();
24
+ expect(gate.isCurrent(t)).toBe(true);
25
+ });
26
+
27
+ test("R6.1 starting a second load makes the first one not current", async () => {
28
+ const { createRequestGate } = await load();
29
+ const gate = createRequestGate();
30
+ const first = gate.begin();
31
+ const second = gate.begin();
32
+ expect(gate.isCurrent(first)).toBe(false);
33
+ expect(gate.isCurrent(second)).toBe(true);
34
+ });
35
+
36
+ test("R6.1 each begin() returns a new token", async () => {
37
+ const { createRequestGate } = await load();
38
+ const gate = createRequestGate();
39
+ const tokens = Array.from({ length: 50 }, () => gate.begin());
40
+ expect(new Set(tokens).size).toBe(tokens.length);
41
+ });
42
+
43
+ test("R6.1 after many loads start, only the most recent one is current", async () => {
44
+ const { createRequestGate } = await load();
45
+ const gate = createRequestGate();
46
+ const tokens = Array.from({ length: 20 }, () => gate.begin());
47
+ const current = tokens.filter((t) => gate.isCurrent(t));
48
+ expect(current).toEqual([tokens[tokens.length - 1] as number]);
49
+ });
50
+
51
+ test("R6.1 asking isCurrent does not change which token is current", async () => {
52
+ const { createRequestGate } = await load();
53
+ const gate = createRequestGate();
54
+ const a = gate.begin();
55
+ const b = gate.begin();
56
+ for (let i = 0; i < 3; i++) {
57
+ expect(gate.isCurrent(a)).toBe(false);
58
+ expect(gate.isCurrent(b)).toBe(true);
59
+ }
60
+ });
61
+
62
+ test("R6.1 two gates are independent", async () => {
63
+ const { createRequestGate } = await load();
64
+ const plugins = createRequestGate();
65
+ const other = createRequestGate();
66
+ const p = plugins.begin();
67
+ other.begin();
68
+ other.begin();
69
+ expect(plugins.isCurrent(p)).toBe(true);
70
+ });
71
+ });
72
+
73
+ describe("C6 — the latest Plugins load wins, whichever finishes last", () => {
74
+ /** A screen that applies a load's result only if its token is still current. */
75
+ async function race(order: "older-finishes-last" | "older-finishes-first") {
76
+ const { createRequestGate } = await load();
77
+ const gate = createRequestGate();
78
+ const applied: string[] = [];
79
+
80
+ const older = deferred<string>();
81
+ const newer = deferred<string>();
82
+
83
+ const start = (result: Promise<string>) => {
84
+ const token = gate.begin();
85
+ return result.then((value) => {
86
+ if (gate.isCurrent(token)) applied.push(value);
87
+ });
88
+ };
89
+
90
+ const olderDone = start(older.promise);
91
+ const newerDone = start(newer.promise);
92
+
93
+ if (order === "older-finishes-last") {
94
+ newer.resolve("newer");
95
+ await newerDone;
96
+ older.resolve("older");
97
+ await olderDone;
98
+ } else {
99
+ older.resolve("older");
100
+ await olderDone;
101
+ newer.resolve("newer");
102
+ await newerDone;
103
+ }
104
+ return applied;
105
+ }
106
+
107
+ test("R6.1 an earlier load that finishes AFTER the later one is discarded", async () => {
108
+ expect(await race("older-finishes-last")).toEqual(["newer"]);
109
+ });
110
+
111
+ test("R6.1 an earlier load that finishes BEFORE the later one is discarded too", async () => {
112
+ expect(await race("older-finishes-first")).toEqual(["newer"]);
113
+ });
114
+
115
+ test("R6.1 three overlapping loads: only the last started updates the screen", async () => {
116
+ const { createRequestGate } = await load();
117
+ const gate = createRequestGate();
118
+ const applied: number[] = [];
119
+ const ds = [deferred<number>(), deferred<number>(), deferred<number>()];
120
+ const done = ds.map((d) => {
121
+ const token = gate.begin();
122
+ return d.promise.then((v) => {
123
+ if (gate.isCurrent(token)) applied.push(v);
124
+ });
125
+ });
126
+ // Finish in a scrambled order: middle, last, first.
127
+ for (const i of [1, 2, 0]) {
128
+ ds[i]?.resolve(i);
129
+ await done[i];
130
+ }
131
+ expect(applied).toEqual([2]);
132
+ });
133
+ });