phase: 07_canvas_client_and_hud

**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+750
View File
@@ -0,0 +1,750 @@
"""Phase 07 task 03 — the snapshot buffer, the interpolation, the delta
decoder and the event queue, driven through the page's own modules.
The pure modules (`net/snapshots.js`, `net/decode.js`, `net/events.js`,
`render/loop.js`) are tested *in the browser* — one `page.evaluate`
assertion per module path (the task's completion criterion) — with an
injected clock, so the synthetic streams are exact: no real network, no
wall clock, no waiting. The server (the `round_server` campaign) is
only the page's host; the wire half — `applyDelta` against a frame
encoded by the *real* `encode_snapshot` — is the one place the Python
and the JS meet (deep equality, field by field).
The stream contract under test (docs/CLIENT.md "The snapshot buffer"):
frames keyed by `t`, in order (older/duplicate dropped, a `t` jump a
recorded gap), a gap *longer than* `MAX_GAP_TICKS` (6) fires the
resync — debounced to one request per `RESYNC_DEBOUNCE_MS` (500 ms) —
and the stream's full cadence clears the latch; no frame for
`maxAgeMs` and the sample **freezes** (it never extrapolates).
"""
from __future__ import annotations
from dataclasses import replace
from typing import TYPE_CHECKING
import pytest
from undercooked.server.encoding import encode_snapshot
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Facing,
Hazard,
HazardKind,
Item,
ItemForm,
ItemKind,
KitchenState,
Order,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
#: The per-step bound the smoothness suite asserts (the task's number):
#: `MAX_SPEED * (1 / SNAPSHOT_HZ) * 1.35` = 3.5 * 0.05 * 1.35.
STEP_BOUND = 3.5 * 0.05 * 1.35
def _load(page: Page, url: str) -> None:
page.goto(url, wait_until="networkidle")
# ---------------------------------------------------------------- the buffer
def test_buffer_drops_older_frames_and_records_gaps(page: Page, round_server) -> None:
"""In-order delivery: older and duplicate `t` are dropped (counted),
a `t` jump of 3 (under the 6) is a recorded gap, not a resync."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createSnapshotBuffer } = await import('/static/js/net/snapshots.js');
let fake = 0;
const buf = createSnapshotBuffer({ now: () => fake });
const fr = (t, x, full = true) => {
const p = [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }];
if (full) {
return { full: true, t, s: 0, tgt: 20, tl: 1, p, st: [], o: [], hz: [], e: [] };
}
return { full: false, t, s: 0, tgt: 20, tl: 1, d: { p, st: [], o: [], hz: [] }, e: [] };
};
fake = 0; const a0 = buf.push(fr(0, 0));
fake = 50; const a1 = buf.push(fr(1, 0.175));
fake = 100; const older = buf.push(fr(0, 0)); // older: dropped
fake = 150; const dup = buf.push(fr(1, 0.175)); // duplicate: dropped
fake = 200; const jump = buf.push(fr(4, 0.7)); // gap of 3: recorded
return { a0, a1, older, dup, jump, stats: buf.stats(), pending: buf.needsFullResync() };
}
"""
)
assert result["a0"] is True and result["a1"] is True
assert result["older"] is False and result["dup"] is False
assert result["jump"] is True
assert result["stats"]["pushed"] == 3
assert result["stats"]["dropped"] == 2
assert result["stats"]["gaps"] == 1
assert result["stats"]["maxGapTicks"] == 3
assert result["pending"] is False # a gap of 3 is not a resync
def test_buffer_gap_over_six_ticks_triggers_a_debounced_resync(page: Page, round_server) -> None:
"""A gap *longer than* 6 ticks fires the resync request — once —
holds delta frames out until a full frame arrives (the stream's
cadence is the resync), debounces a second gap inside the 500 ms
window, and fires again after it."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createSnapshotBuffer } = await import('/static/js/net/snapshots.js');
let fake = 0;
let resyncs = 0;
const buf = createSnapshotBuffer({ now: () => fake, onResync: () => { resyncs += 1; } });
const fr = (t, x, full = true) => {
const p = [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }];
if (full) {
return { full: true, t, s: 0, tgt: 20, tl: 1, p, st: [], o: [], hz: [], e: [] };
}
return { full: false, t, s: 0, tgt: 20, tl: 1, d: { p, st: [], o: [], hz: [] }, e: [] };
};
fake = 0; buf.push(fr(0, 0));
fake = 50; buf.push(fr(1, 0.175));
// The gap of 8 (> 6), on a *delta* frame: the request fires.
const snap = () => {
const s = buf.stats();
return { pending: buf.needsFullResync(), resyncs, skipped: s.skippedUntilFull };
};
fake = 100; const gapped = buf.push(fr(9, 1.6, false));
const afterGap = snap();
// A further delta while pending: skipped, no second request.
fake = 150; const skipped = buf.push(fr(10, 1.8, false));
const afterSkip = snap();
// A second big gap inside the 500 ms debounce window: no fire.
fake = 200; buf.push(fr(20, 3.5, false));
const debounced = { resyncs, pending: buf.needsFullResync() };
// The stream's full cadence (a full frame) clears the latch.
fake = 300; const cleared = buf.push(fr(21, 3.7));
const afterFull = { pending: buf.needsFullResync(), resyncs, cleared };
// And after the 500 ms window, a big gap fires again.
fake = 900; buf.push(fr(30, 5.3, false));
const refired = { resyncs, pending: buf.needsFullResync() };
fake = 1000; buf.push(fr(31, 5.45, false));
return {
gapped, afterGap, skipped, afterSkip, debounced, afterFull, refired,
latest: buf.latest().t,
};
}
"""
)
assert result["gapped"] is False, "the gapped delta is held back, not recorded"
assert result["afterGap"]["pending"] is True, "the gap over six must set the latch"
assert result["afterGap"]["resyncs"] == 1, "the resync request fires exactly once"
assert result["skipped"] is False, "a delta while pending is skipped"
assert result["afterSkip"]["resyncs"] == 1, "the debounce holds the second request"
assert result["afterSkip"]["skipped"] == 2, (
"both the gapped delta and the pending one are held back"
)
assert result["debounced"]["resyncs"] == 1, "a gap inside 500 ms is debounced"
assert result["debounced"]["pending"] is True
assert result["afterFull"]["cleared"] is True
assert result["afterFull"]["pending"] is False, "a full frame clears the latch"
assert result["refired"]["resyncs"] == 2, "after 500 ms the request fires again"
assert result["refired"]["pending"] is True, (
"the gapped delta re-set the latch; the next full frame will clear it"
)
assert result["latest"] == 21, "the view holds the last good (full) frame"
def test_buffer_freezes_when_the_stream_goes_quiet(page: Page, round_server) -> None:
"""No frame for `maxAgeMs`: the sample holds the last frame
(`a === b`, `alpha 0`) — the buffer never extrapolates, a frozen
cook stays exactly where the last frame put it."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createSnapshotBuffer, lerpState } = await import('/static/js/net/snapshots.js');
let fake = 0;
const buf = createSnapshotBuffer({ interpDelayMs: 100, maxAgeMs: 1000, now: () => fake });
const fr = (t, x) => ({
full: true, t, s: 0, tgt: 20, tl: 1,
p: [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }],
st: [], o: [], hz: [], e: [],
});
fake = 0; buf.push(fr(0, 0));
fake = 50; buf.push(fr(1, 0.175));
fake = 100; buf.push(fr(2, 0.35));
// The stream goes quiet for well over `maxAgeMs` (1 s).
fake = 5000;
const sample = buf.sample(5000);
const state = lerpState(sample.a, sample.b, sample.alpha);
return {
sameFrame: sample.a === sample.b,
alpha: sample.alpha,
frozen: sample.frozen,
x: state.p[0].x,
latestT: buf.latest().t,
};
}
"""
)
assert result["sameFrame"] is True
assert result["alpha"] == 0
assert result["frozen"] is True
assert result["x"] == 0.35, "the frozen hold is the last frame's position, verbatim"
assert result["latestT"] == 2
def test_synthetic_thirty_frame_stream_is_monotonic_and_bounded(page: Page, round_server) -> None:
"""The 30-frame synthetic stream: a cook walking at `MAX_SPEED`
(3.5 tiles/s, 0.175 per 50 ms tick). The sampled positions are
monotonic, the first sample is the walk's start (0), the last
in-stream sample is the frame it brackets, no sample-to-sample
step exceeds `MAX_SPEED * (1/snapshot_hz) * 1.35` (0.23625), and
past the final frame the position holds (no extrapolation).
28 samples: render time 100..1450 (50 ms steps) over a 100 ms
interp delay is 0..1350."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createSnapshotBuffer, lerpState } = await import('/static/js/net/snapshots.js');
let fake = 0;
// `maxAgeMs` wide enough to keep all 30 frames in the window.
const buf = createSnapshotBuffer({
interpDelayMs: 100, maxAgeMs: 100000, now: () => fake,
});
const fr = (t) => ({
full: true, t, s: 0, tgt: 20, tl: 1,
p: [{ i: 'c', x: t * 0.175, y: 5, f: 1, h: null, s: 'moving', pi: 0 }],
st: [], o: [], hz: [], e: [],
});
for (let t = 0; t < 30; t += 1) {
fake = t * 50;
buf.push(fr(t));
}
const xs = [];
for (let now = 100; now <= 1450; now += 50) {
const sample = buf.sample(now);
const state = lerpState(sample.a, sample.b, sample.alpha);
xs.push(state.p[0].x);
}
// The tail: past the final frame the sample holds.
const tailSample = buf.sample(2000);
const tailState = lerpState(tailSample.a, tailSample.b, tailSample.alpha);
let monotonic = true;
let maxStep = 0;
for (let i = 1; i < xs.length; i += 1) {
const step = xs[i] - xs[i - 1];
if (step < 0) monotonic = false;
if (step > maxStep) maxStep = step;
}
return {
xs,
monotonic,
maxStep,
first: xs[0],
last: xs[xs.length - 1],
tailX: tailState.p[0].x,
tailFrozen: tailSample.frozen,
};
}
"""
)
xs = result["xs"]
assert len(xs) == 28, "28 samples: render time 100..1450 in 50 ms steps"
assert result["monotonic"] is True, f"the sampled walk went backwards: {xs}"
assert result["first"] == 0, f"the first sample is the walk's start: {xs[0]}"
assert result["last"] == pytest.approx(27 * 0.175, abs=1e-9), (
f"the last in-stream sample is the frame it brackets: {xs[-1]}"
)
assert result["maxStep"] <= STEP_BOUND, (
f"a sample-to-sample step of {result['maxStep']} exceeds {STEP_BOUND}"
)
assert result["tailX"] == pytest.approx(29 * 0.175, abs=1e-9), (
"past the final frame the position holds (no extrapolation)"
)
assert result["tailFrozen"] is True
# ------------------------------------------------------------- lerpState
def test_lerp_state_interpolates_and_snaps(page: Page, round_server) -> None:
"""`lerpState`: positions lerp along the chord, the facing (and the
carried item and state) snap at the `alpha >= 0.5` boundary, the
station progress and the order time-left lerp, an entity added at
`b` appears for the whole interval (at `b`'s position), and one
removed vanishes exactly at the boundary (held at `a`'s values
until `alpha >= 0.5`, then gone)."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { lerpState } = await import('/static/js/net/snapshots.js');
const a = {
t: 10, s: 0, tgt: 20, tl: 59.5,
p: [
{ i: 'stay', x: 1, y: 1, f: 0, h: 'tomato_raw', s: 'moving', pi: 0 },
{ i: 'vanish', x: 4, y: 4, f: 2, h: null, s: 'idle', pi: 1 },
],
st: [{ k: 'stove', x: 3, y: 2, g: 'onion_raw', q: 0.9, p: 0.2, s: 'working' }],
o: [{ id: 7, r: 'soup', l: 0.8, pts: 40 }],
hz: [],
e: [{ k: 'chopped', x: 4, y: 1 }],
};
const b = {
t: 11, s: 5, tgt: 20, tl: 59.45,
p: [
{ i: 'stay', x: 2, y: 1, f: 1, h: 'tomato_chopped', s: 'moving', pi: 0 },
{ i: 'appear', x: 8, y: 1, f: 3, h: null, s: 'idle', pi: 2 },
],
st: [{ k: 'stove', x: 3, y: 2, g: 'onion_raw', q: 0.9, p: 0.4, s: 'working' }],
o: [{ id: 7, r: 'soup', l: 0.75, pts: 40 }],
hz: [],
e: [{ k: 'chopped', x: 4, y: 1 }],
};
const at = (alpha) => lerpState(a, b, alpha);
const at0 = at(0);
const at4 = at(0.4);
const at5 = at(0.5);
const at1 = at(1);
const pick = (s, i) => s.p.find((p) => p.i === i);
return {
at0: {
x: at0.p[0].x, f: at0.p[0].f, h: at0.p[0].h,
vanish: pick(at0, 'vanish'), appear: pick(at0, 'appear'),
sp: at0.st[0].p, tl: at0.tl,
},
at4: {
x: at4.p[0].x, f: at4.p[0].f, h: at4.p[0].h,
vanish: pick(at4, 'vanish'), sp: at4.st[0].p,
},
at5: {
x: at5.p[0].x, f: at5.p[0].f, h: at5.p[0].h, s: at5.p[0].s,
vanish: pick(at5, 'vanish'), appear: pick(at5, 'appear'),
sp: at5.st[0].p,
},
at1: {
x: at1.p[0].x, f: at1.p[0].f,
appear: pick(at1, 'appear'), vanish: pick(at1, 'vanish'),
sp: at1.st[0].p, l: at1.o[0].l, e: at1.e,
},
};
}
"""
)
# `alpha 0`: the chord's start, `a`'s discrete values; the vanishing
# entity is still visible, the new one is here too (it appears for
# the whole interval, at `b`'s position).
assert result["at0"]["x"] == 1
assert result["at0"]["f"] == 0, "below the boundary the facing holds `a`"
assert result["at0"]["h"] == "tomato_raw", "the carried item holds `a`"
assert result["at0"]["vanish"]["x"] == 4, "the vanishing entity is visible below 0.5"
assert result["at0"]["appear"] is not None, "the added entity appears for the whole interval"
assert result["at0"]["appear"]["x"] == 8
assert result["at0"]["sp"] == 0.2
assert result["at0"]["tl"] == pytest.approx(59.5)
# `alpha 0.4`: mid-chord lerp, the discrete fields still `a`.
assert result["at4"]["x"] == pytest.approx(1.4)
assert result["at4"]["f"] == 0
assert result["at4"]["h"] == "tomato_raw"
assert result["at4"]["vanish"]["x"] == 4
assert result["at4"]["sp"] == pytest.approx(0.28)
# `alpha 0.5`: the boundary — the discrete fields snap to `b`, the
# vanishing entity is gone, the new one is here.
assert result["at5"]["x"] == pytest.approx(1.5)
assert result["at5"]["f"] == 1, "at the boundary the facing snaps to `b`"
assert result["at5"]["h"] == "tomato_chopped"
assert result["at5"]["s"] == "moving"
assert result["at5"]["vanish"] is None, "at the boundary the vanishing entity is gone"
assert result["at5"]["appear"] is not None
assert result["at5"]["sp"] == pytest.approx(0.3)
# `alpha 1`: `b` verbatim.
assert result["at1"]["x"] == 2
assert result["at1"]["f"] == 1
assert result["at1"]["appear"]["x"] == 8
assert result["at1"]["vanish"] is None
assert result["at1"]["sp"] == pytest.approx(0.4)
assert result["at1"]["l"] == pytest.approx(0.75)
assert result["at1"]["e"] == [{"k": "chopped", "x": 4, "y": 1}]
# ------------------------------------------------------------ applyDelta
def _make_states() -> tuple[KitchenState, KitchenState]:
"""Two hand-built states (the `test_encoding_no_leak.py` machine,
extended): state A is the baseline (two cooks, a stove with
content, an empty board, an order, a fire); state B is one tick
on — the first cook moved and turned, the second *left the round*,
the stove advanced to ready, the board took content and turned
busy, a second order appeared, and the fire was put out. Every
delta path (an in-place change, an append, a tombstone) is
exercised at least once."""
grid = Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64)
a = KitchenState(
level_id="01_first_kitchen",
seed=0x1234,
grid=grid,
tick=100,
stations=(
Station(
id="stove_1",
kind=StationKind.STOVE,
x=3.234,
y=2.0,
contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98),),
state=StationState.WORKING,
progress=0.42,
),
Station(
id="chop_board_1",
kind=StationKind.CHOP_BOARD,
x=5.0,
y=7.0,
state=StationState.IDLE,
),
),
players=(
Player(
id="tok-a",
pos=Vec2(1.234, 2.345),
facing=Facing.RIGHT,
carrying=Item(ItemKind.TOMATO, ItemForm.RAW, quality=0.9),
state=PlayerState.MOVING,
seat=0,
),
Player(
id="tok-b",
pos=Vec2(6.0, 6.0),
facing=Facing.DOWN,
state=PlayerState.IDLE,
seat=1,
),
),
orders=(Order(id=7, recipe_id="soup", time_left=30.0, deadline=60, value=40),),
order_deadline=60,
hazards=(Hazard(id="hz-1", kind=HazardKind.FIRE, pos=Vec2(7.65, 5.2), life=12.34),),
score=0,
target_score=20,
time_left=59.95,
rng_state=0xCAFEBABE,
)
b = a.with_players(
(replace(a.players[0], pos=Vec2(1.409, 2.345), facing=Facing.DOWN),)
)
b = b.with_orders(
(
Order(id=7, recipe_id="soup", time_left=29.95, deadline=60, value=40),
Order(id=8, recipe_id="salad", time_left=60.0, deadline=60, value=30),
)
)
b = b.with_hazards(())
b = b.with_stations(
(
replace(b.stations[0], progress=0.47, state=StationState.READY),
replace(
b.stations[1],
contents=(Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.95),),
state=StationState.BUSY,
),
)
)
b = b.with_tick(101).with_score(5)
return a, b
def test_apply_delta_decodes_the_server_frame_to_the_full_frame(
page: Page, round_server
) -> None:
"""`applyDelta` (the client's decoder) applied to a delta frame
encoded by the *real* `encode_snapshot` lands, field by field, on
the full frame the server would have sent — positions, the carried
short, the tombstones (the cook who left, the put-out fire), the
appended order, the scalars, the events."""
_load(page, f"{round_server.url}/")
a, b = _make_states()
full_a = encode_snapshot(a)
delta = encode_snapshot(b, full_a)
full_b = encode_snapshot(b)
assert delta["full"] is False # the encoder really did make a delta
# The JS decoder, in the page's own module graph.
result = page.evaluate(
"""
async ([fullA, delta]) => {
const { applyDelta } = await import('/static/js/net/decode.js');
return applyDelta(delta, fullA);
}
""",
[full_a, delta],
)
assert result["full"] is True
# The scalars and events ride the delta: taken from the frame.
assert result["t"] == full_b["t"]
assert result["s"] == full_b["s"]
assert result["tgt"] == full_b["tgt"]
assert result["tl"] == full_b["tl"]
# Field by field, collection by collection (a deep equality).
assert result["p"] == full_b["p"], "the players (a move, a turn, a tombstone)"
assert result["st"] == full_b["st"], "the stations (a progress change, a content change)"
assert result["o"] == full_b["o"], "the orders (an in-place update, an append)"
assert result["hz"] == full_b["hz"], "the hazards (a tombstone)"
assert result == full_b, "the whole decoded frame equals the full frame"
def test_apply_delta_chains_delta_over_delta(page: Page, round_server) -> None:
"""The encoder diffs against *the previous frame* — full or delta:
`applyDelta` mirrors that (a delta frame's entities live under `d`,
its `gone` tombstones are not state). A full → delta1 → delta2
chain decodes to the final full frame."""
_load(page, f"{round_server.url}/")
a, b = _make_states()
full_a = encode_snapshot(a)
delta1 = encode_snapshot(b, full_a)
c = b.with_players(tuple(replace(p, pos=Vec2(p.pos.x + 0.175, p.pos.y)) for p in b.players))
delta2 = encode_snapshot(c, delta1)
full_c = encode_snapshot(c)
result = page.evaluate(
"""
async ([fullA, d1, d2]) => {
const { applyDelta } = await import('/static/js/net/decode.js');
const one = applyDelta(d1, fullA);
return applyDelta(d2, one);
}
""",
[full_a, delta1, delta2],
)
assert result == full_c, "the chained delta decodes to the final full frame"
# ---------------------------------------------------------- the event queue
def test_event_queue_ttl_dedup_and_tick_reset(page: Page, round_server) -> None:
"""The event queue: a per-kind `ttlMs` (a `served` outlives a
`chopped`), the dedup window (the same event inside 100 ms is
counted, not re-queued), `drift` prunes the expired, and a `push`
whose tick goes *backwards* is a resync — the queue clears, so a
`served` from the previous round cannot replay over the new one.
The standalone `ev` message's shape (`kind`) is admitted too."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createEventQueue } = await import('/static/js/net/events.js');
let fake = 0;
const q = createEventQueue({ now: () => fake });
fake = 0;
const first = q.push(1, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]);
const echo = q.push(2, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]);
fake = 200;
const again = q.push(3, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]);
const served = q.push(3, [{ k: 'served', x: 9, y: 1, oi: 7, d: 44 }]);
const unknown = q.push(3, [{ k: 'no_such_kind', x: 0, y: 0 }]);
const viaKind = q.push(3, [{ kind: 'score', x: 0, y: 0, pts: 44 }]);
const at1000 = { due: q.due(1000).map((e) => e.k), size: q.stats().size };
const pruned = q.drift(1500);
const at1500 = { due: q.due(1500).map((e) => e.k), size: q.stats().size };
// The tick goes backwards (a resync / a new round): the queue clears.
const resetPush = q.push(2, [{ k: 'score', x: 1, y: 1, pts: 5 }]);
const afterReset = {
due: q.due().map((e) => e.k),
size: q.stats().size,
resyncs: q.stats().resyncs,
};
return {
first, echo, again, served, unknown, viaKind, at1000, pruned,
at1500, resetPush, afterReset, stats: q.stats(),
};
}
"""
)
assert result["first"] == 1
assert result["echo"] == 0, "the dedup window holds the echo"
assert result["stats"]["deduped"] == 1
assert result["again"] == 1, "after the window the event queues again"
assert result["served"] == 1
assert result["unknown"] == 0, "an unknown kind is no FX, not an error"
assert result["viaKind"] == 1, "the standalone `ev` shape (`kind`) is admitted"
# At 1000: the second `chopped` (ttl 800 from 200), the `served`
# (ttl 1200 from 200) and the `score` (ttl 800 from 200) are live;
# the first `chopped` (ttl 800 from 0) is expired (still unpruned).
assert sorted(result["at1000"]["due"]) == ["chopped", "score", "served"]
assert result["at1000"]["size"] == 4 # all four entries, one expired
# At 1500: everything is expired and pruned (the entries and the
# dedup keys past their window).
assert result["at1500"]["due"] == []
assert result["at1500"]["size"] == 0
assert result["pruned"] >= 4
# The reset: only the post-reset event is live.
assert result["resetPush"] == 1
assert result["afterReset"]["due"] == ["score"]
assert result["afterReset"]["size"] == 1
assert result["afterReset"]["resyncs"] == 1
# ------------------------------------------------------------ the loop
def test_render_loop_pauses_on_hidden_and_measures(page: Page, round_server) -> None:
"""The single render loop, driven with an injected clock and a
captured rAF (no real animation): frames render while active, the
`visibilitychange` to hidden stops the chain (no frame renders
while hidden), the resume restarts it — and the delta spanning the
5 s pause is *dropped*, not measured, so the `frameMs` census
(`p50`/`p95`/`max`) stays honest (what `window.__uc.frameMs`
reports)."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createRenderLoop } = await import('/static/js/render/loop.js');
let t = 0;
let pending = null;
const renders = [];
let active = true;
const stub = {
hidden: false,
listeners: {},
addEventListener(ev, fn) { (this.listeners[ev] ??= []).push(fn); },
removeEventListener() {},
};
const loop = createRenderLoop({
now: () => t,
owner: stub,
active: () => active,
render: (now) => { renders.push(now); },
raf: (fn) => { pending = fn; return 1; },
unraf: () => { pending = null; return true; },
});
const drive = (n) => {
for (let i = 0; i < n; i += 1) {
t += 16.7;
const fn = pending;
pending = null;
if (fn) fn();
}
};
loop.start();
drive(10);
const before = loop.frameStats();
// Hide the tab: the pending frame finds `hidden` and stops.
stub.hidden = true;
stub.listeners.visibilitychange[0]();
const framesAtPause = renders.length;
if (pending) { const fn = pending; pending = null; fn(); }
t += 5000; // the pause: a long wall-clock gap
const hiddenRenders = renders.length - framesAtPause;
// Resume: the chain restarts; the delta spanning the pause drops.
stub.hidden = false;
stub.listeners.visibilitychange[0]();
drive(10);
const after = loop.frameStats();
return { before, hiddenRenders, after, rendersCount: renders.length };
}
"""
)
assert result["before"]["frames"] == 10
assert result["before"]["p50"] > 0, "the self-measured delta window is populated"
assert result["hiddenRenders"] == 0, "no frame renders while hidden"
assert result["after"]["frames"] == 20
assert result["after"]["hidden"] is False
assert result["after"]["max"] < 1000, (
f"the delta spanning the 5 s pause was measured (max {result['after']['max']})"
)
assert result["after"]["p95"] < 100
def test_the_idle_loop_keeps_the_chain_alive_for_play(page: Page, round_server) -> None:
"""A non-active frame renders nothing but keeps the rAF chain alive:
when the screen turns `PLAYING`, the loop picks it up on the very
next frame — no event, no restart needed."""
_load(page, f"{round_server.url}/")
result = page.evaluate(
"""
async () => {
const { createRenderLoop } = await import('/static/js/render/loop.js');
let t = 0;
let pending = null;
const renders = [];
let active = false; // the screen is not PLAYING yet
const stub = {
hidden: false,
listeners: {},
addEventListener() {},
removeEventListener() {},
};
const loop = createRenderLoop({
now: () => t,
owner: stub,
active: () => active,
render: (now) => { renders.push(now); },
raf: (fn) => { pending = fn; return 1; },
unraf: () => { pending = null; return true; },
});
const drive = (n) => {
for (let i = 0; i < n; i += 1) {
t += 16.7;
const fn = pending;
pending = null;
if (fn) fn();
}
};
loop.start();
drive(5); // idle: no renders, the chain stays alive
const idleRenders = renders.length;
active = true; // the screen turns PLAYING
drive(5); // picked up on the next frames
return { idleRenders, afterRenders: renders.length, stats: loop.frameStats() };
}
"""
)
assert result["idleRenders"] == 0, "an idle screen renders nothing"
assert result["afterRenders"] == 5, "the chain was alive: PLAYING is picked up"
assert result["stats"]["frames"] == 5
# ---------------------------------------------------------- the seams
def test_url_params_and_seams_are_wired(page: Page, round_server) -> None:
"""The composition root's seams: `window.__undercooked` (the machine,
plus the buffer/queue/params the boot attaches) and `window.__uc`
(the census and the pure modules) are set by module evaluation,
and `?freeze=1` parses (the sample-time freeze is the composition's
business; the buffer's freeze half is the synthetic stream above)."""
_load(page, f"{round_server.url}/?freeze=1")
result = page.evaluate(
"""
() => ({
app: typeof window.__undercooked,
screen: window.__undercooked.screen,
params: window.__undercooked.params,
buffer: typeof window.__undercooked.snapshotBuffer,
events: typeof window.__undercooked.eventQueue,
uc: typeof window.__uc,
ucParams: window.__uc.params,
hasFrameMs: typeof window.__uc.frameMs,
})
"""
)
assert result["app"] == "object"
assert result["screen"] == "landing"
assert result["buffer"] == "object"
assert result["events"] == "object"
assert result["uc"] == "object"
assert result["ucParams"]["freeze"] is True, "the `?freeze=1` param is parsed"
assert result["ucParams"]["controls"] == "keyboard" # the default
assert result["hasFrameMs"] == "object" # the live census (a getter object)