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