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.TestCasemethods (methods are not discovered). Assert with bareassert. - 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
GOALcorrect calls; a fresh, cookie-less client keeps its arc (thesid-header regression). - The answer never leaks. Every payload is stripped of
_-prefixed keys (test_compiled_catalogs_carry_no_answer_keys, and thepublic()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_propsappears 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.mdfor 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 reflectsmem.endsexactly.
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:
- 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. - 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 as0x0because an ancestor is brieflydisplay:none). - When a box is
0x0, walk the ancestor chain and print each parent'sdisplay/getBoundingClientRect; a zero-size box almost always means a hidden ancestor, not a hidden element. - 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. - 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
#beginbutton, not the Enter key; dismiss any cutscene/onboarding overlay before the loop. - With
Math.random = 0the 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-requiredso audio-gated paths run.