hallway8 / docs /TESTING.md
alvations's picture
Deploy Hallway 8 (multi-arc memory game)
58e1249 verified
|
Raw
History Blame Contribute Delete
6.95 kB

Testing: how this game is verified, and how to reproduce it

Two layers, one command each. The first proves the server's math and payloads without a browser. The second drives the real client in headless Chromium to prove the rendering, which unit tests cannot reach. Both are designed to run anywhere: the browser layer skips cleanly when Chromium is absent, so the fast suite and browserless CI stay green.

python tests/test_game.py            # fast: server + i18n unit/regression tests
H8_EMU=1 python tests/test_game.py   # also runs the headless-browser emulation checks

The zero-dependency runner

tests/test_game.py is both a test module and the runner. Run directly it auto-discovers every tests/test_*.py, collects each module's top-level test_* functions, calls them, and reports PASS/FAIL (a function that does not raise passed). No pytest required (though pytest tests/ also works). Consequences worth knowing:

  • Tests are plain functions, not unittest.TestCase methods (methods are not discovered). Assert with bare assert.
  • A test that wants to skip just prints a note and returns (it counts as a pass). This is how the browser layer degrades gracefully.

Layer 1: server tests (tests/test_game.py, test_i18n.py, test_audio.py)

These exercise app.py / hallway.py / anomalies.py / memory.py in-process via Flask's test_client(), and read the server's hidden answer to play optimally (A.STATE[sid]["room"]["_has_anomaly"]). They guard the invariants that must never regress:

  • The core loop is honest. A perfect player wins in exactly GOAL correct calls; a fresh, cookie-less client keeps its arc (the sid-header regression).
  • The answer never leaks. Every payload is stripped of _-prefixed keys (test_compiled_catalogs_carry_no_answer_keys, and the public() check).
  • Fairness. The mutated property is always among the shown details; the aggro-item is held out then revealed and can never be the change on the loop it first appears (test_aggro_item_is_held_then_revealed_fairly, 300 seeds/arc).
  • Difficulty ramp is monotonic and bounded, and level 0 leans clean (test_difficulty_ramp).
  • Flare gate. flare_props appears only on a correct turn-back where two details changed, never otherwise (test_flare_props_only_on_correct_double_turn_back).
  • Act 2 touches are flavour-only and leak-proof; every exit points at a real ending.
  • i18n completeness/exposure gates (see docs/LOCALIZATION.md for the full pipeline): every UI string compiled for every exposed locale, exactly the chosen locales exposed, no answer keys in catalogs, no em-dashes.

Add a server test by writing a test_* function in the relevant file. To drive a specific decision branch, start a slot with _new(c, arc) and overwrite A.STATE[sid]["room"] with the fields you want (this is how the flare test forces each branch deterministically).

Layer 2: browser emulation (tests/emulation.cjs + tests/test_emulation.py)

tests/test_emulation.py boots the app on a free port and runs tests/emulation.cjs (Playwright) against it, asserting the client rendering that only a browser exercises:

  • reduced-motion prose renders in canonical order and is fully visible at settle;
  • Act 2 touch verbs render and stay visible under reduced motion;
  • the flavour line appears on a touch, then auto-fades after a readable dwell, and keeps a real opacity fade even under reduced motion;
  • the alternate-ending achievement (end_<arc>) never unlocks through ordinary play, and its predicate reflects mem.ends exactly.

Opt-in and self-skipping. It runs only when H8_EMU=1 and Node + Playwright + Chromium are present; otherwise test_emulation prints a skip and returns. Overridable env: H8_CHROMIUM (browser path), NODE_PATH (where playwright resolves), H8_EMU (enable).

How the harness reaches the client: static/game.js is a classic script, so its top-level let/function bindings (current, mem, computeAch, ...) are addressable by bare name inside page.evaluate. That lets a check read current.room.shown, mutate mem, or call computeAch() directly. To make a probabilistic feature deterministic, override Math.random before load with addInitScript(() => { Math.random = () => 0; }) so, e.g., every touch roll fires. Seed localStorage.h8_mem with arcStarted for each arc to skip the first-run Act 2 suppression, and set h8_motion for reduced motion. Add a check by pushing to results via the check(name, ok, detail) helper.

Diagnostic workflow: disprove or confirm a perceived rendering bug

The most useful lesson of this project's QA. When a UI bug is reported ("the verbs never show under reduced motion"), do not patch on suspicion. Build a deterministic emulation harness that isolates the variable:

  1. Force the probabilistic thing on (Math.random = () => 0) so a feature that normally appears 15-45% of the time appears every eligible loop. Now absence is signal, not noise.
  2. Measure at settle, never during a transition. Wait for #controls:not(.hidden) and a short beat; a sample taken right after a commit catches the previous loop mid-swap and lies (elements read as 0x0 because an ancestor is briefly display:none).
  3. When a box is 0x0, walk the ancestor chain and print each parent's display/getBoundingClientRect; a zero-size box almost always means a hidden ancestor, not a hidden element.
  4. Emulate the real trigger both ways. Reduced motion via the in-app toggle (localStorage.h8_motion) and via the OS (newContext({ reducedMotion: 'reduce' })) can differ.
  5. Compare deterministically. With randomness pinned, any difference between two conditions is caused by the variable under test, not variance.

In this repo that method disproved the reported "verbs vanish under reduced motion" (they render 18/18 and stay visible) and pointed at the real defect: the flavour line was not fading. Turning a hunch into a measurement saved a wrong fix and produced the regression test that now guards it.

Browser-test gotchas (baked into the harness)

  • Advance the intro with the #begin button, not the Enter key; dismiss any cutscene/onboarding overlay before the loop.
  • With Math.random = 0 the doubt prompt always fires; handle it (click [data-conf="4"]) or commits silently stall.
  • After a commit there is a ~1.5s pause (flare + dwell) before the next render; wait it out before sampling.
  • Serve test instances on 8070+; Chromium refuses "unsafe" ports (5060, 6000).
  • Chromium lives at /opt/pw-browsers/chromium-*/chrome-linux/chrome; launch with --autoplay-policy=no-user-gesture-required so audio-gated paths run.