phase: 05_anonymous_lobbies_and_room_lifecycle
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`.
This commit is contained in:
@@ -0,0 +1,570 @@
|
||||
"""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
|
||||
@@ -0,0 +1,815 @@
|
||||
"""Integration: the lobby (phase 05, task 03; US-02, US-03) — seats,
|
||||
ready-up, the host's authority, level picking and spectators.
|
||||
|
||||
The contract, driven by hand on a `FakeClock` (no test sleeps):
|
||||
|
||||
* **ready-up** — `Seat.toggle_ready` / the room's `set_ready`, visible in
|
||||
`lobby_payload` (the `ready` flags and `can_start`: a solo host, or ≥ 1
|
||||
ready non-host, and a selected level, and a crown holder),
|
||||
* **start** — `Room.start(token)`: `LOBBY` only, host only
|
||||
(`NotInLobby` / `NotHost`), then task 02's preconditions as
|
||||
`NothingToStart` (a `StartRefused` — the reason rides along),
|
||||
* **level pick** — `Room.set_level(token, level_id)`: `LOBBY` only, host
|
||||
only, the id in the campaign (`UnknownLevel`) and its `order` within
|
||||
the requesting session's `unlocked_level` (`LevelLocked`; a token
|
||||
without a session record counts as unlocked 1),
|
||||
* **the host rules** — the creator's seat 0 is the host; when the crown
|
||||
holder leaves or their connection drops in `LOBBY`/`ROUND_END` the
|
||||
crown moves to the lowest occupied, *connected* seat; in
|
||||
`COUNTDOWN`/`PLAYING` the host slot is emptied and **no mid-round
|
||||
election happens** (asserted explicitly); a returning host does not
|
||||
automatically regain the crown,
|
||||
* **spectators** — a `join` in `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a
|
||||
spectator (no seat, `role="spectator"`, a colour from the same
|
||||
first-free palette); a spectator's input is an `err not_seated`; a
|
||||
`leave` removes the entry; `to_lobby` promotes them into the free
|
||||
seats (PLAN §6: "late joins wait in `SPECTATING` until `LOBBY`"),
|
||||
* **colours** — the 4-colour palette, first free in seat order; a
|
||||
token's colour is remembered for the life of the room (a reconnect
|
||||
keeps it),
|
||||
* **emotes** — the `EMOTES` whitelist (`UnknownEmote`), the 2-per-1.0 s
|
||||
per-token throttle, re-broadcast as an `ev` event,
|
||||
* **names** — sanitized at the boundary (a markup name comes back ≤ 16
|
||||
chars, markup-free).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.clock import FakeClock
|
||||
from undercooked.server.room import Room, RoomState, StartRefused
|
||||
from undercooked.server.rooms import PlayerNotInRoom, RoomSupervisor
|
||||
from undercooked.server.seats import (
|
||||
EMOTE_EVENT_KIND,
|
||||
EMOTES,
|
||||
SEAT_PALETTE,
|
||||
LevelLocked,
|
||||
NothingToStart,
|
||||
NotHost,
|
||||
NotInLobby,
|
||||
Seat,
|
||||
Spectator,
|
||||
UnknownEmote,
|
||||
UnknownLevel,
|
||||
)
|
||||
from undercooked.server.sessions import SessionRecord, new_session
|
||||
from undercooked.sim.content import ContentBundle
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import EventKind, ItemForm, ItemKind, StationKind
|
||||
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
|
||||
from undercooked.sim.step import PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
|
||||
def make_book() -> RecipeBook:
|
||||
"""A one-recipe book: the small levels' order pool names `soup`."""
|
||||
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="serve", station=StationKind.SERVE, seconds=0.0)],
|
||||
)
|
||||
},
|
||||
ingredients=[ItemKind.TOMATO],
|
||||
)
|
||||
|
||||
|
||||
def make_level(
|
||||
level_id: str, order: int, *, time_limit: float = 3.0, target: int = 1000
|
||||
) -> Level:
|
||||
"""A 7x4 kitchen (one spawn shelf, one serve window) at campaign
|
||||
`order`; `target` above any idle score, so a round ends `LOST` on
|
||||
the buzzer — the shortest path to `ROUND_END`."""
|
||||
grid = [
|
||||
"#######",
|
||||
"#S...V#",
|
||||
"#.....#",
|
||||
"#######",
|
||||
]
|
||||
tiles: dict[str, dict[str, str]] = {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
}
|
||||
data: dict[str, object] = {
|
||||
"id": level_id,
|
||||
"name": f"Lobby kitchen {level_id}",
|
||||
"order": order,
|
||||
"time_limit": time_limit,
|
||||
"target_score": target,
|
||||
"grid": copy.deepcopy(grid),
|
||||
"tiles": copy.deepcopy(tiles),
|
||||
"spawns": [[2, 2]],
|
||||
"orders": {
|
||||
"pool": ["soup"],
|
||||
"concurrent": 1,
|
||||
"spawn_interval": [4, 6],
|
||||
"deadline": 30,
|
||||
},
|
||||
}
|
||||
return Level.build(LevelDef.model_validate(data))
|
||||
|
||||
|
||||
def make_bundle(*levels: Level) -> ContentBundle:
|
||||
return ContentBundle(recipe_book=make_book(), levels=levels)
|
||||
|
||||
|
||||
def two_level_bundle() -> ContentBundle:
|
||||
return make_bundle(make_level("05a_first", 1), make_level("05b_second", 2))
|
||||
|
||||
|
||||
def make_room(
|
||||
bundle: ContentBundle | None = None,
|
||||
sessions: dict[str, SessionRecord] | None = None,
|
||||
**settings: Any,
|
||||
) -> tuple[Room, FakeClock]:
|
||||
"""A `LOBBY` room on a `FakeClock`; `sessions` (or its absence) is
|
||||
the room's token → session lookup (`None` = no lookup: a token counts
|
||||
as unlocked 1)."""
|
||||
clock = FakeClock()
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(**settings),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id if bundle and bundle.levels else None,
|
||||
sessions=(sessions or {}).get,
|
||||
)
|
||||
return room, clock
|
||||
|
||||
|
||||
def make_supervisor(
|
||||
bundle: ContentBundle | None = None, **settings: Any
|
||||
) -> tuple[RoomSupervisor, FakeClock]:
|
||||
clock = FakeClock()
|
||||
supervisor = RoomSupervisor(Settings(**settings), clock, bundle or two_level_bundle())
|
||||
return supervisor, clock
|
||||
|
||||
|
||||
def prime(room: Room, now: float = 0.0) -> None:
|
||||
room.tick_once(now=now)
|
||||
|
||||
|
||||
def drive_countdown(room: Room, now: float = 0.0) -> float:
|
||||
"""The 3 s countdown in 1 s frames; returns the final `now`, with the
|
||||
room in `PLAYING` (the round built)."""
|
||||
prime(room, now)
|
||||
for offset in (1.0, 2.0, 3.0):
|
||||
room.tick_once(now=now + offset)
|
||||
assert room.state is RoomState.PLAYING
|
||||
return now + 3.0
|
||||
|
||||
|
||||
def play_round_to_end(room: Room, now: float = 3.0) -> float:
|
||||
"""1 s frames through the 3.0 s round and its 1.0 s settle window,
|
||||
until `ROUND_END`; returns the final `now`."""
|
||||
while room.state is RoomState.PLAYING:
|
||||
now += 1.0
|
||||
room.tick_once(now=now)
|
||||
assert room.state is RoomState.ROUND_END
|
||||
return now
|
||||
|
||||
|
||||
def seat(room: Room, token: str) -> Seat:
|
||||
"""Narrow `seat_for_token` for the tests (the token is seated)."""
|
||||
got = room.seat_for_token(token)
|
||||
assert got is not None
|
||||
return got
|
||||
|
||||
|
||||
def watching(room: Room, token: str) -> Spectator:
|
||||
"""Narrow `spectator_for_token` for the tests (the token is watching)."""
|
||||
got = room.spectator_for_token(token)
|
||||
assert got is not None
|
||||
return got
|
||||
|
||||
|
||||
def seat_host_and_ready(room: Room) -> None:
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
assert room.set_ready("tok-b", True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ready / start
|
||||
|
||||
|
||||
def test_ready_toggles_are_visible_in_the_lobby_payload() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
cy = room.add_seat("tok-c", "Cy", False)
|
||||
|
||||
payload: dict[str, Any] = room.lobby_payload()
|
||||
assert [seat["ready"] for seat in payload["seats"]] == [False, False, False]
|
||||
assert payload["can_start"] is False # a host, a level, but nobody ready
|
||||
|
||||
assert cy.toggle_ready() is True
|
||||
assert cy.toggle_ready() is False # the toggle flips back
|
||||
assert seat(room, "tok-b").toggle_ready() is True
|
||||
|
||||
lobby_view: dict[str, Any] = room.lobby_payload()
|
||||
ready = {seat["index"]: seat["ready"] for seat in lobby_view["seats"]}
|
||||
assert ready == {0: False, 1: True, 2: False}
|
||||
assert room.lobby_payload()["can_start"] is True # one ready non-host is enough
|
||||
|
||||
assert room.set_ready("tok-b", False)
|
||||
assert room.lobby_payload()["can_start"] is False
|
||||
|
||||
|
||||
def test_solo_start_is_allowed_and_the_payload_says_can_start() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
assert room.lobby_payload()["can_start"] is True # a solo host: no ready needed
|
||||
room.start("tok-a")
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
assert room.round_no == 1
|
||||
|
||||
|
||||
def test_start_requires_a_ready_non_host_and_names_the_refusal() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
seat_host_and_ready(room)
|
||||
seat(room, "tok-b").ready = False # the ready-up is not there yet
|
||||
|
||||
with pytest.raises(NothingToStart, match="no_ready_players") as exc_info:
|
||||
room.start("tok-a")
|
||||
assert exc_info.value.reason == "no_ready_players"
|
||||
assert isinstance(exc_info.value, StartRefused) # the task-02 contract rides along
|
||||
assert room.state is RoomState.LOBBY # the refusal leaves no state change
|
||||
|
||||
room.set_ready("tok-b", True)
|
||||
room.start("tok-a")
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
|
||||
|
||||
def test_start_without_a_level_refuses_no_level() -> None:
|
||||
room, _ = make_room(None) # no content bundle: no level selected
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
with pytest.raises(NothingToStart, match="no_level"):
|
||||
room.start("tok-a")
|
||||
assert room.state is RoomState.LOBBY
|
||||
|
||||
|
||||
def test_start_and_set_level_by_a_non_host_are_rejected() -> None:
|
||||
bundle = two_level_bundle()
|
||||
sessions = {"tok-b": SessionRecord("tok-b", "Bob", 0.0, 2)}
|
||||
room, _ = make_room(bundle, sessions)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
room.set_ready("tok-b", True) # so the host's start can run past the guards
|
||||
|
||||
with pytest.raises(NotHost):
|
||||
room.start("tok-b")
|
||||
with pytest.raises(NotHost):
|
||||
room.start("tok-stranger")
|
||||
with pytest.raises(NotHost):
|
||||
room.set_level("tok-b", "05a_first")
|
||||
|
||||
# Outside the lobby, neither verb runs — even for the host.
|
||||
room.start("tok-a")
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
with pytest.raises(NotInLobby):
|
||||
room.start("tok-a")
|
||||
with pytest.raises(NotInLobby):
|
||||
room.set_level("tok-a", "05a_first")
|
||||
|
||||
|
||||
# ------------------------------------------------------------- level picking
|
||||
|
||||
|
||||
def test_set_level_requires_a_known_unlocked_level() -> None:
|
||||
sessions = {
|
||||
"tok-a": SessionRecord("tok-a", "Ada", 0.0, 1),
|
||||
"tok-b": SessionRecord("tok-b", "Bob", 0.0, 2),
|
||||
}
|
||||
room, _ = make_room(two_level_bundle(), sessions)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
|
||||
with pytest.raises(UnknownLevel):
|
||||
room.set_level("tok-a", "05_zzz")
|
||||
with pytest.raises(LevelLocked, match="order 2"):
|
||||
room.set_level("tok-a", "05b_second") # the session is unlocked 1
|
||||
assert room.level_id == "05a_first" # the refusal leaves the pick unchanged
|
||||
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
seat(room, "tok-a").host = False # the crown moves to Bob (unlocked 2)
|
||||
seat(room, "tok-b").host = True
|
||||
level = room.set_level("tok-b", "05b_second")
|
||||
assert level.id == "05b_second"
|
||||
assert room.level_id == "05b_second"
|
||||
|
||||
|
||||
def test_set_level_without_a_session_lookup_counts_as_unlocked_1() -> None:
|
||||
room, _ = make_room(two_level_bundle()) # no sessions map at all
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
with pytest.raises(LevelLocked):
|
||||
room.set_level("tok-a", "05b_second")
|
||||
with pytest.raises(UnknownLevel):
|
||||
room.set_level("tok-a", "05_zzz")
|
||||
picked = room.set_level("tok-a", "05a_first") # the floor is always pickable
|
||||
assert picked.id == "05a_first"
|
||||
|
||||
|
||||
def test_set_level_refuses_an_id_outside_a_bundleless_campaign() -> None:
|
||||
room, _ = make_room(None) # no content bundle: no campaign to pick from
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
with pytest.raises(UnknownLevel):
|
||||
room.set_level("tok-a", "05a_first")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the host rules
|
||||
|
||||
|
||||
def test_host_handoff_in_the_lobby_goes_to_the_lowest_connected_seat() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
room.add_seat("tok-c", "Cy", False)
|
||||
|
||||
# A dropped host connection is a host leave: the crown moves to the
|
||||
# lowest occupied, *connected* seat.
|
||||
assert room.mark_seat_disconnected("tok-a")
|
||||
assert not seat(room, "tok-a").host
|
||||
assert seat(room, "tok-b").host is True # seat 1, the lowest connected
|
||||
assert room.lobby_payload()["host_seat"] == 1
|
||||
|
||||
# The host's leave does the same: on to the next connected seat.
|
||||
assert room.remove_seat("tok-b")
|
||||
assert seat(room, "tok-c").host is True # seat 2
|
||||
|
||||
# When no connected seat remains, the crown is left empty.
|
||||
room.mark_seat_disconnected("tok-c")
|
||||
room.mark_seat_disconnected("tok-a")
|
||||
assert not any(seat.host for seat in room.seats)
|
||||
assert room.lobby_payload()["host_seat"] is None
|
||||
assert room.lobby_payload()["can_start"] is False # nobody can press Start
|
||||
|
||||
|
||||
def test_host_handoff_in_round_end_elects_the_lowest_connected_seat() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
seat_host_and_ready(room)
|
||||
room.start("tok-a")
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now)
|
||||
assert room.state is RoomState.ROUND_END
|
||||
|
||||
assert room.mark_seat_disconnected("tok-a")
|
||||
assert seat(room, "tok-b").host is True # elected in ROUND_END
|
||||
assert room.lobby_payload()["host_seat"] == 1
|
||||
|
||||
|
||||
def test_no_mid_round_host_election_happens() -> None:
|
||||
"""The documented rule, asserted explicitly (not by omission): in
|
||||
`COUNTDOWN`/`PLAYING` the host slot is emptied when the host leaves
|
||||
or drops — and the round continues, with no election, until
|
||||
`ROUND_END` (and no election happens on the state change either)."""
|
||||
room, _ = make_room(two_level_bundle())
|
||||
seat_host_and_ready(room)
|
||||
room.start("tok-a")
|
||||
now = drive_countdown(room) # PLAYING
|
||||
|
||||
assert room.mark_seat_disconnected("tok-a")
|
||||
assert all(not seat.host for seat in room.seats) # the host slot is empty
|
||||
assert room.lobby_payload()["host_seat"] is None
|
||||
assert room.state is RoomState.PLAYING # the round continues
|
||||
|
||||
now = play_round_to_end(room, now)
|
||||
assert all(not seat.host for seat in room.seats) # still empty at ROUND_END
|
||||
assert room.lobby_payload()["host_seat"] is None
|
||||
assert room.lobby_payload()["can_start"] is False
|
||||
|
||||
# The host's *leave* mid-round: the same rule.
|
||||
room2, _ = make_room(two_level_bundle())
|
||||
seat_host_and_ready(room2)
|
||||
room2.start("tok-a")
|
||||
drive_countdown(room2)
|
||||
assert room2.remove_seat("tok-a")
|
||||
assert all(not seat.host for seat in room2.seats)
|
||||
assert room2.state is RoomState.PLAYING
|
||||
|
||||
|
||||
def test_a_returning_host_does_not_regain_the_crown() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
room.mark_seat_disconnected("tok-a") # the crown moves to Bob
|
||||
assert seat(room, "tok-b").host is True
|
||||
|
||||
# Ada's connection returns: the seat is live again, the crown stays.
|
||||
assert room.mark_seat_connected("tok-a")
|
||||
assert seat(room, "tok-a").connected is True
|
||||
assert not seat(room, "tok-a").host
|
||||
assert seat(room, "tok-b").host is True
|
||||
|
||||
# Ada leaves the lobby and re-joins: the fresh seat is not the host.
|
||||
room.remove_seat("tok-a")
|
||||
again = room.add_seat("tok-a", "Ada", False)
|
||||
assert again.host is False
|
||||
assert seat(room, "tok-b").host is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------- spectators
|
||||
|
||||
|
||||
def test_join_during_play_becomes_a_spectator() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
supervisor.join(room.code, bob.token, "Bob")
|
||||
assert room.set_ready(bob.token, True)
|
||||
room.start(ada.token)
|
||||
drive_countdown(room) # PLAYING
|
||||
|
||||
cy = new_session("Cy", clock)
|
||||
supervisor.join(room.code, cy.token, "Cy")
|
||||
assert room.seat_for_token(cy.token) is None # no seat
|
||||
spec = room.spectator_for_token(cy.token)
|
||||
assert spec is not None
|
||||
assert spec.role == "spectator" and spec.connected
|
||||
assert spec.display_name == "Cy"
|
||||
assert spec.color == SEAT_PALETTE[2] # first free, after the two seats
|
||||
|
||||
payload = room.lobby_payload(ada.token)
|
||||
assert payload["spectators"] == [{"name": "Cy", "color": SEAT_PALETTE[2]}]
|
||||
assert supervisor.stats()["spectators"] == 1
|
||||
# The token index still finds the watcher (by_token works for them).
|
||||
assert supervisor.by_token(cy.token) is room
|
||||
|
||||
|
||||
def test_a_full_room_still_accepts_spectators() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
eve = new_session("Eve", clock)
|
||||
room = supervisor.create_room(eve, "Eve")
|
||||
tokens = [eve.token]
|
||||
for name in ("Fay", "Gus", "Hal"):
|
||||
token = new_session(name, clock).token
|
||||
supervisor.join(room.code, token, name)
|
||||
tokens.append(token)
|
||||
assert len(room.seats) == 4 # max_players
|
||||
room.set_ready(tokens[1], True)
|
||||
room.start(eve.token)
|
||||
drive_countdown(room)
|
||||
|
||||
imran = new_session("Imran", clock)
|
||||
supervisor.join(room.code, imran.token, "Imran") # the 5th player: a watcher
|
||||
assert room.seat_for_token(imran.token) is None
|
||||
assert room.spectator_for_token(imran.token) is not None
|
||||
# Four seats take the whole palette: the late watcher has no colour.
|
||||
assert watching(room, imran.token).color == ""
|
||||
|
||||
|
||||
def test_join_during_countdown_becomes_a_spectator() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
ida = new_session("Ida", clock)
|
||||
room = supervisor.create_room(ida, "Ida")
|
||||
room.start(ida.token) # a solo host: the countdown runs
|
||||
prime(room) # the 3 s is in flight
|
||||
|
||||
jon = new_session("Jon", clock)
|
||||
supervisor.join(room.code, jon.token, "Jon")
|
||||
assert room.seat_for_token(jon.token) is None
|
||||
assert room.spectator_for_token(jon.token) is not None
|
||||
|
||||
|
||||
def test_spectator_input_is_rejected_with_not_seated() -> None:
|
||||
"""A spectator's `input` is ignored — as a named `err`, not a
|
||||
silent no-op (and distinct from `unknown_player`)."""
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
room.start(ada.token)
|
||||
drive_countdown(room)
|
||||
|
||||
cy = new_session("Cy", clock)
|
||||
supervisor.join(room.code, cy.token, "Cy")
|
||||
assert room.queue_input(cy.token, PlayerInput(player_id=cy.token)) is False
|
||||
|
||||
events = room.take_events()
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.ERROR
|
||||
assert events[0].payload == {
|
||||
"reason": "not_seated",
|
||||
"player_id": cy.token,
|
||||
"seq": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_spectator_leave_removes_the_entry_and_disconnect_keeps_it() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
room.start(ada.token)
|
||||
drive_countdown(room)
|
||||
|
||||
cy = new_session("Cy", clock)
|
||||
supervisor.join(room.code, cy.token, "Cy")
|
||||
supervisor.mark_disconnected(room.code, cy.token) # the entry is kept
|
||||
spec = room.spectator_for_token(cy.token)
|
||||
assert spec is not None and spec.connected is False
|
||||
|
||||
supervisor.leave(room.code, cy.token)
|
||||
assert room.spectator_for_token(cy.token) is None
|
||||
assert supervisor.stats()["spectators"] == 0
|
||||
with pytest.raises(PlayerNotInRoom):
|
||||
supervisor.leave(room.code, cy.token)
|
||||
|
||||
|
||||
def test_spectator_rejoin_keeps_the_entry_and_the_color() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
room.start(ada.token)
|
||||
drive_countdown(room)
|
||||
|
||||
cy = new_session("Cy", clock)
|
||||
supervisor.join(room.code, cy.token, "Cy")
|
||||
color = watching(room, cy.token).color
|
||||
assert color == SEAT_PALETTE[1] # first free, after Ada's seat
|
||||
|
||||
supervisor.mark_disconnected(room.code, cy.token)
|
||||
supervisor.join(room.code, cy.token, "Cy") # the re-watch
|
||||
spec = watching(room, cy.token)
|
||||
assert spec.connected is True
|
||||
assert spec.color == color # the same entry, the same colour
|
||||
|
||||
|
||||
def test_spectators_are_promoted_to_free_seats_on_to_lobby() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
supervisor.join(room.code, bob.token, "Bob")
|
||||
assert room.set_ready(bob.token, True)
|
||||
room.start(ada.token)
|
||||
now = drive_countdown(room)
|
||||
cy = new_session("Cy", clock)
|
||||
di = new_session("Di", clock)
|
||||
supervisor.join(room.code, cy.token, "Cy")
|
||||
supervisor.join(room.code, di.token, "Di")
|
||||
now = play_round_to_end(room, now)
|
||||
|
||||
room.to_lobby()
|
||||
assert room.state is RoomState.LOBBY
|
||||
assert len(room.seats) == 4 # both watchers were promoted
|
||||
assert room.spectators == []
|
||||
assert not any(seat.ready for seat in room.seats) # a fresh lobby readies up
|
||||
for token in (cy.token, di.token):
|
||||
seat = room.seat_for_token(token)
|
||||
assert seat is not None and seat.host is False and seat.connected
|
||||
|
||||
|
||||
def test_a_spectator_of_a_full_room_stays_watching_on_to_lobby() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
eve = new_session("Eve", clock)
|
||||
room = supervisor.create_room(eve, "Eve")
|
||||
tokens = [eve.token]
|
||||
for name in ("Fay", "Gus", "Hal"):
|
||||
token = new_session(name, clock).token
|
||||
supervisor.join(room.code, token, name)
|
||||
tokens.append(token)
|
||||
room.set_ready(tokens[1], True)
|
||||
room.start(eve.token)
|
||||
now = drive_countdown(room)
|
||||
imran = new_session("Imran", clock)
|
||||
supervisor.join(room.code, imran.token, "Imran")
|
||||
now = play_round_to_end(room, now)
|
||||
|
||||
room.to_lobby()
|
||||
assert len(room.seats) == 4 # the room is full: nobody new can sit
|
||||
assert len(room.spectators) == 1
|
||||
assert room.spectator_for_token(imran.token) is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ colours
|
||||
|
||||
|
||||
def test_colors_are_first_free_in_seat_order() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
ada = room.add_seat("tok-a", "Ada", True)
|
||||
bob = room.add_seat("tok-b", "Bob", False)
|
||||
cy = room.add_seat("tok-c", "Cy", False)
|
||||
dee = room.add_seat("tok-d", "Dee", False)
|
||||
assert (ada.color, bob.color, cy.color, dee.color) == SEAT_PALETTE
|
||||
|
||||
# Seat 2 leaves: the next joiner takes the first free colour (c2).
|
||||
room.remove_seat("tok-c")
|
||||
eve = room.add_seat("tok-e", "Eve", False)
|
||||
assert eve.seat_index == 2 and eve.color == SEAT_PALETTE[2]
|
||||
|
||||
# Seat 0 leaves: the next joiner takes the first free colour (c0).
|
||||
room.remove_seat("tok-a")
|
||||
fred = room.add_seat("tok-f", "Fred", False)
|
||||
assert fred.seat_index == 0 and fred.color == SEAT_PALETTE[0]
|
||||
|
||||
|
||||
def test_a_returning_token_keeps_its_color_over_the_first_free() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True) # palette[0]
|
||||
room.add_seat("tok-b", "Bob", False) # palette[1]
|
||||
room.add_seat("tok-c", "Cy", False) # palette[2]
|
||||
room.remove_seat("tok-b")
|
||||
room.add_seat("tok-d", "Dee", False) # seat 1, first free = palette[1]
|
||||
room.remove_seat("tok-a") # palette[0] is now the first free
|
||||
bob2 = room.add_seat("tok-b", "Bob", False) # seat 0: first free = palette[0]
|
||||
assert bob2.seat_index == 0
|
||||
assert bob2.color == SEAT_PALETTE[1] # Bob's remembered colour wins
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ emotes
|
||||
|
||||
|
||||
def test_emote_whitelist_and_throttle() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
|
||||
assert room.emote("tok-a", "wave") is True # t=0
|
||||
assert room.emote("tok-a", "cheer", now=0.1) is True
|
||||
assert room.emote("tok-a", "star", now=0.2) is False # the 3rd in 1 s: dropped
|
||||
assert room.emote("tok-a", "star", now=1.1) is True # the window has passed
|
||||
|
||||
events = room.take_events()
|
||||
assert [(e.kind, dict(e.payload)) for e in events] == [
|
||||
(EMOTE_EVENT_KIND, {"emote": "wave", "player_id": "tok-a"}),
|
||||
(EMOTE_EVENT_KIND, {"emote": "cheer", "player_id": "tok-a"}),
|
||||
(EMOTE_EVENT_KIND, {"emote": "star", "player_id": "tok-a"}),
|
||||
]
|
||||
# The per-token windows are independent.
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
assert room.emote("tok-b", "wave", now=0.2) is True # unthrottled for Bob
|
||||
|
||||
with pytest.raises(UnknownEmote):
|
||||
room.emote("tok-a", "dance") # outside the whitelist: a named refusal
|
||||
assert room.emote("tok-stranger", "wave") is False # an unknown token: dropped
|
||||
assert set(EMOTES) == {"wave", "cheer", "thumbs_up", "thumbs_down", "angry", "star"}
|
||||
|
||||
|
||||
def test_emotes_are_dropped_outside_the_emote_states_and_for_spectators_too() -> None:
|
||||
room, _ = make_room(two_level_bundle())
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_spectator("tok-b", "Bob")
|
||||
assert room.emote("tok-b", "wave") is True # a seated-or-watching token may
|
||||
|
||||
room.start("tok-a") # COUNTDOWN
|
||||
assert room.emote("tok-a", "wave") is False # dropped, not an error
|
||||
events = room.take_events()
|
||||
# Only the accepted LOBBY emote is buffered; the dropped one is not.
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EMOTE_EVENT_KIND
|
||||
assert events[0].payload == {"emote": "wave", "player_id": "tok-b"}
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the payload
|
||||
|
||||
|
||||
def test_the_lobby_payload_shape_and_viewer_unlock() -> None:
|
||||
sessions = {"tok-a": SessionRecord("tok-a", "Ada", 0.0, 2)}
|
||||
room, _ = make_room(two_level_bundle(), sessions)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
room.set_ready("tok-b", True)
|
||||
room.add_spectator("tok-c", "Cy")
|
||||
|
||||
payload: dict[str, Any] = room.lobby_payload("tok-a")
|
||||
assert set(payload) == {
|
||||
"code",
|
||||
"state",
|
||||
"level",
|
||||
"host_seat",
|
||||
"can_start",
|
||||
"seats",
|
||||
"spectators",
|
||||
"unlocked",
|
||||
"round_no",
|
||||
}
|
||||
assert payload["code"] == room.code
|
||||
assert payload["state"] == "lobby"
|
||||
assert payload["level"] == "05a_first"
|
||||
assert payload["host_seat"] == 0
|
||||
assert payload["can_start"] is True
|
||||
assert payload["round_no"] == 0
|
||||
assert payload["unlocked"] == 2 # the viewer's session
|
||||
assert set(payload["seats"][0]) == {
|
||||
"index",
|
||||
"name",
|
||||
"color",
|
||||
"ready",
|
||||
"host",
|
||||
"connected",
|
||||
}
|
||||
assert [seat["host"] for seat in payload["seats"]] == [True, False]
|
||||
assert [seat["name"] for seat in payload["seats"]] == ["Ada", "Bob"]
|
||||
assert payload["seats"][0]["color"] == SEAT_PALETTE[0]
|
||||
assert payload["spectators"] == [{"name": "Cy", "color": SEAT_PALETTE[2]}]
|
||||
|
||||
# An anonymous viewer sees no unlock, and a token never leaks out.
|
||||
assert room.lobby_payload()["unlocked"] is None
|
||||
assert room.lobby_payload("tok-stranger")["unlocked"] is None
|
||||
blob = str(room.lobby_payload("tok-a"))
|
||||
assert "tok-a" not in blob and "tok-c" not in blob
|
||||
|
||||
|
||||
# -------------------------------------------------- names at the boundary
|
||||
|
||||
|
||||
def test_a_markup_display_name_comes_back_sanitized() -> None:
|
||||
"""US-02 criterion 6, at the boundary: the markup name is stored
|
||||
sanitized (≤ 16 chars, markup-free) for a create and a join."""
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
room = supervisor.create_room(new_session(None, clock), "<script>alert(1)</script>")
|
||||
name = room.seats[0].display_name
|
||||
assert name == "scriptalert(1)/s"
|
||||
assert len(name) <= 16
|
||||
assert not {"<", ">", '"'} & set(name)
|
||||
|
||||
cy = new_session(None, clock)
|
||||
supervisor.join(room.code, cy.token, "<script>alert(1)</script>")
|
||||
assert seat(room, cy.token).display_name == "scriptalert(1)/s"
|
||||
|
||||
# A whitespace-only name falls back to the seat number.
|
||||
dee = new_session(None, clock)
|
||||
supervisor.join(room.code, dee.token, " ")
|
||||
assert seat(room, dee.token).display_name == "Cook #3"
|
||||
|
||||
|
||||
# -------------------------------------------------- seats' own methods
|
||||
|
||||
|
||||
def test_the_seat_and_spectator_verbs() -> None:
|
||||
"""The `Seat`/`Spectator` verbs and the room's bookkeeping edges:
|
||||
`is_host`, `next_seat_index` on a full room, the not-seated / not-
|
||||
watching refusals, the `add_spectator` reclaim, the session lookup's
|
||||
`None` branch."""
|
||||
room, _ = make_room(two_level_bundle())
|
||||
host = room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
assert host.is_host is True
|
||||
assert seat(room, "tok-b").is_host is False
|
||||
|
||||
assert room.mark_seat_connected("tok-stranger") is False # not seated
|
||||
assert room.mark_spectator_connected("tok-stranger") is False # not watching
|
||||
|
||||
# The reclaim path: a second `add_spectator` for the same token keeps
|
||||
# the entry, re-connects it, and updates the name when a new one is
|
||||
# typed (an empty name keeps the old one).
|
||||
first = room.add_spectator("tok-b", "Bob")
|
||||
assert first.color == SEAT_PALETTE[2] # first free, after the two seats
|
||||
again = room.add_spectator("tok-b", "Bobby")
|
||||
assert again is first and again.connected is True
|
||||
assert again.display_name == "Bobby"
|
||||
room.add_spectator("tok-b", "")
|
||||
assert again.display_name == "Bobby" # an empty name keeps the old one
|
||||
|
||||
# A full room: `next_seat_index` names the refusal, `add_seat` refuses.
|
||||
room.add_seat("tok-c", "Cy", False)
|
||||
room.add_seat("tok-d", "Dee", False)
|
||||
assert room.is_full
|
||||
with pytest.raises(IndexError, match="no free seat"):
|
||||
room.next_seat_index()
|
||||
with pytest.raises(ValueError, match="is full"):
|
||||
room.add_seat("tok-e", "Eve", False)
|
||||
|
||||
# A test-built room (no `sessions` argument): the lookup is `None`, and
|
||||
# `_session_for` says so (a token counts as unlocked 1).
|
||||
bare2 = Room(code="T2", settings=Settings(), clock=FakeClock())
|
||||
assert bare2.sessions is None
|
||||
assert bare2._session_for("tok-anyone") is None
|
||||
|
||||
|
||||
# -------------------------------------------------- sessions at the boundary
|
||||
|
||||
|
||||
def test_a_join_with_an_unknown_token_mints_a_session() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
room = supervisor.all_rooms()[0]
|
||||
|
||||
assert supervisor.session_for("tok-unknown") is None
|
||||
supervisor.join(room.code, "tok-unknown", "Zed")
|
||||
record = supervisor.session_for("tok-unknown")
|
||||
assert record is not None
|
||||
assert record.display_name == "Zed"
|
||||
assert record.unlocked_level == 1 # a fresh session: the campaign's floor
|
||||
|
||||
|
||||
def test_a_known_session_record_is_left_alone_on_rejoin() -> None:
|
||||
supervisor, clock = make_supervisor(two_level_bundle())
|
||||
supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
room = supervisor.all_rooms()[0]
|
||||
unlocked = SessionRecord("tok-u2", "U2", 0.0, 3)
|
||||
supervisor.create_room(unlocked, None) # U2 owns a room of their own
|
||||
|
||||
supervisor.join(room.code, "tok-u2", None) # evicted from their room
|
||||
assert supervisor.session_for("tok-u2") is unlocked # the record is intact
|
||||
assert unlocked.unlocked_level == 3
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Integration (grep-style): the lifecycle modules' time discipline
|
||||
(phase 05, task 05).
|
||||
|
||||
Complements `tests/unit/test_server_clock_discipline.py` (which already
|
||||
greps *all* of `server/` for `time` imports and wall-clock calls): this
|
||||
pins the task file's rules on the lifecycle modules themselves —
|
||||
`room.py`, `rooms.py`, `sessions.py` — so the lifecycle stays
|
||||
deterministically testable:
|
||||
|
||||
* **no direct wall-clock call** — `time.time()` / `time.monotonic()`
|
||||
(all timing goes through the injected `Clock`; `server/clock.py` is
|
||||
the only module that may import `time` — the unit guard above),
|
||||
* **no `import time` in the lifecycle modules** — the `Clock` is the
|
||||
seam,
|
||||
* **no `asyncio.sleep(` with a non-zero literal** — the cadence comes
|
||||
from the deadline computed on the injected `Clock` (`Room.tick_loop`,
|
||||
the supervisor's `gc_loop`), and the loop's sleep is injectable
|
||||
(`Room(sleep=...)`, `RoomSupervisor(sleep=...)`), so a test drives
|
||||
the whole lifecycle with a `FakeClock` and never sleeps. A
|
||||
`asyncio.sleep(0.05)` literal would reintroduce a real-time
|
||||
dependency on the exact path this test guards. (A *named* argument —
|
||||
the injected sleep's seconds — and `asyncio.sleep(0)` are legal.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER_DIR = REPO_ROOT / "src" / "undercooked" / "server"
|
||||
|
||||
#: The lifecycle modules the task file names (`room*.py` → `room.py`,
|
||||
#: plus `rooms.py` and `sessions.py`).
|
||||
LIFECYCLE_MODULES = ("room.py", "rooms.py", "sessions.py")
|
||||
|
||||
#: The wall-clock calls the contract forbids in server code.
|
||||
WALL_CLOCK_CALLS = ("time.monotonic()", "time.time()")
|
||||
|
||||
#: `time` may be imported by exactly one module (the unit guard checks
|
||||
#: that it is `clock.py`).
|
||||
TIME_IMPORT = re.compile(r"^\s*(?:import time\b|from time import\b)", re.MULTILINE)
|
||||
|
||||
#: An `asyncio.sleep(` whose argument is a literal (number or identifier).
|
||||
SLEEP_CALL = re.compile(r"asyncio\.sleep\(\s*([A-Za-z_][A-Za-z0-9_]*|[\d.]+)\s*\)")
|
||||
|
||||
#: A (possibly exponential, signed) floating-point or integer literal.
|
||||
NUMERIC_LITERAL = re.compile(r"-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
|
||||
|
||||
|
||||
def lifecycle_modules() -> list[Path]:
|
||||
paths = []
|
||||
for name in LIFECYCLE_MODULES:
|
||||
path = SERVER_DIR / name
|
||||
assert path.is_file(), f"the lifecycle module vanished: {name}"
|
||||
paths.append(path)
|
||||
return paths
|
||||
|
||||
|
||||
def test_no_wall_clock_call_lives_in_the_lifecycle_modules() -> None:
|
||||
offenders = [
|
||||
f"{path.name}: {literal}"
|
||||
for path in lifecycle_modules()
|
||||
for literal in WALL_CLOCK_CALLS
|
||||
if literal in path.read_text(encoding="utf-8")
|
||||
]
|
||||
assert offenders == [], "wall-clock calls leaked into the lifecycle:\n " + "\n ".join(
|
||||
offenders
|
||||
)
|
||||
|
||||
|
||||
def test_no_time_import_lives_in_the_lifecycle_modules() -> None:
|
||||
offenders = [
|
||||
path.name
|
||||
for path in lifecycle_modules()
|
||||
if TIME_IMPORT.search(path.read_text(encoding="utf-8"))
|
||||
]
|
||||
assert offenders == [], "the lifecycle may only read time through the Clock: " + ", ".join(
|
||||
offenders
|
||||
)
|
||||
|
||||
|
||||
def test_no_asyncio_sleep_with_a_nonzero_literal() -> None:
|
||||
offenders = []
|
||||
for path in lifecycle_modules():
|
||||
for match in SLEEP_CALL.finditer(path.read_text(encoding="utf-8")):
|
||||
literal = match.group(1)
|
||||
# A named argument (the injected sleep's seconds) is legal;
|
||||
# only a numeric literal is a wall-time dependency.
|
||||
if not NUMERIC_LITERAL.fullmatch(literal):
|
||||
continue
|
||||
if float(literal) > 0:
|
||||
offenders.append(f"{path.name}: asyncio.sleep({literal})")
|
||||
assert offenders == [], (
|
||||
"a non-zero asyncio.sleep literal would tie the lifecycle to real "
|
||||
"time (drive it with a FakeClock instead):\n " + "\n ".join(offenders)
|
||||
)
|
||||
|
||||
|
||||
def test_the_guard_is_not_vacuous() -> None:
|
||||
"""A guard that never fires is no guard: prove each check detects a
|
||||
planted violation, and that the modules it greps exist."""
|
||||
assert WALL_CLOCK_CALLS[0] in "x = time.monotonic()\n"
|
||||
assert WALL_CLOCK_CALLS[1] in "y = time.time()\n"
|
||||
assert TIME_IMPORT.search("import time\n") is not None
|
||||
assert TIME_IMPORT.search("from time import monotonic\n") is not None
|
||||
# The sleep guard: a non-zero literal fires, zero and named arguments do not.
|
||||
assert SLEEP_CALL.search("asyncio.sleep(0.05)\n") is not None
|
||||
assert SLEEP_CALL.search("asyncio.sleep(seconds)\n") is not None
|
||||
match = SLEEP_CALL.search("asyncio.sleep(0.05)\n")
|
||||
assert match is not None
|
||||
assert NUMERIC_LITERAL.fullmatch(match.group(1))
|
||||
assert float(match.group(1)) > 0
|
||||
assert lifecycle_modules()
|
||||
@@ -0,0 +1,695 @@
|
||||
"""Integration: the `Room` state machine (phase 05, task 02) — the guarded
|
||||
FSM in `server/room.py`.
|
||||
|
||||
The whole contract, driven by hand on a `FakeClock` (no test sleeps):
|
||||
|
||||
* **the transition table is data** — every `(state, event)` pair of the
|
||||
5 states x 9 events is tested through `Room._fire`: the 14 legal pairs
|
||||
land on the table's target, the 31 illegal ones raise `IllegalTransition`
|
||||
and leave the state untouched (no ad-hoc state writes exist),
|
||||
* **the preconditions are named** — `request_start` refuses `"no_level"`
|
||||
and `"no_ready_players"` (≥ 1 ready non-host, or a solo host, PLAN §6),
|
||||
* **the countdown is 3 s** — it counts 3 frames of 1 s to zero (and the
|
||||
20 Hz cadence: 60 frames of 0.05 s) and the round's state is built at
|
||||
`countdown_done` (level, a fresh seed, the shelf stock and the prop
|
||||
counters the room owns — `sim/stations.py`'s `DEFAULT_STOCK` is only the
|
||||
stand-in a room-less build sees),
|
||||
* **`round_over` stores the result** — after the sim's 1.0 s settle window
|
||||
(`sim/round_state.SETTLE_S`), the room is `ROUND_END` and `last_result`
|
||||
is `result_payload` of the state at the verdict,
|
||||
* **`retry` reuses the level with a new seed; `next_level` switches to an
|
||||
explicitly supplied level id** (task 04 wires that value from the session
|
||||
store),
|
||||
* **`close` is idempotent and frees the seats** (the universal teardown,
|
||||
legal from every live state),
|
||||
* **the idle rules** — `all_seats_empty` closes after the 30 s grace,
|
||||
`idle_expired` closes past `lobby_idle_timeout_s` (the room's own copy of
|
||||
the GC's rules),
|
||||
* **queued inputs are per-tick and PLAYING-only** — dropped outside
|
||||
`PLAYING`, an unknown player is a buffered `err` event, and `take_events`
|
||||
drains the buffer exactly once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.clock import FakeClock
|
||||
from undercooked.server.room import (
|
||||
COUNTDOWN_S,
|
||||
ROOM_EVENTS,
|
||||
ROOM_TRANSITIONS,
|
||||
SEAT_GRACE_S,
|
||||
IllegalTransition,
|
||||
Room,
|
||||
RoomState,
|
||||
StartRefused,
|
||||
)
|
||||
from undercooked.sim.content import ContentBundle
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import (
|
||||
EventKind,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
RoundResult,
|
||||
StationKind,
|
||||
)
|
||||
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
|
||||
from undercooked.sim.step import PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
|
||||
def make_book() -> RecipeBook:
|
||||
"""A one-recipe book: the small levels' order pool names `soup`, and
|
||||
the room passes the book to `step`, so a due order spawn must find it."""
|
||||
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="serve", station=StationKind.SERVE, seconds=0.0)],
|
||||
)
|
||||
},
|
||||
ingredients=[ItemKind.TOMATO],
|
||||
)
|
||||
|
||||
|
||||
def make_level(level_id: str, *, time_limit: float = 3.0, target: int = 1000) -> Level:
|
||||
"""A 7x4 kitchen: one spawn shelf, one serve window, one spawn point.
|
||||
|
||||
`target` is above anything an idle cook scores, so a `PLAYING` round
|
||||
on this level always ends `LOST` on the buzzer — the shortest path to
|
||||
`ROUND_END`.
|
||||
"""
|
||||
grid = [
|
||||
"#######",
|
||||
"#S...V#",
|
||||
"#.....#",
|
||||
"#######",
|
||||
]
|
||||
tiles: dict[str, dict[str, str]] = {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
}
|
||||
data: dict[str, object] = {
|
||||
"id": level_id,
|
||||
"name": f"FSM kitchen {level_id}",
|
||||
"order": 1,
|
||||
"time_limit": time_limit,
|
||||
"target_score": target,
|
||||
"grid": copy.deepcopy(grid),
|
||||
"tiles": copy.deepcopy(tiles),
|
||||
"spawns": [[2, 2]],
|
||||
"orders": {
|
||||
"pool": ["soup"],
|
||||
"concurrent": 1,
|
||||
"spawn_interval": [4, 6],
|
||||
"deadline": 30,
|
||||
},
|
||||
}
|
||||
return Level.build(LevelDef.model_validate(data))
|
||||
|
||||
|
||||
def make_bundle(*levels: Level) -> ContentBundle:
|
||||
return ContentBundle(recipe_book=make_book(), levels=levels)
|
||||
|
||||
|
||||
def make_room(
|
||||
bundle: ContentBundle | None = None,
|
||||
**settings: Any,
|
||||
) -> tuple[Room, FakeClock]:
|
||||
"""A `LOBBY` room on a `FakeClock`; `bundle=None` is the task-01 style
|
||||
(no content: the room cannot start a round it has no level for)."""
|
||||
clock = FakeClock()
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(**settings),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id if bundle and bundle.levels else None,
|
||||
)
|
||||
return room, clock
|
||||
|
||||
|
||||
def seat_solo_host(room: Room, player_id: str = "tok-a", name: str = "Ada") -> None:
|
||||
room.add_seat(player_id, name, True)
|
||||
|
||||
|
||||
def seat_ready_non_host(room: Room, player_id: str = "tok-b", name: str = "Bob") -> None:
|
||||
seat = room.add_seat(player_id, name, False)
|
||||
assert room.set_ready(player_id, True)
|
||||
assert seat is not None
|
||||
|
||||
|
||||
def prime(room: Room, now: float = 0.0) -> None:
|
||||
"""The first `tick_once` (dt 0) — the room's wall clock starts here."""
|
||||
room.tick_once(now=now)
|
||||
|
||||
|
||||
def drive_countdown(room: Room, now: float = 0.0) -> float:
|
||||
"""The 3 s countdown in 1 s frames (the task's own words); returns the
|
||||
final `now` (3.0), with the room in `PLAYING`."""
|
||||
prime(room, now)
|
||||
for offset in (1.0, 2.0, 3.0):
|
||||
room.tick_once(now=now + offset)
|
||||
assert room.state is RoomState.PLAYING
|
||||
return now + 3.0
|
||||
|
||||
|
||||
def play_round_to_end(room: Room, now: float = 3.0) -> float:
|
||||
"""One second-frames through the 3.0 s round (each frame's `dt` clamps
|
||||
to the sim's `MAX_DT` of 0.1 s — 2 substeps) and its 1.0 s settle
|
||||
window (2 more frames), until `ROUND_END`; returns the final `now`."""
|
||||
while room.state is RoomState.PLAYING:
|
||||
now += 1.0
|
||||
room.tick_once(now=now)
|
||||
assert room.state is RoomState.ROUND_END
|
||||
return now
|
||||
|
||||
|
||||
# ------------------------------------------------- the transition matrix
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", list(RoomState))
|
||||
@pytest.mark.parametrize("event", ROOM_EVENTS)
|
||||
def test_the_full_states_x_events_matrix(state: RoomState, event: str) -> None:
|
||||
"""Every pair of the 5 states x 9 events: the 14 legal ones (the table)
|
||||
land on the target; the 31 illegal ones raise `IllegalTransition` and
|
||||
leave the state exactly where it was."""
|
||||
room, _ = make_room()
|
||||
room.state = state # test setup only: `_fire` is the guarded path
|
||||
expected = ROOM_TRANSITIONS.get((state, event))
|
||||
if expected is None:
|
||||
with pytest.raises(IllegalTransition):
|
||||
room._fire(event)
|
||||
assert room.state is state # unchanged
|
||||
else:
|
||||
assert room._fire(event) is expected
|
||||
|
||||
|
||||
def test_the_table_is_closed_over_the_state_and_event_namespaces() -> None:
|
||||
"""The table only names known states and events, and every event is
|
||||
legal somewhere (no orphaned event, no transition out of the FSM)."""
|
||||
for (state, event), to in ROOM_TRANSITIONS.items():
|
||||
assert isinstance(state, RoomState), (state, event)
|
||||
assert event in ROOM_EVENTS, (state, event)
|
||||
assert isinstance(to, RoomState), (state, event)
|
||||
assert {event for _, event in ROOM_TRANSITIONS} == set(ROOM_EVENTS)
|
||||
# `CLOSED` is an exit, never a source: no transition leaves it.
|
||||
assert not any(from_state is RoomState.CLOSED for (from_state, _) in ROOM_TRANSITIONS)
|
||||
|
||||
|
||||
def test_every_event_is_legal_from_the_states_the_diagram_names() -> None:
|
||||
"""PLAN §6's diagram, spelled out: the legal pairs are exactly the
|
||||
table's 14 (so a future edit of either is a test failure)."""
|
||||
legal = {
|
||||
(RoomState.LOBBY, "host_start"),
|
||||
(RoomState.COUNTDOWN, "countdown_done"),
|
||||
(RoomState.PLAYING, "round_over"),
|
||||
(RoomState.ROUND_END, "to_lobby"),
|
||||
(RoomState.ROUND_END, "retry"),
|
||||
(RoomState.ROUND_END, "next_level"),
|
||||
(RoomState.LOBBY, "close"),
|
||||
(RoomState.COUNTDOWN, "close"),
|
||||
(RoomState.PLAYING, "close"),
|
||||
(RoomState.ROUND_END, "close"),
|
||||
(RoomState.LOBBY, "all_seats_empty"),
|
||||
(RoomState.ROUND_END, "all_seats_empty"),
|
||||
(RoomState.LOBBY, "idle_expired"),
|
||||
(RoomState.ROUND_END, "idle_expired"),
|
||||
}
|
||||
assert set(ROOM_TRANSITIONS) == legal
|
||||
assert len(legal) == 14 # 45 pairs - 31 illegal = 14 legal
|
||||
|
||||
|
||||
# ------------------------------------------------------------ preconditions
|
||||
|
||||
|
||||
def test_request_start_requires_a_solo_host_or_a_ready_non_host() -> None:
|
||||
bundle = make_bundle(make_level("05_start"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start() # the solo host: no ready flag needed
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
assert room.round_no == 1
|
||||
assert room.round_seed is not None
|
||||
|
||||
|
||||
def test_request_start_refuses_without_a_ready_non_host() -> None:
|
||||
bundle = make_bundle(make_level("05_start"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.add_seat("tok-b", "Bob", False) # not ready
|
||||
with pytest.raises(StartRefused, match="no_ready_players"):
|
||||
room.request_start()
|
||||
assert room.state is RoomState.LOBBY # the refusal leaves no state change
|
||||
|
||||
|
||||
def test_request_start_accepts_one_ready_non_host_among_the_unready() -> None:
|
||||
bundle = make_bundle(make_level("05_start"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
seat_ready_non_host(room, "tok-b", "Bob")
|
||||
room.add_seat("tok-c", "Cy", False) # unready, but one ready is enough
|
||||
room.request_start()
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
|
||||
|
||||
def test_request_start_refuses_without_a_level() -> None:
|
||||
room, _ = make_room(None) # no content bundle: no level to play
|
||||
seat_solo_host(room)
|
||||
with pytest.raises(StartRefused, match="no_level"):
|
||||
room.request_start()
|
||||
assert room.state is RoomState.LOBBY
|
||||
|
||||
|
||||
def test_request_start_is_refused_outside_the_lobby() -> None:
|
||||
bundle = make_bundle(make_level("05_start"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
with pytest.raises(IllegalTransition):
|
||||
room.request_start() # already COUNTDOWN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- countdown
|
||||
|
||||
|
||||
def test_the_countdown_counts_three_frames_of_one_second() -> None:
|
||||
bundle = make_bundle(make_level("05_count"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
assert room.countdown_left == COUNTDOWN_S == 3.0
|
||||
prime(room)
|
||||
room.tick_once(now=1.0)
|
||||
assert room.state is RoomState.COUNTDOWN and room.countdown_left == 2.0
|
||||
room.tick_once(now=2.0)
|
||||
assert room.state is RoomState.COUNTDOWN and room.countdown_left == 1.0
|
||||
room.tick_once(now=3.0) # the third second lands it on zero
|
||||
assert room.state is RoomState.PLAYING # `countdown_done` fired
|
||||
|
||||
|
||||
def test_the_countdown_runs_the_20_hz_cadence_too() -> None:
|
||||
"""60 frames of 0.05 s are the same 3.0 s (the real loop's cadence)."""
|
||||
bundle = make_bundle(make_level("05_count"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
prime(room)
|
||||
for frame in range(1, 61):
|
||||
room.tick_once(now=frame * 0.05)
|
||||
if room.state is RoomState.PLAYING:
|
||||
assert frame >= 59 # float noise may cost one frame at most
|
||||
break
|
||||
assert room.state is RoomState.PLAYING
|
||||
|
||||
|
||||
def test_countdown_done_builds_the_round_from_level_seats_and_seed() -> None:
|
||||
bundle = make_bundle(make_level("05_build"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room, "tok-a", "Ada")
|
||||
seat_ready_non_host(room, "tok-b", "Bob")
|
||||
room.request_start()
|
||||
drive_countdown(room)
|
||||
|
||||
assert room.sim_state is not None
|
||||
state = room.sim_state
|
||||
assert state.level_id == "05_build"
|
||||
assert state.seed == room.round_seed # the fresh seed, on the state
|
||||
assert state.time_left == state.time_limit == 3.0
|
||||
assert [player.id for player in state.players] == ["tok-a", "tok-b"] # seat order
|
||||
# The room's seeding job (sim/stations.py's DEFAULT_STOCK is only the
|
||||
# stand-in a room-less build sees): the shelf stock and the prop caps.
|
||||
shelf = next(s for s in state.stations if s.id.startswith("spawn_shelf"))
|
||||
assert len(shelf.contents) == 8 # the room's shelf seed
|
||||
assert state.counters == {
|
||||
"plates_clean": 99,
|
||||
"plates_dirty": 0,
|
||||
"pots_clean": 99,
|
||||
"pots_dirty": 0,
|
||||
}
|
||||
# A queued input from the countdown is gone (per-tick, cleared by the
|
||||
# round's start).
|
||||
assert room._queued_inputs == []
|
||||
|
||||
|
||||
# ------------------------------------------------------------- round end
|
||||
|
||||
|
||||
def test_round_over_stores_last_result_and_the_payload() -> None:
|
||||
bundle = make_bundle(make_level("05_end"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now) # the 3 s round + the 1 s settle
|
||||
|
||||
assert room.state is RoomState.ROUND_END
|
||||
assert room.last_result is not None
|
||||
payload = room.last_result
|
||||
# The exact key set of `result_payload` (PLAN §7's `round` body).
|
||||
assert set(payload) == {
|
||||
"result",
|
||||
"score",
|
||||
"target",
|
||||
"stars",
|
||||
"time_left",
|
||||
"counters",
|
||||
"longest_streak",
|
||||
"players",
|
||||
"level_id",
|
||||
"seed",
|
||||
"ticks",
|
||||
}
|
||||
assert payload["result"] == "lost" # the idle cook loses on the buzzer
|
||||
assert payload["score"] == 0
|
||||
assert payload["stars"] == 0
|
||||
assert payload["level_id"] == "05_end"
|
||||
assert payload["seed"] == room.round_seed
|
||||
# The settle window ran: the verdict tick is not the state's last tick.
|
||||
assert int(str(payload["ticks"])) > 0
|
||||
|
||||
|
||||
def test_the_settle_window_keeps_the_room_playing_before_round_over() -> None:
|
||||
"""The sim's `SETTLE_S` contract, on the room's side: the tick the
|
||||
verdict lands does not end the round — the room keeps ticking the
|
||||
frozen sim for 1.0 s of wall time first."""
|
||||
bundle = make_bundle(make_level("05_settle"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
# 1 s frames (each `dt` clamps to the sim's 0.1 s) until the verdict lands.
|
||||
for _ in range(32):
|
||||
now += 1.0
|
||||
room.tick_once(now=now)
|
||||
if room.sim_state is not None and room.sim_state.result is not None:
|
||||
break
|
||||
assert room.sim_state is not None
|
||||
assert room.sim_state.result is RoundResult.LOST
|
||||
# The verdict tick: the settle window is open (the sim ticks on, frozen),
|
||||
# and the payload has not landed yet.
|
||||
assert room.state is RoomState.PLAYING # still settling
|
||||
assert room.last_result is None # the payload lands with `round_over`
|
||||
# The window closes 1.0 s of wall time after it opened.
|
||||
now += 1.0
|
||||
room.tick_once(now=now)
|
||||
assert room.state is RoomState.ROUND_END
|
||||
assert room.last_result is not None and room.last_result["result"] == "lost"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ retry / next
|
||||
|
||||
|
||||
def test_retry_reuses_the_level_with_a_new_seed() -> None:
|
||||
bundle = make_bundle(make_level("05_retry"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
seed_one = room.round_seed
|
||||
assert seed_one is not None
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now)
|
||||
|
||||
room.retry()
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
assert room.round_no == 2
|
||||
assert room.round_seed is not None
|
||||
assert room.round_seed != seed_one # the seed is fresh
|
||||
assert room.level_id == "05_retry" # the level is the same
|
||||
|
||||
drive_countdown(room, now)
|
||||
assert room.state is RoomState.PLAYING
|
||||
assert room.sim_state is not None
|
||||
assert room.sim_state.level_id == "05_retry" # reused
|
||||
assert room.sim_state.seed == room.round_seed # and fresh
|
||||
|
||||
|
||||
def test_next_level_switches_to_the_supplied_level_id() -> None:
|
||||
bundle = make_bundle(make_level("05_a"), make_level("05_b"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now)
|
||||
|
||||
room.next_level("05_b") # the explicitly supplied id (task 04 wires it)
|
||||
assert room.state is RoomState.COUNTDOWN
|
||||
assert room.level_id == "05_b" and room.level is not None
|
||||
drive_countdown(room, now)
|
||||
assert room.state is RoomState.PLAYING
|
||||
assert room.sim_state is not None
|
||||
assert room.sim_state.level_id == "05_b" # the new level, on the state
|
||||
|
||||
|
||||
def test_next_level_refuses_an_id_outside_the_campaign_without_a_state_change() -> None:
|
||||
bundle = make_bundle(make_level("05_a"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now)
|
||||
|
||||
with pytest.raises(ValueError, match="not in room"):
|
||||
room.next_level("05_missing")
|
||||
assert room.state is RoomState.ROUND_END # untouched
|
||||
assert room.level_id == "05_a"
|
||||
with pytest.raises(ValueError, match="no content bundle"):
|
||||
make_room(None)[0].select_level("05_a") # a bundle-less room
|
||||
|
||||
|
||||
def test_to_lobby_returns_to_the_lobby_and_resets_the_ready_flags() -> None:
|
||||
bundle = make_bundle(make_level("05_lobby"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
seat_ready_non_host(room) # Bob is ready
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now)
|
||||
|
||||
room.to_lobby()
|
||||
assert room.state is RoomState.LOBBY
|
||||
assert all(not seat.ready for seat in room.seats) # ready up again
|
||||
assert room.last_result is not None # kept for inspection
|
||||
# Ready up again, and the next round starts from the fresh lobby.
|
||||
assert room.set_ready("tok-b", True)
|
||||
room.request_start()
|
||||
assert room.state is RoomState.COUNTDOWN and room.round_no == 2
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ close
|
||||
|
||||
|
||||
async def test_close_is_idempotent_and_frees_the_seats() -> None:
|
||||
bundle = make_bundle(make_level("05_close"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
seat_ready_non_host(room)
|
||||
|
||||
await room.close()
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.seats == []
|
||||
assert room.closed_at is not None
|
||||
await room.close() # idempotent: no error, no second state write
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.closed_at is not None
|
||||
|
||||
|
||||
async def test_close_works_from_every_live_state() -> None:
|
||||
bundle = make_bundle(make_level("05_close_any"))
|
||||
for target in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END):
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.state = target # test setup: the table's `close` row is what's tested
|
||||
await room.close()
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.seats == []
|
||||
|
||||
|
||||
def test_a_closed_room_ticks_nothing() -> None:
|
||||
bundle = make_bundle(make_level("05_closed_tick"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.state = RoomState.CLOSED # test setup
|
||||
room.tick_once(now=99.0)
|
||||
assert room.state is RoomState.CLOSED
|
||||
|
||||
|
||||
# ------------------------------------------------------- the idle rules
|
||||
|
||||
|
||||
def test_all_seats_empty_closes_after_the_grace() -> None:
|
||||
bundle = make_bundle(make_level("05_grace"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room, "tok-a")
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
room.remove_seat("tok-b")
|
||||
room.remove_seat("tok-a")
|
||||
assert room.empty_since == 0.0
|
||||
|
||||
prime(room)
|
||||
room.tick_once(now=SEAT_GRACE_S) # exactly 30 s is not past the grace
|
||||
assert room.state is RoomState.LOBBY
|
||||
room.tick_once(now=SEAT_GRACE_S + 1.0)
|
||||
assert room.state is RoomState.CLOSED
|
||||
|
||||
|
||||
def test_idle_expired_closes_past_the_lobby_timeout() -> None:
|
||||
bundle = make_bundle(make_level("05_idle"))
|
||||
room, _ = make_room(bundle, lobby_idle_timeout_s=10.0)
|
||||
seat_solo_host(room) # occupied: the idle-lobby rule, not the grace
|
||||
|
||||
prime(room)
|
||||
room.tick_once(now=10.0) # exactly the timeout is not past it
|
||||
assert room.state is RoomState.LOBBY
|
||||
room.tick_once(now=10.5)
|
||||
assert room.state is RoomState.CLOSED
|
||||
|
||||
|
||||
def test_the_idle_rules_run_in_round_end_too() -> None:
|
||||
bundle = make_bundle(make_level("05_idle_end"))
|
||||
room, _ = make_room(bundle, lobby_idle_timeout_s=10.0)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
now = play_round_to_end(room, now)
|
||||
assert room.state is RoomState.ROUND_END
|
||||
|
||||
room.tick_once(now=now + 10.5) # > 10 s of lobby-idle in ROUND_END
|
||||
assert room.state is RoomState.CLOSED
|
||||
|
||||
|
||||
# ------------------------------------------------------ defensive edges
|
||||
|
||||
|
||||
def test_the_settings_and_content_properties_expose_the_injected_values() -> None:
|
||||
bundle = make_bundle(make_level("06_props"))
|
||||
settings = Settings()
|
||||
room = Room(code="TEST1", settings=settings, clock=FakeClock(), content=bundle)
|
||||
assert room.settings is settings
|
||||
assert room.content is bundle
|
||||
empty = Room(code="TEST2", settings=Settings(), clock=FakeClock())
|
||||
assert empty.content is None # content is optional (the supervisor always sets it)
|
||||
|
||||
|
||||
def test_a_full_room_refuses_new_seats() -> None:
|
||||
bundle = make_bundle(make_level("06_full"))
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(max_players=1),
|
||||
clock=FakeClock(),
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id,
|
||||
)
|
||||
first = room.add_seat("tok-a", "Ada", True)
|
||||
with pytest.raises(ValueError, match="is full"):
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
assert first is room.seats[0]
|
||||
|
||||
|
||||
def test_set_ready_for_an_unknown_token_is_a_noop() -> None:
|
||||
bundle = make_bundle(make_level("06_ready"))
|
||||
room, _ = make_room(bundle)
|
||||
assert room.set_ready("tok-ghost", True) is False
|
||||
assert room.set_ready("tok-ghost", False) is False
|
||||
|
||||
|
||||
def test_ticking_a_room_with_no_content_bundle_raises_an_internal_error() -> None:
|
||||
clock = FakeClock()
|
||||
room = Room(code="TEST1", settings=Settings(), clock=clock) # no content
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
# A level is attached (so `request_start` passes) but the bundle itself is
|
||||
# absent: the round build cannot load recipes or seats' props.
|
||||
room.level = make_level("06_nolevel")
|
||||
room.request_start()
|
||||
clock.advance(COUNTDOWN_S)
|
||||
# White-box: `tick_once` crash-isolates (by design), so the build is
|
||||
# exercised directly to pin the internal error.
|
||||
with pytest.raises(RuntimeError, match="no content bundle"):
|
||||
room._build_sim_state()
|
||||
|
||||
|
||||
def test_ticking_a_corrupted_playing_room_raises_the_internal_invariant() -> None:
|
||||
bundle = make_bundle(make_level("06_corrupt"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
for frame in range(62): # through the countdown: the round is built
|
||||
room.tick_once(now=frame * 0.05)
|
||||
assert room.sim_state is not None
|
||||
room.sim_state = None # corrupt: PLAYING with no built state
|
||||
# White-box: `tick_once` crash-isolates (by design), so the tick body is
|
||||
# exercised directly to pin the internal error.
|
||||
with pytest.raises(RuntimeError, match="no built state"):
|
||||
room._tick(0.0, 62 * 0.05)
|
||||
|
||||
|
||||
# -------------------------------------------------- input legality / drain
|
||||
|
||||
|
||||
def test_queued_inputs_are_dropped_outside_playing() -> None:
|
||||
bundle = make_bundle(make_level("05_input"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
frame = PlayerInput(player_id="tok-a", seq=1)
|
||||
|
||||
assert room.queue_input("tok-a", frame) is False # LOBBY: dropped
|
||||
assert room.events_since_snapshot == [] # not an error: the client is not at fault
|
||||
room.state = RoomState.COUNTDOWN
|
||||
assert room.queue_input("tok-a", frame) is False
|
||||
|
||||
|
||||
def test_queue_input_rejects_an_unknown_player_with_an_err_event() -> None:
|
||||
bundle = make_bundle(make_level("05_input_err"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
drive_countdown(room)
|
||||
|
||||
assert room.queue_input("tok-stranger", PlayerInput(player_id="tok-stranger")) is False
|
||||
events = room.take_events()
|
||||
assert len(events) == 1
|
||||
assert events[0].kind is EventKind.ERROR
|
||||
assert events[0].payload == {
|
||||
"reason": "unknown_player",
|
||||
"player_id": "tok-stranger",
|
||||
"seq": 0,
|
||||
}
|
||||
# A known seat is queued, stamped with the sim's tick, and the queue is
|
||||
# per-tick: the next `tick_once` consumes and clears it.
|
||||
frame = PlayerInput(player_id="tok-a", seq=7)
|
||||
assert room.queue_input("tok-a", frame) is True
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
assert room._queued_inputs[-1].received_at_tick == state.tick
|
||||
assert room._queued_inputs[-1].seq == 7
|
||||
room.tick_once()
|
||||
assert room._queued_inputs == []
|
||||
|
||||
|
||||
def test_take_events_drains_the_buffer_exactly_once() -> None:
|
||||
bundle = make_bundle(make_level("05_drain"))
|
||||
room, _ = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
drive_countdown(room)
|
||||
room.queue_input("tok-stranger", PlayerInput(player_id="tok-stranger")) # an event
|
||||
|
||||
first = room.take_events()
|
||||
assert len(first) == 1 and first[0].kind is EventKind.ERROR
|
||||
assert room.take_events() == () # the second drain is empty
|
||||
assert room.snapshot_seq == 2
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Integration: one room's crash does not take the server down (phase 05,
|
||||
task 05; PLAN §6 "crash isolation") — the task's `UNDERCOOKED_TEST_HOOK`
|
||||
seam in action.
|
||||
|
||||
Two live rooms, both created through the supervisor and both driven by
|
||||
manual `tick_once` (a `FakeClock`; no test sleeps). An exception injected
|
||||
into room A's sim through the test-only `Room._test_step_override` seam
|
||||
(set only here — a `Callable` with `step`'s signature the `PLAYING` tick
|
||||
calls in the kernel's place; never read from the network):
|
||||
|
||||
* room A becomes `CLOSED` — the sim marked `ABORTED` through
|
||||
`sim.round_state.abort`, `abort_reason` names the exception type, and
|
||||
the log line carries the room's code and round **never a player
|
||||
token**;
|
||||
* the `err` event is delivered — one `tick_crash` in the buffer, the
|
||||
round number in the payload, drained exactly once;
|
||||
* room B keeps ticking correctly — its sim advances one substep per
|
||||
0.05 s frame, still `PLAYING`, untouched by A's fault;
|
||||
* the supervisor's `gc_pass()` reaps room A (dropped from the registry —
|
||||
the code no longer resolves, its token purged) and leaves room B
|
||||
registered;
|
||||
* a room that is `CLOSED` ticks nothing;
|
||||
* the server keeps serving `/healthz` (200, the live room count is one —
|
||||
room B).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
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 Room, RoomState
|
||||
from undercooked.server.rooms import RoomSupervisor
|
||||
from undercooked.server.sessions import new_session
|
||||
from undercooked.sim.content import ContentBundle
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import (
|
||||
Event,
|
||||
EventKind,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
RoundResult,
|
||||
StationKind,
|
||||
)
|
||||
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
|
||||
from undercooked.sim.step import PlayerInput
|
||||
from undercooked.sim.step import step as real_step
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: The test-isolated content dir (a recipe book, no levels) — the app
|
||||
#: boots on it; the supervisor's bundle carries the two test levels.
|
||||
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
|
||||
|
||||
#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`).
|
||||
PERIOD = 0.05
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ content
|
||||
|
||||
|
||||
def make_book() -> RecipeBook:
|
||||
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="serve", station=StationKind.SERVE),
|
||||
],
|
||||
)
|
||||
},
|
||||
ingredients=[ItemKind.TOMATO],
|
||||
)
|
||||
|
||||
|
||||
def make_level(level_id: str, *, time_limit: float, target: int) -> Level:
|
||||
"""A 7x4 kitchen (one shelf, one serve window) — the rooms only need
|
||||
to reach `PLAYING`; room A never runs its round (the injected fault
|
||||
fires on the first playing tick) and room B never needs a verdict
|
||||
(the 60 s limit outlives the test)."""
|
||||
data: dict[str, object] = {
|
||||
"id": level_id,
|
||||
"name": f"Isolation kitchen {level_id}",
|
||||
"order": 1,
|
||||
"time_limit": time_limit,
|
||||
"target_score": target,
|
||||
"grid": [
|
||||
"#######",
|
||||
"#S...V#",
|
||||
"#.....#",
|
||||
"#######",
|
||||
],
|
||||
"tiles": {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
},
|
||||
"spawns": [[2, 2]],
|
||||
"orders": {
|
||||
"pool": ["soup"],
|
||||
"concurrent": 1,
|
||||
"spawn_interval": [4, 6],
|
||||
"deadline": 30,
|
||||
},
|
||||
}
|
||||
return Level.build(LevelDef.model_validate(data))
|
||||
|
||||
|
||||
def make_bundle() -> ContentBundle:
|
||||
"""The two levels: `08_bad` (room A — the injected fault) and
|
||||
`08_good` (room B — the one that must keep ticking)."""
|
||||
return ContentBundle(
|
||||
recipe_book=make_book(),
|
||||
levels=(
|
||||
make_level("08_bad", time_limit=60.0, target=1000),
|
||||
make_level("08_good", time_limit=60.0, target=1000),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- app
|
||||
|
||||
|
||||
class IsolationApp:
|
||||
"""An app whose supervisor runs on a `FakeClock` (the
|
||||
`FakeClockSupervisorApp` pattern `test_rooms_api.py` pins) with the
|
||||
test's two-level bundle. The `TestClient` is used without the
|
||||
context manager: the rooms' tick tasks are never started — every
|
||||
tick is the test's own `tick_once`, and `gc_pass` runs in a fresh
|
||||
`asyncio.run` with nothing to cancel."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the test
|
||||
|
||||
|
||||
def bring_both_to_playing(
|
||||
room_a: Room, room_b: Room, clock: FakeClock
|
||||
) -> None:
|
||||
"""Both rooms through the 3 s countdown (0.05 s wall frames); both
|
||||
end `PLAYING` with a built, un-decided round."""
|
||||
for _ in range(90):
|
||||
clock.advance(PERIOD)
|
||||
room_a.tick_once()
|
||||
room_b.tick_once()
|
||||
if room_a.state is RoomState.PLAYING and room_b.state is RoomState.PLAYING:
|
||||
break
|
||||
assert room_a.state is RoomState.PLAYING
|
||||
assert room_b.state is RoomState.PLAYING
|
||||
assert room_a.sim_state is not None and room_b.sim_state is not None
|
||||
assert room_a.sim_state.result is None
|
||||
assert room_b.sim_state.result is None
|
||||
|
||||
|
||||
def inject_the_fault(room_a: Room) -> None:
|
||||
"""The UNDERCOOKED_TEST_HOOK: room A's sim raises on its first
|
||||
playing tick; room B's level is left to the real kernel."""
|
||||
|
||||
def boom(
|
||||
state: KitchenState,
|
||||
dt: float,
|
||||
inputs: Sequence[PlayerInput],
|
||||
book: RecipeBook | None,
|
||||
) -> tuple[KitchenState, tuple[Event, ...]]:
|
||||
if state.level_id == "08_bad":
|
||||
msg = "sim exploded in room A"
|
||||
raise RuntimeError(msg)
|
||||
return real_step(state, dt, inputs, book)
|
||||
|
||||
room_a._test_step_override = boom
|
||||
|
||||
|
||||
def check_room_a_is_aborted(
|
||||
room_a: Room,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
token_a: str,
|
||||
token_b: str,
|
||||
) -> None:
|
||||
"""Room A: aborted and closed; the err event delivered; the log line
|
||||
carries the code and the round, never a player token."""
|
||||
assert room_a.state is RoomState.CLOSED
|
||||
assert room_a.sim_state is not None
|
||||
assert room_a.sim_state.result is RoundResult.ABORTED
|
||||
assert room_a.abort_reason is not None
|
||||
assert "RuntimeError" in room_a.abort_reason
|
||||
events = room_a.take_events()
|
||||
crash = [
|
||||
e
|
||||
for e in events
|
||||
if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash"
|
||||
]
|
||||
assert len(crash) == 1
|
||||
assert crash[0].payload["round"] == room_a.round_no
|
||||
assert room_a.take_events() == ()
|
||||
assert room_a.code in caplog.text
|
||||
assert "round 1" in caplog.text
|
||||
assert token_a not in caplog.text
|
||||
assert token_b not in caplog.text
|
||||
|
||||
|
||||
def check_room_b_keeps_ticking(room_b: Room, clock: FakeClock) -> None:
|
||||
"""Room B: untouched — still playing, still ticking correctly (one
|
||||
substep per 0.05 s frame: no slip); and a closed room ticks nothing."""
|
||||
sim = room_b.sim_state
|
||||
assert sim is not None
|
||||
tick_before = sim.tick
|
||||
for _ in range(20): # 1.0 s of B's round
|
||||
clock.advance(PERIOD)
|
||||
room_b.tick_once()
|
||||
assert room_b.state is RoomState.PLAYING
|
||||
assert room_b.sim_state is not None
|
||||
assert room_b.sim_state.tick == tick_before + 20
|
||||
assert room_b.sim_state.result is None
|
||||
|
||||
|
||||
async def check_gc_reaps_a_leaves_b(
|
||||
sup: RoomSupervisor,
|
||||
room_a: Room,
|
||||
room_b: Room,
|
||||
token_a: str,
|
||||
token_b: str,
|
||||
) -> None:
|
||||
"""The GC reaps A (already `CLOSED`: the pass drops it — the close
|
||||
itself happened in the crash isolation, not here) and leaves B
|
||||
registered."""
|
||||
assert await sup.gc_pass() == 0
|
||||
assert sup.get(room_a.code) is None # A is gone: the code no longer resolves
|
||||
assert sup.get(room_b.code) is room_b
|
||||
assert sup.by_token(token_a) is None # A's token is purged
|
||||
assert sup.by_token(token_b) is room_b
|
||||
assert sup.stats()["rooms"] == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the test
|
||||
|
||||
|
||||
def test_one_room_fails_without_affecting_the_other(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR, logger="undercooked.server.room")
|
||||
fixture = IsolationApp()
|
||||
sup = fixture.supervisor
|
||||
clock = fixture.clock
|
||||
|
||||
async def scenario() -> None:
|
||||
# Two live rooms, both created through the supervisor (the
|
||||
# creator is seat 0, host; the rooms get the store as their
|
||||
# `store`).
|
||||
ada = new_session("Ada", clock)
|
||||
bob = new_session("Bob", clock)
|
||||
room_a = sup.create_room(ada, "Ada")
|
||||
room_b = sup.create_room(bob, "Bob")
|
||||
room_b.select_level("08_good") # A plays the campaign's first
|
||||
assert room_a.level_id == "08_bad"
|
||||
|
||||
# Both are solo hosts: the start is legal as-is.
|
||||
room_a.start(ada.token)
|
||||
room_b.start(bob.token)
|
||||
bring_both_to_playing(room_a, room_b, clock)
|
||||
|
||||
inject_the_fault(room_a)
|
||||
# The crash tick — isolated: it must not propagate. The wall
|
||||
# time is not advanced: A takes this tick at the last wall time
|
||||
# (dt 0), so B's cadence below stays exactly one 0.05 s frame
|
||||
# per substep.
|
||||
room_a.tick_once()
|
||||
|
||||
check_room_a_is_aborted(room_a, caplog, ada.token, bob.token)
|
||||
check_room_b_keeps_ticking(room_b, clock)
|
||||
|
||||
# A closed room ticks nothing.
|
||||
clock.advance(PERIOD)
|
||||
room_a.tick_once()
|
||||
assert room_a.state is RoomState.CLOSED
|
||||
|
||||
await check_gc_reaps_a_leaves_b(sup, room_a, room_b, ada.token, bob.token)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
# The server keeps serving /healthz — room B is the live room.
|
||||
client = TestClient(fixture.app)
|
||||
body = client.get("/healthz").json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["rooms"] == 1
|
||||
@@ -0,0 +1,443 @@
|
||||
"""Integration: the `Room`'s tick ownership (phase 05, task 02) — the 20 Hz
|
||||
loop, the synchronous `tick_once` core, and crash isolation.
|
||||
|
||||
What this file pins:
|
||||
|
||||
* `tick_once` drives the sim forward — a scripted `move` input moves the
|
||||
cook (the room queues the frame, the kernel steps it, the buffer drains),
|
||||
* the cadence has no drift — 100 manual calls at 0.05 s land on exactly
|
||||
100 substeps and exactly 5.0 s of wall time, and the async loop
|
||||
(deadline-scheduled, PLAN §6) makes one tick per 0.05 s of wall time for
|
||||
100 ticks,
|
||||
* a clock jump resynchronises instead of paying a catch-up burst (the sim's
|
||||
`MAX_DT` clamp makes a burst worthless; the tick count proves there was
|
||||
none),
|
||||
* the event buffer drains exactly once (`take_events`),
|
||||
* crash isolation — an exception in the tick (the `step` seam, or the round
|
||||
build) is logged (code + round, never a player token), aborts the sim
|
||||
(`sim.round_state.abort`), buffers an `err` event, fires `close`, and
|
||||
never propagates out of `tick_once` or the loop; the loop runs to its
|
||||
own exit (the task ends normally, not cancelled),
|
||||
* a crash closes *only* that room: a second live room (registered in the
|
||||
supervisor, tick task running) keeps ticking, and the supervisor stays
|
||||
alive,
|
||||
* a `Room` driven by `tick_once` reaches `WON` on the shipped level 1 with
|
||||
a scripted cook (the phase-04 full-round policy, through the room's own
|
||||
build, queue and settle).
|
||||
|
||||
All time is a `FakeClock`; no test sleeps in real time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from test_room_fsm import (
|
||||
make_bundle,
|
||||
make_level,
|
||||
make_room,
|
||||
seat_solo_host,
|
||||
)
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.clock import FakeClock
|
||||
from undercooked.server.room import Room, RoomState
|
||||
from undercooked.server.rooms import RoomSupervisor
|
||||
from undercooked.server.sessions import new_session
|
||||
from undercooked.sim.content import ContentBundle, load_level_file
|
||||
from undercooked.sim.model import Event, EventKind, ItemKind, KitchenState, RoundResult, Vec2
|
||||
from undercooked.sim.recipe import RecipeBook
|
||||
from undercooked.sim.step import PlayerInput
|
||||
from undercooked.sim.step import step as real_step
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: The scripted full-round 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]
|
||||
LEVEL1_YAML,
|
||||
CookController,
|
||||
load_book,
|
||||
shelf_id_for,
|
||||
)
|
||||
|
||||
#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`).
|
||||
PERIOD = 0.05
|
||||
|
||||
|
||||
def drive_countdown_via_clock(room: Room, clock: FakeClock) -> None:
|
||||
"""The 3.0 s countdown in 0.05 s wall frames (clock-driven, the way the
|
||||
manual `tick_once` tests run); leaves the room in `PLAYING`."""
|
||||
for _ in range(80):
|
||||
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 make_playing_room(**settings: Any) -> tuple[Room, FakeClock]:
|
||||
"""A room in `PLAYING` with a built sim state (the countdown already ran)."""
|
||||
bundle = make_bundle(make_level("06_playing"))
|
||||
room, clock = make_room(bundle, **settings)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
drive_countdown_via_clock(room, clock)
|
||||
return room, clock
|
||||
|
||||
|
||||
# ------------------------------------------------------------- tick_once
|
||||
|
||||
|
||||
def test_tick_once_drives_the_sim_forward_under_a_scripted_input() -> None:
|
||||
room, clock = make_playing_room()
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
start_x = state.players[0].pos.x
|
||||
# An empty press at open floor: a named refusal (a sim event in the
|
||||
# buffer, drained with the rest). The cook is on open floor at spawn,
|
||||
# facing down into a wall row: no target in reach at all.
|
||||
room.queue_input(
|
||||
"tok-a",
|
||||
PlayerInput(player_id="tok-a", action=True, face=state.players[0].facing),
|
||||
)
|
||||
clock.advance(PERIOD)
|
||||
room.tick_once()
|
||||
events = room.take_events()
|
||||
denied = [e for e in events if e.kind is EventKind.DENIED]
|
||||
assert denied, "the empty press must land a named refusal in the buffer"
|
||||
assert denied[0].payload["reason"] == "nothing_here"
|
||||
# And 50 scripted move ticks move the cook (the queue feeds `step`).
|
||||
for _ in range(50):
|
||||
room.queue_input("tok-a", PlayerInput(player_id="tok-a", move=Vec2(1.0, 0.0)))
|
||||
clock.advance(PERIOD)
|
||||
room.tick_once()
|
||||
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
assert state.tick == 51 # one 0.05 s substep per wall 0.05 s: no slip
|
||||
player = state.players[0]
|
||||
assert player.pos.x > start_x # the scripted intent moved the cook
|
||||
assert player.facing is not None and player.facing.value == "right"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- cadence
|
||||
|
||||
|
||||
def test_tick_cadence_has_no_drift_over_100_manual_calls() -> None:
|
||||
# A 60 s level: the 100 ticks (5.0 s of sim) must not reach the verdict.
|
||||
bundle = make_bundle(make_level("06_cadence", time_limit=60.0))
|
||||
room, clock = make_room(bundle)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
drive_countdown_via_clock(room, clock)
|
||||
assert room.sim_state is not None and room.sim_state.tick == 0
|
||||
wall_at_start = clock.now()
|
||||
for _ in range(100):
|
||||
clock.advance(PERIOD)
|
||||
room.tick_once()
|
||||
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
assert state.tick == 100 # exactly 100 substeps: no tick lost or doubled
|
||||
# Wall time and sim time agree to the float: 100 ticks x 0.05 s = 5.0 s.
|
||||
assert abs((clock.now() - wall_at_start) - 5.0) < 1e-9
|
||||
|
||||
|
||||
async def test_the_tick_loop_schedules_on_deadlines_without_drift() -> None:
|
||||
"""The async loop (PLAN §6 "one asyncio tick task") at the 20 Hz
|
||||
deadline cadence: 100 ticks over exactly 5.0 s of `FakeClock` time —
|
||||
the first the room's prime (dt 0), 60 the 3.0 s countdown, 39 the
|
||||
round — with the wall time and the sim tick count agreeing to the
|
||||
float."""
|
||||
clock = FakeClock()
|
||||
# A 60 s level: the 5.0 s of sim must not reach the verdict.
|
||||
bundle = make_bundle(make_level("06_loop", time_limit=60.0))
|
||||
|
||||
async def yield_sleep(_seconds: float) -> None:
|
||||
await asyncio.sleep(0) # the test is the time source (no pre-advance)
|
||||
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id,
|
||||
sleep=yield_sleep,
|
||||
)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
room.start_ticking()
|
||||
assert room.task is not None and not room.task.done()
|
||||
|
||||
await asyncio.sleep(0) # the room runs its first wait (0.05 s) and parks
|
||||
for _ in range(100):
|
||||
clock.advance(PERIOD)
|
||||
await asyncio.sleep(0) # the room ticks to the deadline: one tick per period
|
||||
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
assert room.state is RoomState.PLAYING
|
||||
assert state.tick == 39 # 100 ticks: 1 prime (dt 0) + 60 countdown + 39 playing
|
||||
assert abs(clock.now() - 5.0) < 1e-9 # 100 x 0.05 s, no drift
|
||||
assert room.task is not None and not room.task.done() # still ticking
|
||||
await room.close()
|
||||
assert room.state is RoomState.CLOSED and room.task.cancelled()
|
||||
|
||||
|
||||
async def test_the_tick_loop_resynchronises_after_a_clock_jump() -> None:
|
||||
"""A clock jump (a lid, a test advancing the `FakeClock`) must not buy a
|
||||
catch-up burst: the room takes at most one (clamped) catch-up tick and
|
||||
then waits the normal period. A burst would show as an inflated sim tick
|
||||
count (each catch-up tick clamps to 0.1 s = 2 substeps)."""
|
||||
clock = FakeClock()
|
||||
bundle = make_bundle(make_level("06_jump"))
|
||||
|
||||
async def yield_sleep(_seconds: float) -> None:
|
||||
await asyncio.sleep(0) # the test is the time source (no pre-advance)
|
||||
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id,
|
||||
sleep=yield_sleep,
|
||||
)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
room.start_ticking()
|
||||
|
||||
for _ in range(10): # 0.5 s: 1 prime + 9 decrements, the countdown is at 2.55 s
|
||||
clock.advance(PERIOD)
|
||||
await asyncio.sleep(0)
|
||||
clock.advance(10.0) # the jump
|
||||
await asyncio.sleep(0) # the one catch-up tick: dt 10.0 (clamped), the countdown ends
|
||||
for _ in range(10): # 0.5 s of playing
|
||||
clock.advance(PERIOD)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
assert room.state is RoomState.PLAYING
|
||||
assert state.tick == 10 # the 0.5 s of playing: no burst (a burst would be >= 200)
|
||||
assert abs(clock.now() - 11.0) < 1e-9 # 0.5 + 10.0 + 0.5
|
||||
await room.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------- crash isolation
|
||||
|
||||
|
||||
def test_an_injected_exception_aborts_and_closes_without_raising(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR, logger="undercooked.server.room")
|
||||
room, clock = make_playing_room()
|
||||
assert room.sim_state is not None and room.sim_state.result is None
|
||||
|
||||
def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]:
|
||||
msg = "sim exploded"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
monkeypatch.setattr("undercooked.server.room.step", boom)
|
||||
clock.advance(PERIOD)
|
||||
room.tick_once() # must not propagate
|
||||
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.sim_state is not None
|
||||
assert room.sim_state.result is RoundResult.ABORTED # the sim's `abort` seam
|
||||
assert room.abort_reason is not None
|
||||
assert "RuntimeError" in room.abort_reason
|
||||
events = room.take_events()
|
||||
crash_events = [
|
||||
e for e in events if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash"
|
||||
]
|
||||
assert len(crash_events) == 1
|
||||
assert crash_events[0].payload["round"] == room.round_no
|
||||
# The log line carries the code and the round — never a player token.
|
||||
assert "TEST1" in caplog.text and "round 1" in caplog.text
|
||||
assert "tok-a" not in caplog.text
|
||||
|
||||
|
||||
def test_a_crash_building_the_round_closes_the_room(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`countdown_done` builds the round; a build that raises (a room with no
|
||||
level can be put in `COUNTDOWN` by the table directly) is crash-isolated
|
||||
the same way — the room ends `CLOSED`, no state, no propagation."""
|
||||
room, clock = make_room(None) # no content bundle
|
||||
seat_solo_host(room)
|
||||
room._fire("host_start") # test setup: the table's row
|
||||
for _ in range(80):
|
||||
clock.advance(PERIOD)
|
||||
room.tick_once()
|
||||
assert room.state is RoomState.CLOSED # the build raised; the crash isolated it
|
||||
assert room.sim_state is None
|
||||
assert room.abort_reason is not None and "RuntimeError" in room.abort_reason
|
||||
|
||||
|
||||
async def test_the_tick_loop_swallows_the_crash_and_exits_cleanly(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
clock = FakeClock()
|
||||
bundle = make_bundle(make_level("06_loop_crash"))
|
||||
|
||||
async def advancing_sleep(seconds: float) -> None:
|
||||
clock.advance(seconds)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id,
|
||||
sleep=advancing_sleep,
|
||||
)
|
||||
seat_solo_host(room)
|
||||
room.request_start()
|
||||
|
||||
def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]:
|
||||
msg = "sim exploded"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
monkeypatch.setattr("undercooked.server.room.step", boom)
|
||||
room.start_ticking()
|
||||
for _ in range(80):
|
||||
if room.task is not None and room.task.done():
|
||||
break
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.task is not None and room.task.done()
|
||||
assert not room.task.cancelled() # the loop ran to its own exit
|
||||
assert room.sim_state is not None
|
||||
assert room.sim_state.result is RoundResult.ABORTED
|
||||
assert await room.task is None # no exception escaped the loop
|
||||
|
||||
|
||||
async def test_a_crash_closes_only_that_room(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The phase's completion criterion: an injected sim exception aborts and
|
||||
closes one room; a second live room (supervisor-registered, tick task
|
||||
running) keeps ticking, and the supervisor stays alive."""
|
||||
clock = FakeClock()
|
||||
bundle = make_bundle(make_level("06_bad"), make_level("06_good"))
|
||||
|
||||
async def parked_sleep(_seconds: float) -> None:
|
||||
await asyncio.Event().wait() # never set: the tasks run but never step
|
||||
|
||||
supervisor = RoomSupervisor(Settings(), clock, bundle, sleep=parked_sleep)
|
||||
room_bad = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
room_good = supervisor.create_room(new_session("Bob", clock), "Bob")
|
||||
await supervisor.start() # starts the GC loop and every room's tick task
|
||||
room_good.select_level("06_good")
|
||||
room_bad.request_start()
|
||||
room_good.request_start()
|
||||
|
||||
for _ in range(80): # both to PLAYING
|
||||
clock.advance(PERIOD)
|
||||
room_bad.tick_once()
|
||||
room_good.tick_once()
|
||||
await asyncio.sleep(0)
|
||||
assert room_bad.state is RoomState.PLAYING
|
||||
assert room_good.state is RoomState.PLAYING
|
||||
assert room_good.task is not None and not room_good.task.done() # a live room
|
||||
assert room_good.sim_state is not None
|
||||
good_tick_before = room_good.sim_state.tick
|
||||
|
||||
def selective(
|
||||
state: KitchenState,
|
||||
dt: float,
|
||||
inputs: tuple[PlayerInput, ...],
|
||||
book: RecipeBook | None,
|
||||
) -> tuple[KitchenState, tuple[Event, ...]]:
|
||||
if state.level_id == "06_bad":
|
||||
msg = "sim exploded in the bad room"
|
||||
raise RuntimeError(msg)
|
||||
return real_step(state, dt, inputs, book)
|
||||
|
||||
monkeypatch.setattr("undercooked.server.room.step", selective)
|
||||
for _ in range(30):
|
||||
clock.advance(PERIOD)
|
||||
room_bad.tick_once() # the first one crashes; the rest are no-ops
|
||||
room_good.tick_once()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# The bad room: aborted and closed, its exception isolated.
|
||||
assert room_bad.state is RoomState.CLOSED
|
||||
assert room_bad.sim_state is not None
|
||||
assert room_bad.sim_state.result is RoundResult.ABORTED
|
||||
assert room_bad.abort_reason is not None
|
||||
# The good room: untouched — still playing, still ticking, task alive.
|
||||
assert room_good.state is RoomState.PLAYING
|
||||
assert room_good.sim_state is not None
|
||||
assert room_good.sim_state.tick == good_tick_before + 30
|
||||
assert room_good.task is not None and not room_good.task.done()
|
||||
# And the supervisor itself is alive.
|
||||
assert supervisor.stats()["rooms"] == 2
|
||||
await supervisor.shutdown()
|
||||
assert room_bad.state is RoomState.CLOSED
|
||||
assert room_good.state is RoomState.CLOSED # the shutdown teardown, not a crash
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ level 1
|
||||
|
||||
|
||||
def test_a_room_driven_by_tick_once_reaches_won_on_level_1() -> None:
|
||||
"""The phase's completion criterion: a `Room` driven by `tick_once` — its
|
||||
own build (level + seat + seed + the room's shelf/prop seeding), its
|
||||
input queue, its settle — reaches `WON` on the shipped level 1 with the
|
||||
phase-04 scripted cook (the same policy `test_full_rounds.py` proves
|
||||
headless, through the room instead of the bare kernel)."""
|
||||
level = load_level_file(LEVEL1_YAML)
|
||||
bundle = ContentBundle(recipe_book=load_book(), levels=(level,))
|
||||
clock = FakeClock()
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=level.id,
|
||||
seed=0x5EED_0406, # the phase-04 harness's seed: round 1 plays it as-is
|
||||
)
|
||||
room.add_seat("tok-0", "cook 1", True)
|
||||
room.request_start() # the solo host
|
||||
|
||||
drive_countdown_via_clock(room, clock)
|
||||
assert room.sim_state is not None
|
||||
assert room.sim_state.seed == room.round_seed
|
||||
|
||||
controller = CookController(0, shelf_id_for(room.sim_state, ItemKind.TOMATO), "any")
|
||||
for _ in range(4000): # 150 s of level + the settle, with slack
|
||||
if room.state is not RoomState.PLAYING:
|
||||
break
|
||||
clock.advance(PERIOD)
|
||||
if room.sim_state.result is None:
|
||||
frame = controller.next_frame(room.sim_state, room.sim_state.tick)
|
||||
assert room.queue_input(frame.player_id, frame)
|
||||
room.tick_once()
|
||||
|
||||
assert room.state is RoomState.ROUND_END
|
||||
state = room.sim_state
|
||||
assert state is not None
|
||||
assert state.result is RoundResult.WON
|
||||
assert state.score >= state.target_score
|
||||
assert room.last_result is not None
|
||||
assert room.last_result["result"] == "won"
|
||||
assert room.last_result["score"] == state.score
|
||||
assert room.last_result["stars"] in (1, 2, 3) # a win grades at least one star
|
||||
# The verdict's events (incl. the one `ROUND_END`) landed in the buffer,
|
||||
# and the buffer drains 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() == () # the second drain is empty
|
||||
assert room.snapshot_seq == 2
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Integration: the room HTTP surface (`routes_rooms.py`) over a live
|
||||
in-process app.
|
||||
|
||||
Covers the three endpoints — `POST /api/rooms` (201 + the once-returned
|
||||
server-minted token), `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` —
|
||||
plus the named refusals (`room_full` 503 for a full room *and* a full
|
||||
process, `room_closed` 410, `room_not_found` 404 for a malformed *or*
|
||||
unknown code: the two must stay indistinguishable, US-02 criterion 4),
|
||||
no-token-leakage for other players, name sanitization at the boundary, and
|
||||
`/healthz`'s live room count. Time-dependent tests swap the app's
|
||||
supervisor for one on a `FakeClock` *before* the TestClient's lifespan runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
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.codes import CROCKFORD
|
||||
from undercooked.server.rooms import RoomSupervisor
|
||||
from undercooked.server.sessions import new_session
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: The test-isolated content dir (a recipe book, no levels) — explicit, so
|
||||
#: the app never reads the shipped `levels/` implicitly.
|
||||
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
|
||||
|
||||
|
||||
def make_app(**settings_overrides: Any) -> FastAPI:
|
||||
settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides)
|
||||
return create_app(settings)
|
||||
|
||||
|
||||
class FakeClockSupervisorApp:
|
||||
"""An app whose supervisor runs on a `FakeClock`.
|
||||
|
||||
Built by swapping `app.state.supervisor` *before* the TestClient's
|
||||
lifespan runs `supervisor.start()`; the lifespan then starts (and later
|
||||
shuts down) the fake-clock supervisor, so GC is drivable without sleeps.
|
||||
"""
|
||||
|
||||
def __init__(self, **settings_overrides: Any) -> None:
|
||||
self.app = make_app(**settings_overrides)
|
||||
self.clock = FakeClock()
|
||||
self.app.state.supervisor = RoomSupervisor(
|
||||
self.app.state.settings, self.clock, self.app.state.content
|
||||
)
|
||||
|
||||
|
||||
def create_room(client: TestClient, name: str | None = None) -> dict[str, Any]:
|
||||
response = client.post("/api/rooms", json={"name": name} if name is not None else {})
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- create
|
||||
|
||||
|
||||
def test_create_room_returns_201_with_a_crockford_code_seats_and_you() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
body = create_room(client, "Ada")
|
||||
|
||||
assert len(body["room_code"]) == 5
|
||||
assert set(body["room_code"]) <= set(CROCKFORD)
|
||||
assert body["state"] == "lobby"
|
||||
assert body["seats"] == [
|
||||
{"index": 0, "name": "Ada", "host": True, "connected": True}
|
||||
]
|
||||
assert len(body["you"]["token"]) == 32 # token_urlsafe(24): 24 bytes, base64url
|
||||
assert body["you"]["seat"] == 0
|
||||
assert body["you"]["host"] is True
|
||||
|
||||
|
||||
def test_create_room_with_no_name_defaults_to_cook_1() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
body = create_room(client)
|
||||
assert body["seats"][0]["name"] == "Cook #1"
|
||||
|
||||
|
||||
def test_create_room_sanitizes_the_name_at_the_boundary() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
body = client.post(
|
||||
"/api/rooms", json={"name": " A<>d\x01a B\n"}
|
||||
).json()
|
||||
assert body["seats"][0]["name"] == "Ada B"
|
||||
|
||||
with TestClient(make_app()) as client:
|
||||
body = client.post(
|
||||
"/api/rooms", json={"name": "A" * 40}
|
||||
).json()
|
||||
assert body["seats"][0]["name"] == "A" * 16 # the 16-char cap
|
||||
|
||||
|
||||
def test_create_room_without_any_body_is_the_bare_curl_criterion() -> None:
|
||||
"""The phase's completion criterion: `curl -X POST /api/rooms`
|
||||
(no JSON body at all) must not 422 — it reads as no name, no
|
||||
token (a server-minted token, the `Cook #1` default)."""
|
||||
with TestClient(make_app()) as client:
|
||||
response = client.post("/api/rooms")
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["seats"] == [
|
||||
{"index": 0, "name": "Cook #1", "host": True, "connected": True}
|
||||
]
|
||||
assert len(body["you"]["token"]) == 32
|
||||
|
||||
|
||||
def test_create_room_at_process_capacity_is_503_room_full() -> None:
|
||||
with TestClient(make_app(max_rooms=1)) as client:
|
||||
assert create_room(client)["room_code"]
|
||||
response = client.post("/api/rooms", json={})
|
||||
assert response.status_code == 503
|
||||
assert response.json() == {"code": "room_full"}
|
||||
|
||||
|
||||
def test_create_room_rejects_malformed_bodies() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
assert client.post("/api/rooms", json={"name": "Ada", "extra": 1}).status_code == 422
|
||||
assert client.post("/api/rooms", json={"n": "Ada"}).status_code == 422
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ get
|
||||
|
||||
|
||||
def test_get_room_shows_seats_without_any_tokens() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
code = create_room(client, "Ada")["room_code"]
|
||||
body = client.get(f"/api/rooms/{code.lower()}").json() # normalized
|
||||
|
||||
assert body["room_code"] == code
|
||||
assert body["state"] == "lobby"
|
||||
assert len(body["seats"]) == 1
|
||||
assert set(body["seats"][0]) == {"index", "name", "host", "connected"}
|
||||
assert "token" not in body["seats"][0]
|
||||
assert body["you"] is None # no caller identity exists in a GET
|
||||
|
||||
|
||||
def test_get_unknown_and_malformed_codes_are_indistinguishable() -> None:
|
||||
"""US-02 criterion 4: `ZZZZZ` (unknown) and `!!!` (malformed) must
|
||||
produce the *same* response, so the API is not a room-existence oracle."""
|
||||
with TestClient(make_app()) as client:
|
||||
unknown = client.get("/api/rooms/ZZZZZ")
|
||||
malformed = client.get("/api/rooms/!!!")
|
||||
assert unknown.status_code == 404
|
||||
assert malformed.status_code == 404
|
||||
assert unknown.json() == malformed.json() == {"code": "room_not_found"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- join
|
||||
|
||||
|
||||
def test_join_seats_the_player_in_the_next_seat_and_hides_other_tokens() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
code = create_room(client, "Ada")["room_code"]
|
||||
status, body = join_room(client, code, "tok-bob", "Bob")
|
||||
|
||||
assert status == 200
|
||||
assert body["you"] == {"token": "tok-bob", "seat": 1, "host": False}
|
||||
assert [seat["name"] for seat in body["seats"]] == ["Ada", "Bob"]
|
||||
for seat in body["seats"]:
|
||||
assert "token" not in seat
|
||||
blob = str(body["seats"])
|
||||
assert "tok-bob" not in blob # never in the *other* players' views
|
||||
|
||||
|
||||
def test_join_rejoin_is_idempotent_and_keeps_the_seat() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
code = create_room(client, "Ada")["room_code"]
|
||||
status_first, body_first = join_room(client, code, "tok-bob", "Bob")
|
||||
status_second, body_second = join_room(client, code, "tok-bob")
|
||||
|
||||
assert status_first == 200 and status_second == 200
|
||||
assert body_second["you"]["seat"] == body_first["you"]["seat"] == 1
|
||||
assert len(body_second["seats"]) == 2 # no second seat was minted
|
||||
|
||||
|
||||
def test_join_updates_the_name_only_when_one_is_typed() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
code = create_room(client, "Ada")["room_code"]
|
||||
join_room(client, code, "tok-bob", "Bob")
|
||||
status, body = join_room(client, code, "tok-bob", "Robert")
|
||||
assert status == 200
|
||||
assert body["seats"][1]["name"] == "Robert"
|
||||
status, body = join_room(client, code, "tok-bob")
|
||||
assert status == 200
|
||||
assert body["seats"][1]["name"] == "Robert" # untouched on rejoin
|
||||
|
||||
|
||||
def test_join_a_full_room_is_503_room_full() -> None:
|
||||
with TestClient(make_app(max_players=1)) as client:
|
||||
code = create_room(client, "Ada")["room_code"]
|
||||
status, body = join_room(client, code, "tok-bob", "Bob")
|
||||
assert status == 503
|
||||
assert body == {"code": "room_full"}
|
||||
|
||||
|
||||
def test_join_a_closed_room_is_410_room_closed() -> None:
|
||||
"""Close the room *before* the app starts (no tick task exists yet, so
|
||||
the close happens in this test's own loop — never across the TestClient's
|
||||
portal loop), then watch the API report the closed room until the GC
|
||||
pass would drop it."""
|
||||
fixture = FakeClockSupervisorApp()
|
||||
session = new_session("Ada", fixture.clock)
|
||||
room = fixture.app.state.supervisor.create_room(session, "Ada")
|
||||
asyncio.run(fixture.app.state.supervisor.close_room(room.code))
|
||||
|
||||
with TestClient(fixture.app) as client:
|
||||
inspect = client.get(f"/api/rooms/{room.code}")
|
||||
assert inspect.status_code == 200 # still in the registry
|
||||
assert inspect.json()["state"] == "closed"
|
||||
status, body = join_room(client, room.code, "tok-bob", "Bob")
|
||||
assert status == 410
|
||||
assert body == {"code": "room_closed"}
|
||||
|
||||
with TestClient(fixture.app) as client: # after shutdown the room is gone
|
||||
assert client.get(f"/api/rooms/{room.code}").status_code == 404
|
||||
|
||||
|
||||
def test_join_unknown_and_malformed_codes_are_indistinguishable() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
unknown = client.post("/api/rooms/ZZZZZ/join", json={"token": "tok"})
|
||||
malformed = client.post("/api/rooms/!!!/join", json={"token": "tok"})
|
||||
assert unknown.status_code == 404
|
||||
assert malformed.status_code == 404
|
||||
assert unknown.json() == malformed.json() == {"code": "room_not_found"}
|
||||
|
||||
|
||||
def test_join_rejects_malformed_bodies() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
code = create_room(client)["room_code"]
|
||||
assert client.post(f"/api/rooms/{code}/join", json={"n": "tok"}).status_code == 422
|
||||
assert (
|
||||
client.post(f"/api/rooms/{code}/join", json={"token": ""}).status_code == 422
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rooms/{code}/join", json={"token": "tok", "extra": 1}
|
||||
).status_code
|
||||
== 422
|
||||
)
|
||||
|
||||
|
||||
def test_join_moves_a_token_between_rooms() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
code_a = create_room(client, "Ada")["room_code"]
|
||||
code_b = create_room(client, "Bob")["room_code"]
|
||||
status, _ = join_room(client, code_a, "tok-ada2", "Ada2")
|
||||
assert status == 200
|
||||
status, body = join_room(client, code_b, "tok-ada2")
|
||||
assert status == 200
|
||||
assert body["you"]["seat"] == 1 # seated in room B
|
||||
room_a = client.get(f"/api/rooms/{code_a}").json()
|
||||
assert len(room_a["seats"]) == 1 # the token was evicted from room A
|
||||
|
||||
|
||||
def test_healthz_reports_the_live_room_count() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
assert client.get("/healthz").json()["rooms"] == 0
|
||||
create_room(client, "Ada")
|
||||
create_room(client, "Bob")
|
||||
body = client.get("/healthz").json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["rooms"] == 2
|
||||
@@ -0,0 +1,755 @@
|
||||
"""Integration: the in-memory session store and its wiring (phase 05,
|
||||
task 04; US-07 campaign progression) — all on a `FakeClock` (no test
|
||||
sleeps).
|
||||
|
||||
Covers the store's contract (the task file's list):
|
||||
|
||||
* **unlock only on win** — a `LOST` / `ABORTED` result never touches
|
||||
`unlocked_level`; a `WON` result raises it to `level.order + 1`,
|
||||
* **the unlock is monotonic** — win L1, lose L2: `unlocked_level` is 2
|
||||
and stays 2 (a loss never decreases it; a lower-order win never
|
||||
decreases it either),
|
||||
* **the best score replaces only when higher** — a tie keeps the older
|
||||
run (and its `completed_at`); the stars are the winning run's,
|
||||
* **counters accumulate across rooms** — `rounds_played` per finished
|
||||
round, `dishes_served` per served dish, summed over `record_result`
|
||||
calls,
|
||||
* **`to_public` leaks nothing beyond the public shape** — the exact key
|
||||
set; no `token`, no `created_at` / `last_seen`;
|
||||
* **an unknown token auto-creates at `unlocked_level=1`**
|
||||
(`get_or_create` and `to_public`),
|
||||
* **the LRU evicts the stalest and never the current session** (and
|
||||
10 001 sessions stay bounded at the 10 000 cap),
|
||||
* **the wiring** — the supervisor owns the store (the rooms get it as
|
||||
their `store`; `POST /api/rooms` takes the session from the
|
||||
`X-Undercooked-Token` header or the body's `token`, minting
|
||||
otherwise, and returns the token once; a known token's progression is
|
||||
never reset), `GET /api/session` (200 for a fresh token —
|
||||
`unlocked: 1`, empty `best`; 400 `no_token` without the header),
|
||||
`Room.set_level` asks the store's `is_unlocked`,
|
||||
`Room.next_level_id` reads the store's `next_level_id`, and
|
||||
`round_over` records the result into every seated player's session
|
||||
(spectators are not players and are not recorded).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
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 Room, RoomState
|
||||
from undercooked.server.rooms import RoomSupervisor
|
||||
from undercooked.server.seats import LevelLocked, UnknownLevel
|
||||
from undercooked.server.sessions import SessionRecord, SessionStore, new_session
|
||||
from undercooked.sim.content import ContentBundle
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import ItemForm, ItemKind, 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.
|
||||
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
|
||||
|
||||
# ------------------------------------------------------------------ fixtures
|
||||
|
||||
|
||||
def make_book() -> RecipeBook:
|
||||
"""A one-recipe book: the small levels' order pool names `soup`."""
|
||||
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="serve", station=StationKind.SERVE, seconds=0.0)],
|
||||
)
|
||||
},
|
||||
ingredients=[ItemKind.TOMATO],
|
||||
)
|
||||
|
||||
|
||||
def make_level(
|
||||
level_id: str, order: int, *, time_limit: float = 3.0, target: int = 1000
|
||||
) -> Level:
|
||||
"""A 7x4 kitchen (one spawn shelf, one serve window) at campaign
|
||||
`order`; `target` above any idle score, so a round ends `LOST` on
|
||||
the buzzer — the shortest path to `ROUND_END`."""
|
||||
grid = [
|
||||
"#######",
|
||||
"#S...V#",
|
||||
"#.....#",
|
||||
"#######",
|
||||
]
|
||||
tiles: dict[str, dict[str, str]] = {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
}
|
||||
data: dict[str, object] = {
|
||||
"id": level_id,
|
||||
"name": f"Session kitchen {level_id}",
|
||||
"order": order,
|
||||
"time_limit": time_limit,
|
||||
"target_score": target,
|
||||
"grid": copy.deepcopy(grid),
|
||||
"tiles": copy.deepcopy(tiles),
|
||||
"spawns": [[2, 2]],
|
||||
"orders": {
|
||||
"pool": ["soup"],
|
||||
"concurrent": 1,
|
||||
"spawn_interval": [4, 6],
|
||||
"deadline": 30,
|
||||
},
|
||||
}
|
||||
return Level.build(LevelDef.model_validate(data))
|
||||
|
||||
|
||||
def make_bundle(*levels: Level) -> ContentBundle:
|
||||
return ContentBundle(recipe_book=make_book(), levels=levels)
|
||||
|
||||
|
||||
def two_level_bundle() -> ContentBundle:
|
||||
return make_bundle(make_level("05a_first", 1), make_level("05b_second", 2))
|
||||
|
||||
|
||||
def three_level_bundle() -> ContentBundle:
|
||||
return make_bundle(
|
||||
make_level("05a_first", 1),
|
||||
make_level("05b_second", 2),
|
||||
make_level("05c_third", 3),
|
||||
)
|
||||
|
||||
|
||||
def make_store(bundle: ContentBundle, **overrides: Any) -> tuple[SessionStore, FakeClock]:
|
||||
clock = FakeClock()
|
||||
store = SessionStore(
|
||||
clock=clock,
|
||||
campaign={level.id: level.order for level in bundle.levels},
|
||||
**overrides,
|
||||
)
|
||||
return store, clock
|
||||
|
||||
|
||||
def make_room_with_store(
|
||||
bundle: ContentBundle, **settings: Any
|
||||
) -> tuple[Room, FakeClock, SessionStore]:
|
||||
"""A `LOBBY` room on a `FakeClock`, wired to its own store (the
|
||||
supervisor's wiring, in miniature)."""
|
||||
clock = FakeClock()
|
||||
store = SessionStore(
|
||||
clock=clock, campaign={level.id: level.order for level in bundle.levels}
|
||||
)
|
||||
room = Room(
|
||||
code="TEST1",
|
||||
settings=Settings(**settings),
|
||||
clock=clock,
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id if bundle.levels else None,
|
||||
sessions=store.get,
|
||||
store=store,
|
||||
)
|
||||
return room, clock, store
|
||||
|
||||
|
||||
def make_app(**settings_overrides: Any) -> FastAPI:
|
||||
settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides)
|
||||
return create_app(settings)
|
||||
|
||||
|
||||
def record_of(store: SessionStore, token: str) -> SessionRecord:
|
||||
"""Narrow `store.get` for the tests (the record exists)."""
|
||||
record = store.get(token)
|
||||
assert record is not None
|
||||
return record
|
||||
|
||||
|
||||
def won(
|
||||
level: Level, *, score: int = 100, stars: int = 2, served: int = 1
|
||||
) -> dict[str, object]:
|
||||
"""A `result_payload`-shaped `WON` body (the sim's exact key set)."""
|
||||
return {
|
||||
"result": "won",
|
||||
"score": score,
|
||||
"target": 0,
|
||||
"stars": stars,
|
||||
"time_left": 1.0,
|
||||
"counters": {"served": served, "missed": 0, "wasted": 0},
|
||||
"longest_streak": 0,
|
||||
"players": [],
|
||||
"level_id": level.id,
|
||||
"seed": 0,
|
||||
"ticks": 0,
|
||||
}
|
||||
|
||||
|
||||
def lost(level: Level, *, served: int = 0) -> dict[str, object]:
|
||||
"""A `result_payload`-shaped `LOST` body."""
|
||||
return {
|
||||
"result": "lost",
|
||||
"score": 0,
|
||||
"target": 0,
|
||||
"stars": 0,
|
||||
"time_left": 0.0,
|
||||
"counters": {"served": served, "missed": 1, "wasted": 0},
|
||||
"longest_streak": 0,
|
||||
"players": [],
|
||||
"level_id": level.id,
|
||||
"seed": 0,
|
||||
"ticks": 0,
|
||||
}
|
||||
|
||||
|
||||
def drive_countdown(room: Room, now: float = 0.0) -> float:
|
||||
"""The 3 s countdown in 1 s frames; returns the final `now`, with the
|
||||
room in `PLAYING` (the round built)."""
|
||||
room.tick_once(now=now)
|
||||
for offset in (1.0, 2.0, 3.0):
|
||||
room.tick_once(now=now + offset)
|
||||
assert room.state is RoomState.PLAYING
|
||||
return now + 3.0
|
||||
|
||||
|
||||
def play_round_to_end(room: Room, now: float = 3.0) -> float:
|
||||
"""1 s frames through the 3.0 s round and its 1.0 s settle window,
|
||||
until `ROUND_END`; returns the final `now`."""
|
||||
while room.state is RoomState.PLAYING:
|
||||
now += 1.0
|
||||
room.tick_once(now=now)
|
||||
assert room.state is RoomState.ROUND_END
|
||||
return now
|
||||
|
||||
|
||||
# ------------------------------------------------------------ get / create
|
||||
|
||||
|
||||
def test_fresh_token_auto_creates_at_the_campaign_floor() -> None:
|
||||
bundle = two_level_bundle()
|
||||
store, clock = make_store(bundle)
|
||||
record = store.get_or_create("tok-a")
|
||||
assert record.token == "tok-a" # noqa: S105 - a test token, not a credential
|
||||
assert record.unlocked_level == 1 # the campaign's floor
|
||||
assert record.best == {}
|
||||
assert record.rounds_played == 0 and record.dishes_served == 0
|
||||
assert record.endless_best is None
|
||||
assert record.created_at == 0.0 and record.last_seen == 0.0
|
||||
assert store.get("tok-a") is record # the same object on the second read
|
||||
clock.advance(1.0)
|
||||
again = store.get_or_create("tok-a") # a touch, not a re-create
|
||||
assert again is record
|
||||
assert again.last_seen == 1.0 and again.created_at == 0.0
|
||||
|
||||
|
||||
def test_get_never_auto_creates_a_record() -> None:
|
||||
"""A lookup is not an identity: `get` never creates (only
|
||||
`get_or_create` / `put` do)."""
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
assert store.get("tok-unknown") is None
|
||||
assert len(store) == 0
|
||||
|
||||
|
||||
def test_the_cap_must_be_at_least_one() -> None:
|
||||
with pytest.raises(ValueError, match="max_entries"):
|
||||
SessionStore(max_entries=0)
|
||||
|
||||
|
||||
def test_put_keeps_an_existing_record_alone() -> None:
|
||||
"""Progression is never replaced: a `put` for a known token is a
|
||||
no-op (a re-creator keeps their unlocks)."""
|
||||
bundle = two_level_bundle()
|
||||
store, clock = make_store(bundle)
|
||||
first = new_session("Ada", clock)
|
||||
store.put(first)
|
||||
store.record_result(first.token, bundle.levels[0], won(bundle.levels[0]))
|
||||
assert record_of(store, first.token).unlocked_level == 2
|
||||
|
||||
impostor = SessionRecord(
|
||||
token=first.token, display_name="Imp", created_at=clock.now(), unlocked_level=1
|
||||
)
|
||||
store.put(impostor)
|
||||
assert store.get(first.token) is first # the original, intact
|
||||
assert record_of(store, first.token).unlocked_level == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- unlocking
|
||||
|
||||
|
||||
def test_unlock_happens_only_on_a_win() -> None:
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
level1 = bundle.levels[0]
|
||||
store.record_result("tok-a", level1, lost(level1))
|
||||
assert record_of(store, "tok-a").unlocked_level == 1 # a loss: no unlock
|
||||
store.record_result(
|
||||
"tok-a", level1, {"result": "aborted", "score": 0, "stars": 0, "counters": {}}
|
||||
)
|
||||
assert record_of(store, "tok-a").unlocked_level == 1 # an abort: no unlock
|
||||
store.record_result("tok-a", level1, won(level1))
|
||||
assert record_of(store, "tok-a").unlocked_level == 2 # a win: order + 1
|
||||
|
||||
|
||||
def test_unlock_is_monotonic_across_wins_and_losses() -> None:
|
||||
"""Win L1, lose L2: L2 stays unlocked (the task's criterion); a
|
||||
lower-order win and a loss never decrease the unlock."""
|
||||
bundle = three_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
l1, l2, l3 = bundle.levels
|
||||
store.record_result("tok-a", l1, won(l1))
|
||||
assert record_of(store, "tok-a").unlocked_level == 2
|
||||
store.record_result("tok-a", l2, lost(l2))
|
||||
assert record_of(store, "tok-a").unlocked_level == 2 # a loss: still unlocked
|
||||
store.record_result("tok-a", l1, won(l1)) # a lower-order win
|
||||
assert record_of(store, "tok-a").unlocked_level == 2 # never decreases
|
||||
store.record_result("tok-a", l2, won(l2))
|
||||
assert record_of(store, "tok-a").unlocked_level == 3
|
||||
store.record_result("tok-a", l3, lost(l3))
|
||||
assert record_of(store, "tok-a").unlocked_level == 3
|
||||
|
||||
|
||||
# --------------------------------------------------------------- best runs
|
||||
|
||||
|
||||
def test_best_score_replaces_only_when_higher() -> None:
|
||||
"""A lower score never overwrites a stored best; a tie keeps the
|
||||
older run (and its `completed_at`); a higher one replaces — with
|
||||
the new run's `completed_at`."""
|
||||
bundle = two_level_bundle()
|
||||
store, clock = make_store(bundle)
|
||||
level1 = bundle.levels[0]
|
||||
store.record_result("tok-a", level1, won(level1, score=100, stars=2))
|
||||
first = record_of(store, "tok-a").best["05a_first"]
|
||||
assert first.score == 100
|
||||
|
||||
clock.advance(1.0)
|
||||
store.record_result("tok-a", level1, won(level1, score=90, stars=1))
|
||||
assert record_of(store, "tok-a").best["05a_first"] is first # lower: kept
|
||||
|
||||
clock.advance(1.0)
|
||||
store.record_result("tok-a", level1, won(level1, score=100, stars=3))
|
||||
assert record_of(store, "tok-a").best["05a_first"] is first # a tie: the older kept
|
||||
assert first.completed_at == 0.0 # the first to set it
|
||||
|
||||
clock.advance(1.0)
|
||||
store.record_result("tok-a", level1, won(level1, score=120, stars=3))
|
||||
best = record_of(store, "tok-a").best["05a_first"]
|
||||
assert best is not first
|
||||
assert best.score == 120
|
||||
assert best.completed_at == 3.0
|
||||
|
||||
|
||||
def test_stars_are_recorded_with_the_best_run() -> None:
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
level1 = bundle.levels[0]
|
||||
store.record_result("tok-a", level1, won(level1, score=50, stars=1, served=2))
|
||||
assert record_of(store, "tok-a").best["05a_first"].stars == 1
|
||||
store.record_result("tok-a", level1, won(level1, score=60, stars=3))
|
||||
assert record_of(store, "tok-a").best["05a_first"].stars == 3 # the new best's stars
|
||||
|
||||
|
||||
# --------------------------------------------------------------- counters
|
||||
|
||||
|
||||
def test_counters_accumulate_across_rooms() -> None:
|
||||
"""`rounds_played` per finished round (a loss counts too),
|
||||
`dishes_served` per served dish — summed over `record_result` calls
|
||||
that stand in for different rooms."""
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
store.record_result("tok-a", bundle.levels[0], lost(bundle.levels[0], served=3))
|
||||
store.record_result("tok-a", bundle.levels[1], won(bundle.levels[1], served=5))
|
||||
record = record_of(store, "tok-a")
|
||||
assert record.rounds_played == 2
|
||||
assert record.dishes_served == 8
|
||||
|
||||
|
||||
def test_record_result_copies_floats_and_reads_malformed_types() -> None:
|
||||
"""The payload is a plain-JSON contract: a float is read as an int;
|
||||
a wrong type (or a non-integral float) reads as the default —
|
||||
never a crash."""
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
level1 = bundle.levels[0]
|
||||
float_payload = won(level1)
|
||||
float_payload["score"] = 42.0 # a float is read as an int
|
||||
store.record_result("tok-a", level1, float_payload)
|
||||
assert record_of(store, "tok-a").best["05a_first"].score == 42
|
||||
bad_payload = won(level1)
|
||||
bad_payload["score"] = 50
|
||||
bad_payload["stars"] = "three" # a wrong type reads as the default
|
||||
bad_payload["counters"] = {"served": 2.5} # a non-integral float: default
|
||||
store.record_result("tok-a", level1, bad_payload)
|
||||
best = record_of(store, "tok-a").best["05a_first"]
|
||||
assert best.score == 50 # 50 > 42: the higher one still replaces
|
||||
assert best.stars == 0 # the `stars` default, not a crash
|
||||
assert record_of(store, "tok-a").dishes_served == 1 # 1 + (2.5 read as 0)
|
||||
|
||||
|
||||
def test_the_store_exposes_its_clock_and_cap() -> None:
|
||||
bundle = two_level_bundle()
|
||||
store, clock = make_store(bundle)
|
||||
assert store.max_entries == 10_000
|
||||
assert store.clock is clock
|
||||
store.get_or_create("tok-a")
|
||||
assert len(store) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- to_public
|
||||
|
||||
|
||||
def test_to_public_leaks_nothing_beyond_the_public_shape() -> None:
|
||||
bundle = two_level_bundle()
|
||||
store, _clock = make_store(bundle)
|
||||
store.get_or_create("tok-secret-123")
|
||||
store.record_result("tok-secret-123", bundle.levels[0], won(bundle.levels[0]))
|
||||
public = store.to_public("tok-secret-123")
|
||||
assert set(public) == {
|
||||
"display_name",
|
||||
"unlocked",
|
||||
"best",
|
||||
"rounds_played",
|
||||
"dishes_served",
|
||||
"endless_best",
|
||||
}
|
||||
assert set(public["best"]["05a_first"]) == {"score", "stars", "completed_at"}
|
||||
text = json.dumps(public)
|
||||
assert "tok-secret-123" not in text # the token never leaves the store
|
||||
assert "created_at" not in public and "last_seen" not in public
|
||||
assert public["unlocked"] == 2 and public["rounds_played"] == 1
|
||||
|
||||
|
||||
def test_to_public_for_a_fresh_token_reads_the_floor_and_an_empty_best() -> None:
|
||||
"""The task's completion criterion, at the store: a fresh token
|
||||
auto-creates and reads `unlocked: 1` with an empty `best`."""
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
public = store.to_public("tok-fresh-999")
|
||||
assert public["unlocked"] == 1
|
||||
assert public["best"] == {}
|
||||
assert store.get("tok-fresh-999") is not None # the read created it
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- LRU
|
||||
|
||||
|
||||
def test_lru_evicts_the_stalest_and_never_the_current_session() -> None:
|
||||
clock = FakeClock()
|
||||
store = SessionStore(max_entries=3, clock=clock)
|
||||
store.get_or_create("tok-a")
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-b")
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-c")
|
||||
# Touch c, then b: a is now the stalest
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-c")
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-b")
|
||||
# Insert d: a goes — never d (the session being created)
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-d")
|
||||
assert store.get("tok-a") is None
|
||||
assert store.get("tok-b") is not None
|
||||
assert store.get("tok-c") is not None
|
||||
assert store.get("tok-d") is not None
|
||||
assert len(store) == 3
|
||||
assert store.stats()["evictions"] == 1
|
||||
# Full again; touch d, insert e: the stalest of {b, c, d} goes — c
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-d")
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-e")
|
||||
assert store.get("tok-c") is None
|
||||
assert store.get("tok-b") is not None
|
||||
assert store.get("tok-d") is not None
|
||||
assert store.get("tok-e") is not None
|
||||
assert len(store) == 3
|
||||
assert store.stats()["evictions"] == 2
|
||||
|
||||
|
||||
def test_lru_never_evicts_the_session_being_touched() -> None:
|
||||
"""Cap 1: a re-touch at the cap evicts nothing (the store holds only
|
||||
the current session); a *new* token evicts the old one — and is
|
||||
never evicted itself."""
|
||||
clock = FakeClock()
|
||||
store = SessionStore(max_entries=1, clock=clock)
|
||||
store.get_or_create("tok-a")
|
||||
assert len(store) == 1
|
||||
clock.advance(1.0)
|
||||
record = store.get_or_create("tok-a")
|
||||
assert len(store) == 1
|
||||
assert record is store.get("tok-a") # still there, untouched
|
||||
clock.advance(1.0)
|
||||
store.get_or_create("tok-b")
|
||||
assert store.get("tok-a") is None
|
||||
assert store.get("tok-b") is not None
|
||||
assert len(store) == 1
|
||||
|
||||
|
||||
def test_ten_thousand_one_sessions_stay_bounded() -> None:
|
||||
clock = FakeClock()
|
||||
store = SessionStore(max_entries=10_000, clock=clock)
|
||||
seen_max = 0
|
||||
for index in range(10_001):
|
||||
store.get_or_create(f"t-{index:05d}")
|
||||
seen_max = max(seen_max, len(store))
|
||||
assert seen_max == 10_000 # never above the cap
|
||||
assert store.stats() == {
|
||||
"sessions": 10_000,
|
||||
"max_entries": 10_000,
|
||||
"evictions": 1, # only the 10 001st insert evicted (one)
|
||||
}
|
||||
|
||||
|
||||
def test_stats_reports_the_census() -> None:
|
||||
clock = FakeClock()
|
||||
store = SessionStore(max_entries=2, clock=clock)
|
||||
store.get_or_create("tok-a")
|
||||
store.get_or_create("tok-b")
|
||||
store.get_or_create("tok-c") # evicts a
|
||||
assert store.stats() == {"sessions": 2, "max_entries": 2, "evictions": 1}
|
||||
|
||||
|
||||
# ------------------------------------------------------ is_unlocked / next
|
||||
|
||||
|
||||
def test_is_unlocked_reads_the_campaign_and_the_session() -> None:
|
||||
bundle = two_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
assert store.is_unlocked("tok-a", "05a_first") is True # the floor, no record
|
||||
assert store.is_unlocked("tok-a", "05b_second") is False
|
||||
assert store.is_unlocked("tok-a", "05_zzz") is False # unknown: locked by default
|
||||
store.record_result("tok-a", bundle.levels[0], won(bundle.levels[0]))
|
||||
assert store.is_unlocked("tok-a", "05b_second") is True
|
||||
bare = SessionStore(clock=FakeClock())
|
||||
assert bare.is_unlocked("tok-a", "05a_first") is False # no campaign: nothing
|
||||
|
||||
|
||||
def test_next_level_id_walks_the_campaign() -> None:
|
||||
bundle = three_level_bundle()
|
||||
store, _ = make_store(bundle)
|
||||
l1, l2, l3 = bundle.levels
|
||||
assert store.next_level_id("tok-a", bundle) == "05a_first" # fresh: the floor
|
||||
store.record_result("tok-a", l1, won(l1))
|
||||
assert store.next_level_id("tok-a", bundle) == "05b_second" # after a win
|
||||
store.record_result("tok-a", l2, won(l2))
|
||||
assert store.next_level_id("tok-a", bundle) == "05c_third"
|
||||
store.record_result("tok-a", l3, lost(l3)) # a loss: retry the level just played
|
||||
assert store.next_level_id("tok-a", bundle) == "05c_third"
|
||||
store.record_result("tok-a", l3, won(l3))
|
||||
assert store.next_level_id("tok-a", bundle) is None # the campaign is done
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ wiring
|
||||
|
||||
|
||||
def test_the_supervisor_owns_the_session_store_and_the_rooms_get_it() -> None:
|
||||
bundle = two_level_bundle()
|
||||
clock = FakeClock()
|
||||
supervisor = RoomSupervisor(Settings(), clock, bundle)
|
||||
assert isinstance(supervisor.sessions, SessionStore)
|
||||
session = new_session("Ada", clock)
|
||||
room = supervisor.create_room(session, "Ada")
|
||||
assert room.store is supervisor.sessions # the same store the room records into
|
||||
assert supervisor.session_for(session.token) is session
|
||||
supervisor.join(room.code, "tok-join", "Bob")
|
||||
record = supervisor.session_for("tok-join")
|
||||
assert record is not None
|
||||
assert record.display_name == "Bob"
|
||||
assert record.unlocked_level == 1 # a fresh session: the campaign's floor
|
||||
|
||||
|
||||
# ------------------------------------------------------- POST /api/rooms
|
||||
|
||||
|
||||
def test_post_api_rooms_takes_the_token_from_the_header() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
response = client.post(
|
||||
"/api/rooms",
|
||||
json={"name": "Ada"},
|
||||
headers={"X-Undercooked-Token": "tok-header-1"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["you"]["token"] == "tok-header-1" # noqa: S105 - a test token, not a credential
|
||||
assert len(body["seats"]) == 1 and body["seats"][0]["name"] == "Ada"
|
||||
|
||||
|
||||
def test_post_api_rooms_takes_the_token_from_the_body_when_no_header() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
response = client.post("/api/rooms", json={"name": "Bob", "token": "tok-body-2"})
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["you"]["token"] == "tok-body-2" # noqa: S105 - a test token, not a credential
|
||||
|
||||
|
||||
def test_the_header_token_wins_over_the_body_token() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
response = client.post(
|
||||
"/api/rooms",
|
||||
json={"token": "tok-body"},
|
||||
headers={"X-Undercooked-Token": "tok-header"},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["you"]["token"] == "tok-header" # noqa: S105 - a test token, not a credential
|
||||
|
||||
|
||||
def test_post_api_rooms_without_any_token_mints_one_and_it_lives_in_the_store() -> None:
|
||||
app = make_app()
|
||||
store = app.state.supervisor.sessions
|
||||
with TestClient(app) as client:
|
||||
body = client.post("/api/rooms", json={}).json()
|
||||
token = body["you"]["token"]
|
||||
assert len(token) == 32 # token_urlsafe(24)
|
||||
assert store.get(token) is not None
|
||||
assert record_of(store, token).unlocked_level == 1
|
||||
assert record_of(store, token).created_at >= 0.0
|
||||
|
||||
|
||||
def test_a_known_tokens_progression_survives_a_recreate() -> None:
|
||||
"""A token that already has progression (and a room) re-creating a
|
||||
room keeps it: the one-token-one-room eviction drops the old seat,
|
||||
the store's record is untouched."""
|
||||
app = make_app()
|
||||
store = app.state.supervisor.sessions
|
||||
with TestClient(app) as client:
|
||||
first = client.post(
|
||||
"/api/rooms", json={"name": "Ada"}, headers={"X-Undercooked-Token": "tok-p"}
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
standalone = make_level("zz_progression", 1)
|
||||
store.record_result(
|
||||
"tok-p", standalone, {"result": "won", "score": 5, "stars": 1,
|
||||
"counters": {"served": 1}}
|
||||
)
|
||||
assert record_of(store, "tok-p").unlocked_level == 2
|
||||
second = client.post("/api/rooms", json={}, headers={"X-Undercooked-Token": "tok-p"})
|
||||
assert second.status_code == 201, second.text
|
||||
assert first.json()["you"]["token"] == "tok-p" == second.json()["you"]["token"] # noqa: S105 - a test token, not a credential
|
||||
record = record_of(store, "tok-p")
|
||||
assert record.unlocked_level == 2 # intact
|
||||
assert record.best["zz_progression"].score == 5
|
||||
|
||||
|
||||
# --------------------------------------------------------- GET /api/session
|
||||
|
||||
|
||||
def test_get_api_session_for_a_fresh_token_returns_unlocked_1_and_empty_best() -> None:
|
||||
"""The task's completion criterion, over HTTP."""
|
||||
with TestClient(make_app()) as client:
|
||||
response = client.get(
|
||||
"/api/session", headers={"X-Undercooked-Token": "tok-fresh-42"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["unlocked"] == 1
|
||||
assert body["best"] == {}
|
||||
assert body["display_name"] is None
|
||||
assert body["rounds_played"] == 0
|
||||
assert body["dishes_served"] == 0
|
||||
assert body["endless_best"] is None
|
||||
|
||||
|
||||
def test_get_api_session_without_a_header_is_400_no_token() -> None:
|
||||
with TestClient(make_app()) as client:
|
||||
response = client.get("/api/session")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"code": "no_token"}
|
||||
|
||||
|
||||
def test_get_api_session_shows_recorded_progression() -> None:
|
||||
app = make_app()
|
||||
store = app.state.supervisor.sessions
|
||||
with TestClient(app) as client:
|
||||
created = client.post(
|
||||
"/api/rooms", json={"name": "Ada"}, headers={"X-Undercooked-Token": "tok-s1"}
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
store.record_result(
|
||||
"tok-s1",
|
||||
make_level("zz_progression", 1),
|
||||
{"result": "won", "score": 40, "stars": 2, "counters": {"served": 3}},
|
||||
)
|
||||
body = client.get(
|
||||
"/api/session", headers={"X-Undercooked-Token": "tok-s1"}
|
||||
).json()
|
||||
assert body["unlocked"] == 2
|
||||
assert body["display_name"] == "Ada"
|
||||
assert body["rounds_played"] == 1
|
||||
assert body["dishes_served"] == 3
|
||||
assert body["best"]["zz_progression"]["score"] == 40
|
||||
assert body["best"]["zz_progression"]["stars"] == 2
|
||||
assert isinstance(body["best"]["zz_progression"]["completed_at"], float)
|
||||
|
||||
|
||||
# --------------------------------------------------- the room's store verbs
|
||||
|
||||
|
||||
def test_round_over_records_the_result_into_every_seated_players_sessions() -> None:
|
||||
"""`round_over`'s half (a loss here — the store's win semantics are
|
||||
pinned above): every seated player's session counts the round;
|
||||
spectators are not players and are not recorded."""
|
||||
bundle = two_level_bundle()
|
||||
room, _clock, store = make_room_with_store(bundle)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
room.set_ready("tok-b", True) # so the host's start can run
|
||||
room.add_spectator("tok-s", "Spy")
|
||||
room.request_start()
|
||||
now = drive_countdown(room)
|
||||
play_round_to_end(room, now)
|
||||
|
||||
assert room.last_result is not None
|
||||
assert room.last_result["result"] == "lost"
|
||||
assert record_of(store, "tok-a").rounds_played == 1
|
||||
assert record_of(store, "tok-b").rounds_played == 1
|
||||
assert record_of(store, "tok-a").unlocked_level == 1 # a loss: no unlock
|
||||
assert store.get("tok-s") is None # the spectator was not recorded
|
||||
assert room.last_result is not None # the payload itself is intact
|
||||
|
||||
|
||||
def test_set_level_asks_the_store_is_unlocked() -> None:
|
||||
bundle = two_level_bundle()
|
||||
room, _clock, store = make_room_with_store(bundle)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
|
||||
with pytest.raises(UnknownLevel):
|
||||
room.set_level("tok-a", "05_zzz") # still checked before the unlock
|
||||
with pytest.raises(LevelLocked, match="order 2"):
|
||||
room.set_level("tok-a", "05b_second") # a fresh session: unlocked 1
|
||||
|
||||
store.record_result("tok-a", bundle.levels[0], won(bundle.levels[0]))
|
||||
picked = room.set_level("tok-a", "05b_second") # now unlocked 2
|
||||
assert picked.id == "05b_second"
|
||||
assert record_of(store, "tok-a").unlocked_level == 2
|
||||
|
||||
|
||||
def test_next_level_id_reads_the_store() -> None:
|
||||
bundle = two_level_bundle()
|
||||
room, _clock, store = make_room_with_store(bundle)
|
||||
assert room.next_level_id("tok-a") == "05a_first" # fresh: the floor
|
||||
store.record_result("tok-a", bundle.levels[0], won(bundle.levels[0]))
|
||||
assert room.next_level_id("tok-a") == "05b_second"
|
||||
store.record_result("tok-a", bundle.levels[1], won(bundle.levels[1]))
|
||||
assert room.next_level_id("tok-a") is None # the campaign is done
|
||||
|
||||
bare = Room(
|
||||
code="T2",
|
||||
settings=Settings(),
|
||||
clock=FakeClock(),
|
||||
content=bundle,
|
||||
level_id=bundle.levels[0].id,
|
||||
)
|
||||
assert bare.next_level_id("tok-a") is None # no store: no advice
|
||||
@@ -0,0 +1,475 @@
|
||||
"""Integration: the `RoomSupervisor` — create/join/leave, capacity, rejoin,
|
||||
GC, and teardown, all driven by a `FakeClock` (no test sleeps).
|
||||
|
||||
`RoomSupervisor` is the process-wide registry (PLAN §6): code minting with
|
||||
collision checks, `max_rooms` / `max_players` capacity, the one-token-
|
||||
one-room index (a token joining a second room is evicted from the first,
|
||||
and the rejoin path keeps *disconnected* seats in place), the 5 s GC
|
||||
(empty rooms past `SEAT_GRACE_S`, idle `LOBBY` rooms past
|
||||
`lobby_idle_timeout_s`, closed rooms dropped, stale tokens purged), and
|
||||
`start`/`shutdown` as the only task teardown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
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.rooms import (
|
||||
GC_INTERVAL_S,
|
||||
SEAT_GRACE_S,
|
||||
PlayerNotInRoom,
|
||||
RoomCapacityError,
|
||||
RoomClosed,
|
||||
RoomFull,
|
||||
RoomNotFound,
|
||||
RoomSupervisor,
|
||||
)
|
||||
from undercooked.server.sessions import new_session
|
||||
from undercooked.sim.content import ContentBundle
|
||||
from undercooked.sim.recipe import RecipeBook
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def empty_bundle() -> ContentBundle:
|
||||
"""A legal empty campaign: the supervisor's tests never need real levels."""
|
||||
return ContentBundle(
|
||||
recipe_book=RecipeBook(recipes={}, ingredients=[]),
|
||||
levels=(),
|
||||
)
|
||||
|
||||
|
||||
def make_supervisor(
|
||||
**settings_overrides: Any,
|
||||
) -> tuple[RoomSupervisor, FakeClock, ContentBundle]:
|
||||
"""A supervisor on a `FakeClock`, ready to be driven by hand."""
|
||||
settings = Settings(**settings_overrides)
|
||||
clock = FakeClock()
|
||||
bundle = empty_bundle()
|
||||
supervisor = RoomSupervisor(settings, clock, bundle)
|
||||
return supervisor, clock, bundle
|
||||
|
||||
|
||||
def advancing_sleep(clock: FakeClock) -> Callable[[float], Awaitable[None]]:
|
||||
"""A sleep that advances the `FakeClock` instead of waiting in real time."""
|
||||
|
||||
async def sleep(seconds: float) -> None:
|
||||
clock.advance(seconds)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
return sleep
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- create / join
|
||||
|
||||
|
||||
async def test_create_mints_a_code_and_seats_the_creator_as_host() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
session = new_session("Ada", clock)
|
||||
room = supervisor.create_room(session, "Ada")
|
||||
|
||||
assert len(room.code) == 5
|
||||
assert set(room.code) <= set(CROCKFORD)
|
||||
assert room.state is RoomState.LOBBY
|
||||
assert room.level_id is None # the empty campaign has no default level
|
||||
seat = room.seats[0]
|
||||
assert seat.token == session.token
|
||||
assert seat.host and seat.seat_index == 0 and seat.connected
|
||||
assert seat.display_name == "Ada"
|
||||
assert room.created_at == clock.now()
|
||||
|
||||
assert supervisor.get(room.code) is room
|
||||
assert supervisor.get(f" {room.code.lower()}-") is room # normalized lookup
|
||||
assert supervisor.by_token(session.token) is room
|
||||
|
||||
|
||||
async def test_create_uses_the_session_name_when_no_name_is_typed() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
session = new_session("Ada", clock)
|
||||
room = supervisor.create_room(session, None)
|
||||
assert room.seats[0].display_name == "Ada"
|
||||
anonymous = new_session(None, clock)
|
||||
room = supervisor.create_room(anonymous, None)
|
||||
assert room.seats[0].display_name == "Cook #1"
|
||||
|
||||
|
||||
async def test_create_evicts_a_token_from_its_previous_room() -> None:
|
||||
"""One token, one room: the creator's old seat is dropped first."""
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
session = new_session("Ada", clock)
|
||||
first = supervisor.create_room(session, "Ada")
|
||||
supervisor.create_room(new_session("Bob", clock), "Bob")
|
||||
second = supervisor.create_room(session, "Ada")
|
||||
|
||||
assert first.seats == []
|
||||
assert first.empty_since == clock.now()
|
||||
assert supervisor.by_token(session.token) is second
|
||||
|
||||
|
||||
async def test_join_seats_the_player_and_normalizes_the_code() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
|
||||
joined = supervisor.join(f" {room.code.lower()}-", bob.token, "Bob")
|
||||
|
||||
assert joined is room
|
||||
seat = room.seat_for_token(bob.token)
|
||||
assert seat is not None
|
||||
assert seat.seat_index == 1 and not seat.host
|
||||
assert seat.display_name == "Bob"
|
||||
assert supervisor.by_token(bob.token) is room
|
||||
assert room.last_activity == clock.now()
|
||||
|
||||
|
||||
async def test_join_unknown_and_malformed_codes_are_indistinguishable() -> None:
|
||||
"""US-02 criterion 4: a bad format must fail exactly like an unknown code,
|
||||
so the supervisor (and the API above it) is not a room-existence oracle."""
|
||||
supervisor, _, _ = make_supervisor()
|
||||
for bad in ("ZZZZZ", "!!!", "", "ABC", "ABCDEF", "ABCDE?", "AB.CD"):
|
||||
with pytest.raises(RoomNotFound):
|
||||
supervisor.join(bad, "token", "x")
|
||||
assert supervisor.get("ZZZZZ") is supervisor.get("!!!") is None
|
||||
|
||||
|
||||
async def test_join_a_full_room_raises_room_full() -> None:
|
||||
supervisor, clock, _ = make_supervisor(max_players=2)
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
supervisor.join(room.code, new_session("Bob", clock).token, "Bob")
|
||||
|
||||
with pytest.raises(RoomFull, match="full"):
|
||||
supervisor.join(room.code, new_session("Cy", clock).token, "Cy")
|
||||
assert len(room.seats) == 2
|
||||
|
||||
|
||||
async def test_create_at_max_rooms_raises_room_capacity_error() -> None:
|
||||
supervisor, clock, _ = make_supervisor(max_rooms=3)
|
||||
for name in ("Ada", "Bob", "Cy"):
|
||||
supervisor.create_room(new_session(name, clock), name)
|
||||
|
||||
with pytest.raises(RoomCapacityError, match="capacity"):
|
||||
supervisor.create_room(new_session("Dee", clock), "Dee")
|
||||
assert supervisor.stats()["rooms"] == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ leave / rejoin
|
||||
|
||||
|
||||
async def test_leave_removes_the_seat_and_starts_the_grace_clock() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
supervisor.join(room.code, bob.token, "Bob")
|
||||
|
||||
supervisor.leave(room.code, bob.token)
|
||||
assert not room.is_empty and room.empty_since is None
|
||||
assert supervisor.by_token(bob.token) is None
|
||||
|
||||
supervisor.leave(room.code, ada.token)
|
||||
assert room.is_empty and room.empty_since == clock.now()
|
||||
assert supervisor.stats()["tokens"] == 0
|
||||
|
||||
|
||||
async def test_leave_by_a_stranger_raises_player_not_in_room() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
with pytest.raises(PlayerNotInRoom):
|
||||
supervisor.leave(room.code, "stranger")
|
||||
|
||||
|
||||
async def test_mark_disconnected_keeps_the_seat_for_the_rejoin_path() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
supervisor.mark_disconnected(room.code, room.seats[0].token)
|
||||
|
||||
seat = room.seats[0]
|
||||
assert seat.connected is False
|
||||
assert seat.last_seen == clock.now() # the seat is kept, not removed
|
||||
|
||||
|
||||
async def test_mark_disconnected_by_a_stranger_raises() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
with pytest.raises(PlayerNotInRoom):
|
||||
supervisor.mark_disconnected(room.code, "stranger")
|
||||
|
||||
|
||||
async def test_rejoin_reclaims_a_disconnected_seat() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
supervisor.join(room.code, bob.token, "Bob")
|
||||
supervisor.mark_disconnected(room.code, bob.token)
|
||||
|
||||
clock.advance(10.0)
|
||||
again = supervisor.join(room.code, bob.token, None)
|
||||
|
||||
assert again is room
|
||||
seat = room.seat_for_token(bob.token)
|
||||
assert seat is not None
|
||||
assert seat.connected is True
|
||||
assert seat.seat_index == 1 # the same seat, not a second one
|
||||
assert seat.last_seen == 10.0
|
||||
assert supervisor.stats()["seats"] == 2
|
||||
|
||||
|
||||
async def test_join_moves_a_token_between_rooms() -> None:
|
||||
"""One token, one room: joining room B evicts the seat in room A."""
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room_a = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
room_b = supervisor.create_room(new_session("Bob", clock), "Bob")
|
||||
ada = room_a.seats[0].token
|
||||
|
||||
supervisor.join(room_b.code, ada, None)
|
||||
|
||||
assert room_a.seats == []
|
||||
assert room_a.empty_since == clock.now()
|
||||
assert supervisor.by_token(ada) is room_b
|
||||
assert room_b.seat_for_token(ada) is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------- stats / queries
|
||||
|
||||
|
||||
async def test_stats_and_all_rooms_are_a_consistent_census() -> None:
|
||||
supervisor, clock, _ = make_supervisor(max_rooms=7)
|
||||
room_a = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
room_b = supervisor.create_room(new_session("Bob", clock), "Bob")
|
||||
supervisor.join(room_a.code, new_session("Cy", clock).token, "Cy")
|
||||
supervisor.mark_disconnected(room_a.code, room_a.seats[0].token)
|
||||
|
||||
assert supervisor.stats() == {
|
||||
"rooms": 2,
|
||||
"max_rooms": 7,
|
||||
"seats": 3,
|
||||
"states": {"lobby": 2},
|
||||
"tokens": 3,
|
||||
"spectators": 0,
|
||||
}
|
||||
assert [room.code for room in supervisor.all_rooms()] == sorted(
|
||||
[room_a.code, room_b.code]
|
||||
)
|
||||
|
||||
|
||||
async def test_close_room_keeps_the_room_visible_until_the_next_gc_pass() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
|
||||
await supervisor.close_room(room.code)
|
||||
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.closed_at == clock.now()
|
||||
with pytest.raises(RoomClosed):
|
||||
supervisor.join(room.code, new_session("Bob", clock).token, "Bob")
|
||||
|
||||
await supervisor.close_room(room.code) # idempotent
|
||||
assert await supervisor.gc_pass() == 0 # the pass drops it, closes nothing
|
||||
assert supervisor.get(room.code) is None
|
||||
with pytest.raises(RoomNotFound):
|
||||
supervisor.join(room.code, new_session("Bob", clock).token, "Bob")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
|
||||
|
||||
async def test_room_close_cancels_its_tick_task_and_is_idempotent() -> None:
|
||||
clock = FakeClock()
|
||||
room = Room(
|
||||
code="TEST1", settings=Settings(), clock=clock, sleep=advancing_sleep(clock)
|
||||
)
|
||||
room.start_ticking()
|
||||
first_task = room.task
|
||||
assert first_task is not None
|
||||
room.start_ticking() # idempotent while the task runs
|
||||
assert room.task is first_task
|
||||
|
||||
await room.close()
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.task is not None
|
||||
assert room.task.cancelled()
|
||||
assert room.seats == []
|
||||
await room.close() # idempotent
|
||||
assert room.closed_at is not None
|
||||
|
||||
|
||||
async def test_tick_once_in_a_fresh_occupied_lobby_moves_nothing() -> None:
|
||||
"""Task 02: `tick_once` is a real tick (the FSM body, crash-isolated),
|
||||
but a fresh room with a seat and no idle past a threshold stays
|
||||
`LOBBY` — the idle rules are the only things a `LOBBY` tick does."""
|
||||
clock = FakeClock()
|
||||
room = Room(code="TEST1", settings=Settings(), clock=clock)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.tick_once()
|
||||
room.tick_once(now=1.0) # 1 s < the 900 s lobby idle timeout
|
||||
assert room.state is RoomState.LOBBY # nothing moved
|
||||
|
||||
|
||||
async def test_shutdown_cancels_the_gc_loop_and_every_room_task() -> None:
|
||||
clock = FakeClock()
|
||||
supervisor = RoomSupervisor(
|
||||
Settings(), clock, empty_bundle(), sleep=advancing_sleep(clock)
|
||||
)
|
||||
await supervisor.start()
|
||||
assert supervisor.gc_task is not None and not supervisor.gc_task.done()
|
||||
|
||||
room = supervisor.create_room(new_session("Ada", clock), "Ada")
|
||||
room.start_ticking()
|
||||
assert room.task is not None and not room.task.done()
|
||||
|
||||
await supervisor.shutdown()
|
||||
|
||||
assert room.state is RoomState.CLOSED
|
||||
assert room.task is not None
|
||||
assert room.task.cancelled()
|
||||
assert supervisor.gc_task is None
|
||||
assert supervisor.stats()["rooms"] == 0
|
||||
assert all(room_.state is RoomState.CLOSED for room_ in supervisor.all_rooms())
|
||||
|
||||
|
||||
async def test_start_is_idempotent_and_a_second_start_restarts_the_gc_loop() -> None:
|
||||
clock = FakeClock()
|
||||
supervisor = RoomSupervisor(
|
||||
Settings(), clock, empty_bundle(), sleep=advancing_sleep(clock)
|
||||
)
|
||||
await supervisor.start()
|
||||
first = supervisor.gc_task
|
||||
assert first is not None
|
||||
await supervisor.start()
|
||||
assert supervisor.gc_task is first # not restarted while it runs
|
||||
await supervisor.shutdown()
|
||||
await supervisor.start()
|
||||
assert supervisor.gc_task is not first
|
||||
assert supervisor.gc_task is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- GC
|
||||
|
||||
|
||||
async def test_gc_closes_exactly_what_it_should() -> None:
|
||||
"""The orphan rule, at the default 900 s idle timeout: the 30 s grace is
|
||||
the only clock that can fire, and it must fire on the empty room alone."""
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
# A: empties at t=0 (grace clock starts then)
|
||||
ada = new_session("Ada", clock)
|
||||
room_a = supervisor.create_room(ada, "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
supervisor.join(room_a.code, bob.token, "Bob")
|
||||
supervisor.leave(room_a.code, bob.token)
|
||||
supervisor.leave(room_a.code, ada.token)
|
||||
assert room_a.empty_since == 0.0
|
||||
# C: a lobby with a seat, activity at t=0 (idle 900 s — survives)
|
||||
supervisor.create_room(new_session("Cy", clock), "Cy")
|
||||
# E: a PLAYING room — the idle-lobby rule must never touch it
|
||||
room_e = supervisor.create_room(new_session("Eve", clock), "Eve")
|
||||
room_e.state = RoomState.PLAYING
|
||||
|
||||
assert await supervisor.gc_pass() == 0 # t=0: nothing is past a threshold
|
||||
clock.advance(5.0)
|
||||
assert await supervisor.gc_pass() == 0 # t=5: 5 < 30 grace
|
||||
clock.advance(20.0) # t=25
|
||||
assert await supervisor.gc_pass() == 0 # 25 < 30 grace: still no
|
||||
|
||||
clock.advance(5.5) # t=30.5
|
||||
assert await supervisor.gc_pass() == 1 # A only: grace 30.5 > 30
|
||||
assert supervisor.get(room_a.code) is None
|
||||
assert supervisor.get(room_e.code) is not None
|
||||
assert supervisor.stats() == {
|
||||
"rooms": 2,
|
||||
"max_rooms": 200,
|
||||
"seats": 2,
|
||||
"states": {"lobby": 1, "playing": 1},
|
||||
"tokens": 2,
|
||||
"spectators": 0,
|
||||
}
|
||||
assert SEAT_GRACE_S == 30.0 # the constant the test measures against
|
||||
|
||||
|
||||
async def test_gc_closes_idle_lobbies_past_the_timeout() -> None:
|
||||
"""The idle-lobby rule: a `LOBBY` room with no activity for longer than
|
||||
`lobby_idle_timeout_s` is closed — an empty room stays `LOBBY`, so the
|
||||
idle rule reaps it too (the grace rule is the *other* way an empty
|
||||
room dies, covered above)."""
|
||||
supervisor, clock, _ = make_supervisor(lobby_idle_timeout_s=10.0)
|
||||
# A: empties at t=0 (stays LOBBY, last activity t=0)
|
||||
ada = new_session("Ada", clock)
|
||||
room_a = supervisor.create_room(ada, "Ada")
|
||||
supervisor.leave(room_a.code, ada.token)
|
||||
# C: an idle lobby with a seat (last activity t=0)
|
||||
room_c = supervisor.create_room(new_session("Cy", clock), "Cy")
|
||||
# D: a lobby with recent activity (t=1)
|
||||
room_d = supervisor.create_room(new_session("Dee", clock), "Dee")
|
||||
room_d.note_activity(1.0)
|
||||
# E: a PLAYING room — the idle-lobby rule must never touch it
|
||||
room_e = supervisor.create_room(new_session("Eve", clock), "Eve")
|
||||
room_e.state = RoomState.PLAYING
|
||||
|
||||
assert await supervisor.gc_pass() == 0 # t=0: nothing is past 10 s
|
||||
clock.advance(10.5)
|
||||
assert await supervisor.gc_pass() == 2 # A and C: idle 10.5 > 10
|
||||
assert supervisor.get(room_a.code) is None
|
||||
assert supervisor.get(room_c.code) is None
|
||||
assert supervisor.get(room_d.code) is not None # idle 9.5 < 10
|
||||
assert supervisor.get(room_e.code) is not None # not a LOBBY
|
||||
|
||||
clock.advance(20.0) # t=30.5
|
||||
assert await supervisor.gc_pass() == 1 # D: idle 29.5 > 10
|
||||
assert supervisor.get(room_d.code) is None
|
||||
assert supervisor.get(room_e.code) is not None
|
||||
assert supervisor.stats()["rooms"] == 1
|
||||
|
||||
|
||||
async def test_gc_purges_stale_token_entries_but_keeps_disconnected_ones() -> None:
|
||||
supervisor, clock, _ = make_supervisor()
|
||||
ada = new_session("Ada", clock)
|
||||
room = supervisor.create_room(ada, "Ada")
|
||||
bob = new_session("Bob", clock)
|
||||
supervisor.join(room.code, bob.token, "Bob")
|
||||
supervisor.mark_disconnected(room.code, ada.token) # Ada's seat is kept
|
||||
|
||||
await supervisor.close_room(room.code)
|
||||
assert supervisor.by_token(bob.token) is room # registry still holds it
|
||||
assert supervisor.by_token(ada.token) is room
|
||||
|
||||
clock.advance(1.0)
|
||||
await supervisor.gc_pass() # drops the closed room, purges both tokens
|
||||
assert supervisor.get(room.code) is None
|
||||
assert supervisor.by_token(bob.token) is None
|
||||
assert supervisor.by_token(ada.token) is None
|
||||
assert supervisor.stats()["tokens"] == 0
|
||||
|
||||
# a merely disconnected seat in a *live* room keeps its index entry
|
||||
live = supervisor.create_room(new_session("Cy", clock), "Cy")
|
||||
supervisor.mark_disconnected(live.code, live.seats[0].token)
|
||||
clock.advance(5.0)
|
||||
await supervisor.gc_pass()
|
||||
assert supervisor.by_token(live.seats[0].token) is live
|
||||
|
||||
|
||||
async def test_gc_loop_runs_on_the_injected_cadence_and_is_cancellable() -> None:
|
||||
clock = FakeClock()
|
||||
loop_task: dict[asyncio.Task[None], None] = {}
|
||||
|
||||
async def stopper_sleep(seconds: float) -> None:
|
||||
assert seconds == GC_INTERVAL_S
|
||||
clock.advance(seconds)
|
||||
if clock.now() >= 3 * GC_INTERVAL_S:
|
||||
next(iter(loop_task)).cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
supervisor = RoomSupervisor(
|
||||
Settings(), clock, empty_bundle(), sleep=stopper_sleep
|
||||
)
|
||||
loop_task[asyncio.create_task(supervisor.gc_loop())] = None
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await next(iter(loop_task))
|
||||
assert clock.now() == 3 * GC_INTERVAL_S # exactly three 5-second cycles
|
||||
Reference in New Issue
Block a user