"""Phase 07 task 04 — the canvas renderer, camera, FX and HUD, end to end. The task's e2e list, one suite: * **the canvas is not blank** — a pixel sample of the mounted `#kcanvas` (once the renderer has sized and unhid it) has a non-background fraction and colour variance, and **changes after a move** (a signature diff after the host's cook walks); * **`renderStats().drawCalls` within a sane band** — the per-frame draw count is neither zero (nothing drawn) nor a per-frame explosion (the task's < 4 000 ceiling); * **frame time ≤ 16 ms p95 over the scripted round** — the renderer's self-measured `frameMsP95` (the `window.__uc.renderStats` census) after the full ~25 s soup run (well past the 10 s budget window); * **the HUD exists and updates** — the readouts are in the DOM (the task's a11y half), the order ticket appears when the order spawns (the campaign's 16-20 s spawn interval) with its points and the deadline bar, the **score text changes within 200 ms of the serve** (measured in-page from the serve press; the `levels_us04` campaign — target 100, 120 s — is a single-serve round that does *not* end on the serve, so the HUD update is observable instead of being raced out by the result screen), the **timer decreases**, and the FX's census shows the scripted `chopped` / `served` events landed; * **`document.hidden` pauses the loop** — the loop's frame count stops advancing while `hidden` is forced, and resumes on the `visibilitychange` back to visible; * **reduced-motion mode** — `?motion=0` sets `data-motion="reduced"`, the FX reports reduced, a `denied` event produces the static marker but **no shake** (`shakeApplied` stays 0) and **no particles**; an offscreen renderer draw of a `fire` hazard under reduced motion still shows the marker's pixels (the static fallback's "hazard markers still visible" half — the campaign itself has no hazards); * **the mapping walk** — every (station kind * station state) and (item kind * item form) pair maps, through the module's `stationSpriteId` / `itemSpriteId`, to an atlas id that resolves (the task's completion criterion; the unit `test_client_sprite_map.py` keeps the item forms equal to the sim's fixpoint). The harness is the phase-07 e2e machine (`conftest.py`): a live uvicorn on an ephemeral loopback, two fresh contexts for the multi-player tests (the `round_server` / `e2e_server` fixtures), the `window.__undercooked` app seam (the `us03`/`us06` choreography — the same waypoints and holds), and the `window.__uc` census seam (task 03's loop; task 04's `renderStats` / `runtime` / `fx` fields). """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import pytest from conftest import app_screen, load_app, open_two_pages if TYPE_CHECKING: from playwright.sync_api import Browser, Page pytestmark = pytest.mark.e2e REPO_ROOT = Path(__file__).resolve().parents[2] #: The single-serve round that does not end on the serve (target 100: #: one soup is 44-66 — the HUD's score update is observable). US04_LEVELS_DIR: Path = REPO_ROOT / "tests" / "fixtures" / "levels_us04" def _join_ui(page: Page, code: str) -> None: """The keyboard-first join (the us03 machine).""" page.locator("#join-code").focus() page.keyboard.type(code) page.keyboard.press("Enter") def _start_solo(page: Page) -> None: """A single-player room: create, ready, start (the solo-host start, PLAN §6).""" page.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") page.locator("#room-code").wait_for(state="visible", timeout=20_000) page.get_by_role("button", name="Ready up").focus() page.keyboard.press("Enter") page.wait_for_function( "() => { const b = document.querySelector('#start-btn'); " "return b !== null && !b.disabled; }", timeout=20_000, ) page.get_by_role("button", name="Start").focus() page.keyboard.press("Enter") page.locator("#kcanvas").wait_for(state="attached", timeout=30_000) assert app_screen(page) == "playing" def _start_duo(page_a: Page, page_b: Page) -> None: """The us03 lobby: both ready up, the host starts.""" page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") page_a.locator("#room-code").wait_for(state="visible", timeout=20_000) code = page_a.locator("#room-code").inner_text() _join_ui(page_b, code) page_b.locator("#room-code").wait_for(state="visible", timeout=20_000) page_b.get_by_role("button", name="Ready up").focus() page_b.keyboard.press("Enter") start_enabled = ( "() => { const b = document.querySelector('#start-btn'); " "return b !== null && !b.disabled; }" ) page_a.wait_for_function(start_enabled, timeout=20_000) page_a.get_by_role("button", name="Start").focus() page_a.keyboard.press("Enter") page_a.locator("#kcanvas").wait_for(state="attached", timeout=30_000) page_b.locator("#kcanvas").wait_for(state="attached", timeout=30_000) assert app_screen(page_a) == "playing" assert app_screen(page_b) == "playing" #: A canvas pixel census (the non-blank check + the signature). The #: `blocks` signature is a 16-by-9 grid of mean luminance (the after-a- #: move diff's input); `hash` a 32-bit fold of the buffer (the #: non-identical-across-time check is not asserted — an idle kitchen #: is a still image; the move is the temporal check). PIXEL_CENSUS = """() => { const canvas = document.getElementById('kcanvas'); const w = canvas.width, h = canvas.height; const data = canvas.getContext('2d').getImageData(0, 0, w, h).data; let nonBackground = 0; const colors = new Set(); for (let i = 0; i < data.length; i += 4) { const a = data[i + 3]; const sum = data[i] + data[i + 1] + data[i + 2]; if (a > 8 && sum > 30) nonBackground += 1; if (a > 8) colors.add(((sum / 3) | 0) & 15); } const COLS = 16, ROWS = 9; const blocks = []; const bw = Math.max(1, (w / COLS) | 0); const bh = Math.max(1, (h / ROWS) | 0); for (let r = 0; r < ROWS; r += 1) { for (let c = 0; c < COLS; c += 1) { let sum = 0, n = 0; for (let y = r * bh; y < Math.min(h, (r + 1) * bh); y += 4) { for (let x = c * bw; x < Math.min(w, (c + 1) * bw); x += 4) { const i = (y * w + x) * 4; sum += (data[i] + data[i + 1] + data[i + 2]) / 3; n += 1; } } blocks.push(n > 0 ? (sum / n / 128).toFixed(3) : '0.000'); } } return { w, h, nonBackgroundFraction: nonBackground / (w * h), distinctColors: colors.size, blocks, }; }""" #: The `document.hidden` pause probe (the loop's frame count must stop #: advancing while `hidden` is forced, then resume). HIDDEN_PAUSE_PROBE = """async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const frames = () => window.__uc.loop.frameStats().frames; await sleep(300); // the loop is live and rendering const f0 = frames(); await sleep(300); const f1 = frames(); if (f1 <= f0) return { error: 'the loop was not advancing before the pause' }; Object.defineProperty(document, 'hidden', { value: true, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); await sleep(300); const f2 = frames(); await sleep(400); const f3 = frames(); Object.defineProperty(document, 'hidden', { value: false, configurable: true }); document.dispatchEvent(new Event('visibilitychange')); await sleep(500); const f4 = frames(); return { f0, f1, f2, f3, f4, hidden: window.__uc.loop.frameStats().hidden }; }""" #: The mapping walk (the task's completion criterion, in the browser — #: the real mapping modules, the real atlas): every (station kind * #: state) and (item kind * form) pair must map to an atlas id that #: resolves. MAPPING_WALK = """async () => { const canvasMod = await import('/static/js/render/canvas.js'); const enums = await import('/static/js/shared/enums.js'); const atlas = await import('/static/js/render/atlas.js'); await atlas.ready(); const missing = []; let checked = 0; for (const kind of enums.STATION_KIND) { for (const state of enums.STATION_STATE) { checked += 1; const id = canvasMod.stationSpriteId(kind, state); if (atlas.meta(id) === null) missing.push(id); } } for (const kind of enums.ITEM_KIND) { for (const form of enums.ITEM_FORM) { checked += 1; const id = canvasMod.itemSpriteId(kind, form); if (atlas.meta(id) === null) missing.push(id); } } return { checked, missing }; }""" #: The soup with task 04's instrumentation (the us03 choreography — the #: same waypoints and holds; the additions: the `__uc_perf` budget #: sample before the serve walk, the timer's two reads, the HUD score #: delay measured from the serve press, the FX census). SOUP_CHOREOGRAPHY = """async () => { const app = window.__undercooked; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const ownSeat = app.you.seat; const player = () => Object.values(app.view.players).find((p) => p.pi === ownSeat); const walkTo = async (target, band, settle, waitS) => { const deadline = performance.now() + waitS * 1000; for (;;) { const me = player(); if (me === undefined) throw new Error('the scripted cook vanished from the view'); const dx = target[0] - me.x; const dy = target[1] - me.y; const distance = Math.hypot(dx, dy); if (distance <= band) { if (settle) { app.sendInput(null, false); await sleep(150); // the settle: the view catches up const again = player(); if (again === undefined) throw new Error('the scripted cook vanished (settle)'); const tail = Math.hypot(target[0] - again.x, target[1] - again.y); if (tail <= band + 0.2) return; // the deceleration tail continue; // the settle was not enough: walk again } return; // pass the waypoint (a stopped cook never leaves the band) } if (performance.now() > deadline) throw new Error('timed out walking'); const length = distance > 1e-6 ? distance : 1.0; app.sendInput({ x: dx / length, y: dy / length }, false); await sleep(50); // ~20 Hz: one frame per tick — a tick with no // frame brakes the body, so the feed must ride every tick window // (walk and hold are sequential: the total is 20 Hz + the ping, // under the 40/s token bucket) } }; const walk = async (waypoints, band, waitS) => { for (let i = 0; i < waypoints.length - 1; i += 1) { await walkTo(waypoints[i], 0.35, false, waitS); } await walkTo(waypoints[waypoints.length - 1], band, true, waitS); }; const press = async () => { app.sendInput(null, true); // the rising edge await sleep(90); app.sendInput(null, false); // the release }; // The pick after a hold (or a set-up press), self-healing: an // explicit release before each attempt breaks a stuck hold; a missed // attempt retries on a fresh rising edge. The 250 ms spread keeps a // tight 3-frame cluster off the 40/s token bucket. const pick = async (what, short) => { for (let i = 0; i < 3; i += 1) { if (carrying(short)) return; // an early pick: no press while in hand app.sendInput(null, false); await sleep(250); await press(); const deadline = performance.now() + 6000; while (!carrying(short) && performance.now() < deadline) { await sleep(50); } if (carrying(short)) return; } throw new Error('timed out: ' + what); }; const holdUntil = async (predicate, waitS) => { const deadline = performance.now() + waitS * 1000; while (!predicate()) { if (performance.now() > deadline) throw new Error('timed out holding'); app.sendInput(null, true); await sleep(50); // ~20 Hz: one frame per tick — a 1-tick gap resets // the gate's hold (the work freezes); the next frame re-presses // and re-claims it (20 Hz + the walk feed, under the 40/s bucket) } }; const waitUntil = async (predicate, what, waitS) => { const deadline = performance.now() + waitS * 1000; while (!predicate()) { if (performance.now() > deadline) throw new Error('timed out: ' + what); await sleep(50); } }; const stationReady = (kind) => { const station = Object.values(app.view.stations).find((s) => s.k === kind); return station !== undefined && station.s === 'ready'; }; const carrying = (short) => { const me = player(); return me !== undefined && me.h === short; }; // The phase-06 waypoints and staging points, verbatim. const SHELF_STAGE = [2.5, 1.5]; // facing LEFT at the shelf (1, 1) const BOARD_STAGE = [3.5, 1.5]; // facing RIGHT at the board (4, 1) const STOVE_STAGE = [7.5, 1.5]; // facing RIGHT at the stove (8, 1) const PREP_STAGE = [3.5, 3.5]; // facing LEFT at the prep (2, 3) const SERVE_STAGE = [8.8, 3.5]; // in front of the window (9, 3): a stop // west of its centre keeps the window ahead of the facing plane // (`not_facing`) // 1. The tomato, off the spawn shelf. Self-healing: a dropped press // frame is a silent no-op; the shelf is still full for a fresh // rising edge. await walk([[3.5, 7.5], [3.5, 1.5], SHELF_STAGE], 0.4, 120); await pick('pick the tomato', 'tomato_raw'); // 2. Chop it (0.9 s of work). await walk([BOARD_STAGE], 0.4, 90); await holdUntil(() => stationReady('chop_board'), 60); await pick('pick the chopped tomato', 'tomato_chopped'); // 3. Cook it (6 s of work). await walk([[3.5, 2.5], [5.5, 2.5], [5.5, 1.5], STOVE_STAGE], 0.4, 120); await holdUntil(() => stationReady('stove'), 90); await pick('pick the cooked tomato', 'tomato_cooked'); // 4. Plate it (0.6 s of work). await walk([[7.5, 3.5], PREP_STAGE], 0.4, 90); await holdUntil(() => stationReady('prep'), 60); await pick('pick the plated dish', 'tomato_plated'); // 5. The budget sample (the run has well outlasted the 10 s window) // and the timer's two reads (2.5 s apart). window.__uc_perf = window.__uc.renderStats(); const timerEl = document.querySelector('#hud-timer'); if (timerEl === null) throw new Error('the HUD timer is not in the DOM'); const timer1 = Number(timerEl.textContent); await sleep(2500); const timer2 = Number(document.querySelector('#hud-timer').textContent); window.__uc_timer = [timer1, timer2]; // 6. Serve it — and measure the HUD's score change from the press // (the campaign's target is 100: this serve does not end the // round, so the update is observable within the 200 ms window). await walk([SERVE_STAGE], 0.4, 90); // Self-healing serve: a dropped press frame (or a `not_facing` stop) // is a silent no-op; the dish stays in the hand for a fresh rising // edge. `servedAt` marks the first real serve (the measurement // window's origin). let servedAt = -1; for (let i = 0; i < 3; i += 1) { app.sendInput(null, false); await sleep(250); app.sendInput(null, true); // the rising edge await sleep(90); app.sendInput(null, false); // the release // The server-event time: when the `served` FX first lands (the task's // "200 ms of the server event" — the press->event latency is excluded; // the `served` and `score` events land in the same snapshot, so this is // the `score` event's landing to within one frame). const eventDeadline = performance.now() + 3000; while (performance.now() < eventDeadline) { const fx = window.__uc.fx !== null ? window.__uc.fx.stats() : null; if (fx !== null && (fx.processed.served || 0) >= 1) { servedAt = performance.now(); break; } await sleep(15); } if (servedAt >= 0) break; } if (servedAt < 0) throw new Error('the serve never landed (3 attempts)'); let scoreDelay = -1; { const deadline = performance.now() + 300; while (performance.now() < deadline) { const el = document.querySelector('#hud-score'); if (el !== null && Number(el.textContent) > 0) { scoreDelay = performance.now() - servedAt; break; } await sleep(15); } } window.__uc_score_delay = scoreDelay; window.__uc_fx = window.__uc.fx !== null ? window.__uc.fx.stats() : null; return true; }""" #: The reduced-motion offscreen renderer smoke (the campaign has no #: hazards, so the "hazard markers still visible" half is proven on an #: offscreen canvas: a synthetic frame with a `fire` hazard, drawn #: under `data-motion="reduced"`, must still show the marker's #: pixels — the static fallback — while a `denied` produces no shake #: and a `score` launches particles on the normal-motion canvas). REDUCED_MOTION_SMOKE = """async () => { const canvasMod = await import('/static/js/render/canvas.js'); const cameraMod = await import('/static/js/render/camera.js'); const fxMod = await import('/static/js/render/fx.js'); const atlas = await import('/static/js/render/atlas.js'); await atlas.ready(); const mk = (reduced) => { const c = document.createElement('canvas'); c.width = 800; c.height = 450; if (reduced) c.dataset.motion = 'reduced'; const cam = cameraMod.createCamera({ canvas: c, getSize: () => ({ w: 800, h: 450 }) }); cam.attach(); // the size (the custom getSize) + the resize listeners const fx = fxMod.createFx({ canvas: c }); const r = canvasMod.createRenderer({ canvas: c, camera: cam, fx }); return { c, cam, fx, r }; }; const view = { s: 0, tgt: 100, tl: 60, p: [{ i: 0, pi: 0, x: 2.5, y: 2.5, f: 2, s: 'idle', h: null }], st: [{ k: 'stove', x: 5, y: 2, g: null, q: 0, p: 0.4, s: 'working' }], o: [], hz: [{ t: 'fire', x: 5.5, y: 2.5, l: 1 }], }; const out = {}; // The kitchen bounds (the renderer's own arithmetic — `kitchenBounds`: // the stations' trunc cells + the players' floor cells, the 1-tile // `FLOOR_MARGIN`, `w = maxX - minX + 1 + FLOOR_MARGIN`). let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; for (const s of view.st) { minX = Math.min(minX, Math.trunc(s.x)); maxX = Math.max(maxX, Math.trunc(s.x)); minY = Math.min(minY, Math.trunc(s.y)); maxY = Math.max(maxY, Math.trunc(s.y)); } for (const p of view.p) { minX = Math.min(minX, Math.floor(p.x)); maxX = Math.max(maxX, Math.floor(p.x)); minY = Math.min(minY, Math.floor(p.y)); maxY = Math.max(maxY, Math.floor(p.y)); } minX -= 1; minY -= 1; const R = mk(true); await R.r.ensureReady(); R.fx.consume([{ k: 'denied', x: 2.5, y: 2.5, payload: { i: 0, r: 'no_target' } }], view, 1000); R.r.drawFrame(view, 1000); R.r.drawFrame(view, 1125); out.reducedFlag = R.fx.reduced(); out.reducedDrawCalls = R.r.renderStats().drawCalls; out.reducedShake = R.fx.shakeOffset(0, 1100); const hs = R.cam.worldToScreen(view.hz[0].x - minX, view.hz[0].y - minY); const hx = hs.x, hy = hs.y; const data = R.c.getContext('2d').getImageData( Math.max(0, hx - 40), Math.max(0, hy - 40), 80, 80 ).data; let lit = 0; for (let i = 0; i < data.length; i += 4) { if (data[i + 3] > 8 && data[i] + data[i + 1] + data[i + 2] > 40) lit += 1; } out.reducedHazardLitFraction = lit / (80 * 80); const N = mk(false); await N.r.ensureReady(); N.fx.consume([{ k: 'score', x: 3, y: 3, payload: { pts: 44, stk: 1 } }], view, 2000); N.fx.update(2100); N.fx.consume([{ k: 'denied', x: 2.5, y: 2.5, payload: { i: 0, r: 'no_target' } }], view, 2100); N.r.drawFrame(view, 2100); out.normalReduced = N.fx.reduced(); out.normalParticles = N.fx.stats().particlesLaunched; out.normalShake = N.fx.shakeOffset(0, 2110) !== null; return out; }""" def test_the_rendered_kitchen_is_not_blank_and_responds_to_input( browser: Browser, round_server ) -> None: """One tab, a solo room: the canvas is not blank, `drawCalls` is within the band, the HUD exists (the ticket appears with the order, the timer decreases), a move changes the pixels, the `document.hidden` pause holds the frame count, and the mapping walk resolves every pair.""" url = round_server.url context = browser.new_context() page = context.new_page() try: load_app(page, url) _start_solo(page) # The canvas is visible once the renderer is ready (task 04 # un-hides it; the us03 contract keeps it out of the a11y # tree — `aria-hidden` stays true). page.locator("#kcanvas").wait_for(state="visible", timeout=30_000) assert page.locator("#kcanvas").get_attribute("aria-hidden") == "true" page.wait_for_timeout(800) # a few frames at the fit # Not blank: the non-background fraction and the colour census. census = page.evaluate(PIXEL_CENSUS) assert census["nonBackgroundFraction"] > 0.05, ( f"the canvas is blank (non-background fraction " f"{census['nonBackgroundFraction']:.3f})" ) assert census["distinctColors"] >= 4, ( f"the canvas has no colour variance ({census['distinctColors']} colours)" ) # The per-frame draw census: something is drawn, and no # per-frame explosion (the task's < 4 000 band). stats = page.evaluate("() => window.__uc.renderStats()") assert 50 < stats["drawCalls"] < 4000, ( f"drawCalls {stats['drawCalls']} is outside the sane band (50, 4000)" ) # The HUD exists (the task's a11y half: all DOM). for selector in ("#hud-score", "#hud-timer", "#hud-conn", "#hud-dirty"): assert page.locator(selector).count() == 1, f"{selector} is missing" # The timer decreases (two reads, 2.5 s apart — the # interpolated `tl`, one second of game per second of wall). timer1 = int(page.locator("#hud-timer").inner_text()) page.wait_for_timeout(2500) timer2 = int(page.locator("#hud-timer").inner_text()) assert timer2 < timer1, f"the timer did not decrease ({timer1} -> {timer2})" # The order ticket appears (the campaign's 16-20 s spawn # interval), with its points and the deadline bar; the head # (the oldest) is highlighted. page.locator(".ticket").first.wait_for(state="visible", timeout=30_000) ticket = page.locator(".ticket").first assert ticket.locator(".ticket-pts").inner_text().endswith("pts") assert ticket.locator(".ticket-bar-fill").count() == 1 assert "oldest" in (ticket.get_attribute("class") or ""), ( "the oldest ticket is not highlighted" ) # A move changes the pixels (the signature diff). before = page.evaluate(PIXEL_CENSUS)["blocks"] page.evaluate( """async () => { const app = window.__undercooked; for (let i = 0; i < 20; i += 1) { app.sendInput({ x: 1, y: 0 }, false); // velocity holds per send await new Promise((resolve) => setTimeout(resolve, 100)); } app.sendInput(null, false); await new Promise((resolve) => setTimeout(resolve, 500)); }""" ) after = page.evaluate(PIXEL_CENSUS)["blocks"] changed = sum( 1 for a, b in zip(before, after, strict=True) if abs(float(a) - float(b)) > 0.02 ) assert changed >= 3, ( f"a move did not change the rendered frame (only {changed} of " f"{len(before)} blocks differ)" ) assert changed < len(before) * 0.9, ( f"a move changed {changed} of {len(before)} blocks — a full redraw, not a move" ) # The `document.hidden` pause: the loop's frame count stops # advancing, then resumes on the `visibilitychange`. probe = page.evaluate(HIDDEN_PAUSE_PROBE) assert "error" not in probe, probe.get("error") assert probe["f3"] == probe["f2"], ( f"the loop kept advancing while hidden ({probe['f2']} -> {probe['f3']})" ) assert probe["f4"] > probe["f3"], ( f"the loop did not resume on the visibilitychange ({probe['f3']} -> {probe['f4']})" ) # The mapping walk: every (station * state) and (item * form) # pair resolves in the atlas (the task's completion criterion). walk = page.evaluate(MAPPING_WALK) assert walk["checked"] >= 50, f"the mapping walk checked too few pairs: {walk['checked']}" assert walk["missing"] == [], f"the mapping left pairs without sprites: {walk['missing']}" finally: context.close() def test_the_frame_budget_and_the_hud_score_update( browser: Browser, e2e_server ) -> None: """Two tabs, the `levels_us04` campaign (target 100, 120 s): the scripted soup (the us03 machine) with the task's instrumented half — the renderer's `frameMsP95` ≤ 16 ms over the run (well past the 10 s window), `drawCalls` within the band, the HUD's score text changed within 200 ms of the serve (the serve does not end the round: target 100, one soup is 44-66), the timer decreased, and the FX's census shows the scripted `chopped` and `served` events landed (with the particles' launch).""" url = e2e_server(US04_LEVELS_DIR).url context_a, page_a, context_b, page_b = open_two_pages(browser, url) try: # The scripted tab drives the wire through the seam, so the # keyboard module is off for it (`?controls=off`, the documented # scripted-test mode): its 10 Hz zero-intent keep-alive would # race the script's frames on the same seat (an `act=false` # frame breaks the holds, a zero vector brakes the walks). load_app(page_a, url + "?controls=off") load_app(page_b, url) _start_duo(page_a, page_b) page_a.set_default_timeout(600_000) try: page_a.evaluate(SOUP_CHOREOGRAPHY) finally: page_a.set_default_timeout(30_000) perf = page_a.evaluate("() => window.__uc_perf") assert perf is not None, ( "the budget sample was not taken (the round did not reach the serve?)" ) # The task's budget: frame time ≤ 16 ms p95 (the run has # well outlasted the 10 s window). assert perf["frameMsP95"] <= 16.0, ( f"frameMsP95 {perf['frameMsP95']:.2f} ms exceeds the 16 ms budget " f"(max {perf['frameMsMax']:.2f} ms over {perf['frames']} frames)" ) # The draw census band (the same ceiling as the smoke test). assert 50 < perf["drawCalls"] < 4000, ( f"drawCalls {perf['drawCalls']} is outside the sane band (50, 4000)" ) # The HUD's score changed within 200 ms of the `served` event (the # task's criterion — the press->event latency is excluded). delay = page_a.evaluate("() => window.__uc_score_delay") assert delay >= 0, ( "the HUD's score never changed after the `served` event (the 300 ms window missed)" ) assert delay <= 200.0, ( f"the HUD's score changed {delay:.0f} ms after the `served` event (> 200 ms)" ) # The timer decreased over the 2.5 s window. timer = page_a.evaluate("() => window.__uc_timer") assert timer[1] < timer[0], f"the HUD timer did not decrease: {timer}" # The FX's census: the scripted `chopped` and `served` events # landed, and the particles launched (the normal-motion half). fx = page_a.evaluate("() => window.__uc_fx") assert fx is not None, "the FX census was not taken" assert fx["processed"].get("chopped", 0) >= 1, f"no `chopped` FX: {fx['processed']}" assert fx["processed"].get("served", 0) >= 1, f"no `served` FX: {fx['processed']}" assert fx["particlesLaunched"] > 0, "no particles launched (the pool never fired)" finally: context_a.close() context_b.close() def test_reduced_motion_mode(browser: Browser, round_server) -> None: """`?motion=0` on a solo room: the canvas's `data-motion="reduced"`, the FX reports reduced, a live `denied` produces the static marker with **no shake** and **no particles** (the task's "no shake transform, hazard markers still visible" half — the live half is the dataset + the census; the marker's visibility is the offscreen smoke: a synthetic `fire` drawn under reduced motion still shows its pixels, while a `score` on the normal canvas launches the particles and a `denied` there shakes).""" url = round_server.url context = browser.new_context() page = context.new_page() try: page.goto(f"{url}/?motion=0", wait_until="networkidle") _start_solo(page) page.locator("#kcanvas").wait_for(state="visible", timeout=30_000) assert page.locator("#kcanvas").get_attribute("data-motion") == "reduced" assert page.evaluate("() => window.__uc.fx !== null && window.__uc.fx.reduced()") is True # A live `denied` (an act press with no station in reach): the # static marker is consumed; the reduced-motion half is no # shake and no particles. Self-healing press: the keyboard # module's 10 Hz zero-intent keep-alive (the default # `?controls=keyboard` — `?motion=0` only changes the FX) can win # the same tick ahead of the single rising edge; its # `act=false` frame drops the press, so a release + a fresh # rising edge, up to three times. page.evaluate( """async () => { const app = window.__undercooked; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); for (let i = 0; i < 3; i += 1) { app.sendInput(null, false); await sleep(150); app.sendInput(null, true); await sleep(120); app.sendInput(null, false); await sleep(500); const fx = window.__uc.fx.stats(); if ((fx.processed.denied || 0) >= 1) return; } }""" ) fx = page.evaluate("() => window.__uc.fx.stats()") assert fx["processed"].get("denied", 0) >= 1, ( f"no `denied` event consumed: {fx['processed']}" ) assert fx["shakeApplied"] == 0, ( f"the reduced-motion mode shook ({fx['shakeApplied']} frames)" ) assert fx["particlesLaunched"] == 0, ( f"the reduced-motion mode launched particles ({fx['particlesLaunched']})" ) # The offscreen smoke: the hazard marker's pixels under reduced # motion (the static fallback), the no-shake half, the # particles' launch on the normal canvas. smoke = page.evaluate(REDUCED_MOTION_SMOKE) assert smoke["reducedFlag"] is True, "the reduced canvas does not report reduced" assert smoke["reducedDrawCalls"] > 10, ( f"the offscreen reduced frame drew too little ({smoke['reducedDrawCalls']} calls)" ) assert smoke["reducedHazardLitFraction"] > 0.01, ( f"the fire hazard's marker is not visible under reduced motion " f"({smoke['reducedHazardLitFraction']:.3f} lit pixels)" ) assert smoke["reducedShake"] is None, ( f"a reduced-motion `denied` shook ({smoke['reducedShake']})" ) assert smoke["normalReduced"] is False, "the normal canvas reports reduced" assert smoke["normalParticles"] > 0, "no particles launched on the normal canvas" assert smoke["normalShake"] is True, "a normal-motion `denied` did not shake" finally: context.close()