File size: 6,948 Bytes
58e1249 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 | <!-- SPDX-License-Identifier: Apache-2.0 -->
<!-- Copyright 2026 alvations (Melon Lab) -->
# 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.
```bash
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.
|