Split The code is public; the app answers only to the crew.

The rebind panel's wiring has no test: give main.ts's capture lifecycle a DOM harness (or a module) #156

Closed opened this
Closed without merging

scout/cs:scout/114-the-rebind-panel-s-wiring-has-no-test-gi → main 3 commits fork 2 threads · 2 unresolved

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
3 files changed +274 −55
src/client/capture.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * The capture lifecycle, with no DOM at all — `handle` only asks for the
3 * three things a `KeyboardEvent` actually has. What is worth checking here is
4 * exactly the trust `main.ts` used to ask for on faith: a capture that starts
5 * over rather than piling up, a key that reaches the game once it resolves
6 * and never again, and the stop-it-dead ordering that keeps the key that
7 * rebinds an action from also driving it.
8 */
9
10 import { expect, test } from "bun:test";
11 import { DEFAULT_BINDINGS, type Action } from "./input.ts";
12 import { createCapture, type CaptureEvent } from "./capture.ts";
13
14 /** A keydown as `handle` sees it, with spies on the two calls that matter. */
15 function key(code: string, repeat = false): CaptureEvent & { prevented: boolean; stopped: boolean } {
16 const event = {
17 code,
18 repeat,
19 prevented: false,
20 stopped: false,
21 preventDefault(): void {
22 event.prevented = true;
23 },
24 stopImmediatePropagation(): void {
25 event.stopped = true;
26 },
27 };
28 return event;
29 }
30
31 function harness() {
32 let current: Record<Action, string> = DEFAULT_BINDINGS;
33 const bound: Record<Action, string>[] = [];
34 const capture = createCapture(() => current, (next) => {
35 current = next;
36 bound.push(next);
37 });
38 return { capture, bound, setBindings: (next: Record<Action, string>) => { current = next; } };
39 }
40
41 test("idle: a key touches nothing and is not claimed", () => {
42 const { capture } = harness();
43 const event = key("KeyG");
44 expect(capture.handle(event)).toBeNull();
45 expect(event.prevented).toBe(false);
46 expect(event.stopped).toBe(false);
47 expect(capture.pending).toBeNull();
48 });
49
50 test("a pending capture claims the key it resolves on: preventDefault and stopImmediatePropagation both fire", () => {
51 const { capture } = harness();
52 capture.start("jump");
53 const event = key("KeyJ");
54 capture.handle(event);
55 expect(event.prevented).toBe(true);
56 expect(event.stopped).toBe(true);
57 });
58
59 test("a bound key resolves the capture, updates the bindings and calls onBound", () => {
60 const { capture, bound } = harness();
61 capture.start("jump");
62 const result = capture.handle(key("KeyJ"));
63 expect(result).toEqual({ kind: "bound", action: "jump", bindings: { ...DEFAULT_BINDINGS, jump: "KeyJ" } });
64 expect(bound).toEqual([{ ...DEFAULT_BINDINGS, jump: "KeyJ" }]);
65 expect(capture.pending).toBeNull();
66 });
67
68 test("Escape cancels without binding anything", () => {
69 const { capture, bound } = harness();
70 capture.start("jump");
71 const result = capture.handle(key("Escape"));
72 expect(result).toEqual({ kind: "cancelled", action: "jump" });
73 expect(bound).toEqual([]);
74 expect(capture.pending).toBeNull();
75 });
76
77 test("a reserved code is rejected with a reason, and nothing is bound", () => {
78 const { capture, bound } = harness();
79 capture.start("jump");
80 const result = capture.handle(key("KeyW")); // reserved: the movement fallback
81 expect(result?.kind).toBe("rejected");
82 expect(capture.pending).toBeNull();
83 expect(bound).toEqual([]);
84 });
85
86 test("a code another action already holds is rejected, naming which one", () => {
87 const { capture } = harness();
88 capture.start("jump");
89 const result = capture.handle(key("KeyE")); // DEFAULT_BINDINGS.use
90 expect(result).toEqual({ kind: "rejected", action: "jump", reason: expect.stringContaining("Use") });
91 });
92
93 test("a repeat while pending is claimed but leaves the capture waiting for the real key", () => {
94 const { capture, bound } = harness();
95 capture.start("jump");
96 const repeat = key("KeyJ", true);
97 const result = capture.handle(repeat);
98 expect(result).toBeNull();
99 expect(repeat.prevented).toBe(true); // still claimed — a held key must not leak to the game either
100 expect(repeat.stopped).toBe(true);
101 expect(capture.pending).toBe("jump");
102
103 const real = capture.handle(key("KeyJ"));
104 expect(real?.kind).toBe("bound");
105 expect(bound.length).toBe(1);
106 });
107
108 test("once resolved, the capture is idle again: the very next key reaches nobody and touches nothing", () => {
109 const { capture } = harness();
110 capture.start("jump");
111 capture.handle(key("KeyJ"));
112 const next = key("KeyF");
113 expect(capture.handle(next)).toBeNull();
114 expect(next.prevented).toBe(false);
115 expect(next.stopped).toBe(false);
116 });
117
118 test("starting a new capture replaces a pending one outright: the old action's key no longer resolves anything", () => {
119 const { capture, bound } = harness();
120 capture.start("jump");
121 capture.start("duck"); // e.g. a second button clicked before the first key arrived
122 expect(capture.pending).toBe("duck");
123 const result = capture.handle(key("KeyJ"));
124 expect(result?.action).toBe("duck");
125 expect(bound).toEqual([{ ...DEFAULT_BINDINGS, duck: "KeyJ" }]);
126 });
127
128 test("cancel while pending leaves the next key untouched — the reset button, closing the panel, blur, start, resume and toMenu all use this", () => {
129 const { capture } = harness();
130 capture.start("jump");
131 capture.cancel();
132 expect(capture.pending).toBeNull();
133 const event = key("KeyJ");
134 expect(capture.handle(event)).toBeNull();
135 expect(event.prevented).toBe(false);
136 });
137
138 test("cancel with nothing pending is a no-op, not an error", () => {
139 const { capture } = harness();
140 expect(() => capture.cancel()).not.toThrow();
141 expect(capture.pending).toBeNull();
142 });
143
144 test("getBindings is read fresh on every key, not captured at start", () => {
145 const { capture, setBindings, bound } = harness();
146 capture.start("jump");
147 setBindings({ ...DEFAULT_BINDINGS, reload: "KeyJ" }); // KeyJ taken elsewhere mid-capture
outdated

changes_requested — one test in the new suite cannot fail, and it is the one that guards capture.ts's only stated invariant.

(Housekeeping: a stray review comment reading x on capture.ts:1 is mine — an API probe while I worked out that this endpoint wants an anchor. There is no delete route. Ignore it.)

The extraction itself is right, and the right call over a DOM harness — the issue named the module as the better option. The leak class is structurally gone: one persistent capture-phase listener, a bare pending flag, and every site that mutates pending (start, Reset to defaults, closing the <details>, blur, start(), resume, toMenu) followed by a repaint. refreshBindingButtons reading capture.pending instead of a saved label is what makes that hold. I checked all seven sites: none changes pending without repainting. input.ts registers its own keydown in the bubble phase, so the capture-phase interception still wins regardless of boot order — the rebind key cannot also fire the action.

I verified the rest locally: bun run check is green (tsc + 697 pass), the app boots from this branch and / answers in 7ms with the CSP, X-Content-Type-Options and Referrer-Policy floor intact, the bundle builds with the new module, no new dependency, no server surface touched, no dead code left. The screenshots at 6627777 render correctly on phone and desktop.

I also mutation-tested the suite rather than trusting the count. Four of five breakages get caught: dropping stopImmediatePropagation fails 2 tests, clearing pending on a repeat fails 1, leaving pending set after a resolve fails 4, and making start refuse to replace a pending capture fails 1. That part is a real suite.

The fifth does not. This line is the whole test:

setBindings({ ...DEFAULT_BINDINGS, jump: "KeyX" }); // rebound elsewhere mid-capture

The mid-capture write is to jump — the same action being captured — and decideBind overwrites jump either way. Stale map (jump: "Space") and fresh map (jump: "KeyX") both yield { ...DEFAULT_BINDINGS, jump: "KeyJ" }, so the assertion is true no matter when getBindings() is read. I patched createCapture to snapshot getBindings() in start and read the snapshot in handle — precisely the race the doc comment on line 44 says cannot happen — and all 12 tests still passed.

Fix this first: write to a different action, so only a fresh read can produce the right answer.

test("getBindings is read fresh on every key, not captured at start", () => {
  const { capture, bound, setBindings } = harness();
  capture.start("jump");
  setBindings({ ...DEFAULT_BINDINGS, reload: "KeyJ" }); // KeyJ taken elsewhere mid-capture
  const result = capture.handle(key("KeyJ"));
  expect(result).toEqual({ kind: "rejected", action: "jump", reason: expect.stringContaining("Reload") });
  expect(bound).toEqual([]);
});

A snapshot taken at start binds KeyJ here; a fresh read rejects it as taken. Confirm by making that snapshot mutation locally and watching this one go red before you push.

Two notes, neither blocking on its own:

  • captureStatus (main.ts:257) is set on click and never set back to null, and a cancel leaves the last rejection reason on screen. That matches the old code, so it is not a regression — but now that the lifecycle has an owner, clearing the status inside cancelCapture would put the last piece of per-capture bookkeeping where the rest of it lives.
  • The preview at https://cs--156--forge-trevato-apps.open-platform.sh returns a plain-text 404 not found from the proxy on /, not the app's own HTML 404, on every try over several minutes. The screenshots at this head and a local boot both say the app is fine, so I read this as the preview being asleep rather than anything in the diff — worth a glance when you push the fix.

Everything else on my list passes: no identity or write path touched, inputs still validated at the boundary, nothing shelled or interpolated, no secret added, src/tty.ts unaffected (bindings are a browser-local setting it never exposed), and the README needs nothing because no behaviour changed. Fix the one test and this is an approve.

approved — the one blocking finding is fixed, and I confirmed the fix bites rather than just reading it.

The freshness test now writes to a different action mid-capture, so only a fresh getBindings() can produce the right answer:

setBindings({ ...DEFAULT_BINDINGS, reload: "KeyJ" }); // KeyJ taken elsewhere mid-capture
expect(result).toEqual({ kind: "rejected", action: "jump", reason: expect.stringContaining("Reload") });

I re-ran the same mutation as last round — snapshot getBindings() in start, read the snapshot in handle — and this time the test goes red, alone and by name. Then I widened the battery to nine mutations of capture.ts and every one is caught: dropping stopImmediatePropagation (2 fail), dropping preventDefault (2), clearing pending on a repeat (1), keeping pending after a resolve (4), start refusing to replace (1), losing the Escape case (1), never calling onBound (3), cancel becoming a no-op (1), and claiming the key while idle (3). There is no longer a line in this module I can break without the suite saying so.

The captureStatus note is taken too: cancelCapture now clears the status line, so the last piece of per-capture bookkeeping lives with the rest. Blur, closing the <details>, start(), resume and toMenu all wipe a stale rejection reason, which is the behaviour you want from a transient message.

Verified rather than assumed, against the pull's base (a3d302b) in a scratch worktree:

  • bun run check green — tsc --noEmit clean, 697 pass, 0 fail.
  • All seven sites that mutate pending repaint; the Reset handler's direct capture.cancel() is still followed by its own status.textContent = "" and refreshBindingButtons(), so it is equivalent to cancelCapture.
  • input.ts registers its keydown in the bubble phase, so the single capture-phase listener still wins regardless of boot order — the key that rebinds an action cannot also drive it.
  • The app boots from the patched tree, / answers in 8ms, and the CSP / X-Content-Type-Options / Referrer-Policy / HttpOnly-session floor is intact. Client-only diff: no identity, write, body-cap, rate-limit, SQL or shell surface is touched, and src/tty.ts is unaffected (bindings are a browser-local setting it never exposed, so there is no door to keep level here and the README owes nothing — no behaviour changed).
  • Three files, no new dependency, net main.ts shorter, one module named for one concept, nothing commented out and no TODO left. decideBind's only caller moved cleanly from main.ts to capture.ts.
  • Mergeable against main as it stands now (two commits ahead of this base, no conflicts).
  • The screenshots at aaed005 render correctly on both sizes — 360px with no horizontal scroll, the controls panel where it belongs.

Two things I am not blocking on, for whenever you are next in this file:

  • harness(bindings = DEFAULT_BINDINGS) in capture.test.ts:31 — no caller ever passes the argument. Dead parameter; delete it or use it.
  • The preview at https://cs--156--forge-trevato-apps.open-platform.sh still answers / with a plain-text 404 not found from the proxy, not the app's own HTML 404, across both review rounds and several minutes of retries. The screenshots at this exact head and a local boot both say the app is healthy, so this is the preview being asleep, not the diff. Worth a glance from whoever owns the deploy — I am not holding the pull for it.

Good change. It does what #114 asked, in the shape the issue preferred, and it leaves the rebind panel with a lifecycle that cannot silently eat the next match's first key.

148 const result = capture.handle(key("KeyJ"));
149 expect(result).toEqual({ kind: "rejected", action: "jump", reason: expect.stringContaining("Reload") });
150 expect(bound).toEqual([]);
151 });
src/client/capture.ts added +74
@@ -0,0 +1,74 @@
1 /**

x

2 * The rebind panel's capture lifecycle: at most one key is ever being waited
3 * for. `main.ts` owns a single persistent window listener, added once in the
4 * capturing phase, that forwards every keydown here — `start` and `cancel`
5 * only flip a pending flag, so there is no per-capture `addEventListener` to
6 * forget a `removeEventListener` for. That forgetting was the bug: a capture
7 * left pending past a resume or a new match silently ate, and rebound, the
8 * first key the next thing to run saw.
9 */
10
11 import { decideBind } from "./bind.ts";
12 import type { Action } from "./input.ts";
13
14 export type CaptureResult =
15 | { kind: "bound"; action: Action; bindings: Record<Action, string> }
16 | { kind: "cancelled"; action: Action }
17 | { kind: "rejected"; action: Action; reason: string };
18
19 /** The one shape of event `handle` needs — a real `KeyboardEvent` satisfies it, and so does a test's plain object. */
20 export interface CaptureEvent {
21 code: string;
22 repeat: boolean;
23 preventDefault(): void;
24 stopImmediatePropagation(): void;
25 }
26
27 export interface Capture {
28 /** The action a key is currently being waited for, or `null` when idle. */
29 readonly pending: Action | null;
30 /** Begin waiting for `action`'s next key. Replaces whatever was pending first, if anything was. */
31 start(action: Action): void;
32 /** Stop waiting, with nothing bound. A no-op when nothing is pending. */
33 cancel(): void;
34 /**
35 * Every keydown the window sees, forwarded here first. `null` and untouched
36 * when idle. While pending, the event is always claimed — `preventDefault`
37 * and `stopImmediatePropagation`, so the key that rebinds fire does not also
38 * fire it — and the capture resolves unless the key is a repeat of one
39 * already held down, which leaves it pending for the key that actually comes next.
40 */
41 handle(event: CaptureEvent): CaptureResult | null;
42 }
43
44 /** `getBindings` is read fresh on every key, so a bind made elsewhere mid-capture is never raced against a stale copy. */
45 export function createCapture(
46 getBindings: () => Record<Action, string>,
47 onBound: (bindings: Record<Action, string>) => void,
48 ): Capture {
49 let pending: Action | null = null;
50 return {
51 get pending(): Action | null {
52 return pending;
53 },
54 start(action: Action): void {
55 pending = action;
56 },
57 cancel(): void {
58 pending = null;
59 },
60 handle(event: CaptureEvent): CaptureResult | null {
61 if (pending === null) return null;
62 event.preventDefault();
63 event.stopImmediatePropagation();
64 if (event.repeat) return null;
65 const action = pending;
66 pending = null;
67 if (event.code === "Escape") return { kind: "cancelled", action };
68 const outcome = decideBind(action, event.code, getBindings());
69 if (!outcome.ok) return { kind: "rejected", action, reason: outcome.reason };
70 onBound(outcome.bindings);
71 return { kind: "bound", action, bindings: outcome.bindings };
72 },
73 };
74 }
src/client/main.ts +49 −55
@@ -9,8 +9,9 @@
99 */
1010
1111 import { createAudio } from "./audio.ts";
12 import { ACTION_LABELS, decideBind, labelForCode } from "./bind.ts";
12 import { ACTION_LABELS, labelForCode } from "./bind.ts";
1313 import { buyMenu, type BuyRow } from "./buy.ts";
14 import { createCapture } from "./capture.ts";
1415 import { cuesHeard } from "./earshot.ts";
1516 import { attachFullscreen } from "./fullscreen.ts";
1617 import { drawHud } from "./hud.ts";
@@ -239,10 +240,27 @@ function boot(): void {
239240 controlsMenu.innerHTML = controlsPanelHtml;
240241 controlsPause.innerHTML = controlsPanelHtml;
241242
243 // What a captured key does to the map is capture.ts's decision — it only
244 // ever holds one pending action and never a listener, so there is nothing
245 // here to forget: `refreshBindingButtons` reads `capture.pending` fresh
246 // every time, which is what makes cancelling anywhere just work.
247 const capture = createCapture(
248 () => settings.bindings,
249 (bindings) => {
250 settings = { ...settings, bindings };
251 controls.setBindings(bindings);
252 saveSettings(settings);
253 },
254 );
255 // The status line belongs to whichever container's button opened the
256 // capture still pending — there are two (menu, pause) with one apiece.
257 let captureStatus: HTMLElement | null = null;
258
242259 const refreshBindingButtons = (): void => {
243260 for (const container of [controlsMenu, controlsPause]) {
244261 for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) {
245 button.textContent = labelForCode(settings.bindings[button.dataset.bind as Action]);
262 const action = button.dataset.bind as Action;
263 button.textContent = capture.pending === action ? "Press a key…" : labelForCode(settings.bindings[action]);
246264 }
247265 }
248266 };
@@ -250,64 +268,40 @@ function boot(): void {
250268
251269 // At most one capture is ever live: starting a new one, or anything that
252270 // takes the player off the panel — resuming, starting a match, the window
253 // losing focus — cancels whatever capture was pending first. Without that
254 // a forgotten capture's window listener outlives the click that opened it,
255 // silently eating (and rebinding) the first key the next match sees.
256 let cancelCapture: (() => void) | null = null;
271 // losing focus — cancels whatever was pending first.
272 const cancelCapture = (): void => {
273 capture.cancel();
274 if (captureStatus !== null) captureStatus.textContent = "";
275 refreshBindingButtons();
276 };
257277
258 /**
259 * One key, captured with the window itself in the capturing phase so it is
260 * seen and stopped dead — `stopImmediatePropagation`, not just
261 * `stopPropagation` — before it ever reaches input.ts's own window
262 * listener or a second capture on another button: without that, the key
263 * that rebinds fire also fires it.
264 */
265 function captureBinding(action: Action, button: HTMLButtonElement, status: HTMLElement): void {
266 cancelCapture?.();
267 const label = button.textContent;
268 const finish = (): void => {
269 window.removeEventListener("keydown", onKey, true);
270 cancelCapture = null;
271 };
272 cancelCapture = () => {
273 finish();
274 button.textContent = label;
275 };
276 button.textContent = "Press a key…";
277 status.textContent = "";
278 const onKey = (event: KeyboardEvent): void => {
279 event.preventDefault();
280 event.stopImmediatePropagation();
281 if (event.repeat) return;
282 finish();
283 if (event.code === "Escape") {
284 button.textContent = label;
285 return;
286 }
287 const outcome = decideBind(action, event.code, settings.bindings);
288 if (!outcome.ok) {
289 status.textContent = outcome.reason;
290 button.textContent = label;
291 return;
292 }
293 settings = { ...settings, bindings: outcome.bindings };
294 controls.setBindings(settings.bindings);
295 saveSettings(settings);
296 refreshBindingButtons();
297 };
298 window.addEventListener("keydown", onKey, true);
299 }
278 // One persistent listener, added once, in the capturing phase so it is
279 // seen and stopped dead — `stopImmediatePropagation`, not just
280 // `stopPropagation` — before a pending capture's key ever reaches
281 // input.ts's own window listener: without that, the key that rebinds fire
282 // also fires it. Idle, `capture.handle` touches nothing and this is a pass-through.
283 window.addEventListener("keydown", (event) => {
284 const result = capture.handle(event);
285 if (result === null) return;
286 if (captureStatus !== null) captureStatus.textContent = result.kind === "rejected" ? result.reason : "";
287 refreshBindingButtons();
288 }, true);
300289
301290 for (const container of [controlsMenu, controlsPause]) {
302291 const status = container.querySelector(".bind-status") as HTMLElement;
303292 for (const button of Array.from(container.querySelectorAll<HTMLButtonElement>("button.bind"))) {
304293 const action = button.dataset.bind as Action;
305 button.addEventListener("click", () => captureBinding(action, button, status));
294 button.addEventListener("click", () => {
295 captureStatus = status;
296 status.textContent = "";
297 capture.start(action);
298 refreshBindingButtons();
299 });
306300 button.addEventListener("keydown", (event) => event.stopPropagation());
307301 }
308302 const reset = container.querySelector(".reset-bindings") as HTMLButtonElement;
309303 reset.addEventListener("click", () => {
310 cancelCapture?.();
304 capture.cancel();
311305 settings = { ...settings, bindings: { ...DEFAULT_BINDINGS } };
312306 controls.setBindings(settings.bindings);
313307 saveSettings(settings);
@@ -323,10 +317,10 @@ function boot(): void {
323317 const summary = details.querySelector("summary") as HTMLElement;
324318 summary.addEventListener("keydown", (event) => event.stopPropagation());
325319 details.addEventListener("toggle", () => {
326 if (!details.open) cancelCapture?.();
320 if (!details.open) cancelCapture();
327321 });
328322 }
329 window.addEventListener("blur", () => cancelCapture?.());
323 window.addEventListener("blur", () => cancelCapture());
330324
331325 let world: World | null = null;
332326 let matchId = "";
@@ -605,7 +599,7 @@ function boot(): void {
605599 }
606600
607601 function start(): void {
608 cancelCapture?.();
602 cancelCapture();
609603 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
610604 const team = sideField.value === "t" ? "t" : "ct";
611605 const name = nameField.value.trim().slice(0, 24) || "you";
@@ -635,13 +629,13 @@ function boot(): void {
635629 $("play").addEventListener("click", start);
636630 resumeButton.addEventListener("click", () => {
637631 if (renderer.lost) return;
638 cancelCapture?.();
632 cancelCapture();
639633 show(pause, false);
640634 refreshPad();
641635 lock();
642636 });
643637 const toMenu = (): void => {
644 cancelCapture?.();
638 cancelCapture();
645639 // A match still going when the menu is reached — left from the pause
646640 // overlay, not from a summary already posted as finished — gets the one
647641 // post that says so, sent before the world it reads is gone.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs