**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`.
407 lines
19 KiB
Python
407 lines
19 KiB
Python
"""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()
|