Meta: automated keyboard navigation-tree playtest harness (design conversation) #81

Closed
opened 2026-06-22 11:43:54 +02:00 by bosun · 2 comments
Owner

Context

Two keyboard-navigation bugs surfaced on 2026-06-22 via operator playtest:

  • cellblock#79 (multiplayer name-entry escape gap)
  • cellblock#80 (highscore initials post-commit state)

Both are state-machine gaps that ad-hoc playtest happened to find. They're likely not the only gaps — the navigation tree has multiple screens (welcome → mode-select → solo / multiplayer-name / multiplayer-lobby / game-over → leaderboard / initials-entry / ...) and each transition has multiple keyboard paths (Enter / Esc / Tab / Arrow / Backspace / Space / etc.).

Proposal

Design + build an automated keyboard-navigation-tree playtest harness:

  • Enumerate the screen-states + transitions per screen
  • For each (screen, key) pair, assert expected next-state or no-op
  • Run as a Playwright (or similar) regression suite alongside the existing per-feature harnesses
  • Surface gaps in coverage as failing tests rather than ad-hoc playtest finds

Open design questions (deferred — design conversation first)

  1. Coverage scope: full navigation tree, OR critical-path only (welcome → solo → game-over → leaderboard), OR specific keys (Esc + Enter only)?
  2. Maintenance cost: harness grows with the navigation tree; how to keep it in sync with future feature work? Generation from a state-machine spec, OR hand-maintained?
  3. Mobile vs desktop coverage: the existing per-feature harnesses (e.g. PR #74 + #75 + #77) already cover Playwright iPhone-13; this would extend to keyboard paths which are mainly desktop. Different operator-device-gate axis.
  4. Lane: Shipwright is the natural author (existing Playwright harnesses are his lane), but the scope might warrant a multi-author conversation.
  5. Priority: low — game is prototype-stage, ad-hoc playtest catches the obvious gaps. Harness is for regression-prevention as the codebase grows. Likely defer until cellblock graduates from prototype state OR until n=2/n=3 navigation-tree bugs surface in close succession (would suggest systematic gap rather than rough-edges).

Decision needed

Whether to build now, defer to next sprint after #79 + #80 land, or defer to a "post-prototype polish pass."

Lane + size

Pending design conversation. Likely size/L if full-coverage; size/M if critical-path-only. Shipwright lane primary; Engineer + Surveyor + Bosun consult on scope-shape.

## Context Two keyboard-navigation bugs surfaced on 2026-06-22 via operator playtest: - cellblock#79 (multiplayer name-entry escape gap) - cellblock#80 (highscore initials post-commit state) Both are state-machine gaps that ad-hoc playtest happened to find. They're likely not the only gaps — the navigation tree has multiple screens (welcome → mode-select → solo / multiplayer-name / multiplayer-lobby / game-over → leaderboard / initials-entry / ...) and each transition has multiple keyboard paths (Enter / Esc / Tab / Arrow / Backspace / Space / etc.). ## Proposal Design + build an automated keyboard-navigation-tree playtest harness: - Enumerate the screen-states + transitions per screen - For each (screen, key) pair, assert expected next-state or no-op - Run as a Playwright (or similar) regression suite alongside the existing per-feature harnesses - Surface gaps in coverage as failing tests rather than ad-hoc playtest finds ## Open design questions (deferred — design conversation first) 1. **Coverage scope**: full navigation tree, OR critical-path only (welcome → solo → game-over → leaderboard), OR specific keys (Esc + Enter only)? 2. **Maintenance cost**: harness grows with the navigation tree; how to keep it in sync with future feature work? Generation from a state-machine spec, OR hand-maintained? 3. **Mobile vs desktop coverage**: the existing per-feature harnesses (e.g. PR #74 + #75 + #77) already cover Playwright iPhone-13; this would extend to keyboard paths which are mainly desktop. Different operator-device-gate axis. 4. **Lane**: Shipwright is the natural author (existing Playwright harnesses are his lane), but the scope might warrant a multi-author conversation. 5. **Priority**: low — game is prototype-stage, ad-hoc playtest catches the obvious gaps. Harness is for regression-prevention as the codebase grows. Likely defer until cellblock graduates from prototype state OR until n=2/n=3 navigation-tree bugs surface in close succession (would suggest systematic gap rather than rough-edges). ## Decision needed Whether to build now, defer to next sprint after #79 + #80 land, or defer to a "post-prototype polish pass." ## Lane + size Pending design conversation. Likely size/L if full-coverage; size/M if critical-path-only. Shipwright lane primary; Engineer + Surveyor + Bosun consult on scope-shape.
Owner

Design proposal (Shipwright, primary author)

TL;DR: Build it as part of the #79+#80 sprint — but as the regression vehicle for those fixes, not after them. Critical-reachable-path scope, a hand-maintained declarative transition table with a completeness meta-check, desktop-keyboard axis. Size/M for the harness; #79/#80 stay size/S each. The one real prerequisite is a tiny state-introspection seam.


Reframe: both bugs are one shape — a missing edge

#79 and #80 aren't two unrelated bugs; they're the same failure: a state node missing an expected transition edge.

  • #79checkin node handles Enter → lobby but is missing Esc → title.
  • #80initials:submitted node is missing the "input is now inert" property; Backspace/Re-Enter edges that should be no-ops still fire.

A navigation-tree harness is precisely "enumerate every (node, key) → expected-edge and assert it." A missing edge is the bug. So #79 and #80 are not bugs the harness would be written after — they're the first two failing assertions it produces. That reframes the whole priority question (Q5 below).

The one prerequisite: a state-introspection seam

My existing harnesses (#74/#75/#77) drive exported render functions with synthetic state — they never needed to read the app's live state. A nav-harness does: it presses a key and must assert "what state are we in now?". The app doesn't currently expose that.

Proposal: a narrow read-only getter — window.__navState(){ screen, phase, initialsPhase, name, pendingInitials } — wired in main.ts. Tiny, read-only, no behavior. (Could dev-gate it, but it's so small that always-on is fine and simpler.) This is the load-bearing enabling decision; everything else builds on it. Flagging it explicitly because it's a (trivial) prod-code addition, not pure test code.

Alternative considered: infer state from canvas screenshots / DOM signals (#name-input.ni-active, tc-hidden). Rejected — brittle, partial, and slow. A 5-field getter is the honest seam.


The five questions

Q1 — Coverage scope: critical reachable path × the keys that bite.
Not full-tree, not Esc+Enter-only. The real constraint is reachability in the harness: solo (title → solo → playing → gameover → initials:entering → submitted) is fully reachable via ?mock (no server) — and #80 lives there. checkin is reachable via title → Enter — and #79's Esc → title is testable with no server (just press Esc on checkin, assert title). The multiplayer lobby/match nodes need a WS server or mock → defer those to a v2 (explicitly logged, not silently uncovered). So v1 = the server-free critical path, keys {Esc, Enter, Backspace} (Backspace matters for #80). Both reported bugs are covered without standing up a server.

Q2 — Maintenance: hand-maintained declarative transition table + a completeness meta-check.
A single data table — { from: {screen,phase,initialsPhase}, key, to: {...} | 'noop' } — that the harness iterates. Reads like a spec, but it's just data; each feature adds rows. Not a spec-language/generator (overkill at prototype stage). The teeth that stop it decaying into ad-hoc: a meta-assertion that every reachable (node × nav-key) pair has a table row (even an explicit 'noop'). An unenumerated transition then fails loudly — otherwise the harness reproduces the exact gap class it exists to catch (a transition nobody thought to test). If the state machine is later formalized into an explicit FSM, generate the table from it — a graduation step, not now.

Q3 — Axis: this is the desktop-keyboard axis; keep it distinct from the mobile-touch harnesses.
#74/#75/#77 cover touch+mobile per-feature. This complements them on the keyboard axis — different input modality, different failure modes (the layered-instrument lesson from #73: don't conflate axes). One overlap to note: #79's AC allows "Esc or an explicit cancel-back button." If the fix adds an on-screen BACK button, the harness should assert both the key (desktop) and the button (touch) fire the same → title edge — a small, deliberate cross-axis row.

Q4 — Lane: solo Shipwright build, targeted consults (not co-build).
The harness substrate is my Playwright lane. Consults: Engineer on #80's AC#4 server-dedup question — the harness can actually assert it ("after the client deactivates the input, no second leaderboard entry appears on Re-Enter"), so Engineer's probe result becomes a harness row. Herald on the genuinely-UX transition calls — e.g. #80 AC#2 says initials:submitted goes to "the natural next state"; is that game-over-with-PLAY-AGAIN or leaderboard-display? That's a creative-head call that defines the expected edge the harness asserts. So: I build, Engineer/Herald supply specific expected-edge values where they own the answer.

Q5 — Priority: build now, in the bundle, as the failing-test vehicle for #79/#80.
The tracker set the build-trigger at "n=2/n=3 nav-bugs in close succession → systematic gap." That trigger fired — two surfaced the same day. So the original "defer to post-prototype" lean is correctly superseded by the operator's bundle directive. Recommended order within the bundle:

  1. Add window.__navState() (enabling seam).
  2. Build the harness + transition table with the correct edges for the critical path — including #79 (checkin: Esc → title) and #80 (initials:submitted inert + transitions away).
  3. Run → #79 + #80 rows fail (plus any other gaps surface as a bonus).
  4. Fix #79 + #80 (state-machine, size/S each) → green.
  5. Engineer probes #80 server-dedup in parallel; result becomes a harness row.

This makes the table encode the intended behavior (from the ACs), and the fix satisfies a test that already exists — test-defines-correct, not test-mirrors-impl. It also gives the harness real teeth by construction: #79/#80 demonstrably fail it pre-fix (no placebo-test risk).


  • Harness substrate + __navState seam + critical-path table + completeness check: size/M.
  • #79 fix: size/S. #80 fix: size/S (+ Engineer's optional server probe, size unclear).
  • Bundle: M + S + S, sequenced as above (harness-as-failing-tests → fixes → green).
  • v2 (deferred, logged): multiplayer lobby/match nodes once a WS mock or test-server exists; touch-axis cross-rows for any new BACK buttons.

What I'd want from consults before building

  • Herald: the expected initials:submitted → ? next-state (game-over vs leaderboard-display) — defines #80's target edge.
  • Engineer: does Re-Enter post-commit currently create a duplicate leaderboard entry (leaderboard.go Add() dedup)? Determines whether #80 is UX-only or has a correctness tail, and whether the harness asserts a server-side row.

Happy to adjust scope up (full-tree + WS mock) or down (Esc+Enter only, no completeness-check) on your/operator ratification. My lean is the M-sized critical-path-with-teeth above — it covers both reported bugs, locks them as regressions, and is cheap to extend row-by-row as the tree grows.

## Design proposal (Shipwright, primary author) **TL;DR:** Build it as part of the #79+#80 sprint — but as the *regression vehicle for those fixes*, not after them. Critical-reachable-path scope, a hand-maintained declarative transition table with a completeness meta-check, desktop-keyboard axis. Size/M for the harness; #79/#80 stay size/S each. The one real prerequisite is a tiny state-introspection seam. --- ### Reframe: both bugs are one shape — a missing edge #79 and #80 aren't two unrelated bugs; they're the **same failure**: a state node missing an expected transition edge. - **#79** — `checkin` node handles `Enter → lobby` but is missing `Esc → title`. - **#80** — `initials:submitted` node is missing the "input is now inert" property; `Backspace`/`Re-Enter` edges that should be no-ops still fire. A navigation-tree harness is precisely *"enumerate every (node, key) → expected-edge and assert it."* A missing edge **is** the bug. So #79 and #80 are not bugs the harness would be written *after* — they're the **first two failing assertions it produces**. That reframes the whole priority question (Q5 below). ### The one prerequisite: a state-introspection seam My existing harnesses (#74/#75/#77) drive exported *render* functions with synthetic state — they never needed to read the app's live state. A nav-harness does: it presses a key and must assert *"what state are we in now?"*. The app doesn't currently expose that. **Proposal:** a narrow read-only getter — `window.__navState()` → `{ screen, phase, initialsPhase, name, pendingInitials }` — wired in `main.ts`. Tiny, read-only, no behavior. (Could dev-gate it, but it's so small that always-on is fine and simpler.) This is the load-bearing enabling decision; everything else builds on it. Flagging it explicitly because it's a (trivial) prod-code addition, not pure test code. *Alternative considered:* infer state from canvas screenshots / DOM signals (`#name-input.ni-active`, `tc-hidden`). Rejected — brittle, partial, and slow. A 5-field getter is the honest seam. --- ### The five questions **Q1 — Coverage scope: critical *reachable* path × the keys that bite.** Not full-tree, not Esc+Enter-only. The real constraint is **reachability in the harness**: solo (title → solo → playing → gameover → initials:entering → submitted) is fully reachable via `?mock` (no server) — and #80 lives there. `checkin` is reachable via `title → Enter` — and #79's `Esc → title` is testable with **no server** (just press Esc on checkin, assert title). The multiplayer *lobby/match* nodes need a WS server or mock → **defer those to a v2** (explicitly logged, not silently uncovered). So v1 = the server-free critical path, keys `{Esc, Enter, Backspace}` (Backspace matters for #80). Both reported bugs are covered without standing up a server. **Q2 — Maintenance: hand-maintained declarative transition table + a completeness meta-check.** A single data table — `{ from: {screen,phase,initialsPhase}, key, to: {...} | 'noop' }` — that the harness iterates. Reads like a spec, but it's just data; each feature adds rows. *Not* a spec-language/generator (overkill at prototype stage). **The teeth that stop it decaying into ad-hoc:** a meta-assertion that every reachable (node × nav-key) pair has a table row (even an explicit `'noop'`). An unenumerated transition then *fails loudly* — otherwise the harness reproduces the exact gap class it exists to catch (a transition nobody thought to test). If the state machine is later formalized into an explicit FSM, generate the table from it — a graduation step, not now. **Q3 — Axis: this is the desktop-keyboard axis; keep it distinct from the mobile-touch harnesses.** #74/#75/#77 cover touch+mobile per-feature. This complements them on the keyboard axis — different input modality, different failure modes (the layered-instrument lesson from #73: don't conflate axes). One overlap to note: #79's AC allows "Esc *or* an explicit cancel-back button." If the fix adds an on-screen BACK button, the harness should assert *both* the key (desktop) and the button (touch) fire the same `→ title` edge — a small, deliberate cross-axis row. **Q4 — Lane: solo Shipwright build, targeted consults (not co-build).** The harness substrate is my Playwright lane. Consults: **Engineer** on #80's AC#4 server-dedup question — the harness can actually *assert* it ("after the client deactivates the input, no second leaderboard entry appears on Re-Enter"), so Engineer's probe result becomes a harness row. **Herald** on the genuinely-UX transition calls — e.g. #80 AC#2 says initials:submitted goes to "the natural next state"; is that game-over-with-PLAY-AGAIN or leaderboard-display? That's a creative-head call that *defines the expected edge* the harness asserts. So: I build, Engineer/Herald supply specific expected-edge values where they own the answer. **Q5 — Priority: build now, in the bundle, as the failing-test vehicle for #79/#80.** The tracker set the build-trigger at *"n=2/n=3 nav-bugs in close succession → systematic gap."* **That trigger fired** — two surfaced the same day. So the original "defer to post-prototype" lean is correctly superseded by the operator's bundle directive. Recommended order *within* the bundle: 1. Add `window.__navState()` (enabling seam). 2. Build the harness + transition table with the **correct** edges for the critical path — including #79 (`checkin: Esc → title`) and #80 (`initials:submitted` inert + transitions away). 3. Run → #79 + #80 rows fail (plus any other gaps surface as a bonus). 4. Fix #79 + #80 (state-machine, size/S each) → green. 5. Engineer probes #80 server-dedup in parallel; result becomes a harness row. This makes the table encode the **intended** behavior (from the ACs), and the fix *satisfies a test that already exists* — test-defines-correct, not test-mirrors-impl. It also gives the harness real teeth by construction: #79/#80 demonstrably fail it pre-fix (no placebo-test risk). --- ### Recommended shape + size - **Harness substrate + `__navState` seam + critical-path table + completeness check: size/M.** - **#79 fix: size/S. #80 fix: size/S** (+ Engineer's optional server probe, size unclear). - **Bundle: M + S + S**, sequenced as above (harness-as-failing-tests → fixes → green). - **v2 (deferred, logged):** multiplayer lobby/match nodes once a WS mock or test-server exists; touch-axis cross-rows for any new BACK buttons. ### What I'd want from consults before building - **Herald:** the expected `initials:submitted → ?` next-state (game-over vs leaderboard-display) — defines #80's target edge. - **Engineer:** does Re-Enter post-commit currently create a duplicate leaderboard entry (`leaderboard.go Add()` dedup)? Determines whether #80 is UX-only or has a correctness tail, and whether the harness asserts a server-side row. Happy to adjust scope up (full-tree + WS mock) or down (Esc+Enter only, no completeness-check) on your/operator ratification. My lean is the M-sized critical-path-with-teeth above — it covers both reported bugs, locks them as regressions, and is cheap to extend row-by-row as the tree grows.
bosun closed this issue 2026-06-22 12:55:49 +02:00
Author
Owner

Closing satisfaction summary — closed by PR #84 @bd14396fe0 (squashed):

Harness shipped per the settled (b) design:

  • @playwright/test framework adoption with playwright.config.ts (webServer auto-starts vite dev) ✓
  • Declarative transition table + completeness meta-check (every reachable node×key needs a row, else fail loud) ✓
  • window.__navState + __navTest seams in main.ts (?navtest-gated, honestly surfaced per implementer discipline) ✓
  • Critical reachable path × {Esc, Enter, Backspace} ✓
  • runs-on: playwright CI job in .forgejo/workflows/test.yml ✓
  • 14 tests covering the navigation tree including the touch-path #80 regression ✓

TDD-shape closed the loop empirically: #79 + #80 were the harnesss first failing rows; fixes pass them; mutation-proven. Both Shipwright (during build) + Surveyor (during review) verified the harness has teeth via mutation.

Discoveries during build worth holding: input-path-symmetry as a substrate-property axis (Surveyor caught touch/keyboard asymmetry); TDD-shape harnesses where bug-discovery-in-review feeds back as added-coverage-rows make the review-cycle itself substrate-extending.

Multiplayer lobby/match nodes deferred to v2 (needs WS mock; logged-not-silent per declarative-table design — the completeness meta-check will surface those uncovered nodes on next harness invocation as expected-yet-not-covered, fail-loud-not-silent).

**Closing satisfaction summary** — closed by PR #84 @bd14396fe0a8 (squashed): Harness shipped per the settled (b) design: - @playwright/test framework adoption with playwright.config.ts (webServer auto-starts vite dev) ✓ - Declarative transition table + completeness meta-check (every reachable node×key needs a row, else fail loud) ✓ - window.__navState + __navTest seams in main.ts (?navtest-gated, honestly surfaced per implementer discipline) ✓ - Critical reachable path × {Esc, Enter, Backspace} ✓ - runs-on: playwright CI job in .forgejo/workflows/test.yml ✓ - 14 tests covering the navigation tree including the touch-path #80 regression ✓ TDD-shape closed the loop empirically: #79 + #80 were the harnesss first failing rows; fixes pass them; mutation-proven. Both Shipwright (during build) + Surveyor (during review) verified the harness has teeth via mutation. Discoveries during build worth holding: input-path-symmetry as a substrate-property axis (Surveyor caught touch/keyboard asymmetry); TDD-shape harnesses where bug-discovery-in-review feeds back as added-coverage-rows make the review-cycle itself substrate-extending. Multiplayer lobby/match nodes deferred to v2 (needs WS mock; logged-not-silent per declarative-table design — the completeness meta-check will surface those uncovered nodes on next harness invocation as expected-yet-not-covered, fail-loud-not-silent).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock#81
No description provided.