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