"""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)