"""Integration: the whole lobby/room lifecycle over HTTP (phase 05, task 05; US-01/US-02/US-03) — every path the task file names, driven by `TestClient` plus manual `tick_once` (a `FakeClock`; no test sleeps in real time). * **the happy path** — `POST /api/rooms` → three `POST .../join` → ready-up → the host's `start` → the 3 s countdown in 0.05 s wall frames → a scripted full-pipeline round to `WON` → the `ROUND_END` payload is `result_payload` (the exact key set, the per-seat `players`) → `next_level` → a second (losing) round → `to_lobby`: back in `LOBBY` with the same seats, tokens and colours, the ready flags reset, * **the solo path** — create, `start` with no other players, a scripted win, and the session's progression (the task-04 wiring through the supervisor's store: `unlocked_level` up to 2, a `best` run, the counters, the `next_level_id` advice), * **the full-room path** — the 5th join is a 503 `room_full` and the room's code is retained (still 200, still four seats), * **the bad-code path** — `GET /api/rooms/ZZZZZ` (unknown) and `GET /api/rooms/nope!` (malformed) answer with the same status and the same body (US-02 criterion 4: the API is not a room-existence oracle), with no stack trace in the response, * **the abandon path** — every player leaves a `LOBBY`; exactly at `SEAT_GRACE_S` the room is still up, one `gc_pass()` after the grace closes and drops it (the code no longer resolves — a 404), * **the idle-lobby expiry** — a `FakeClock` advanced past `lobby_idle_timeout_s` and one `gc_pass()` close an occupied idle lobby (exactly at the timeout: not yet), * **the capacity** — with `max_rooms=2`, the 3rd `POST /api/rooms` is a 503 `room_full` and the existing rooms are untouched. The app's supervisor is swapped before any request (a `FakeClock` + the test's own content bundle — the pattern `test_rooms_api.py` pins), and the `TestClient` is used **without** the context manager: the lifespan never starts the rooms' tick tasks, so every tick is the test's own `tick_once` call and `gc_pass` runs in a fresh `asyncio.run` with nothing to cancel. (There is no HTTP `start`/`ready` yet — that is the phase-06 transport; `docs/ROOMS.md` "Lifecycle walkthrough" documents the gap.) """ from __future__ import annotations import asyncio import sys from collections.abc import Mapping from pathlib import Path from typing import Any import pytest from fastapi.testclient import TestClient from undercooked.config import Settings from undercooked.server.app import create_app from undercooked.server.clock import FakeClock from undercooked.server.room import SEAT_GRACE_S, Room, RoomState from undercooked.server.rooms import RoomSupervisor from undercooked.sim.content import ContentBundle from undercooked.sim.level import Level, LevelDef from undercooked.sim.model import ( EventKind, ItemForm, ItemKind, KitchenState, RoundResult, StationKind, ) from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step pytestmark = pytest.mark.integration #: The test-isolated content dir (a recipe book, no levels) — explicit, so #: the app never reads the shipped `levels/` implicitly. The bundle the #: supervisor gets carries the test's own two levels. FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels" #: The sim's scripted-cook harness lives with the sim's tests (test-only). _SIM_TESTS = Path(__file__).resolve().parents[1] / "sim" if str(_SIM_TESTS) not in sys.path: sys.path.insert(0, str(_SIM_TESTS)) from helpers import ( # noqa: E402 # type: ignore[reportMissingImports] CookController, shelf_id_for, ) #: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`). PERIOD = 0.05 #: The exact key set of `result_payload` (PLAN §7's `round` body) — the #: `ROUND_END` payload the happy path checks (the phase-04 contract). RESULT_PAYLOAD_KEYS = { "result", "score", "target", "stars", "time_left", "counters", "longest_streak", "players", "level_id", "seed", "ticks", } # ------------------------------------------------------------------ content def make_book() -> RecipeBook: """A one-recipe book: the full-pipeline `soup` (pick → chop → cook → plate → serve), so the scripted cook runs the whole station chain — the same shape the phase-04 full rounds prove.""" return RecipeBook( recipes={ "soup": Recipe( id="soup", name="Soup", base_score=40, ingredients=[ Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=1) ], steps=[ Step(kind="pick", station=StationKind.SPAWN_SHELF), Step(kind="chop", station=StationKind.CHOP_BOARD, seconds=0.9), Step(kind="cook", station=StationKind.STOVE, seconds=4.0), Step(kind="plate", station=StationKind.PREP, seconds=0.4), Step(kind="serve", station=StationKind.SERVE), ], ) }, ingredients=[ItemKind.TOMATO], ) def make_level( level_id: str, *, order: int, time_limit: float, target: int ) -> Level: """An 8x5 kitchen with the full station chain: one spawn shelf (`tomato`), a chop board, a stove, a prep and a serve window — every fixture has a walkable stand cell (the scripted cook's BFS). The four spawn points seat the four cooks in four distinct cells, the host on the working row and the three idle cooks on the back row: an idle cook that shares the one-tile corridor parks where the Goto's detour has no exit cell (the body-cell avoid set covers every neighbour), and a single shared spawn cell overlaps the bodies at start (the soft-push drift is what parks an idle cook there). The back-row bench keeps the corridor free for the scripted cook.""" data: dict[str, object] = { "id": level_id, "name": f"Lifecycle kitchen {level_id}", "order": order, "time_limit": time_limit, "target_score": target, "grid": [ "########", "#S.C.VP#", "#......#", "#T.....#", "########", ], "tiles": { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "C": {"kind": "floor", "station": "chop_board"}, "T": {"kind": "floor", "station": "stove"}, "V": {"kind": "floor", "station": "serve"}, "P": {"kind": "floor", "station": "prep"}, }, "spawns": [[2, 2], [3, 3], [4, 3], [5, 3]], "orders": { "pool": ["soup"], "concurrent": 1, "spawn_interval": [4, 6], "deadline": 30, }, } return Level.build(LevelDef.model_validate(data)) def make_bundle() -> ContentBundle: """The test's two-level campaign: A (order 1, 60 s, a target a single scripted serve clears) and B (order 2, 5 s, a target nothing clears) — the happy path's win and its quick losing follow-up.""" return ContentBundle( recipe_book=make_book(), levels=( make_level("07_win", order=1, time_limit=60.0, target=10), make_level("07_lose", order=2, time_limit=5.0, target=1000), ), ) # --------------------------------------------------------------------- app class LifecycleApp: """An app whose supervisor runs on a `FakeClock` (the `FakeClockSupervisorApp` pattern `test_rooms_api.py` pins), with the test's own content bundle (the fixture dir is a zero-level campaign; the rooms need a level to run a round). The `TestClient` is used without the context manager, so the rooms' tick tasks are never started — every tick is the test's own `tick_once`. The `settings` are the app's (the supervisor reuses them, so the task's `max_rooms=2` / `lobby_idle_timeout_s` overrides apply). """ def __init__(self, **settings_overrides: Any) -> None: self.settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides) self.app = create_app(self.settings) self.clock = FakeClock() self.app.state.supervisor = RoomSupervisor( self.settings, self.clock, make_bundle() ) @property def supervisor(self) -> RoomSupervisor: return self.app.state.supervisor # ---------------------------------------------------------------- helpers def create_room(client: TestClient, name: str | None = None) -> dict[str, Any]: body: dict[str, Any] = {} if name is not None: body["name"] = name response = client.post("/api/rooms", json=body) assert response.status_code == 201, response.text return response.json() def join_room( client: TestClient, code: str, token: str, name: str | None = None ) -> tuple[int, dict[str, Any]]: body: dict[str, Any] = {"token": token} if name is not None: body["name"] = name response = client.post(f"/api/rooms/{code}/join", json=body) return response.status_code, response.json() def drive_countdown_to_playing(room: Room, clock: FakeClock) -> None: """The 3.0 s countdown in 0.05 s wall frames (the manual `tick_once` cadence); leaves the room in `PLAYING` with the round built.""" for _ in range(90): # 60 frames are the 3.0 s; the rest is slack clock.advance(PERIOD) room.tick_once() if room.state is RoomState.PLAYING: break assert room.state is RoomState.PLAYING assert room.sim_state is not None def drive_round_to_end( room: Room, clock: FakeClock, max_ticks: int, active: str | None = None ) -> None: """0.05 s wall frames until the room is out of `PLAYING` (verdict + the 1.0 s settle → `ROUND_END`). When `active` is a seat's token, the phase-04 scripted cook drives that one cook (the controller is built before the loop — the round is already built); every other seat stays idle (no input). The cook stops feeding once the verdict lands (the frozen settle ignores inputs anyway).""" controller: CookController | None = None if active is not None: state = room.sim_state assert state is not None # the countdown has built the round controller = CookController(0, shelf_id_for(state, ItemKind.TOMATO), "any") for _ in range(max_ticks): if room.state is not RoomState.PLAYING: break clock.advance(PERIOD) state = room.sim_state assert state is not None if controller is not None and state.result is None: frame = controller.next_frame(state, state.tick) assert frame.player_id == active # seat 0 is the host's sim player room.queue_input(frame.player_id, frame) room.tick_once() assert room.state is RoomState.ROUND_END def check_won_payload( room: Room, state: KitchenState, level_id: str, n_players: int ) -> Mapping[str, object]: """The `ROUND_END` payload is the `result_payload` of the verdict: the exact key set, the win, the star grade, the per-seat `players`.""" payload = room.last_result assert payload is not None assert set(payload) == RESULT_PAYLOAD_KEYS assert payload["result"] == "won" assert payload["score"] == state.score assert payload["target"] == state.target_score assert payload["stars"] in (1, 2, 3) # a win grades at least one star assert payload["level_id"] == level_id assert payload["seed"] == room.round_seed players = payload["players"] assert isinstance(players, list) assert len(players) == n_players # one per seat return payload def check_round_end_events(room: Room) -> None: """The verdict's events (the `ROUND_END` and the served dishes) landed in the buffer and drain exactly once.""" events = room.take_events() assert any(e.kind is EventKind.ROUND_END for e in events) assert any(e.kind is EventKind.SERVED for e in events) assert room.take_events() == () def check_second_losing_round( room: Room, clock: FakeClock, level_id: str ) -> None: """`next_level`: the campaign's second level, round 2 — an idle round loses on the 5 s buzzer (plus the settle).""" room.next_level(level_id) assert room.state is RoomState.COUNTDOWN assert room.level_id == level_id assert room.round_no == 2 drive_countdown_to_playing(room, clock) assert room.sim_state is not None assert room.sim_state.level_id == level_id drive_round_to_end(room, clock, max_ticks=300) assert room.sim_state is not None assert room.sim_state.result is RoundResult.LOST assert room.last_result is not None and room.last_result["result"] == "lost" def check_lobby_return( client: TestClient, room: Room, code: str, host_token: str, colours: dict[str, str], ) -> None: """Back to the `LOBBY`: the same seats and colours, the ready flags reset, the crown untouched — and over HTTP the room answers with all four seats.""" room.to_lobby() assert room.state is RoomState.LOBBY seated = sorted(room.seats, key=lambda seat: seat.seat_index) assert [seat.token for seat in seated] == [ host_token, "tok-bob", "tok-cy", "tok-di", ] assert {seat.token: seat.color for seat in seated} == colours assert all(not seat.ready for seat in seated) # ready up again assert seated[0].host is True # the crown was never in play get = client.get(f"/api/rooms/{code}") assert get.status_code == 200 body = get.json() assert body["state"] == "lobby" and len(body["seats"]) == 4 # --------------------------------------------------------------- the paths def test_happy_path_from_create_to_won_to_next_level_to_lobby() -> None: """The task's happy path, end to end (HTTP for the lobby shell, manual `tick_once` for the round — no sleeps).""" fixture = LifecycleApp() client = TestClient(fixture.app) sup = fixture.supervisor level_a, level_b = sup.content.levels # create + three joins body = create_room(client, "Ada") code = body["room_code"] host_token = body["you"]["token"] assert body["you"]["seat"] == 0 and body["you"]["host"] is True room = sup.get(code) assert room is not None assert room.level_id == level_a.id # the campaign's first by default for name, token in (("Bob", "tok-bob"), ("Cy", "tok-cy"), ("Di", "tok-di")): status, joined = join_room(client, code, token, name) assert status == 200, joined assert joined["you"]["token"] == token assert len(room.seats) == 4 # ready-up (two of the three; one ready non-host is the precondition) assert room.set_ready("tok-bob", True) assert room.set_ready("tok-cy", True) assert room.can_start # the host's start (the phase-06 `start` message is its transport copy) room.start(host_token) assert room.state is RoomState.COUNTDOWN assert room.round_no == 1 # the countdown completes in 0.05 s wall frames drive_countdown_to_playing(room, fixture.clock) assert room.sim_state is not None assert room.sim_state.level_id == level_a.id # the colours are handed out first-free in seat order — captured before # the round so the return to the lobby can check them colours = {seat.token: seat.color for seat in room.seats} assert len(set(colours.values())) == 4 # the whole palette is used # the round: a scripted full-pipeline round to WON drive_round_to_end(room, fixture.clock, max_ticks=1500, active=host_token) state = room.sim_state assert state is not None assert state.result is RoundResult.WON assert state.score >= state.target_score # the ROUND_END payload is the result_payload of the verdict check_won_payload(room, state, level_a.id, n_players=4) check_round_end_events(room) # `next_level` and the idle second round check_second_losing_round(room, fixture.clock, level_b.id) # back to the LOBBY: the same seats and colours, the ready flags reset check_lobby_return(client, room, code, host_token, colours) def test_solo_path_wins_and_the_unlock_advances() -> None: """Solo (no other players): the host's `start` is legal as-is, the scripted round wins, and the task-04 wiring records the result into the creator's session through the supervisor's store.""" fixture = LifecycleApp() client = TestClient(fixture.app) sup = fixture.supervisor level_a = sup.content.levels[0] level_b = sup.content.levels[1] body = create_room(client, "Solo") code = body["room_code"] token = body["you"]["token"] room = sup.get(code) assert room is not None and len(room.seats) == 1 room.start(token) # the solo host: no ready flag needed assert room.state is RoomState.COUNTDOWN drive_countdown_to_playing(room, fixture.clock) drive_round_to_end(room, fixture.clock, max_ticks=1500, active=token) state = room.sim_state assert state is not None and state.result is RoundResult.WON payload = room.last_result assert payload is not None and payload["result"] == "won" players = payload["players"] assert isinstance(players, list) and len(players) == 1 # the progression: the store's rule, through the room's `round_over` record = sup.sessions.get(token) assert record is not None assert record.unlocked_level == 2 # the campaign's floor raised by the win assert record.best[level_a.id] is not None assert record.best[level_a.id].score == payload["score"] assert record.best[level_a.id].stars in (1, 2, 3) assert record.rounds_played == 1 assert record.dishes_served >= 1 # and the `next_level` advice the campaign hands back assert sup.sessions.next_level_id(token, sup.content) == level_b.id def test_full_room_refuses_the_fifth_join_and_retains_the_code() -> None: """With four seats taken, the 5th join is a 503 `room_full` — and the room's code is retained (still resolvable, still four seats).""" fixture = LifecycleApp() client = TestClient(fixture.app) code = create_room(client, "Ada")["room_code"] for token in ("tok-bob", "tok-cy", "tok-di"): status, _ = join_room(client, code, token) assert status == 200 status, body = join_room(client, code, "tok-eve", "Eve") assert status == 503 assert body == {"code": "room_full"} get = client.get(f"/api/rooms/{code}") assert get.status_code == 200 body = get.json() assert len(body["seats"]) == 4 # the room is untouched assert body["state"] == "lobby" def test_bad_codes_are_indistinguishable_and_stack_trace_free() -> None: """US-02 criterion 4: an unknown code (`ZZZZZ`) and a malformed one (`nope!`) must produce the *same* response — the API is not a room-existence oracle — and the refusal is rate-limit friendly (no stack trace in the body).""" fixture = LifecycleApp() client = TestClient(fixture.app) unknown = client.get("/api/rooms/ZZZZZ") malformed = client.get("/api/rooms/nope!") assert unknown.status_code == 404 assert malformed.status_code == 404 assert unknown.json() == malformed.json() == {"code": "room_not_found"} for response in (unknown, malformed): assert "Traceback" not in response.text assert "Exception" not in response.text def test_all_players_leave_and_the_gc_closes_after_the_grace() -> None: """The abandon path: every player leaves a `LOBBY`; exactly at `SEAT_GRACE_S` the room is still up, and one `gc_pass()` past the grace closes and drops it — the code no longer resolves (a 404).""" fixture = LifecycleApp() client = TestClient(fixture.app) sup = fixture.supervisor body = create_room(client, "Ada") code = body["room_code"] host_token = body["you"]["token"] status, _ = join_room(client, code, "tok-bob", "Bob") assert status == 200 sup.leave(code, "tok-bob") sup.leave(code, host_token) room = sup.get(code) assert room is not None and room.is_empty assert room.empty_since == fixture.clock.now() fixture.clock.advance(SEAT_GRACE_S) # exactly 30 s is not *past* the grace assert asyncio.run(sup.gc_pass()) == 0 assert sup.get(code) is not None # still in the registry fixture.clock.advance(1.0) assert asyncio.run(sup.gc_pass()) == 1 assert sup.get(code) is None # dropped: the code no longer resolves assert client.get(f"/api/rooms/{code}").status_code == 404 def test_idle_lobby_expires_past_the_lobby_timeout() -> None: """The idle-lobby rule (an occupied room, so not the grace): a `FakeClock` advanced past `lobby_idle_timeout_s` and one `gc_pass()` close it; exactly at the timeout it is still up.""" fixture = LifecycleApp(lobby_idle_timeout_s=10.0) client = TestClient(fixture.app) sup = fixture.supervisor code = create_room(client, "Idle")["room_code"] assert sup.get(code) is not None fixture.clock.advance(10.0) # exactly the timeout is not past it assert asyncio.run(sup.gc_pass()) == 0 assert sup.get(code) is not None fixture.clock.advance(0.5) assert asyncio.run(sup.gc_pass()) == 1 assert sup.get(code) is None assert client.get(f"/api/rooms/{code}").status_code == 404 def test_process_capacity_refuses_the_third_room_and_leaves_existing_ones() -> None: """With `max_rooms=2`, the 3rd `POST /api/rooms` is a 503 `room_full` (US-01 criterion 7) and the two existing rooms are untouched.""" fixture = LifecycleApp(max_rooms=2) client = TestClient(fixture.app) code_1 = create_room(client, "One")["room_code"] code_2 = create_room(client, "Two")["room_code"] response = client.post("/api/rooms", json={}) assert response.status_code == 503 assert response.json() == {"code": "room_full"} for code in (code_1, code_2): # the existing rooms are untouched get = client.get(f"/api/rooms/{code}") assert get.status_code == 200 body = get.json() assert len(body["seats"]) == 1 assert body["state"] == "lobby" assert client.get("/healthz").json()["rooms"] == 2