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
+11 -1
View File
@@ -1 +1,11 @@
Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`).
Playwright browser suites, marker `e2e`, driven through the `live_server` and
`context`/`page` fixtures in `tests/e2e/conftest.py`.
Naming contract (guarded by `tests/unit/test_harness.py`):
- A **story suite** is named for its story: `test_usNN_*.py`.
- A **technical suite** guards a phase's machinery rather than a single user
story (e.g. `test_atlas_loads.py`, the phase-07 art/atlas guard, named by
the phase's task file). Technical suites are an explicit, documented
exception: they are listed in `E2E_TECHNICAL_SUITES` in `test_harness.py`
before they may exist.
+219 -5
View File
@@ -6,24 +6,55 @@ a fresh context and a fresh page, all function-scoped (see `browser` for why the
is load-bearing). A fresh context per test matters too: `sessionStorage` (the anonymous
`uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's
identity.
The phase-07 story suites (task 02) need servers a browser can drive end to end:
`e2e_server` is a session-scoped factory that spins up a real `uvicorn.Server`
on an ephemeral `127.0.0.1` port (never a routable interface) with a `Settings`
resolved the way `tests/integration/conftest.py` resolves one (the authoritative
`config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` / `DEBUGPY*` stripped, the
`levels_dir` override pointing at the campaign the factory names). The three
session fixtures built on it:
* `round_server` — the `tests/fixtures/levels_round/` campaign (the shipped level 1,
layout-verbatim, `target_score` 20 / `time_limit` 60 s — one served soup scores
44-66, so the first serve wins the round; the forced-short-round the task-02
result-screen test runs. The task's `time_limit: 4` idea is impossible: the
validator's `TIME_LIMIT_INVALID` floor is `MIN_TIME_LIMIT_S` (30 s) and
`create_app`'s content gate is unconditional — 60 s with a first-serve win is
the shortest round a valid campaign can offer);
* `small_server` — `max_players=2`, for the full-room story;
* `full_server` — `max_rooms=1`, for the process-capacity "server full" story.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import importlib
import os
import threading
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Any
import httpx
import pytest
from playwright.sync_api import sync_playwright
if TYPE_CHECKING:
from collections.abc import Iterator
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
# The default desktop viewport; story tests that care about small screens call
# `page.set_viewport_size` before navigating.
VIEWPORT: ViewportSize = {"width": 1280, "height": 720}
REPO_ROOT = Path(__file__).resolve().parents[2]
#: The playable campaign (phase 06 task 06's test-only copy of the shipped level 1).
ROUND_LEVELS_DIR = REPO_ROOT / "tests" / "fixtures" / "levels_round"
config = importlib.import_module("undercooked.config")
app_module = importlib.import_module("undercooked.server.app")
uvicorn = importlib.import_module("uvicorn")
@pytest.fixture
def browser() -> Iterator[Browser]:
@@ -35,8 +66,8 @@ def browser() -> Iterator[Browser]:
`sync_playwright()` is open — so a session- or module-scoped fixture makes every
later `async def` test in the run die with
`RuntimeError: Runner.run() cannot be called from a running event loop`
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the
test leaves the main thread clean for whatever runs next.
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside
the test leaves the main thread clean for whatever runs next.
"""
with sync_playwright() as playwright:
launched = playwright.chromium.launch(headless=True)
@@ -64,3 +95,186 @@ def page(context: BrowserContext) -> Iterator[Page]:
yield opened
finally:
opened.close()
# ---------------------------------------------------------------- live servers
def _as_env_value(value: object) -> str:
"""Render a Python override value the way `load_settings` would read it."""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, str | os.PathLike):
return str(value)
if isinstance(value, list | tuple):
return ",".join(str(item) for item in value)
return str(value)
def _wait_for_health(url: str, thread: threading.Thread) -> None:
"""Poll `/healthz` until it answers 200, failing fast if the server thread died."""
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
deadline = time.monotonic() + timeout
probe = httpx.Client(timeout=1.0, trust_env=False)
try:
while time.monotonic() < deadline:
try:
if probe.get(f"{url}/healthz").status_code == 200:
return
except httpx.HTTPError:
pass
thread.join(timeout=0.1)
if not thread.is_alive():
raise RuntimeError(f"live server on {url} died during startup")
finally:
probe.close()
raise RuntimeError(f"live server on {url} never became healthy")
class E2EServer:
"""One live uvicorn server + its app (a context manager)."""
def __init__(self, url: str, app: Any, server: Any, thread: threading.Thread) -> None:
self.url = url
self.app = app
self._server = server
self._thread = thread
self._closed = False
def __enter__(self) -> tuple[str, Any]:
return self.url, self.app
def __exit__(self, *exc_info: object) -> None:
self.close()
def close(self) -> None:
if self._closed:
return
self._closed = True
self._server.should_exit = True
self._thread.join(timeout=15)
if self._thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
pytest.fail("live server did not shut down within 15 s")
def _start_e2e_server(overrides: dict[str, object], levels_dir: Path) -> E2EServer:
"""One live uvicorn server on an ephemeral loopback port.
The integration conftest's machine, verbatim in purpose: `Settings` from
`**overrides` (field names, validated against `config.ENV_KEYS`) over the
named `levels_dir` campaign, on an ephemeral `127.0.0.1` port (bound
before the serving thread starts, so the port is known exactly — no log
scraping, no collisions between concurrent runs).
"""
env = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
env["UNDERCOOKED_LEVELS_DIR"] = str(levels_dir)
for name, value in overrides.items():
env[config.ENV_KEYS[name]] = _as_env_value(value)
settings = config.load_settings(env)
application = app_module.create_app(settings)
uv_config = uvicorn.Config(
application,
host="127.0.0.1",
port=0,
log_level="warning",
lifespan="on",
)
sock = uv_config.bind_socket()
url = f"http://127.0.0.1:{sock.getsockname()[1]}"
server = uvicorn.Server(uv_config)
thread = threading.Thread(
target=server.run, args=([sock],), name="undercooked-e2e", daemon=True
)
thread.start()
_wait_for_health(url, thread)
return E2EServer(url, application, server, thread)
@pytest.fixture(scope="session")
def e2e_server() -> Iterator[Callable[..., E2EServer]]:
"""Factory: `e2e_server(ROUND_LEVELS_DIR, max_players=2)` — one call, one
server. Each call starts a fresh `uvicorn.Server` on an ephemeral loopback
port with a `Settings` resolved from the overrides (field names, validated
against `config.ENV_KEYS`) over the named campaign. Shutdown is requested
through `should_exit` so the lifespan stops cleanly; a session-end safety
close covers a leaked server."""
started: list[E2EServer] = []
def _make(levels_dir: Path, **overrides: object) -> E2EServer:
if any(name not in config.ENV_KEYS for name in overrides):
unknown = sorted(name for name in overrides if name not in config.ENV_KEYS)
pytest.fail(f"unknown settings override(s): {unknown}")
live = _start_e2e_server(dict(overrides), levels_dir)
started.append(live)
return live
def _close_all() -> None:
for live in started:
live.close()
_make.close_all = _close_all # type: ignore[attr-defined]
yield _make
_close_all() # type: ignore[attr-defined]
@pytest.fixture(scope="session")
def round_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]:
"""The playable campaign (`tests/fixtures/levels_round/`): the forced-short
round for the result-screen test (first serve wins — the module docstring)."""
live = e2e_server(ROUND_LEVELS_DIR)
try:
yield live
finally:
live.close()
@pytest.fixture(scope="session")
def small_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]:
"""`max_players=2` — the full-room story (US-02)."""
live = e2e_server(ROUND_LEVELS_DIR, max_players=2)
try:
yield live
finally:
live.close()
@pytest.fixture(scope="session")
def full_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]:
"""`max_rooms=1` — the process-capacity "server full" story (US-01)."""
live = e2e_server(ROUND_LEVELS_DIR, max_rooms=1)
try:
yield live
finally:
live.close()
# ---------------------------------------------------------------- page helpers
def open_two_pages(
browser: Browser, url: str
) -> tuple[BrowserContext, Page, BrowserContext, Page]:
"""Two tabs in two fresh contexts (the multiplayer harness, US-01/US-02).
Returns `(context_a, page_a, context_b, page_b)`; the caller closes the
contexts in a `finally` (closing a context is the socket drop a story test
wants when it "closes a laptop")."""
context_a = browser.new_context(viewport=VIEWPORT)
context_b = browser.new_context(viewport=VIEWPORT)
page_a = context_a.new_page()
page_b = context_b.new_page()
return context_a, page_a, context_b, page_b
def load_app(page: Page, url: str) -> None:
"""Load the shell and wait for its module graph to settle."""
page.goto(f"{url}/", wait_until="networkidle")
def app_screen(page: Page) -> str | None:
"""The screen the state machine is on (the `window.__undercooked` seam)."""
return page.evaluate("window.__undercooked ? window.__undercooked.screen : null")
+278
View File
@@ -0,0 +1,278 @@
"""Phase 07 task 01 e2e: the SVG atlas loads in a real browser and draws.
`TestClient` cannot prove that a `file.svg#symbol-id` fragment decodes to
pixels in an actual engine, so this suite drives headless Chromium: it
imports the atlas module the way the renderer will (`import()` of the
served ES module), waits for `atlas.ready()`, and draws onto a probe
canvas. A symbol that the engine cannot render (no `naturalWidth`) makes
`atlas.draw` throw instead of painting nothing — the non-blank pixel
assertions below are what catch that.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
if TYPE_CHECKING:
from playwright.sync_api import Page
pytestmark = pytest.mark.e2e
REPO_ROOT = Path(__file__).resolve().parents[2]
INDEX_PATH = REPO_ROOT / "app" / "static" / "assets" / "index.json"
# A spread across the six files, dense enough to assert on: an
# id the engine failed to decode paints zero pixels and fails loudly.
PROBE_IDS = [
"tile-floor",
"item-tomato-raw",
"item-plate-plated",
"cook-1-down-0",
"station-stove-ready",
"ui-star",
"fx-score",
"fx-flame",
]
@pytest.fixture
def console_errors(page: Page) -> list[str]:
"""Console errors and uncaught exceptions raised while the page runs (empty = good)."""
errors: list[str] = []
page.on(
"console",
lambda message: errors.append(f"{message.type}: {message.text}")
if message.type == "error"
else None,
)
page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}"))
return errors
def import_atlas(page: Page) -> None:
"""The same import the renderer uses; a failure is a loud test error."""
result = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
return atlas.size();
}"""
)
assert result >= 100, f"the atlas decoded only {result} symbols"
def test_atlas_ready_resolves_and_covers_the_index(
page: Page, live_server: str, console_errors: list[str]
) -> None:
page.goto(f"{live_server}/", wait_until="domcontentloaded")
count = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
return atlas.size();
}"""
)
assert count == len(json.loads(INDEX_PATH.read_text(encoding="utf-8")))
assert count >= 100
assert console_errors == []
def test_atlas_meta_answers_for_known_and_unknown_ids(
page: Page, live_server: str
) -> None:
page.goto(f"{live_server}/", wait_until="domcontentloaded")
meta = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
return { known: atlas.meta("item-rice-raw"), unknown: atlas.meta("nope") };
}"""
)
assert meta["known"] is not None and meta["known"]["file"] == "items.svg"
assert meta["known"]["w"] == 32 and meta["known"]["h"] == 32
assert meta["unknown"] is None
def test_draw_produces_non_blank_pixels(
page: Page, live_server: str, console_errors: list[str]
) -> None:
"""A blank kitchen is the failure mode this phase exists to rule out."""
page.goto(f"{live_server}/", wait_until="domcontentloaded")
results = page.evaluate(
"""async (ids) => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
const results = {};
for (const id of ids) {
await atlas.draw(ctx, id, 0, 0, 64, 64);
const data = ctx.getImageData(0, 0, 64, 64).data;
let nonBlank = 0;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) nonBlank += 1;
}
results[id] = nonBlank;
}
return results;
}""",
PROBE_IDS,
)
for symbol_id, non_blank in results.items():
assert non_blank > 80, f"{symbol_id} painted only {non_blank} pixels"
assert console_errors == []
def test_unknown_id_thrrows_and_failed_loads_are_loud(
page: Page, live_server: str
) -> None:
page.goto(f"{live_server}/", wait_until="domcontentloaded")
error = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
const canvas = document.createElement("canvas");
canvas.width = 8;
canvas.height = 8;
const ctx = canvas.getContext("2d");
try {
await atlas.draw(ctx, "definitely-not-a-sprite", 0, 0, 8, 8);
return null;
} catch (error) {
return String(error);
}
}"""
)
assert error is not None
assert "unknown atlas id: definitely-not-a-sprite" in error
def test_alpha_fades_the_sprite(page: Page, live_server: str) -> None:
page.goto(f"{live_server}/", wait_until="domcontentloaded")
max_alpha = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext("2d");
await atlas.draw(ctx, "tile-floor", 0, 0, 64, 64, { alpha: 0.4 });
const data = ctx.getImageData(0, 0, 64, 64).data;
let max = 0;
for (let i = 3; i < data.length; i += 4) max = Math.max(max, data[i]);
return max;
}"""
)
assert 0 < max_alpha < 200, max_alpha
def test_tint_recolours_and_flipx_mirrors(page: Page, live_server: str) -> None:
page.goto(f"{live_server}/", wait_until="domcontentloaded")
result = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
const make = () => {
const canvas = document.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
return canvas.getContext("2d");
};
const read = (ctx) => Array.from(ctx.getImageData(0, 0, 64, 64).data);
const distanceTo = (pixels, r, g, b) => {
let sum = 0;
let count = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] < 200) continue;
sum += Math.abs(pixels[i] - r) + Math.abs(pixels[i + 1] - g);
sum += Math.abs(pixels[i + 2] - b);
count += 1;
}
return count === 0 ? null : sum / count;
};
const baseCtx = make();
await atlas.draw(baseCtx, "item-tomato-raw", 0, 0, 64, 64);
const tintCtx = make();
await atlas.draw(tintCtx, "item-tomato-raw", 0, 0, 64, 64, { tint: "#3d84a8" });
const before = distanceTo(read(baseCtx), 0x3d, 0x84, 0xa8);
const after = distanceTo(read(tintCtx), 0x3d, 0x84, 0xa8);
const plainCtx = make();
await atlas.draw(plainCtx, "item-fish-raw", 0, 0, 64, 64);
const plain = read(plainCtx);
const flippedCtx = make();
await atlas.draw(flippedCtx, "item-fish-raw", 0, 0, 64, 64, { flipX: true });
const flipped = read(flippedCtx);
let mismatchedMirror = 0;
let mismatchedIdentity = 0;
for (let row = 0; row < 64; row += 1) {
for (let col = 0; col < 32; col += 1) {
const mirror = ((row * 64) + (63 - col)) * 4;
const identity = ((row * 64) + col) * 4;
for (let k = 0; k < 4; k += 1) {
if (flipped[identity + k] !== plain[mirror + k]) {
mismatchedMirror += 1;
break;
}
}
for (let k = 0; k < 4; k += 1) {
if (flipped[identity + k] !== plain[identity + k]) {
mismatchedIdentity += 1;
break;
}
}
}
}
return { before, after, mismatchedMirror, mismatchedIdentity };
}"""
)
assert result["before"] is not None and result["after"] is not None
assert result["after"] < result["before"], (
f"tinting did not move pixels toward the tint: {result}"
)
# The fish is asymmetric (tail right): the mirrored draw must match
# the mirror of the plain draw far better than the plain draw itself.
assert result["mismatchedMirror"] < 100, result
assert result["mismatchedMirror"] < result["mismatchedIdentity"], result
def test_the_atlas_only_requests_its_own_origin(
page: Page, live_server: str, console_errors: list[str]
) -> None:
"""A12 in the atlas: the index and all six sprite files, and nothing else."""
requested: list[str] = []
page.on("request", lambda request: requested.append(request.url))
page.goto(f"{live_server}/", wait_until="domcontentloaded")
import_atlas(page)
off_origin = [url for url in requested if not url.startswith(live_server)]
assert off_origin == [], off_origin
assert f"{live_server}/static/assets/index.json" in requested
svg_files = {
url.split("#")[0]
for url in requested
if "/static/assets/" in url and url.split("#")[0].endswith(".svg")
}
assert svg_files == {
f"{live_server}/static/assets/{name}"
for name in ("tiles.svg", "stations.svg", "items.svg", "cooks.svg", "fx.svg", "ui.svg")
}, svg_files
assert console_errors == []
def test_meta_unknown_id_returns_null_before_any_draw(page: Page, live_server: str) -> None:
"""`meta` is a read-only probe: it answers for unknown ids without throwing."""
page.goto(f"{live_server}/", wait_until="domcontentloaded")
meta = page.evaluate(
"""async () => {
const atlas = await import("/static/js/render/atlas.js");
await atlas.ready();
return atlas.meta("definitely-not-a-sprite");
}"""
)
assert meta is None
+247
View File
@@ -0,0 +1,247 @@
"""US-01 (create an anonymous lobby) e2e: a real browser creates a kitchen.
The landing screen's *Create lobby* is wired to `POST /api/rooms` (the
session token in the body — minted per tab, PLAN A16) and the
`/ws/{code}` handshake; the room's 5-character Crockford code is what
the screen shows. This suite asserts the create flow end to end —
keyboard-first (tab to the button, Enter), the code's alphabet, the
creator's seat row (the crown, the name), the *Start* button's
disabled-with-reason state, the process-capacity refusal (US-01
criterion 7: `503 room_full` from a `max_rooms=1` server turns into the
landing's "server is at capacity" message), and the landing's
before-the-wire validation (a short code is rejected without a
connection). The same-origin-only request rule (PLAN A12) is re-asserted
against the new module graph.
"""
from __future__ import annotations
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from conftest import VIEWPORT, app_screen, load_app
if TYPE_CHECKING:
from playwright.sync_api import Browser, Page
pytestmark = pytest.mark.e2e
#: The Crockford base32 alphabet (server/codes.py): I, L, O and U are
#: excluded from a minted code.
CODE_PATTERN = re.compile(r"^[0-9A-HJKMNP-TV-Z]{5}$")
#: The token is `crypto.getRandomValues` 24 bytes → base64url (A16).
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
@pytest.fixture
def console_errors(page: Page) -> list[str]:
"""Console errors and uncaught exceptions raised while the page runs (empty = good)."""
errors: list[str] = []
page.on(
"console",
lambda message: errors.append(f"{message.type}: {message.text}")
if message.type == "error"
else None,
)
page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}"))
return errors
def _new_page(browser: Browser, url: str) -> Page:
context = browser.new_context(viewport=VIEWPORT)
page = context.new_page()
page._owner_context = context # type: ignore[attr-defined]
return page
def _close(page: Page) -> None:
context = getattr(page, "_owner_context", None)
if context is not None:
context.close()
def test_create_lobby_via_keyboard_creates_a_room_and_shows_the_code(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""The door of US-01, keyboard-first: tab to *Create lobby*, Enter."""
url = round_server.url
page = _new_page(browser, url)
try:
load_app(page, url)
expect(page.get_by_role("heading", name="Kitchen lobby")).to_be_visible()
expect(page.get_by_role("button", name="Create lobby")).to_be_visible()
assert app_screen(page) == "landing"
page.get_by_role("button", name="Create lobby").focus()
page.keyboard.press("Enter")
# CONNECTING in between, LOBBY with the code on the other side.
expect(page.locator("#room-code")).to_be_visible(timeout=20000)
assert app_screen(page) == "lobby"
code = page.locator("#room-code").inner_text()
assert CODE_PATTERN.match(code), code
# The creator is seat 1 with the crown, named by the server's
# fallback ("Cook #1" — no name was given).
you = page.locator("#seat-list li[data-you]")
expect(you).to_contain_text("Cook #1")
expect(you.locator(".crown")).to_be_visible()
expect(you.locator(".chip")).to_be_visible()
# Start: enabled — the room pre-selects the campaign's first
# level and a solo host needs no ready-up (PLAN §6, §7).
assert page.locator("#start-btn").is_enabled()
assert page.locator("#start-reason").is_hidden()
# Identity: the per-tab token is in sessionStorage (A16), the code
# is remembered for the next visit.
token = page.evaluate("sessionStorage.getItem('uc.token')")
assert isinstance(token, str) and TOKEN_PATTERN.match(token), token
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == code
assert page.context.cookies() == [] # anonymous by design
assert console_errors == []
finally:
_close(page)
def test_create_lobby_with_a_display_name_shows_it_on_the_seat(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""The name is optional and non-authoritative (PLAN A16): the seam's
`createLobby({name})` puts it on the seat row, on both the creator's
and a joiner's page."""
url = round_server.url
page_a = _new_page(browser, url)
page_b = _new_page(browser, url)
try:
load_app(page_a, url)
load_app(page_b, url)
page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })")
expect(page_a.locator("#room-code")).to_be_visible(timeout=20000)
code = page_a.locator("#room-code").inner_text()
page_b.locator("#join-code").press_sequentially(code)
page_b.locator("#join-form").get_by_role("button", name="Join").click()
expect(page_b.locator("#room-code")).to_be_visible(timeout=20000)
expect(page_a.locator("#seat-list li[data-you]")).to_contain_text("Ada")
expect(page_b.locator("#seat-list")).to_contain_text("Ada")
assert console_errors == []
finally:
_close(page_a)
_close(page_b)
def test_create_lobby_reports_a_full_server(
browser: Browser, full_server, console_errors: list[str]
) -> None:
"""US-01 criterion 7: a `max_rooms=1` process — the second create is a
`503 room_full` and the client turns it into the landing's capacity
message (the screen stays on the landing, the first room is untouched)."""
url = full_server.url
page = _new_page(browser, url)
try:
load_app(page, url)
page.get_by_role("button", name="Create lobby").click()
expect(page.locator("#room-code")).to_be_visible(timeout=20000)
first_code = page.locator("#room-code").inner_text()
page.evaluate("() => window.__undercooked.backToLanding()")
assert app_screen(page) == "landing"
page.get_by_role("button", name="Create lobby").click()
# The refusal lands on the landing screen (no CONNECTING escape).
expect(page.locator("#status")).to_contain_text("capacity", timeout=20000)
assert app_screen(page) == "landing"
# The first room is still live: rejoining its code works.
page.locator("#join-code").press_sequentially(first_code)
page.locator("#join-form").get_by_role("button", name="Join").click()
expect(page.locator("#room-code")).to_have_text(first_code, timeout=20000)
assert console_errors == []
finally:
_close(page)
def test_the_landing_rejects_a_short_code_before_connecting(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""Client-side validation: a 3-character code never reaches the server —
the screen stays on the landing and the field refuses a 6th character."""
url = round_server.url
page = _new_page(browser, url)
try:
load_app(page, url)
page.locator("#join-code").press_sequentially("abc")
page.locator("#join-form").get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text("A room code is 5 characters.")
assert app_screen(page) == "landing"
page.locator("#join-code").fill("")
page.locator("#join-code").press_sequentially("abcdef")
assert page.locator("#join-code").input_value() == "abcde"
assert console_errors == []
finally:
_close(page)
def test_the_landing_only_requests_its_own_origin(
browser: Browser, round_server, console_errors: list[str]
) -> None:
"""PLAN A12 against the task-04 module graph: the page, its two
stylesheets, `main.js` (the composition root) and `app.js`, the
identity/connection modules, the snapshot-buffer, event-queue,
render-loop, canvas-renderer, camera, fx, atlas and HUD modules,
the eight screen modules and nothing else — and every byte
same-origin."""
url = round_server.url
page = _new_page(browser, url)
requested: list[str] = []
try:
page.on("request", lambda request: requested.append(request.url))
load_app(page, url)
page.get_by_role("button", name="Create lobby").click()
expect(page.locator("#room-code")).to_be_visible(timeout=20000)
off_origin = [u for u in requested if not u.startswith(url)]
assert off_origin == [], off_origin
expected = {
f"{url}/",
f"{url}/static/css/base.css",
f"{url}/static/css/screens.css",
f"{url}/static/js/app.js",
f"{url}/static/js/identity.js",
f"{url}/static/js/input/keyboard.js",
f"{url}/static/js/main-flag.js",
f"{url}/static/js/main.js",
f"{url}/static/js/net/connection.js",
f"{url}/static/js/net/decode.js",
f"{url}/static/js/net/events.js",
f"{url}/static/js/net/snapshots.js",
f"{url}/static/js/render/atlas.js",
f"{url}/static/js/render/camera.js",
f"{url}/static/js/render/canvas.js",
f"{url}/static/js/render/fx.js",
f"{url}/static/js/render/loop.js",
f"{url}/static/js/screens/dom.js",
f"{url}/static/js/screens/landing.js",
f"{url}/static/js/screens/connecting.js",
f"{url}/static/js/screens/lobby.js",
f"{url}/static/js/screens/countdown.js",
f"{url}/static/js/screens/play.js",
f"{url}/static/js/screens/round_end.js",
f"{url}/static/js/screens/error.js",
f"{url}/static/js/shared/enums.js",
f"{url}/static/js/ui/help.js",
f"{url}/static/js/ui/hud.js",
f"{url}/api/rooms",
f"{url}/api/session",
f"{url}/api/levels",
}
assert set(requested) == expected, sorted(set(requested) ^ expected)
assert console_errors == []
finally:
_close(page)
+95 -22
View File
@@ -1,9 +1,13 @@
"""US-01 (landing surface) e2e: a real browser loads the shell, cleanly.
Phase 01 has no gameplay, so this suite asserts only what a browser can prove and
`TestClient` cannot: the document parses, the ES module runs without a single
console error, the lobby controls are reachable, and — the point of PLAN A12 —
loading and clicking the page makes requests to *nothing* but this process.
The shell is now the phase-07 screen machine (task 02 replaced phase 01's
placeholder): loading the page runs the module graph without a single
console error, the landing controls are reachable and 44 px, and — the
point of PLAN A12 — loading, creating and joining makes requests to
*nothing* but this process. The create/join flows are asserted here only
as far as the landing surface reaches (the connecting screen, the error
screen for a dead code, the validation); the lobby and round flows are
the US-02/US-03 suites' territory.
"""
from __future__ import annotations
@@ -21,10 +25,9 @@ if TYPE_CHECKING:
pytestmark = pytest.mark.e2e
# Strings a player sees, kept in sync with app/static/index.html and
# app/static/js/app.js by hand: changing one must be a deliberate act.
# Strings a player sees, kept in sync with app/static/js/screens/*.js by
# hand: changing one must be a deliberate act.
READY = "Pick a kitchen to start."
NOT_IMPLEMENTED = "Not implemented until phase 07."
SHORT_CODE = "A room code is 5 characters."
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
@@ -44,7 +47,7 @@ def console_errors(page: Page) -> list[str]:
def open_shell(page: Page, live_server: str) -> None:
"""Load the lobby and wait for its one module request to settle."""
"""Load the shell and wait for the module graph to settle."""
page.goto(f"{live_server}/", wait_until="networkidle")
@@ -73,20 +76,34 @@ def test_the_lobby_is_readable_and_zoomable(page: Page, live_server: str) -> Non
assert page.evaluate("document.documentElement.scrollWidth") <= 360
def test_create_and_join_explain_that_the_phase_is_missing(
def test_create_reaches_the_lobby_and_a_dead_code_reaches_the_error_screen(
page: Page, live_server: str, console_errors: list[str]
) -> None:
"""The landing surface now reaches the wire (phase 02): create shows the
connecting screen and then the room's code; joining a code no room
answers reaches the error screen with the normalised code — no more
'not implemented' placeholder."""
open_shell(page, live_server)
status = page.locator("#status")
page.get_by_role("button", name="Create lobby").click()
expect(status).to_have_text(NOT_IMPLEMENTED)
# The connecting screen is transient (a fast server reaches the
# lobby before this can assert it): wait for the room code either
# way, and get back to the landing through the seam (or the
# *Cancel* button, if the screen is still there).
expect(page.locator("#room-code")).to_have_text(
re.compile(r"[0-9A-HJKMNPQRSTVWXYZ]{5}"), timeout=20_000
)
cancel = page.get_by_role("button", name="Cancel")
if cancel.count() > 0:
cancel.click()
else:
page.evaluate("() => window.__undercooked.backToLanding()")
expect(page.get_by_role("button", name="Create lobby")).to_be_visible(timeout=10_000)
page.locator("#join-code").press_sequentially("abcde")
page.get_by_role("button", name="Join").click()
expect(status).to_have_text(NOT_IMPLEMENTED)
# The code is normalised for the player: uppercase, five characters.
assert page.locator("#join-code").input_value() == "ABCDE"
# The error screen names the refusal and the code it tried, normalised.
expect(page.locator("#error-title")).to_have_text("That kitchen is gone")
expect(page.locator("#error-code")).to_contain_text("Room code: ABCDE")
assert console_errors == []
@@ -102,17 +119,53 @@ def test_a_short_code_is_rejected_before_any_network_use(page: Page, live_server
def test_the_only_requests_the_page_makes_are_ours(page: Page, live_server: str) -> None:
"""PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics."""
"""PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics.
The census is the whole module graph (the two stylesheets, `main.js`
— the composition root, — `app.js`, the identity, connection,
snapshot-buffer, event-queue, render-loop, canvas-renderer, camera,
fx, atlas and HUD modules, and the enum modules, the eight screen
modules) plus the three API calls create and the lobby make — and
nothing else."""
requested: list[str] = []
page.on("request", lambda request: requested.append(request.url))
open_shell(page, live_server)
page.get_by_role("button", name="Create lobby").click()
expect(page.locator("#room-code")).to_be_visible(timeout=20_000)
assert [url for url in requested if not url.startswith(live_server)] == []
# …and the check above is not vacuous: the shell really did fetch its own assets.
assert sorted(url for url in requested if url != f"{live_server}/") == [
# …and the check above is not vacuous: the page really did fetch its own assets.
assert sorted({url for url in requested if url != f"{live_server}/"}) == [
f"{live_server}/api/levels",
f"{live_server}/api/rooms",
f"{live_server}/api/session",
f"{live_server}/static/css/base.css",
f"{live_server}/static/css/screens.css",
f"{live_server}/static/js/app.js",
f"{live_server}/static/js/identity.js",
f"{live_server}/static/js/input/keyboard.js",
f"{live_server}/static/js/main-flag.js",
f"{live_server}/static/js/main.js",
f"{live_server}/static/js/net/connection.js",
f"{live_server}/static/js/net/decode.js",
f"{live_server}/static/js/net/events.js",
f"{live_server}/static/js/net/snapshots.js",
f"{live_server}/static/js/render/atlas.js",
f"{live_server}/static/js/render/camera.js",
f"{live_server}/static/js/render/canvas.js",
f"{live_server}/static/js/render/fx.js",
f"{live_server}/static/js/render/loop.js",
f"{live_server}/static/js/screens/connecting.js",
f"{live_server}/static/js/screens/countdown.js",
f"{live_server}/static/js/screens/dom.js",
f"{live_server}/static/js/screens/error.js",
f"{live_server}/static/js/screens/landing.js",
f"{live_server}/static/js/screens/lobby.js",
f"{live_server}/static/js/screens/play.js",
f"{live_server}/static/js/screens/round_end.js",
f"{live_server}/static/js/shared/enums.js",
f"{live_server}/static/js/ui/help.js",
f"{live_server}/static/js/ui/hud.js",
]
@@ -137,7 +190,11 @@ def test_the_page_can_still_call_its_own_health_endpoint(
return {status: response.status, body: await response.json()};
}"""
)
assert result == {"status": 200, "body": {"status": "ok", "version": __version__, "rooms": 0}}
assert result["status"] == 200
body = result["body"]
assert body["status"] == "ok"
assert body["version"] == __version__
assert isinstance(body["rooms"], int) and body["rooms"] >= 0
assert console_errors == []
@@ -159,16 +216,32 @@ def test_the_last_code_is_remembered_for_the_next_visit(page: Page, live_server:
open_shell(page, live_server)
page.locator("#join-code").press_sequentially("k7xyz")
page.get_by_role("button", name="Join").click()
expect(page.locator("#status")).to_have_text(NOT_IMPLEMENTED)
# K7XYZ is not a live room: the error screen is where a dead code lands.
expect(page.locator("#error-title")).to_have_text(
"That kitchen is gone", timeout=20_000
)
open_shell(page, live_server)
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ"
assert page.locator("#join-code").input_value() == "K7XYZ"
def test_the_canvas_stays_hidden_until_the_renderer_exists(page: Page, live_server: str) -> None:
"""Phase 07 unhides `#kcanvas`; until then it is out of the layout and a11y tree."""
def test_the_canvas_is_absent_outside_the_play_screen(page: Page, live_server: str) -> None:
"""The canvas lives on the play screen (task 02); off it there is no
`#kcanvas` at all, and on it the canvas mounts hidden and out of the
a11y tree until task 04's renderer sizes and unhides it."""
open_shell(page, live_server)
assert page.locator("#kcanvas").count() == 0
assert (
page.evaluate(
"""() => {
const app = window.__undercooked;
app.show('playing', {});
return document.querySelector('#kcanvas');
}"""
)
is not None
)
canvas = page.locator("#kcanvas")
expect(canvas).to_be_hidden()
assert canvas.get_attribute("aria-hidden") == "true"
+175
View File
@@ -0,0 +1,175 @@
"""US-02 (join by code) e2e: a second browser tab joins the kitchen.
The join form normalises the typed code (uppercase, five characters —
the confusable mapping `I L O U` is the server's, not the client's),
remembers it in `sessionStorage` (a reload rejoins fast) and opens
`/ws/{code}`; the seat/spectate decision is the server's (the
`docs/PROTOCOL.md` close-code table). This suite covers: two contexts
seeing each other's seat (the phase's "two tabs in one lobby see each
other's seat" criterion), a bad code (unknown code → `room_gone` → the
error screen without a same-code retry), a full room (a fresh token on
a `max_players=2` room → `room_full` → "try another room"), and the
last-code prefill after a reload. All of it keyboard-first.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from conftest import VIEWPORT, app_screen, load_app, open_two_pages
if TYPE_CHECKING:
from playwright.sync_api import Browser, Page
pytestmark = pytest.mark.e2e
def _join_ui(page: Page, code: str) -> None:
"""The keyboard-first join: focus the code field, type, Enter (submit)."""
page.locator("#join-code").focus()
page.keyboard.type(code)
page.keyboard.press("Enter")
def test_join_by_code_two_contexts_see_each_others_seats(
browser: Browser, round_server
) -> None:
"""A creates (name "Ada"), B joins by the typed code; *both* pages
show both seats — A with the crown, B with the server's fallback
name — and B's page shows A's colour chip (the 4-colour palette)."""
url = round_server.url
context_a, page_a, context_b, page_b = open_two_pages(browser, url)
try:
load_app(page_a, url)
load_app(page_b, url)
page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })")
expect(page_a.locator("#room-code")).to_be_visible(timeout=20000)
code = page_a.locator("#room-code").inner_text()
_join_ui(page_b, code)
expect(page_b.locator("#room-code")).to_have_text(code, timeout=20000)
assert app_screen(page_b) == "lobby"
# B's page: two seats — Ada (crown) and B's own row (the fallback
# name, the colour chip, marked data-you).
expect(page_b.locator("#seat-list")).to_contain_text("Ada")
you_b = page_b.locator("#seat-list li[data-you]")
expect(you_b).to_contain_text("Cook #2")
expect(you_b.locator(".chip")).to_be_visible()
expect(page_b.locator("#seat-list li:has(.crown)")).to_contain_text("Ada")
# A's page: B's join is a lobby re-broadcast — the second seat
# row is there too.
expect(page_a.locator("#seat-list")).to_contain_text("Cook #2")
assert page_a.locator("#seat-list li").count() == 2
finally:
context_a.close()
context_b.close()
def test_a_bad_code_is_a_friendly_error_without_retry(
browser: Browser, round_server
) -> None:
"""An unminted code (a Crockford-valid `ZZZZZ`): the server answers
`err room_gone` + close `4401` — the error screen's copy names the
situation, offers *no* same-code retry (the room is gone) and the
*Back to landing* button works."""
url = round_server.url
context = browser.new_context(viewport=VIEWPORT)
page = context.new_page()
try:
load_app(page, url)
_join_ui(page, "ZZZZZ")
expect(page.locator("#error-title")).to_have_text("That kitchen is gone", timeout=20000)
assert app_screen(page) == "error"
# No same-code retry affordance.
assert page.locator("#error-retry").count() == 0
# …but the code is shown for support, and back-to-landing works.
expect(page.locator("#error-code")).to_contain_text("ZZZZZ")
page.get_by_role("button", name="Back to landing").click()
assert app_screen(page) == "landing"
finally:
context.close()
def test_joining_a_full_room_is_a_friendly_error(
browser: Browser, small_server
) -> None:
"""A `max_players=2` room with two seats: a third fresh token is a
`room_full` refusal (close `4402`) — the error screen's copy points
at another room, and the two seated tabs are untouched."""
url = small_server.url
context_a, page_a, context_b, page_b = open_two_pages(browser, url)
context_c = browser.new_context(viewport=VIEWPORT)
page_c = context_c.new_page()
try:
load_app(page_a, url)
load_app(page_b, url)
load_app(page_c, url)
page_a.evaluate("() => window.__undercooked.createLobby()")
expect(page_a.locator("#room-code")).to_be_visible(timeout=20000)
code = page_a.locator("#room-code").inner_text()
_join_ui(page_b, code)
expect(page_b.locator("#room-code")).to_have_text(code, timeout=20000)
_join_ui(page_c, code)
expect(page_c.locator("#error-title")).to_have_text("That kitchen is full", timeout=20000)
assert page_c.locator("#error-retry").count() == 0
expect(page_c.locator("#error-detail")).to_contain_text("another")
# The two seated tabs are still in the lobby, both seats intact.
assert app_screen(page_a) == "lobby"
assert app_screen(page_b) == "lobby"
assert page_a.locator("#seat-list li").count() == 2
finally:
context_a.close()
context_b.close()
context_c.close()
def test_the_last_code_prefills_after_reload_and_normalises(
browser: Browser, round_server
) -> None:
"""Typed lowercase, normalised to uppercase on submit, remembered in
`sessionStorage` — a reload pre-fills the join form (the task's
last-code prefill)."""
url = round_server.url
context = browser.new_context(viewport=VIEWPORT)
page = context.new_page()
try:
load_app(page, url)
_join_ui(page, "k7xyz") # → normalised to K7XYZ (an unminted code)
# K7XYZ is not a real room here: the error screen is the proof
# the code left the field.
expect(page.locator("#error-code")).to_contain_text("K7XYZ", timeout=20000)
page.get_by_role("button", name="Back to landing").click()
load_app(page, url) # the reload
assert page.locator("#join-code").input_value() == "K7XYZ"
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ"
finally:
context.close()
def test_the_join_form_is_keyboard_operable(browser: Browser, round_server) -> None:
"""Tab order into the join form, type with the keyboard, submit with
Enter (no pointer anywhere). `abcde` is an unminted Crockford code,
so the Enter submit ends on the error screen — the proof the frame
left the field through the keyboard alone."""
url = round_server.url
context = browser.new_context(viewport=VIEWPORT)
page = context.new_page()
try:
load_app(page, url)
page.keyboard.press("Tab") # → the Create button
assert page.evaluate("document.activeElement.id") == "create-lobby"
page.keyboard.press("Tab") # → the code field
assert page.evaluate("document.activeElement.id") == "join-code"
page.keyboard.type("abcde")
page.keyboard.press("Enter")
expect(page.locator("#error-code")).to_contain_text("ABCDE", timeout=20000)
finally:
context.close()
+406
View File
@@ -0,0 +1,406 @@
"""US-03 (ready up and start) e2e: two tabs ready up, the host starts,
and the round runs to a result screen.
Two contexts against the `round_server` (the `levels_round` campaign —
the shipped level 1 layout-verbatim, `target_score` 20 / `time_limit`
60 s: one served soup scores 44-66 by `sim/scoring.py`, so the *first*
serve wins the round — the "forced short round" of the task's test
list; a `time_limit: 4` level would not survive the validator's floor).
The lobby half, over the UI (keyboard-first): both tabs show the ready
state of both seats, the non-host's *Start* is disabled with the named
reason ("Only the host can start"), the host's *Level* picker is fed
by `/api/levels` (difficulty pips, target, time) while the non-host's
is disabled, the host's *Start* is "Waiting for ready cooks" until a
non-host ready-up, enabled after, and the 3-2-1 `countdown` shows on
both tabs before the kitchen (`#kcanvas` host + `#hud` root).
The round half: the host's tab drives the scripted soup through the
`window.__undercooked` seam (`sendInput` — walk, shelf pick, board
hold, stove hold, prep hold, serve press; the phase-06
`test_ws_round_two_clients.py` choreography, ports of its waypoints
and staging points verbatim), the round ends `WON` on the serve, and
*both* tabs show the result screen: the result word, the stars, the
score against the target, the session's best, the per-player rows, the
counters, the exits (no *Next level* — the campaign's single level
unlocks nothing on a win) and the `aria-live="polite"` announcement.
"""
from __future__ import annotations
import itertools
import re
from typing import TYPE_CHECKING
import pytest
from playwright.sync_api import expect
from conftest import app_screen, load_app, open_two_pages
if TYPE_CHECKING:
from playwright.sync_api import Browser, Page
pytestmark = pytest.mark.e2e
def _join_ui(page: Page, code: str) -> None:
"""The keyboard-first join: focus the code field, type, Enter (submit)."""
page.locator("#join-code").focus()
page.keyboard.type(code)
page.keyboard.press("Enter")
def _collect_countdown(page: Page, window_s: float) -> list[str]:
"""The `#countdown-num` texts seen over the window (one per poll)."""
seen: list[str] = []
deadline = page.evaluate("performance.now()") + window_s * 1000
while page.evaluate("performance.now()") < deadline:
page.wait_for_timeout(100)
value = page.evaluate(
"() => {\n"
" const num = document.querySelector('#countdown-num');\n"
" return num === null ? null : num.textContent;\n"
"}"
)
if value is not None:
seen.append(value)
return seen
def _assert_countdown_sequence(name: str, seen: list[str], *, must_start_at_three: bool) -> None:
"""The numerals the tab saw: non-increasing, ending at 1, starting at
the 3 (the starter) or the 2 (the late joiner)."""
assert seen, f"{name} saw no countdown numerals"
assert "1" in seen, f"{name} never reached the 1: {seen}"
for earlier, later in itertools.pairwise(seen):
assert int(later) <= int(earlier), f"{name}'s countdown went up: {seen}"
assert seen[-1] == "1", f"{name}'s countdown did not end on the 1: {seen}"
if must_start_at_three:
assert seen[0] == "3", f"{name} missed the 3: {seen}"
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: a release
// frame dropped by the token bucket sticks the hold, so a press whose
// edge is not a rising edge is a silent no-op. An explicit release
// before each attempt breaks the stuck state; a missed attempt retries
// on a fresh rising edge.
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); // spread the attempts: a tight 3-frame cluster is
// exactly what trips the 40/s bucket under load (4404)
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. With the 20 Hz walk feed (sequential) the total
// stays under the 40/s token 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 (the 3.5 corridor clears the
// blocking fixtures, the body's edge included). 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). The row-2 corridor clears the board and
// the stove; the last leg comes in west-to-east along row 1.
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). The prep's staging point is its east
// neighbour, reached along row 3.
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. Serve it (the window is walkable: stand on it, one press —
// retried: a dropped press frame is a silent no-op, the dish
// stays in the hand for a fresh rising edge).
await walk([SERVE_STAGE], 0.4, 90);
for (let i = 0; i < 3; i += 1) {
if (app.screen !== 'playing') break; // the round is over: an `input`
// frame in ROUND_END is a wrong_state (5 close 4404)
app.sendInput(null, false);
await sleep(250);
if (app.screen !== 'playing') break;
await press();
await sleep(2500);
}
return true;
}"""
def test_ready_start_and_countdown_on_both_clients(
browser: Browser, round_server
) -> None:
"""Both tabs ready up; the non-host's *Start* is disabled with the
named reason and the host's is "Waiting for ready cooks" until the
ready-up, enabled after. The host's level picker is fed by
`/api/levels` (pips, target, time) and selected; the non-host's
picker is disabled. The 3-2-1 countdown shows on both tabs, then
the kitchen on both."""
url = round_server.url
context_a, page_a, context_b, page_b = open_two_pages(browser, url)
try:
load_app(page_a, url)
load_app(page_b, url)
page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })")
expect(page_a.locator("#room-code")).to_be_visible(timeout=20_000)
code = page_a.locator("#room-code").inner_text()
_join_ui(page_b, code)
expect(page_b.locator("#room-code")).to_have_text(code, timeout=20_000)
assert app_screen(page_a) == "lobby"
assert app_screen(page_b) == "lobby"
# Both tabs see both seats (A's join re-broadcast reached B too —
# the US-02 criterion, re-asserted where the story needs it).
expect(page_b.locator("#seat-list")).to_contain_text("Ada")
expect(page_a.locator("#seat-list")).to_contain_text("Cook #2")
# The level picker: Ada's is enabled and fed by /api/levels —
# the campaign's level is selected, with its pips/target/time.
assert page_a.locator("#level-picker").is_enabled()
options_a = page_a.locator("#level-picker option")
assert options_a.count() >= 2 # the "Pick a level" placeholder + the level
assert page_a.locator("#level-picker").input_value() == "01_first_kitchen"
expect(options_a.nth(1)).to_contain_text("First Kitchen")
expect(options_a.nth(1)).to_contain_text("20 pts / 60 s")
expect(options_a.nth(1)).to_contain_text("●") # the difficulty pips
# The non-host's picker is disabled (the host-only control).
assert page_b.locator("#level-picker").is_disabled()
expect(page_b.locator("#level-label")).to_have_text("Level (host only)")
# Start before the ready-up: disabled with the named reasons.
assert page_a.locator("#start-btn").is_disabled()
expect(page_a.locator("#start-reason")).to_have_text("Waiting for ready cooks")
assert page_b.locator("#start-btn").is_disabled()
expect(page_b.locator("#start-reason")).to_have_text("Only the host can start")
# Cook #2 ready-up (keyboard-first): the toggle label shows the
# state; *both* tabs see the ready badge on the seat row.
page_b.get_by_role("button", name="Ready up").focus()
page_b.keyboard.press("Enter")
expect(page_b.locator("#ready-btn")).to_have_text("Unready")
expect(page_b.locator("#seat-list li[data-you]")).to_contain_text("ready")
expect(page_a.locator("#seat-list li:has(.ready-badge)")).to_contain_text("Cook #2")
# The host's Start is enabled now (the reason line hides).
assert page_a.locator("#start-btn").is_enabled()
assert page_a.locator("#start-reason").is_hidden()
# The countdown: the host tab collects its numerals live (the
# 3 s wall-clock countdown, one frame per whole second — the
# *frame identity* on both clients is the phase-06 two-client
# integration suite's assertion; here the e2e proves the host
# tab's 3-2-1 and that the second tab reaches the kitchen).
page_a.get_by_role("button", name="Start").focus()
page_a.keyboard.press("Enter")
countdown_a = _collect_countdown(page_a, 6.0)
_assert_countdown_sequence("Ada", countdown_a, must_start_at_three=True)
# Both tabs on the kitchen (the snap stream): the canvas host
# present (hidden until the renderer sizes it) and the HUD root.
expect(page_b.locator("#kcanvas")).to_have_count(1, timeout=30_000)
expect(page_a.locator("#kcanvas")).to_have_count(1)
expect(page_b.locator("#hud")).to_have_count(1)
expect(page_a.locator("#hud")).to_have_count(1)
# The canvas is still hidden and out of the a11y tree (task 04).
assert page_a.locator("#kcanvas").get_attribute("aria-hidden") == "true"
# The round is live on both tabs.
assert app_screen(page_a) == "playing"
assert app_screen(page_b) == "playing"
finally:
context_a.close()
context_b.close()
def test_the_result_screen_after_a_forced_round(
browser: Browser, round_server
) -> None:
"""The full run: ready up, start, the scripted soup on the host's
tab, the `round` frame, and the result screen on *both* tabs — the
word, the stars, the score/target, the best, the per-player rows,
the counters, the exits, the aria-live announcement."""
url = round_server.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)
page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })")
expect(page_a.locator("#room-code")).to_be_visible(timeout=20_000)
code = page_a.locator("#room-code").inner_text()
_join_ui(page_b, code)
expect(page_b.locator("#room-code")).to_have_text(code, timeout=20_000)
page_b.get_by_role("button", name="Ready up").focus()
page_b.keyboard.press("Enter")
expect(page_a.locator("#start-btn")).to_be_enabled(timeout=20_000)
page_a.get_by_role("button", name="Start").focus()
page_a.keyboard.press("Enter")
# Both tabs on the kitchen before the soup starts.
for page in (page_a, page_b):
expect(page.locator("#kcanvas")).to_have_count(1, timeout=30_000)
assert app_screen(page_a) == "playing"
assert app_screen(page_b) == "playing"
# The scripted soup (the host's tab; the ~25 Hz hold feed keeps the
# holds continuous under load, the walk feed ~16.7 Hz — the phase-06
# choreography, holds until the view's stations read `ready`).
# The run outlasts the 30 s default action timeout, so the window
# is raised for the call and restored after (the choreography's
# under-load margins — 90/60 s walks, 60 s stove hold — sum to
# far less than the 600 s window in the worst case).
page_a.set_default_timeout(600_000)
try:
page_a.evaluate(SOUP_CHOREOGRAPHY)
finally:
page_a.set_default_timeout(30_000)
# The round frame landed on *both* tabs: the result screen.
for page, name in ((page_a, "Ada"), (page_b, "Cook #2")):
expect(page.locator("#result-word")).to_be_visible(timeout=30_000)
assert app_screen(page) == "round_end", name
expect(page.locator("#result-word")).to_have_text("Won")
# Stars: 1-3 (a single soup is 44-66 of the 20 target — the
# ratio's band), labelled for the a11y tree.
expect(page.locator("#stars")).to_have_attribute(
"aria-label", re.compile(r"[1-3] of 3 stars")
)
# Score against target: a served soup is 44-66 pts of 20.
match = re.fullmatch(r"Score (\d+) of 20", page.locator("#score-line").inner_text())
assert match is not None, page.locator("#score-line").inner_text()
assert int(match.group(1)) >= 20
# The per-player rows: both cooks, with a pts/dishes line.
expect(page.locator("#result-players")).to_contain_text("Ada")
expect(page.locator("#result-players")).to_contain_text("Cook #2")
expect(page.locator("#result-players li").first).to_contain_text(
re.compile(r"\d+ pts")
)
# The counters: one served dish, a streak of one.
counters = page.locator("#counters-line").inner_text()
assert "Served 1" in counters, counters
assert "Longest streak 1" in counters, counters
# No *Next level*: the campaign's single level unlocks nothing
# on a win — only Retry and Back to lobby.
assert page.locator("#next-level-btn").count() == 0
expect(page.get_by_role("button", name="Retry")).to_be_visible()
expect(page.get_by_role("button", name="Back to lobby")).to_be_visible()
# The aria-live announcement of the outcome.
expect(page.locator("#result-announce")).to_have_text(
re.compile(r"You won \d+ of 20 points with [1-3] stars?")
)
# The session's best (the `round` frame's record lands for every
# seated player; the fetch resolves it on both tabs).
for page in (page_a, page_b):
expect(page.locator("#best-line")).to_contain_text(
re.compile(r"Best: \d+ pts"), timeout=20_000
)
finally:
context_a.close()
context_b.close()
File diff suppressed because it is too large Load Diff
+724
View File
@@ -0,0 +1,724 @@
"""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()
+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)
+404
View File
@@ -0,0 +1,404 @@
"""Phase 07 task 03 — interpolation smoothness with a 60 ms latency.
The task's smoothness criterion, end to end: two browsers against a
server with `sim_latency_ms=60` (the injected server-side downlink
delay, `config.py`'s netcode seam), the host's tab drives the scripted
soup (the `test_us03_ready_and_start.py` choreography — the same
waypoints and holds), and the second tab's *rendered* state — the
single loop's `sample → lerpState` output published on
`window.__uc.lastView` — is sampled per animation frame while the
round runs.
The assertions:
* **the per-tick displacement is bounded** — a server tick moves a
cook at most `MAX_SPEED * STEP_DT` (3.5 * 0.05 = 0.175); the bound
the task asserts is `MAX_SPEED * (1/snapshot_hz) * 1.35`
(0.23625). Checked on the buffer's *server frames* (consecutive
ticks — the sampler records the frame's `t` and only pairs a
displacement with its one-tick advance),
* **the rendered stream is smooth** — a sample's step is bounded by
its gap's share of the 50 ms tick (a 100 ms gap moves at most two
ticks) plus the 1.35 factor, and the velocity over a normal gap is
at most `MAX_SPEED * 1.35` tiles/s,
* **both clients converge on `ROUND_END` within 1 s** — the round
frame is broadcast once; with the 60 ms injected delay the second
tab's result screen must follow the host's within the second.
The second tab's sampler is a browser-side `requestAnimationFrame`
promise (started while the host's choreography runs, so the sampling
is live), reading only — the single-loop rule is untouched (one loop
owns the buffer; the probe reads the loop's published `lastView` and
the buffer's `latest()`).
"""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import pytest
from conftest import ROUND_LEVELS_DIR, app_screen, load_app, open_two_pages
if TYPE_CHECKING:
from playwright.sync_api import Browser
pytestmark = pytest.mark.e2e
#: The host's tab drives the soup — the `test_us03_ready_and_start.py`
#: choreography, the same waypoints and holds (the phase-06 two-client
#: machine, verbatim).
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, 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. An early pick (the last hold frame
// landed late) is honoured: no press while the item is in hand.
const pick = async (what, short) => {
for (let i = 0; i < 3; i += 1) {
if (carrying(short)) return;
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 (the 3.5 corridor clears the
// blocking fixtures, the body's edge included). 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, 60);
await pick('pick the tomato', 'tomato_raw');
// 2. Chop it (0.9 s of work).
await walk([BOARD_STAGE], 0.4, 45);
await holdUntil(() => stationReady('chop_board'), 30);
await pick('pick the chopped tomato', 'tomato_chopped');
// 3. Cook it (6 s of work). The row-2 corridor clears the board and
// the stove; the last leg comes in west-to-east along row 1.
await walk([[3.5, 2.5], [5.5, 2.5], [5.5, 1.5], STOVE_STAGE], 0.4, 60);
await holdUntil(() => stationReady('stove'), 40);
await pick('pick the cooked tomato', 'tomato_cooked');
// 4. Plate it (0.6 s of work). The prep's staging point is its east
// neighbour, reached along row 3.
await walk([[7.5, 3.5], PREP_STAGE], 0.4, 45);
await holdUntil(() => stationReady('prep'), 30);
await pick('pick the plated dish', 'tomato_plated');
// 5. Serve it — retried: 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. The round ends on the win (target 20), so an
// `input` frame after the verdict is a wrong_state (5 close 4404)
// — the screen check stops the feed the moment it does.
await walk([SERVE_STAGE], 0.4, 45);
for (let i = 0; i < 3; i += 1) {
if (app.screen !== 'playing') break;
app.sendInput(null, false);
await sleep(250);
if (app.screen !== 'playing') break;
await press();
await sleep(1500);
}
return true;
}"""
#: The sampler's contract: it records `{at, x, y, frameT, frameX,
#: frameY, frameStep}` per animation frame — `at` the
#: `performance.now()` of the sample, `x`/`y` the *rendered*
#: (interpolated) position of the remote cook, `frameT` the buffer's
#: latest server frame's tick, and `frameStep` the displacement from
#: the previous sample's server frame — set only when the frame
#: advanced by exactly one tick (a sample that straddles two ticks is
#: not a per-tick displacement). It resolves on the tab's result screen
#: (or after 120 s, whatever it has).
SAMPLER_JS = """
() => {
window.__uc_sampling = new Promise((resolve) => {
const samples = [];
const startedAt = performance.now();
const sample = () => {
const uc = window.__uc;
const app = window.__undercooked;
if (uc !== undefined && app !== null && uc.lastView !== null) {
const view = uc.lastView;
const own = app.you === null ? -1 : app.you.seat;
const remote = (view.p || []).find((p) => p.pi !== own);
if (remote !== undefined) {
const latest = uc.buffer !== null ? uc.buffer.latest() : null;
let frameT = null;
let frameX = null;
let frameY = null;
let frameStep = null;
let frameJump = 1;
if (latest !== null && latest.p.length > 0) {
const latestRemote = latest.p.find((p) => p.pi !== own);
if (latestRemote !== undefined) {
frameT = latest.t;
frameX = latestRemote.x;
frameY = latestRemote.y;
const last = samples[samples.length - 1];
if (last !== undefined && last.frameT !== null) {
const jump = frameT - last.frameT;
if (jump > 1) frameJump = jump; // a delivery hiccup
if (jump === 1) {
frameStep = Math.hypot(frameX - last.frameX, frameY - last.frameY);
}
}
}
}
samples.push({
at: performance.now(), x: remote.x, y: remote.y,
frameT, frameX, frameY, frameStep, frameJump,
});
}
}
if (document.querySelector('#result-word') !== null) { resolve(samples); return; }
if (performance.now() - startedAt > 120000) { resolve(samples); return; }
requestAnimationFrame(sample);
};
sample();
});
return 'started';
}
"""
#: `MAX_SPEED * (1 / SNAPSHOT_HZ) * 1.35` — the task's per-frame bound.
STEP_BOUND = 3.5 * 0.05 * 1.35
#: `MAX_SPEED * 1.35` — the rendered velocity bound (tiles / second).
SPEED_BOUND = 3.5 * 1.35
def _assert_stream(samples: list[dict]) -> None:
"""The two smoothness assertions (the sample's step bound, the
per-tick displacement bound)."""
# Enough samples to talk about a stream (the round ran well past
# the warm-up; 30 samples at ~60 fps is half a second).
assert len(samples) >= 30, f"only {len(samples)} samples recorded"
# The per-tick displacement (consecutive server ticks, from the
# buffer's latest frames): the bound is
# `MAX_SPEED * (1/snapshot_hz) * 1.35` (0.23625).
frame_steps = [
s["frameStep"] for s in samples if s.get("frameStep") is not None
]
assert len(frame_steps) >= 30, (
f"only {len(frame_steps)} one-tick displacements (the buffer was empty?)"
)
worst_tick = max(frame_steps)
assert worst_tick <= STEP_BOUND, (
f"a server tick moved the cook {worst_tick:.4f} tiles "
f"(> {STEP_BOUND:.4f} = MAX_SPEED * 1/snapshot_hz * 1.35)"
)
# The rendered positions: a sample's step is bounded by its gap's
# share of the 50 ms tick (a 100 ms gap moves at most two
# ticks) plus the 1.35 factor; a delivery hiccup (the latest
# frame's tick jumping ahead — a stall's resume) scales the
# allowance by the jump, and the velocity over a normal gap is
# at most `MAX_SPEED * 1.35` tiles/s (a hiccup's jump is not a
# velocity).
for i in range(1, len(samples)):
a, b = samples[i - 1], samples[i]
dt_ms = b["at"] - a["at"]
if dt_ms <= 0:
continue
jump = max(1, b.get("frameJump", 1))
step = ((b["x"] - a["x"]) ** 2 + (b["y"] - a["y"]) ** 2) ** 0.5
allowance = STEP_BOUND * max(dt_ms / 50.0 + 0.5, jump)
assert step <= allowance, (
f"the rendered step {step:.4f} at a {dt_ms:.1f} ms gap (frame jump "
f"{jump}) exceeds the allowed {allowance:.4f}"
)
if dt_ms >= 25.0 and jump <= dt_ms / 50.0 + 0.5:
velocity = step / (dt_ms / 1000.0)
assert velocity <= SPEED_BOUND, (
f"the rendered velocity {velocity:.3f} tiles/s over a "
f"{dt_ms:.1f} ms gap exceeds {SPEED_BOUND:.3f}"
)
def _join_ui(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 test_the_rendered_stream_is_bounded_and_converges_within_a_second(
browser: Browser, e2e_server
) -> None:
"""The 60 ms-latency round: the scripted soup on the host's tab,
per-frame sampling of the second tab's rendered state, the per-tick
displacement bounded by `MAX_SPEED * (1/snapshot_hz) * 1.35`, the
rendered stream smooth, and both clients on the result screen
within 1 s of each other."""
url = e2e_server(ROUND_LEVELS_DIR, sim_latency_ms=60).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)
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")
# The host's Start is enabled once the ready-up's re-broadcast
# lands (a visible-but-disabled Start swallows the 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")
for page in (page_a, page_b):
page.locator("#kcanvas").wait_for(state="attached", timeout=30_000)
assert app_screen(page_a) == "playing"
assert app_screen(page_b) == "playing"
# The second tab's sampler starts now (a browser-side rAF
# promise — live while the choreography's evaluate blocks this
# one); the host's tab drives the soup (the ~60 s run, the
# us03 margins).
assert page_b.evaluate(SAMPLER_JS) == "started"
page_a.set_default_timeout(300_000)
try:
page_a.evaluate(SOUP_CHOREOGRAPHY)
finally:
page_a.set_default_timeout(30_000)
# The host is on the result screen (the round frame landed).
# The window covers the 60 s buzzer: with the injected 60 ms
# lag the host's *own* view trails the server truth, so the
# soup's final serve press can miss the window's reach zone
# and the round runs to the buzzer (a LOST is as good as a
# WON for the smoothness and convergence assertions).
page_a.set_default_timeout(90_000)
try:
page_a.locator("#result-word").wait_for(state="visible", timeout=65_000)
finally:
page_a.set_default_timeout(30_000)
assert app_screen(page_a) == "round_end"
convergence_start = time.monotonic()
# The second tab's sampler resolves on *its* result screen.
page_b.set_default_timeout(120_000)
try:
samples = page_b.evaluate("() => window.__uc_sampling")
finally:
page_b.set_default_timeout(30_000)
convergence_s = time.monotonic() - convergence_start
page_b.locator("#result-word").wait_for(state="visible", timeout=10_000)
assert app_screen(page_b) == "round_end"
assert convergence_s <= 1.0, (
f"the second tab's result screen came {convergence_s:.2f} s after the host's "
"(the 60 ms latency round must converge within the second)"
)
_assert_stream(samples)
finally:
context_a.close()
context_b.close()