All green. Final report:
**Phase 05 final verification pass — complete, 100% green.**
**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions
**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed
**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed
**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).
**Next pending phase:** `06_realtime_websocket_protocol`.
571 lines
22 KiB
Python
571 lines
22 KiB
Python
"""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
|