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()
+44
View File
@@ -0,0 +1,44 @@
# tests/fixtures/levels_us04/01_first_kitchen.yaml — the phase-07 task-04
# campaign: the shipped level 1's layout, with `target_score` 100 and
# `time_limit` 120 s. One served soup scores 44-66
# (`round(40 x 1.0 x 1.1 x (1 + 0.5 x fraction))` — sim/scoring.py), so a
# *single* serve (44-66 < 100) does **not** end the round: the HUD's
# score text is observable changing after the serve (the task's "within
# 200 ms of the server event" assertion needs the round to outlive the
# serve), and the render budget is sampled over a longer window.
id: 01_first_kitchen
name: First Kitchen
order: 1
difficulty: 1
time_limit: 120
target_score: 100
blurb: >-
The e2e campaign's kitchen: one dish at a time, open floor, no hazards.
grid:
- "############"
- "#T..C...S..#"
- "#..........#"
- "#.P......X.#"
- "#..........#"
- "#..K....O..#"
- "#..........#"
- "#..........#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 7], [9, 7]]
hazards: []
orders:
pool: [soup]
concurrent: 1
spawn_interval: [16, 20]
deadline: 60
+80
View File
@@ -0,0 +1,80 @@
# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8).
#
# Every dish the game knows lives here; no Python enumerates recipes. The
# schema is `src/undercooked/sim/recipe.py` and the reference is
# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here
# fails the load loudly instead of silently defaulting.
#
# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf,
# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the
# window. `seconds` feeds only the validator's greedy target-plausibility
# bound; the simulation (phase 04) re-enacts these steps per tick.
# Catalog of the item kinds the campaign's dishes consume (documentation of
# intent; spawn shelves are what actually put items on the floor).
ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate]
recipes:
# tomato + onion -> chop -> pot on the stove -> plate
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, soup]
# lettuce + tomato -> chop -> plate; the fast, no-fire dish
salad:
id: salad
name: Garden Salad
base_score: 30
ingredients:
- {kind: lettuce, form: raw}
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [cold, quick]
# rice + onion straight onto the stove (PLAN §5: raw rice needs no board)
fried_rice:
id: fried_rice
name: Fried Rice
base_score: 50
ingredients:
- {kind: rice, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: cook, station: stove, seconds: 6.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, grain]
# patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now;
# later levels (phase 11) put it in an order pool.
burger:
id: burger
name: Chef's Burger
base_score: 70
ingredients:
- {kind: patty, form: raw}
- {kind: dough, form: raw}
- {kind: lettuce, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.5}
- {kind: serve, station: serve}
tags: [hot, stacked]
+32 -13
View File
@@ -111,25 +111,30 @@ def test_root_serves_the_shell_html(client: TestClient) -> None:
assert response.headers["content-type"].startswith("text/html")
assert int(response.headers["content-length"]) > 500 # streamed from the file
html = response.text
assert 'src="/static/js/app.js"' in html
assert 'src="/static/js/main.js"' in html # the composition root (task 03)
assert 'href="/static/css/base.css"' in html
assert 'id="screen-lobby"' in html
assert 'aria-live="polite"' in html
assert 'href="/static/css/screens.css"' in html
assert 'id="screen-root"' in html
assert (
'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html
)
def test_shell_offers_the_lobby_controls(client: TestClient) -> None:
"""The controls are mounted by the landing screen module (phase 07
task 02); the shell offers the screen root they mount into."""
html = client.get("/").text
assert 'id="create-lobby"' in html
assert 'id="join-form"' in html
assert 'id="join-code"' in html
assert 'inputmode="text"' in html
assert 'autocapitalize="characters"' in html
assert 'size="5"' in html # five characters wide: no 20-char intrinsic box
assert 'maxlength="5"' in html
assert 'for="join-code"' in html # the code input has a real label
assert 'id="screen-root"' in html
landing = client.get("/static/js/screens/landing.js")
assert landing.status_code == 200
text = landing.text
assert 'id: "create-lobby"' in text
assert 'id: "join-form"' in text
assert 'id: "join-code"' in text
assert 'inputmode: "text"' in text
assert 'autocapitalize: "characters"' in text
assert "maxlength: String(CODE_LENGTH)" in text # five characters: no 20-char intrinsic box
assert 'for: "join-code"' in text # the code input has a real label
def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -> None:
@@ -138,10 +143,24 @@ def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -
assert css.headers["content-type"].startswith("text/css")
assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in CSS
script = client.get("/static/js/app.js")
screens_css = client.get("/static/css/screens.css")
assert screens_css.status_code == 200
assert screens_css.headers["content-type"].startswith("text/css")
script = client.get("/static/js/main.js")
assert script.status_code == 200
assert script.headers["content-type"].startswith("text/javascript")
assert "uc.token" in script.text
assert "createApp" in script.text # the composition root imports the machine
app = client.get("/static/js/app.js")
assert app.status_code == 200
assert app.headers["content-type"].startswith("text/javascript")
assert "createApp" in app.text
identity = client.get("/static/js/identity.js")
assert identity.status_code == 200
assert identity.headers["content-type"].startswith("text/javascript")
assert "uc.token" in identity.text # the per-tab token lives in identity.js (A16)
def test_unknown_path_is_404(client: TestClient) -> None:
+24 -1
View File
@@ -21,7 +21,7 @@ import pytest
from undercooked.config import Settings
from undercooked.server.clock import FakeClock
from undercooked.server.codes import CROCKFORD
from undercooked.server.room import Room, RoomState
from undercooked.server.room import TEST_MODE_SEED, Room, RoomState
from undercooked.server.rooms import (
GC_INTERVAL_S,
SEAT_GRACE_S,
@@ -473,3 +473,26 @@ async def test_gc_loop_runs_on_the_injected_cadence_and_is_cancellable() -> None
with pytest.raises(asyncio.CancelledError):
await next(iter(loop_task))
assert clock.now() == 3 * GC_INTERVAL_S # exactly three 5-second cycles
# ---------------------------------------------------------------- the test-mode seed
async def test_create_room_in_test_mode_uses_the_documented_fixed_seed() -> None:
"""The test-only seam: under `UNDERCOOKED_TEST_MODE=1` the room is born
with the documented fixed seed, so a `?seed=20240701` page plays the
seed the room's sim actually draws (the e2e flagship's determinism)."""
supervisor, clock, _ = make_supervisor(test_mode=True)
room = supervisor.create_room(new_session("Ada", clock), "Ada")
assert room._seed_cursor == TEST_MODE_SEED
async def test_create_room_outside_test_mode_draws_a_fresh_seed() -> None:
"""A production room never pins the seam's value: the base seed is a
fresh draw from `secrets.randbelow(_SEED_MODULUS)` (the range check —
a value equal to the constant is possible in principle, so the
assertion is the range, not an inequality)."""
supervisor, clock, _ = make_supervisor() # test_mode defaults to False
room = supervisor.create_room(new_session("Ada", clock), "Ada")
assert isinstance(room._seed_cursor, int)
assert 0 <= room._seed_cursor < 2**63
+31
View File
@@ -195,6 +195,37 @@ async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None:
assert len(lobby2["seats"]) == 2
async def test_a_new_seat_rebroadcasts_the_lobby_to_existing_members(
ws_server: Any,
) -> None:
"""PLAN §7: the lobby is re-broadcast on lobby-state changes, not
only to the joiner — a member who is already in the room must see
the new seat appear (and a ghost rejoin revives it), so the lobby
screen in an open tab updates without a refresh."""
with ws_server() as (url, _app):
code, _ = _create_room(url, "tok-host")
async with await _connect(url, code, "tok-host", name="Host") as host:
welcome = await _next_json(host)
assert welcome["type"] == "welcome"
await _next_json(host) # the initial lobby (one seat)
# The joiner arrives on their own socket: welcome + lobby for
# them, and a *re-broadcast* for the host.
async with await _connect(
url, code, "tok-joiner", name="Joiner"
) as joiner:
j_welcome = await _next_json(joiner)
assert j_welcome["type"] == "welcome"
assert j_welcome["you"]["seat"] == 1
j_lobby = await _next_json(joiner)
assert j_lobby["type"] == "lobby"
host_lobby = await _next_json(host)
assert host_lobby["type"] == "lobby"
assert host_lobby["code"] == code
assert len(host_lobby["seats"]) == 2
assert host_lobby["seats"][1]["name"] == "Joiner"
assert host_lobby["seats"][1]["ready"] is False
async def test_ping_before_hello_gets_pong(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
+16 -8
View File
@@ -243,14 +243,23 @@ def _join(url: str, code: str, token: str) -> None:
async def _start_playing(
url: str, app: Any, ws_client: Any
) -> tuple[str, Any, Any, Any]:
) -> tuple[str, Any, Any, Any, Any]:
"""Two clients, the room, and the lobby, to `PLAYING` (the module
docstring's lobby half); returns `(code, room, a, b)`."""
docstring's lobby half); returns `(code, room, a, b, keepalive)`.
The `keepalive` is the documented keep-alive task (the 12 s heartbeat
dead window): it starts the moment both clients are in, because a
slow run (the full-suite load) can stretch the `start`-to-countdown
gap past the window — a `ping` from every client every 2 s keeps the
watchdog quiet through the lobby, the countdown and into the round.
The caller cancels it in its `finally`.
"""
supervisor = app.state.supervisor
code = _create_room(url, "tok-a")
_join(url, code, "tok-b")
a = await ws_client(url, code, "tok-a", name="A")
b = await ws_client(url, code, "tok-b", name="B")
keepalive = asyncio.create_task(_keepalive((a, b)))
for client in (a, b):
welcome = await client.expect("welcome")
assert welcome["state"] == "lobby"
@@ -286,7 +295,7 @@ async def _start_playing(
# they arrived before the countdown frames, so the queue is in order.
for client in (a, b):
await client.collect("lobby", 3, wait_s=10.0)
return code, room, a, b
return code, room, a, b, keepalive
async def _drive_soup(client: Any, pid: str, seq: _Sequencer) -> None:
@@ -437,7 +446,7 @@ async def test_two_clients_play_a_complete_round(
live_server: Any, ws_client: Any
) -> None:
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
code, room, a, b = await _start_playing(url, app, ws_client)
code, room, a, b, keepalive = await _start_playing(url, app, ws_client)
seq_a, seq_b = _Sequencer(), _Sequencer()
# --- the countdown 3 -> 1, then the snap stream
@@ -455,9 +464,7 @@ async def test_two_clients_play_a_complete_round(
for name, frames in (("a", snaps_a), ("b", snaps_b)):
_assert_snap_ticks(name, frames)
# --- the inputs: A the scripted soup, B the walking loop, both
# kept alive past the heartbeat dead window
keepalive = asyncio.create_task(_keepalive((a, b)))
# --- the inputs: A the scripted soup, B the walking loop
soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a))
try:
await _wander(b, "tok-b", seq_b) # B's inputs, while the soup runs
@@ -542,7 +549,7 @@ async def test_a_third_connection_mid_round_is_a_spectator(
live_server: Any, ws_client: Any
) -> None:
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
code, room, a, b = await _start_playing(url, app, ws_client)
code, room, a, b, keepalive = await _start_playing(url, app, ws_client)
seq_a, seq_b = _Sequencer(), _Sequencer()
await a.collect("countdown", 3, wait_s=20.0)
@@ -587,3 +594,4 @@ async def test_a_third_connection_mid_round_is_a_spectator(
await c.close()
await a.close()
await b.close()
keepalive.cancel()
+485
View File
@@ -0,0 +1,485 @@
"""Phase 07 task 01: the in-repo SVG art, guarded like the sim's goldens.
The art is a contract, not decoration: the browser can only draw what
`index.json` names, and `index.json` is what
`undercooked.tools.gen_art_index` derives from the *sim's own* vocabulary
(`undercooked.sim.model`'s enums) and pipeline data
(`undercooked.sim.stations`' `ACCEPTS` / `TRANSITIONS`). So these tests
ask the mechanical questions:
* **the served output is faithful** — every served asset exists, is under
40 KB, is a byte-for-byte copy of its `assets_src/` source, and the
committed `index.json` is exactly what the generator derives from the
source tree;
* **every id has a drawable** — each `index.json` id is a real
`<symbol>` in its named file, sized by its `viewBox`, unique across the
whole set, and free of external `href`/`src`/`http` references;
* **the sim's vocabulary is fully drawn** — every `ItemKind` in every form
the sim can reach has a symbol (and the walk itself is pinned against
the sim's pipeline data), and every `ItemForm` value is covered;
* **the palette is one palette** — `assets/palette.css` and
`js/render/palette.js` carry identical hex values in both scopes
(default and colourblind), and every `var()` fallback the SVG art
relies on resolves to a palette entry;
* **the generator fails loudly** — run against a temporary copy, it
writes the index on a good tree and names a missing expected id, a
duplicated id, a drifted served copy, a missing file, and a symbol
that cannot be identified or sized.
"""
from __future__ import annotations
import json
import re
import shutil
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
from undercooked.sim.model import ItemForm, ItemKind
from undercooked.tools import gen_art_index
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
ASSETS_SRC = REPO_ROOT / "assets_src"
ASSETS_OUT = REPO_ROOT / "app" / "static" / "assets"
INDEX_PATH = ASSETS_OUT / "index.json"
SVG_FILES = gen_art_index.SVG_FILES
MAX_BYTES = 40 * 1024
def load_index() -> dict[str, dict[str, object]]:
"""The committed `index.json`, as a dict."""
return json.loads(INDEX_PATH.read_text(encoding="utf-8"))
def symbols_of(path: Path) -> dict[str, ET.Element]:
"""Every id-tagged `<symbol>` in one SVG file."""
root = ET.parse(path) # noqa: S314 - own hand-authored art files
return {
symbol_id: element
for element in root.iter()
if element.tag.endswith("symbol") and (symbol_id := element.get("id"))
}
def copy_tree(tmp_path: Path) -> tuple[Path, Path]:
"""A throwaway (source, served) pair under ``tmp_path``."""
src = tmp_path / "assets_src"
out = tmp_path / "app" / "static" / "assets"
shutil.copytree(ASSETS_SRC, src)
shutil.copytree(ASSETS_OUT, out)
return src, out
def remove_symbol(path: Path, symbol_id: str) -> None:
"""Delete one `<symbol id=...>...</symbol>` block from a file's text."""
text = path.read_text(encoding="utf-8")
start = text.index(f'<symbol id="{symbol_id}"')
end = text.index("</symbol>", start) + len("</symbol>")
path.write_text(text[:start] + text[end:], encoding="utf-8")
# --------------------------------------------------------------------------- the served output
def test_served_assets_exist_and_are_small() -> None:
"""Every served asset is present and under the 40 KB guard."""
for name in [*SVG_FILES, "index.json", "palette.css"]:
path = ASSETS_OUT / name
assert path.is_file(), f"{path} is missing"
for name in SVG_FILES:
size = (ASSETS_OUT / name).stat().st_size
assert size <= MAX_BYTES, f"{name} is {size} bytes (limit {MAX_BYTES})"
def test_served_copies_match_the_source_art() -> None:
"""`app/static/assets/` is a faithful copy of `assets_src/`."""
assert gen_art_index.check_served(ASSETS_SRC, ASSETS_OUT) == []
def test_index_json_is_what_the_generator_derives() -> None:
"""The committed index cannot drift from the source tree."""
assert gen_art_index.build_index(ASSETS_SRC) == load_index()
def test_every_index_id_has_a_symbol_with_matching_size() -> None:
"""Each id is a real `<symbol>` in its named file, sized by viewBox."""
index = load_index()
assert len(index) >= 100, f"the art lost symbols: {len(index)}"
by_file: dict[str, dict[str, ET.Element]] = {}
for name in SVG_FILES:
by_file[name] = symbols_of(ASSETS_OUT / name)
for symbol_id, entry in index.items():
file = str(entry["file"])
assert file in by_file, f"{symbol_id} names an unknown file {file!r}"
symbol = by_file[file].get(symbol_id)
assert symbol is not None, f"no <symbol id={symbol_id!r}> in {file}"
view_box = (symbol.get("viewBox") or "").split()
assert len(view_box) == 4, f"{symbol_id} has no parsable viewBox"
assert (float(view_box[2]), float(view_box[3])) == (
entry["w"],
entry["h"],
), f"{symbol_id}: index {entry['w']}x{entry['h']} vs viewBox {view_box}"
def test_no_symbol_id_is_duplicated() -> None:
ids: list[str] = []
for name in SVG_FILES:
ids.extend(symbols_of(ASSETS_OUT / name))
assert len(ids) == len(set(ids)), sorted({i for i in ids if ids.count(i) > 1})
def test_the_art_references_nothing_external() -> None:
"""No `http` outside the w3.org namespace declarations, no non-fragment href."""
for name in SVG_FILES:
text = (ASSETS_OUT / name).read_text(encoding="utf-8")
for match in re.finditer(r"https?://[^\s\"'>]+", text):
assert match.group(0).startswith(
"http://www.w3.org/"
), f"{name}: external reference {match.group(0)!r}"
for match in re.finditer(r"""(?:href|src)\s*=\s*"([^"]*)" """, text):
assert match.group(1).startswith(
"#"
), f"{name}: non-fragment reference {match.group(1)!r}"
def test_svg_var_fallbacks_resolve_to_the_palette() -> None:
"""A `var(--uc-*, #hex)` fallback in the art must be a palette entry."""
palette = parse_js_palette(
(REPO_ROOT / "app" / "static" / "js" / "render" / "palette.js").read_text(
encoding="utf-8"
)
)["default"]
fallbacks: list[tuple[str, str]] = []
for file_name in SVG_FILES:
text = (ASSETS_OUT / file_name).read_text(encoding="utf-8")
fallbacks.extend(re.findall(r"var\(\s*(--uc-[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)", text))
assert len(fallbacks) >= 32, "the cook symbols stopped using palette var() fallbacks"
for variable, fallback in fallbacks:
key = variable.removeprefix("--uc-").replace("-", "")
assert key in palette, f"{variable} is not a palette entry"
assert palette[key].lower() == fallback.lower(), (
f"{variable} falls back to {fallback} but the palette says {palette[key]}"
)
# --------------------------------------------------------------------------- sim-enum coverage
def test_every_reachable_item_form_has_a_symbol() -> None:
"""The whole (kind, form) surface the sim can produce is drawn."""
index = load_index()
for kind, forms in gen_art_index.reachable_item_forms().items():
for form in forms:
symbol_id = f"item-{kind.value}-{form.value}"
assert symbol_id in index, f"no symbol for {symbol_id}"
covered = set().union(*gen_art_index.reachable_item_forms().values())
assert covered == set(ItemForm), (
f"ItemForm values with no symbol anywhere: {set(ItemForm) - covered}"
)
def test_reachability_walks_the_sim_pipeline() -> None:
"""The derivation is pinned: a sim-data change that moves the forms fails here.
Rice never reaches the chop board (PLAN §5: "raw rice needs no board"),
so `CHOPPED` is unreachable for it; a plate is a prop, so it carries
exactly the clean/dirty/plated states; every other ingredient kind can
be raw, chopped, on the heat, cooked and burnt.
"""
forms = gen_art_index.reachable_item_forms()
assert forms[ItemKind.RICE] == frozenset(
{ItemForm.RAW, ItemForm.COOKING, ItemForm.COOKED, ItemForm.BURNT}
)
assert forms[ItemKind.PLATE] == frozenset(
{ItemForm.RAW, ItemForm.DIRTY, ItemForm.PLATED}
)
full = frozenset(
{
ItemForm.RAW,
ItemForm.CHOPPED,
ItemForm.COOKING,
ItemForm.COOKED,
ItemForm.BURNT,
}
)
for kind in (
ItemKind.TOMATO,
ItemKind.ONION,
ItemKind.LETTUCE,
ItemKind.DOUGH,
ItemKind.PATTY,
ItemKind.FISH,
):
assert forms[kind] == full, f"{kind.value}: {sorted(f.value for f in forms[kind])}"
assert ItemForm.CHOPPED not in forms[ItemKind.RICE]
assert set(forms) == set(ItemKind), "the walk must cover every ItemKind"
# --------------------------------------------------------------------------- the palette
def parse_css_palette(text: str) -> dict[str, dict[str, str]]:
"""The ``--uc-<name>: <hex>`` pairs per CSS scope, keyed like the JS object.
The CSS variable `--uc-cook-1` is the JS object's `cook1`: hyphens
are dropped, so the two files compare key-for-key.
"""
scopes: dict[str, dict[str, str]] = {}
for scope, pattern in (
("default", r":root\s*\{([^}]*)\}"),
("colorblind", r'\[data-palette="cb"\]\s*\{([^}]*)\}'),
):
block = re.search(pattern, text)
assert block is not None, f"palette.css lost its {scope} block"
pairs = re.findall(r"--uc-([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})", block.group(1))
assert len(pairs) == len(set(pairs)), f"duplicate custom property in {scope}"
scopes[scope] = {name.replace("-", ""): hex_value for name, hex_value in pairs}
return scopes
def parse_js_palette(text: str) -> dict[str, dict[str, str]]:
"""The ``name: "<hex>"`` pairs of the two exported palette objects."""
scopes: dict[str, dict[str, str]] = {}
for scope, export in (
("default", "DEFAULT_PALETTE"),
("colorblind", "COLORBLIND_PALETTE"),
):
block = re.search(
export + r"\s*=\s*Object\.freeze\(\{([^}]*)\}\)", text
)
assert block is not None, f"palette.js lost its {export}"
pairs = re.findall(r'(\w+):\s*"(#[0-9a-fA-F]{3,8})"', block.group(1))
assert len(pairs) == len(set(pairs)), f"duplicate key in {export}"
scopes[scope] = dict(pairs)
return scopes
def test_palette_css_and_js_agree() -> None:
"""Both files, both scopes.
The CSS colourblind block is an *override* of its `:root` values, so
the effective colourblind palette is `:root` plus the block; the JS
`COLORBLIND_PALETTE` gets the same merged values from a spread of
`DEFAULT_PALETTE` (or a full entry list, which is equally fine).
"""
js_text = (REPO_ROOT / "app" / "static" / "js" / "render" / "palette.js").read_text(
encoding="utf-8"
)
css = parse_css_palette((ASSETS_OUT / "palette.css").read_text(encoding="utf-8"))
js = parse_js_palette(js_text)
assert len(css["default"]) >= 8, f"the default palette lost entries: {css['default']}"
assert css["default"] == js["default"], (
f"default scope disagrees: CSS {css['default']} vs JS {js['default']}"
)
# The override block only recolors the cooks, and it actually changes
# at least one of them.
assert set(css["colorblind"]) <= {f"cook{n}" for n in (1, 2, 3, 4)}, css["colorblind"]
assert any(css["colorblind"][k] != css["default"][k] for k in css["colorblind"]), (
"the colourblind block overrides nothing"
)
# The JS object spreads the default palette (`...DEFAULT_PALETTE`), so
# its effective colourblind values are default-plus-override, exactly
# like the CSS cascade — unless it lists all eight entries outright.
# A block that is neither is a drift.
colorblind_block = re.search(
r"COLORBLIND_PALETTE\s*=\s*Object\.freeze\(\{([^}]*)\}\)", js_text
)
assert colorblind_block is not None
has_spread = "DEFAULT_PALETTE" in colorblind_block.group(1)
assert has_spread or len(js["colorblind"]) >= 8, (
"COLORBLIND_PALETTE lists neither the spread nor the full entry set"
)
effective_css_cb = {**css["default"], **css["colorblind"]}
effective_js_cb = {**js["default"], **js["colorblind"]}
assert effective_css_cb == effective_js_cb, (
f"colourblind scope disagrees:\n"
f" CSS effective: {effective_css_cb}\n JS effective: {effective_js_cb}"
)
def test_the_default_cook_colours_are_four_distinct_values() -> None:
palette = parse_css_palette((ASSETS_OUT / "palette.css").read_text(encoding="utf-8"))[
"default"
]
cooks = [palette[f"cook{n}"] for n in (1, 2, 3, 4)]
assert len(set(cooks)) == 4, cooks
# --------------------------------------------------------------------------- the generator
def test_generator_writes_the_index_on_a_good_tree(tmp_path: Path) -> None:
src, out = copy_tree(tmp_path)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 0
written = json.loads((out / "index.json").read_text(encoding="utf-8"))
assert written == load_index()
assert len(written) >= 100
def test_generator_defaults_come_from_the_repo(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
src, out = copy_tree(tmp_path)
monkeypatch.setattr(gen_art_index, "DEFAULT_SRC", src)
monkeypatch.setattr(gen_art_index, "DEFAULT_OUT", out)
assert gen_art_index.main([]) == 0
assert (out / "index.json").is_file()
def test_generator_names_a_missing_expected_id(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
for name in (src, out):
remove_symbol(name / "items.svg", "item-rice-cooking")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "item-rice-cooking" in report, report
assert "missing expected ids" in report
def test_generator_names_a_duplicated_symbol_id(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
duplicate = '<symbol id="tile-floor" viewBox="0 0 8 8"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "stations.svg"
text = path.read_text(encoding="utf-8")
path.write_text(text.replace("</svg>", f" {duplicate}\n</svg>"), encoding="utf-8")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "tile-floor" in report and "duplicated" in report, report
def test_generator_rejects_a_diverged_served_copy(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
drifted = out / "ui.svg"
drifted.write_text(drifted.read_text(encoding="utf-8") + "<!-- drifted -->", encoding="utf-8")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "ui.svg" in report and "differs" in report, report
def test_generator_rejects_a_missing_served_copy(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
(out / "fx.svg").unlink()
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "fx.svg" in report and "missing" in report, report
def test_generator_rejects_a_missing_source_file(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
(src / "ui.svg").unlink()
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "ui.svg" in report and "missing" in report, report
def test_generator_rejects_a_symbol_without_an_id(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
anonymous = '<symbol viewBox="0 0 8 8"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "fx.svg"
path.write_text(
path.read_text(encoding="utf-8").replace("</svg>", f" {anonymous}\n</svg>"),
encoding="utf-8",
)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "fx.svg" in report and "has no id" in report, report
def test_generator_rejects_a_symbol_without_a_size(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
unsized = '<symbol id="fx-unsized"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "fx.svg"
path.write_text(
path.read_text(encoding="utf-8").replace("</svg>", f" {unsized}\n</svg>"),
encoding="utf-8",
)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "fx-unsized" in report and "no viewBox" in report, report
def test_generator_reports_every_missing_id_not_just_the_first(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
src, out = copy_tree(tmp_path)
for name in (src, out):
remove_symbol(name / "items.svg", "item-tomato-burnt")
remove_symbol(name / "ui.svg", "ui-crown")
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1
report = capsys.readouterr().err
assert "item-tomato-burnt" in report and "ui-crown" in report, report
def test_parse_symbols_walks_every_symbol_in_order(tmp_path: Path) -> None:
"""The parser is total over the file, and the sizes come from viewBox."""
symbols = gen_art_index.parse_symbols(ASSETS_SRC / "tiles.svg")
assert [symbol.id for symbol in symbols] == [
"tile-floor",
"tile-floor-1",
"tile-floor-2",
"tile-floor-3",
"tile-floor-4",
"tile-counter",
"tile-wall",
"tile-void",
]
assert all((symbol.w, symbol.h) == (32.0, 32.0) for symbol in symbols)
def test_symbol_size_falls_back_to_width_height_attributes(tmp_path: Path) -> None:
"""A symbol without a viewBox may still be sized by width/height."""
path = tmp_path / "sized.svg"
path.write_text(
'<svg xmlns="http://www.w3.org/2000/svg">'
'<symbol id="a" width="16" height="8"><rect/></symbol>'
"<symbol id=\"b\" viewBox=\"0 0 12 6\"><rect/></symbol></svg>",
encoding="utf-8",
)
assert gen_art_index.parse_symbols(path) == [
gen_art_index.Symbol(id="a", w=16.0, h=8.0),
gen_art_index.Symbol(id="b", w=12.0, h=6.0),
]
def test_expected_ids_cover_every_family() -> None:
"""A sanity lock on the derivation: every family is represented."""
ids = gen_art_index.expected_ids()
assert len(ids) >= 100
assert any(i.startswith("tile-floor-") for i in ids)
assert any(i.startswith("station-stove-") for i in ids)
assert sum(1 for i in ids if i.startswith("item-")) >= 37
assert sum(1 for i in ids if i.startswith("cook-1-") or i.startswith("cook-2-")
or i.startswith("cook-3-") or i.startswith("cook-4-")) == 32
assert "cook-carry" in ids and "cook-stun" in ids
assert any(i.startswith("fx-") for i in ids)
assert any(i.startswith("ui-") for i in ids)
def test_the_served_index_and_the_source_tree_are_the_same_build(
tmp_path: Path,
) -> None:
"""Re-running the generator on a copy of the committed tree is a no-op."""
src, out = copy_tree(tmp_path)
assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 0
assert (out / "index.json").read_bytes() == INDEX_PATH.read_bytes()
+134
View File
@@ -0,0 +1,134 @@
"""Phase 07 task 03 — the JS wire enums must stay equal to the Python ones.
`app/static/js/shared/enums.js` is the client's vocabulary for the sim's
StrEnums and the protocol's `ErrorCode`. The wire carries the *string
values*; a drift — a value added to the Python side and forgotten in the
JS set, or removed from one side only — would show up in the client as
`undefined` sprites and dead FX. So the parity is a gate, not a
convention: this test reads the JS file (the same source
`app/static/index.html` serves — no copy that could drift), extracts
the frozen sets, and asserts set equality in **both directions**
(mirroring the doc-sync discipline of `tests/unit/test_protocol_doc_sync.py`).
It checks the seven sets named by the task: `TILE_KIND`,
`STATION_KIND`, `ITEM_KIND`, `ITEM_FORM`, `FACING`, `EVENT_KIND`,
`ERROR_CODE`. Removing a value from either side of one of them makes
this test fail, naming the missing value.
"""
from __future__ import annotations
import json
import re
from enum import Enum
from pathlib import Path
import pytest
from undercooked.server.protocol import ErrorCode
from undercooked.sim.model import (
EventKind,
Facing,
ItemForm,
ItemKind,
StationKind,
TileKind,
)
pytestmark = pytest.mark.unit
JS_ENUMS_PATH = (
Path(__file__).resolve().parent.parent.parent
/ "app"
/ "static"
/ "js"
/ "shared"
/ "enums.js"
)
def _js_set(source: str, name: str) -> set[str]:
"""One frozen JS array, as a set of string literals (a tiny, stable
parser: `export const NAME = Object.freeze([ "a", "b" ]);`)."""
match = re.search(
rf"export const {re.escape(name)} = Object\.freeze\(\[(.*?)\]\)",
source,
re.DOTALL,
)
assert match is not None, f"{name} is not a `Object.freeze([...])` export"
body = match.group(1)
assert "'" not in body, (
f"{name} must use double-quoted literals (the test parses them as JSON)"
)
# A JS array may end in a trailing comma; JSON may not.
body = re.sub(r",\s*$", "", body.rstrip())
values = json.loads(f"[{body}]")
assert all(isinstance(v, str) for v in values), f"{name} has a non-string entry"
return set(values)
def _js_ordered(source: str, name: str) -> list[str]:
match = re.search(
rf"export const {re.escape(name)} = Object\.freeze\(\[(.*?)\]\)",
source,
re.DOTALL,
)
assert match is not None, f"{name} is not a `Object.freeze([...])` export"
body = re.sub(r",\s*$", "", match.group(1).rstrip())
return json.loads(f"[{body}]")
def _python_set(enum_cls) -> set[str]:
"""A StrEnum's values — or, for a plain constant class (the
`ErrorCode`'s `Final` strings), the uppercase string attributes."""
if isinstance(enum_cls, type) and issubclass(enum_cls, Enum):
return {member.value for member in enum_cls}
return {
value
for name, value in vars(enum_cls).items()
if name.isupper() and isinstance(value, str)
}
@pytest.fixture(scope="module")
def js_source() -> str:
assert JS_ENUMS_PATH.is_file(), f"the shared enum module is missing: {JS_ENUMS_PATH}"
return JS_ENUMS_PATH.read_text(encoding="utf-8")
@pytest.mark.parametrize(
("js_name", "py_name", "enum_cls"),
[
("TILE_KIND", "TileKind", TileKind),
("STATION_KIND", "StationKind", StationKind),
("ITEM_KIND", "ItemKind", ItemKind),
("ITEM_FORM", "ItemForm", ItemForm),
("FACING", "Facing", Facing),
("EVENT_KIND", "EventKind", EventKind),
("ERROR_CODE", "ErrorCode", ErrorCode),
],
)
def test_enum_parity(js_source, js_name, py_name, enum_cls) -> None:
"""Set equality in both directions, naming the missing value."""
js = _js_set(js_source, js_name)
py = _python_set(enum_cls)
only_py = py - js
only_js = js - py
assert only_py == set() and only_js == set(), (
f"{js_name} (JS) drifted from {py_name} (Python): "
f"missing in the JS set: {sorted(only_py) or '—'}, "
f"missing in Python: {sorted(only_js) or '—'}"
)
def test_facing_order_is_the_wire_index(js_source) -> None:
"""The wire's `f` is an *index* into the facing list (PLAN §4: 0-3):
the JS array's order must be the enum's declaration order, not just
its value set — `decode.js`'s `facingName` maps against that order."""
js_ordered = _js_ordered(js_source, "FACING")
py_ordered = [member.value for member in Facing]
assert js_ordered == py_ordered, (
f"FACING order drifted (the wire index maps against it): "
f"JS {js_ordered} vs Python {py_ordered}"
)
assert len(js_ordered) == 4
+148
View File
@@ -0,0 +1,148 @@
"""Phase 07 task 04 — the client's sprite map must stay equal to the sim's.
`app/static/js/render/canvas.js` holds `REACHABLE_ITEM_FORMS` — the
(client's copy of) the (kind, form) pairs the simulation can produce,
used by `itemSpriteId` to pick an atlas id for every carried or
station-held item. The authoritative source is
`undercooked.tools.gen_art_index.reachable_item_forms()` (a fixpoint
walk over the sim's own `ACCEPTS` / `TRANSITIONS` tables — the same
walk that derives the atlas's expected ids). A drift — a sim change
that orphans a pair, or adds one the client forgot — would render as
a missing sprite. So the parity is a gate, not a convention: this test
reads the JS file (the same source `app/static/index.html` serves) and
asserts set equality in **both directions** (mirroring the
`test_client_enum_parity.py` discipline).
It also pins the two things the renderer's *mapping functions* must
not break: every kind has a `raw` fallback (the unreachable pair's
sprite), and the JS table's kinds are exactly the sim's `ItemKind`
set (no kind left without a table row, no invented kind).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
import pytest
from undercooked.sim.model import ItemForm, ItemKind
from undercooked.tools.gen_art_index import reachable_item_forms
pytestmark = pytest.mark.unit
JS_CANVAS_PATH = (
Path(__file__).resolve().parent.parent.parent
/ "app"
/ "static"
/ "js"
/ "render"
/ "canvas.js"
)
def _js_table(source: str) -> dict[str, list[str]]:
"""The `REACHABLE_ITEM_FORMS` object, as kind → [forms] (a tiny,
stable parser: `kind: ["raw", "chopped", …],` lines inside the
frozen object)."""
match = re.search(
r"export const REACHABLE_ITEM_FORMS = Object\.freeze\(\{(.*?)\}\);",
source,
re.DOTALL,
)
assert match is not None, "REACHABLE_ITEM_FORMS is not a `Object.freeze({...})` export"
body = match.group(1)
assert "'" not in body, (
"the table must use double-quoted literals (the test parses them as JSON)"
)
table: dict[str, list[str]] = {}
for kind_match in re.finditer(r"(\w+):\s*\[(.*?)\]", body):
kind = kind_match.group(1)
forms = json.loads(f"[{kind_match.group(2)}]")
assert all(isinstance(f, str) for f in forms), f"{kind} has a non-string form"
assert kind not in table, f"duplicate kind {kind} in the table"
table[kind] = forms
return table
@pytest.fixture(scope="module")
def js_source() -> str:
assert JS_CANVAS_PATH.is_file(), f"the canvas module is missing: {JS_CANVAS_PATH}"
return JS_CANVAS_PATH.read_text(encoding="utf-8")
def test_table_covers_exactly_the_item_kinds(js_source) -> None:
"""The table's keys are the sim's `ItemKind` values — no kind
missing a row (its unreachable pairs would fall back with no
`raw` row to lean on), no invented kind."""
table = _js_table(js_source)
js_kinds = set(table)
py_kinds = {kind.value for kind in ItemKind}
missing = py_kinds - js_kinds
invented = js_kinds - py_kinds
assert not missing and not invented, (
f"REACHABLE_ITEM_FORMS (JS) kind set drifted from ItemKind (Python): "
f"missing kinds: {sorted(missing) or '—'}, invented kinds: {sorted(invented) or '—'}"
)
def test_forms_match_the_sim_fixpoint(js_source) -> None:
"""Set equality per kind, in both directions, naming the pair."""
table = _js_table(js_source)
sim = reachable_item_forms()
for kind in ItemKind:
js_forms = set(table.get(kind.value, []))
py_forms = {form.value for form in sim[kind]}
only_py = py_forms - js_forms
only_js = js_forms - py_forms
assert not only_py and not only_js, (
f"{kind.value} drifted: missing in the JS table: {sorted(only_py) or '—'}, "
f"missing in the sim: {sorted(only_js) or '—'}"
)
def test_every_table_form_is_a_known_form(js_source) -> None:
"""A form the sim's `ItemForm` does not define is a mapping bug
(the atlas has no `item-<kind>-<form>` for it)."""
table = _js_table(js_source)
known = {form.value for form in ItemForm}
for kind, forms in table.items():
unknown = set(forms) - known
assert not unknown, f"{kind} names unknown form(s): {sorted(unknown)}"
def test_every_kind_has_a_raw_fallback(js_source) -> None:
"""`itemSpriteId`'s unreachable-pair fallback is `item-<kind>-raw` —
every kind must therefore carry `raw` (the sim guarantees it: a
shelf-stocked kind starts raw, and the plate is raw- or
dirty-stocked; the test pins the renderer's contract, not the
sim's)."""
table = _js_table(js_source)
for kind, forms in table.items():
assert "raw" in forms, f"{kind} lacks the `raw` fallback form"
def test_atlas_has_every_reachable_pair() -> None:
"""The closure: every (kind, form) the sim can produce has an
atlas id (the `gen_art_index` contract, checked here against the
shipped `index.json` so a new pair without its sprite fails the
build, naming the pair)."""
index_path = (
Path(__file__).resolve().parent.parent.parent
/ "app"
/ "static"
/ "assets"
/ "index.json"
)
assert index_path.is_file(), f"the atlas index is missing: {index_path}"
index = json.loads(index_path.read_text(encoding="utf-8"))
ids = {entry["id"] if isinstance(entry, dict) else str(entry) for entry in index}
sim = reachable_item_forms()
missing = [
f"item-{kind.value}-{form.value}"
for kind in ItemKind
for form in sim[kind]
if f"item-{kind.value}-{form.value}" not in ids
]
assert not missing, f"the atlas lacks sprite(s): {missing}"
+1
View File
@@ -207,6 +207,7 @@ def test_no_doc_file_exists_that_nothing_promises() -> None:
def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {
"docs/CLIENT.md",
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
+15 -6
View File
@@ -18,6 +18,12 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
HARNESS_MARKERS = ("unit", "integration", "e2e", "slow")
TEST_SUBDIRS = ("unit", "integration", "sim", "e2e")
#: The technical e2e suites that a phase file names but that belong to no
#: single user story (e.g. the phase-07 art/atlas guard). Every entry is a
#: deliberate, named exception to the story-naming contract in
#: `tests/e2e/README.md`; a new technical suite lands here first.
E2E_TECHNICAL_SUITES = frozenset({"test_atlas_loads.py"})
def test_package_metadata() -> None:
assert undercooked.__version__ == "0.1.0"
@@ -59,12 +65,14 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
def test_e2e_tests_follow_the_story_naming_convention() -> None:
"""`pytest -m e2e --collect-only` selects only `test_usNN_*.py` story suites.
"""`pytest -m e2e --collect-only` selects story suites (`test_usNN_*.py`) and
the allowlisted technical suites only.
Task 01 could only assert "the marker collects nothing yet". Now that the first
browser suite exists the durable invariant is the naming contract in
`tests/e2e/README.md`: a story suite that is not named for its story is one that
nobody can find, and one that cannot be collected is one that never runs.
The durable invariant is the naming contract in `tests/e2e/README.md`: a
story suite that is not named for its story is one that nobody can find,
and a suite that cannot be collected is one that never runs. Technical
suites (guards for a phase's machinery, named by the phase files) are
the documented exception, tracked in `E2E_TECHNICAL_SUITES`.
"""
result = subprocess.run( # fixed argv, repo's own interpreter
[
@@ -92,4 +100,5 @@ def test_e2e_tests_follow_the_story_naming_convention() -> None:
assert files, f"the e2e marker selected nothing:\n{result.stdout}"
for path in files:
assert path.startswith("tests/e2e/"), path
assert re.fullmatch(r"test_us\d{2}_.+\.py", Path(path).name), path
name = Path(path).name
assert re.fullmatch(r"test_us\d{2}_.+\.py", name) or name in E2E_TECHNICAL_SUITES, path
+22 -1
View File
@@ -257,7 +257,9 @@ def test_shipped_index_html_loads_only_same_origin_assets() -> None:
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
tags = re.findall(r"<(script|link|img)\b", html, re.I)
assert len(tags) >= 3, "index.html no longer declares the assets this test guards"
assert 'src="/static/js/app.js"' in html
# The page's single entry module (the composition root, phase 07 task 03;
# `main.js` imports `app.js` and the render/decode modules).
assert 'src="/static/js/main.js"' in html
assert scan_element_urls(STATIC_DIR / "index.html", html) == []
@@ -321,6 +323,18 @@ def test_a_planted_html_reference_fails_the_scan(
'<image href="https://x.example.com/a.png"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
],
)
@@ -364,6 +378,13 @@ def test_a_planted_hosted_library_token_fails_the_scan(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="#tile"/></svg>',
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg"'
' xmlns:xml="http://www.w3.org/XML/1998/namespace"'
' xmlns:xhtml="http://www.w3.org/1999/xhtml">'
'<rect width="16" height="16"/></svg>',
),
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"),
(".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
(".js", 'fetch("/static/api/levels");\n'),