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:
+11
-1
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user