Files
undercooked/tests/e2e/test_us04_render_smoke.py
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

725 lines
31 KiB
Python

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