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:
@@ -111,25 +111,30 @@ def test_root_serves_the_shell_html(client: TestClient) -> None:
|
||||
assert response.headers["content-type"].startswith("text/html")
|
||||
assert int(response.headers["content-length"]) > 500 # streamed from the file
|
||||
html = response.text
|
||||
assert 'src="/static/js/app.js"' in html
|
||||
assert 'src="/static/js/main.js"' in html # the composition root (task 03)
|
||||
assert 'href="/static/css/base.css"' in html
|
||||
assert 'id="screen-lobby"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'href="/static/css/screens.css"' in html
|
||||
assert 'id="screen-root"' in html
|
||||
assert (
|
||||
'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html
|
||||
)
|
||||
|
||||
|
||||
def test_shell_offers_the_lobby_controls(client: TestClient) -> None:
|
||||
"""The controls are mounted by the landing screen module (phase 07
|
||||
task 02); the shell offers the screen root they mount into."""
|
||||
html = client.get("/").text
|
||||
assert 'id="create-lobby"' in html
|
||||
assert 'id="join-form"' in html
|
||||
assert 'id="join-code"' in html
|
||||
assert 'inputmode="text"' in html
|
||||
assert 'autocapitalize="characters"' in html
|
||||
assert 'size="5"' in html # five characters wide: no 20-char intrinsic box
|
||||
assert 'maxlength="5"' in html
|
||||
assert 'for="join-code"' in html # the code input has a real label
|
||||
assert 'id="screen-root"' in html
|
||||
landing = client.get("/static/js/screens/landing.js")
|
||||
assert landing.status_code == 200
|
||||
text = landing.text
|
||||
assert 'id: "create-lobby"' in text
|
||||
assert 'id: "join-form"' in text
|
||||
assert 'id: "join-code"' in text
|
||||
assert 'inputmode: "text"' in text
|
||||
assert 'autocapitalize: "characters"' in text
|
||||
assert "maxlength: String(CODE_LENGTH)" in text # five characters: no 20-char intrinsic box
|
||||
assert 'for: "join-code"' in text # the code input has a real label
|
||||
|
||||
|
||||
def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -> None:
|
||||
@@ -138,10 +143,24 @@ def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -
|
||||
assert css.headers["content-type"].startswith("text/css")
|
||||
assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in CSS
|
||||
|
||||
script = client.get("/static/js/app.js")
|
||||
screens_css = client.get("/static/css/screens.css")
|
||||
assert screens_css.status_code == 200
|
||||
assert screens_css.headers["content-type"].startswith("text/css")
|
||||
|
||||
script = client.get("/static/js/main.js")
|
||||
assert script.status_code == 200
|
||||
assert script.headers["content-type"].startswith("text/javascript")
|
||||
assert "uc.token" in script.text
|
||||
assert "createApp" in script.text # the composition root imports the machine
|
||||
|
||||
app = client.get("/static/js/app.js")
|
||||
assert app.status_code == 200
|
||||
assert app.headers["content-type"].startswith("text/javascript")
|
||||
assert "createApp" in app.text
|
||||
|
||||
identity = client.get("/static/js/identity.js")
|
||||
assert identity.status_code == 200
|
||||
assert identity.headers["content-type"].startswith("text/javascript")
|
||||
assert "uc.token" in identity.text # the per-tab token lives in identity.js (A16)
|
||||
|
||||
|
||||
def test_unknown_path_is_404(client: TestClient) -> None:
|
||||
|
||||
@@ -21,7 +21,7 @@ import pytest
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.clock import FakeClock
|
||||
from undercooked.server.codes import CROCKFORD
|
||||
from undercooked.server.room import Room, RoomState
|
||||
from undercooked.server.room import TEST_MODE_SEED, Room, RoomState
|
||||
from undercooked.server.rooms import (
|
||||
GC_INTERVAL_S,
|
||||
SEAT_GRACE_S,
|
||||
@@ -473,3 +473,26 @@ async def test_gc_loop_runs_on_the_injected_cadence_and_is_cancellable() -> None
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await next(iter(loop_task))
|
||||
assert clock.now() == 3 * GC_INTERVAL_S # exactly three 5-second cycles
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the test-mode seed
|
||||
|
||||
|
||||
async def test_create_room_in_test_mode_uses_the_documented_fixed_seed() -> None:
|
||||
"""The test-only seam: under `UNDERCOOKED_TEST_MODE=1` the room is born
|
||||
with the documented fixed seed, so a `?seed=20240701` page plays the
|
||||
seed the room's sim actually draws (the e2e flagship's determinism)."""
|
||||
supervisor, clock, _ = make_supervisor(test_mode=True)
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
assert room._seed_cursor == TEST_MODE_SEED
|
||||
|
||||
|
||||
async def test_create_room_outside_test_mode_draws_a_fresh_seed() -> None:
|
||||
"""A production room never pins the seam's value: the base seed is a
|
||||
fresh draw from `secrets.randbelow(_SEED_MODULUS)` (the range check —
|
||||
a value equal to the constant is possible in principle, so the
|
||||
assertion is the range, not an inequality)."""
|
||||
supervisor, clock, _ = make_supervisor() # test_mode defaults to False
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
assert isinstance(room._seed_cursor, int)
|
||||
assert 0 <= room._seed_cursor < 2**63
|
||||
|
||||
@@ -195,6 +195,37 @@ async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None:
|
||||
assert len(lobby2["seats"]) == 2
|
||||
|
||||
|
||||
async def test_a_new_seat_rebroadcasts_the_lobby_to_existing_members(
|
||||
ws_server: Any,
|
||||
) -> None:
|
||||
"""PLAN §7: the lobby is re-broadcast on lobby-state changes, not
|
||||
only to the joiner — a member who is already in the room must see
|
||||
the new seat appear (and a ghost rejoin revives it), so the lobby
|
||||
screen in an open tab updates without a refresh."""
|
||||
with ws_server() as (url, _app):
|
||||
code, _ = _create_room(url, "tok-host")
|
||||
async with await _connect(url, code, "tok-host", name="Host") as host:
|
||||
welcome = await _next_json(host)
|
||||
assert welcome["type"] == "welcome"
|
||||
await _next_json(host) # the initial lobby (one seat)
|
||||
# The joiner arrives on their own socket: welcome + lobby for
|
||||
# them, and a *re-broadcast* for the host.
|
||||
async with await _connect(
|
||||
url, code, "tok-joiner", name="Joiner"
|
||||
) as joiner:
|
||||
j_welcome = await _next_json(joiner)
|
||||
assert j_welcome["type"] == "welcome"
|
||||
assert j_welcome["you"]["seat"] == 1
|
||||
j_lobby = await _next_json(joiner)
|
||||
assert j_lobby["type"] == "lobby"
|
||||
host_lobby = await _next_json(host)
|
||||
assert host_lobby["type"] == "lobby"
|
||||
assert host_lobby["code"] == code
|
||||
assert len(host_lobby["seats"]) == 2
|
||||
assert host_lobby["seats"][1]["name"] == "Joiner"
|
||||
assert host_lobby["seats"][1]["ready"] is False
|
||||
|
||||
|
||||
async def test_ping_before_hello_gets_pong(ws_server: Any) -> None:
|
||||
with ws_server() as (url, _app):
|
||||
code, _ = _create_room(url)
|
||||
|
||||
@@ -243,14 +243,23 @@ def _join(url: str, code: str, token: str) -> None:
|
||||
|
||||
async def _start_playing(
|
||||
url: str, app: Any, ws_client: Any
|
||||
) -> tuple[str, Any, Any, Any]:
|
||||
) -> tuple[str, Any, Any, Any, Any]:
|
||||
"""Two clients, the room, and the lobby, to `PLAYING` (the module
|
||||
docstring's lobby half); returns `(code, room, a, b)`."""
|
||||
docstring's lobby half); returns `(code, room, a, b, keepalive)`.
|
||||
|
||||
The `keepalive` is the documented keep-alive task (the 12 s heartbeat
|
||||
dead window): it starts the moment both clients are in, because a
|
||||
slow run (the full-suite load) can stretch the `start`-to-countdown
|
||||
gap past the window — a `ping` from every client every 2 s keeps the
|
||||
watchdog quiet through the lobby, the countdown and into the round.
|
||||
The caller cancels it in its `finally`.
|
||||
"""
|
||||
supervisor = app.state.supervisor
|
||||
code = _create_room(url, "tok-a")
|
||||
_join(url, code, "tok-b")
|
||||
a = await ws_client(url, code, "tok-a", name="A")
|
||||
b = await ws_client(url, code, "tok-b", name="B")
|
||||
keepalive = asyncio.create_task(_keepalive((a, b)))
|
||||
for client in (a, b):
|
||||
welcome = await client.expect("welcome")
|
||||
assert welcome["state"] == "lobby"
|
||||
@@ -286,7 +295,7 @@ async def _start_playing(
|
||||
# they arrived before the countdown frames, so the queue is in order.
|
||||
for client in (a, b):
|
||||
await client.collect("lobby", 3, wait_s=10.0)
|
||||
return code, room, a, b
|
||||
return code, room, a, b, keepalive
|
||||
|
||||
|
||||
async def _drive_soup(client: Any, pid: str, seq: _Sequencer) -> None:
|
||||
@@ -437,7 +446,7 @@ async def test_two_clients_play_a_complete_round(
|
||||
live_server: Any, ws_client: Any
|
||||
) -> None:
|
||||
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
|
||||
code, room, a, b = await _start_playing(url, app, ws_client)
|
||||
code, room, a, b, keepalive = await _start_playing(url, app, ws_client)
|
||||
seq_a, seq_b = _Sequencer(), _Sequencer()
|
||||
|
||||
# --- the countdown 3 -> 1, then the snap stream
|
||||
@@ -455,9 +464,7 @@ async def test_two_clients_play_a_complete_round(
|
||||
for name, frames in (("a", snaps_a), ("b", snaps_b)):
|
||||
_assert_snap_ticks(name, frames)
|
||||
|
||||
# --- the inputs: A the scripted soup, B the walking loop, both
|
||||
# kept alive past the heartbeat dead window
|
||||
keepalive = asyncio.create_task(_keepalive((a, b)))
|
||||
# --- the inputs: A the scripted soup, B the walking loop
|
||||
soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a))
|
||||
try:
|
||||
await _wander(b, "tok-b", seq_b) # B's inputs, while the soup runs
|
||||
@@ -542,7 +549,7 @@ async def test_a_third_connection_mid_round_is_a_spectator(
|
||||
live_server: Any, ws_client: Any
|
||||
) -> None:
|
||||
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
|
||||
code, room, a, b = await _start_playing(url, app, ws_client)
|
||||
code, room, a, b, keepalive = await _start_playing(url, app, ws_client)
|
||||
seq_a, seq_b = _Sequencer(), _Sequencer()
|
||||
|
||||
await a.collect("countdown", 3, wait_s=20.0)
|
||||
@@ -587,3 +594,4 @@ async def test_a_third_connection_mid_round_is_a_spectator(
|
||||
await c.close()
|
||||
await a.close()
|
||||
await b.close()
|
||||
keepalive.cancel()
|
||||
|
||||
Reference in New Issue
Block a user