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:
2026-10-08 05:17:21 -04:00
parent 79b7f1e4e0
commit f51ba919af
35 changed files with 8391 additions and 32 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ pytestmark = pytest.mark.e2e
# Strings a player sees, kept in sync with app/static/index.html and
# app/static/js/app.js by hand: changing one must be a deliberate act.
READY = "Pick a kitchen to start."
NOT_IMPLEMENTED = "Not implemented until phase 05."
NOT_IMPLEMENTED = "Not implemented until phase 07."
SHORT_CODE = "A room code is 5 characters."
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
@@ -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
+815
View File
@@ -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
+118
View File
@@ -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()
+695
View File
@@ -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
+312
View File
@@ -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
+443
View File
@@ -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
+282
View File
@@ -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
+755
View File
@@ -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
+475
View File
@@ -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
+57
View File
@@ -0,0 +1,57 @@
"""Unit: `Clock`, `SystemClock` and `FakeClock` — the injectable time contract.
`FakeClock` is the test clock every time-dependent server object is driven
with (phase 05: "no test sleeps — all time via `FakeClock`"); these tests
lock its behaviour so that contract is honest.
"""
from __future__ import annotations
import pytest
from undercooked.server.clock import Clock, FakeClock, SystemClock
pytestmark = pytest.mark.unit
def test_system_clock_reads_a_monotonic_clock() -> None:
clock = SystemClock()
first = clock.now()
second = clock.now()
assert second >= first
def test_system_clock_accepts_a_substitute_time_function() -> None:
values = iter([1.0, 2.5, 7.25])
clock = SystemClock(lambda: next(values))
assert clock.now() == 1.0
assert clock.now() == 2.5
assert clock.now() == 7.25
def test_fake_clock_starts_at_the_given_instant() -> None:
assert FakeClock().now() == 0.0
assert FakeClock(start=12.5).now() == 12.5
def test_fake_clock_advances_and_reports_the_new_instant() -> None:
clock = FakeClock(start=10.0)
assert clock.advance(5.0) == 15.0
assert clock.now() == 15.0
assert clock.advance(0.5) == 15.5
assert clock.advance(0) == 15.5
def test_fake_clock_never_goes_backwards() -> None:
clock = FakeClock()
with pytest.raises(ValueError, match="cannot go backwards"):
clock.advance(-1.0)
assert clock.now() == 0.0
def test_both_clocks_satisfy_the_protocol() -> None:
"""The `Clock` protocol is a structural contract: both implementations
must be usable anywhere a `Clock` is demanded."""
clocks: list[Clock] = [SystemClock(), FakeClock()]
for clock in clocks:
assert isinstance(clock.now(), float)
+194
View File
@@ -0,0 +1,194 @@
"""Unit: the Crockford code alphabet, minting and input normalisation (A15).
`random.Random` seeds are deliberate: the minting contract is *determinism
given a seed*, so the suite re-plays the same draws. The production path
(`rng=None`) is exercised for its shape, not its bytes.
"""
from __future__ import annotations
import random
import pytest
from undercooked.server.codes import (
CODE_LEN,
CROCKFORD,
MAX_MINT_RETRIES,
CodeSpaceFull,
new_code,
normalize_code,
)
pytestmark = pytest.mark.unit
EXPECTED_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
# ------------------------------------------------------------------------ alphabet
def test_the_alphabet_is_crockford_base32_excluding_ilo_u() -> None:
assert len(CROCKFORD) == 32
assert set(CROCKFORD) == set(EXPECTED_ALPHABET)
assert len(set(CROCKFORD)) == 32 # no duplicate symbols
for excluded in "ILOU":
assert excluded not in CROCKFORD, f"{excluded} must be excluded"
for digit in "0123456789":
assert digit in CROCKFORD
def test_codes_are_five_characters_long() -> None:
assert CODE_LEN == 5
# ------------------------------------------------------------------------ minting
def test_twenty_thousand_minted_codes_are_unique_and_in_the_alphabet() -> None:
taken: set[str] = set()
rng = random.Random(0x5EED_0401) # noqa: S311 - seeded, deliberate test draws
for _ in range(20_000):
code = new_code(taken, rng)
taken.add(code)
assert len(taken) == 20_000
for code in taken:
assert len(code) == CODE_LEN
assert set(code) <= set(CROCKFORD)
sample = min(taken)
assert "I" not in sample and "L" not in sample and "O" not in sample and "U" not in sample
def test_new_code_retries_a_collision_until_the_draw_is_free() -> None:
rng = random.Random(7) # noqa: S311 - seeded, deliberate test draws
first = new_code(set(), rng)
second = new_code({first}, rng)
assert second != first
assert second not in {first}
assert len(second) == CODE_LEN
def test_a_deterministic_seed_mints_a_deterministic_first_code() -> None:
"""The same seed must mint the same first code — the A4/A5 determinism
contract, applied to the minting draws."""
a = new_code(set(), random.Random(42)) # noqa: S311 - seeded, deliberate
b = new_code(set(), random.Random(42)) # noqa: S311 - seeded, deliberate
assert a == b
assert len(a) == CODE_LEN
assert set(a) <= set(CROCKFORD)
class _AlwaysTaken:
def __contains__(self, _code: str) -> bool:
return True
class _CountingRandom:
"""A `random.Random` lookalike that counts draws and always collides."""
def __init__(self) -> None:
self.choice_calls = 0
def choice(self, _sequence: str) -> str:
self.choice_calls += 1
return "A"
def test_a_full_space_raises_code_space_full_after_exactly_100_draws() -> None:
rng = _CountingRandom()
with pytest.raises(CodeSpaceFull):
new_code(_AlwaysTaken(), rng) # type: ignore[arg-type] - duck-typed lookalike
assert rng.choice_calls == MAX_MINT_RETRIES * CODE_LEN
def test_code_space_full_names_the_retries_in_its_message() -> None:
with pytest.raises(CodeSpaceFull, match="100 draws all collided"):
new_code(_AlwaysTaken())
def test_production_minting_without_an_rng_yields_a_legal_code() -> None:
code = new_code(set()) # secrets.choice path
assert len(code) == CODE_LEN
assert set(code) <= set(CROCKFORD)
# ------------------------------------------------------------------------ normalisation
@pytest.mark.parametrize(
("raw", "expected"),
[
("ABCDE", "ABCDE"),
("abcde", "ABCDE"),
("aBcDe", "ABCDE"),
("a b c d e", "ABCDE"),
("A B-C D-E", "ABCDE"),
(" AB\tCD E ", "ABCDE"),
("12345", "12345"),
("99999", "99999"),
("VWXYZ", "VWXYZ"),
],
)
def test_normalize_accepts_legal_codes_with_case_and_separators(
raw: str, expected: str
) -> None:
assert normalize_code(raw) == expected
@pytest.mark.parametrize(
("raw", "expected"),
[
("i2345", "12345"),
("I2345", "12345"),
("l2345", "12345"),
("L2345", "12345"),
("o2345", "02345"),
("O2345", "02345"),
("u2345", "V2345"),
("U2345", "V2345"),
],
)
def test_normalize_maps_every_excluded_confusable_to_its_intended_symbol(
raw: str, expected: str
) -> None:
assert normalize_code(raw) == expected
@pytest.mark.parametrize(
"raw",
[
"",
"A",
"AB",
"ABC", # too short
"ABCDEF",
"ABCDEFG", # too long
"AB12CD\n", # six characters after the newline is stripped
"ABC?E", # the question mark is not an alphabet character
"AB.CD", # the dot is not a separator: it stays, and it is invalid
"AB-CD", # the dash is stripped, leaving four characters
"I", # maps to `1`, which is still one character, not five
"U", # maps to `V`, still one character
"O", # maps to `0`, still one character
"L", # maps to `1`, still one character
"?????",
],
)
def test_normalize_rejects_wrong_lengths_and_invalid_characters(raw: str) -> None:
assert normalize_code(raw) is None
@pytest.mark.parametrize("raw", ["ABCDE\n", "ABCDE ", "ABC DE"])
def test_normalize_strips_whitespace_without_making_the_code_illegal(raw: str) -> None:
"""Whitespace is stripped, not fatal: `ABCDE\n` and `ABCDE ` are still
five-character codes, and `ABC DE` normalises to `ABCDE`."""
assert normalize_code(raw) == "ABCDE"
def test_normalize_is_a_pure_format_check_never_a_registry_query() -> None:
"""The US-02 criterion-4 contract: the result depends on the input
alone, so a caller cannot tell "malformed" from "no such room"."""
for _ in range(5):
assert normalize_code("i2345") == "12345"
assert normalize_code("nope!") is None
assert normalize_code("ABCDE") == normalize_code("ABCDE")
+1
View File
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/ROOMS.md",
"docs/SIMULATION.md",
"docs/TESTING.md",
}
+103
View File
@@ -0,0 +1,103 @@
"""Unit: the display-name boundary (phase 05, task 03; US-02 criterion 6).
`sanitize_name` is the one place a player-typed name becomes a stored name
(`server/seats.py`): strip Unicode control and format characters (zero-width
marks, RTL overrides, soft hyphen) and `<>"'`, collapse runs of whitespace,
trim, cap at 16; an empty result falls back to `Cook #{seat_index + 1}`.
Names are non-unique by design and are rendered by the client as text
(`textContent`, never HTML) — this table pins the shape of that boundary:
the output is always `len <= 16`, never contains `<`, `>` or `"`, and an
empty name names the seat.
"""
from __future__ import annotations
import pytest
from undercooked.server.rooms import sanitize_name as rooms_sanitize
from undercooked.server.seats import NAME_MAX_LEN, sanitize_name
pytestmark = pytest.mark.unit
def test_the_table_of_raw_to_stored_names() -> None:
"""The boundary, case by case: fallbacks, control/format stripping,
whitespace collapsing, the 16-char cap, and the non-ASCII keeps."""
cases: list[tuple[str | None, int, str]] = [
# The fallback: no name (or an empty one) names the seat.
(None, 0, "Cook #1"),
(None, 2, "Cook #3"),
("", 0, "Cook #1"),
(" \t\n ", 1, "Cook #2"),
# Plain names pass (whitespace runs collapse to one space).
("Ada", 0, "Ada"),
("Tom Jerry", 0, "Tom Jerry"),
(" Hello World ", 0, "Hello World"),
("Ünïcödé ☂", 0, "Ünïcödé ☂"),
# Control characters (Cc) are stripped, not collapsed: the newline
# between two letters disappears.
("A\nB\x00C\x07D", 0, "ABCD"),
("\tMi la \t", 0, "Mi la"),
# Markup (the angle/quote pair) is stripped — the `textContent`
# backstop.
("O'Brien", 0, "OBrien"),
('"quoted"', 0, "quoted"),
("<script>alert(1)</script>", 0, "scriptalert(1)/s"),
# The cap: 16 chars, trimmed after the cut.
("A" * 40, 0, "A" * 16),
("x" * 1000, 0, "x" * 16),
("a" * 15 + " b", 0, "a" * 15),
("a b " * 20, 0, "a b a b a b a b"),
("a" * 15 + "b", 0, "a" * 15 + "b"),
# Unicode format characters (Cf) are stripped: zero-width, RTL
# override, soft hyphen — a zero-width-only name is a fallback.
("\u202bHELLO", 0, "HELLO"),
("\u200b\u200b\u200b", 0, "Cook #1"),
("\u200bA\u200bB", 0, "AB"),
("Mi\u00adna", 0, "Mina"),
# Emoji and accents are kept (So / Ll), and the cap counts them.
("🍳🍅🧅🐟", 0, "🍳🍅🧅🐟"),
("🍳" * 20, 0, "🍳" * 16),
("Café ☕ ", 0, "Café ☕"),
]
for raw, seat_index, expected in cases:
assert sanitize_name(raw, seat_index) == expected, (raw, seat_index)
assert NAME_MAX_LEN == 16
def test_the_output_is_bounded_and_markup_free() -> None:
"""The shape contract, property-style, over the nasty inputs: the
output is always `len <= 16` and never carries `<`, `>` or `"` —
whatever the input tried to do."""
nasty = [
"<script>alert(1)</script>",
"<<<>>>>>>>",
'"',
"A" * 1000,
(" " * 500) + "x" * 500,
"\u200b" * 100,
"\u202b\u202c\u202d\u202e\u2066\u2067" + "Name" * 100,
"Mi\u00adna\u00ad\u00ad" * 10,
"<'\"\\>\x00\x01\x1f\x7f",
"é" * 500,
]
for raw in nasty:
out = sanitize_name(raw, 3)
assert len(out) <= 16, (raw[:40], out)
assert not {"<", ">", '"'} & set(out), (raw[:40], out)
def test_the_fallback_names_the_seat() -> None:
"""An empty (or control-only) name is `Cook #{seat_index + 1}` — the
client can always address the cook."""
assert sanitize_name(None, 0) == "Cook #1"
assert sanitize_name(None, 7) == "Cook #8"
assert sanitize_name(" \x00\t ", 4) == "Cook #5"
assert sanitize_name("\u200b\u202b", 9) == "Cook #10"
def test_rooms_reexports_the_same_boundary() -> None:
"""`server/rooms.py` keeps its task-01 export: the supervisor's
boundary and the seats' boundary are the one function (imported, not
duplicated)."""
assert rooms_sanitize is sanitize_name
@@ -0,0 +1,60 @@
"""Unit (grep-style): no wall-clock calls anywhere in `server/`.
The phase-05 contract (task 01, work item 1): every time-dependent server
object receives a `Clock`; the *only* file in `src/undercooked/server/`
allowed to import `time` is `clock.py`, and even there the clock function
is held as a reference — `time.monotonic()` / `time.time()` are never
called in server code. This test greps for the literals (and for stray
`time` imports) so a future edit cannot reintroduce a bare wall-clock call
without failing the suite.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SERVER_DIR = REPO_ROOT / "src" / "undercooked" / "server"
#: The wall-clock calls the contract forbids in server code.
FORBIDDEN_CALLS = ("time.monotonic()", "time.time()")
#: `time` may be imported by exactly one module.
TIME_IMPORT = re.compile(r"^\s*(?:import time\b|from time import\b)", re.MULTILINE)
def test_no_wall_clock_call_lives_in_server_code() -> None:
offenders = [
f"{path.name}: {literal}"
for path in sorted(SERVER_DIR.rglob("*.py"))
for literal in FORBIDDEN_CALLS
if literal in path.read_text(encoding="utf-8")
]
assert offenders == [], "wall-clock calls leaked into server/:\n " + "\n ".join(
offenders
)
def test_only_the_clock_module_imports_time() -> None:
offenders = [
path.name
for path in sorted(SERVER_DIR.rglob("*.py"))
if path.name != "clock.py" and TIME_IMPORT.search(path.read_text(encoding="utf-8"))
]
assert offenders == [], (
f"modules that import `time` outside clock.py: {', '.join(offenders)}"
)
def test_the_guard_is_not_vacuous() -> None:
"""A guard that never fires is no guard: prove both checks detect a
planted violation in this very file's patterns."""
assert FORBIDDEN_CALLS[0] in "x = time.monotonic()\n"
assert TIME_IMPORT.search("import time\n") is not None
assert SERVER_DIR.is_dir()
assert (SERVER_DIR / "clock.py").is_file()
+45
View File
@@ -0,0 +1,45 @@
"""Unit: session token minting and `SessionRecord` (A16).
The token is the player's only identity: 24 random bytes, urlsafe
base64 — the same entropy the browser side generates with
`crypto.getRandomValues`. These tests lock the shape that
`SessionStore` (task 04) builds on.
"""
from __future__ import annotations
import string
import pytest
from undercooked.server.clock import FakeClock
from undercooked.server.sessions import mint_token, new_session
pytestmark = pytest.mark.unit
_URLSAFE = set(string.ascii_letters + string.digits + "-_")
def test_mint_token_is_24_random_bytes_of_urlsafe_base64() -> None:
token = mint_token()
assert len(token) == 32 # 24 bytes -> 32 base64url characters
assert set(token) <= _URLSAFE
assert "+" not in token and "/" not in token and "=" not in token
def test_minted_tokens_are_distinct() -> None:
tokens = {mint_token() for _ in range(100)}
assert len(tokens) == 100
def test_new_session_mints_a_token_and_stamps_the_injected_clock() -> None:
clock = FakeClock(start=42.0)
session = new_session("Ada", clock)
assert len(session.token) == 32
assert session.display_name == "Ada"
assert session.created_at == 42.0
anonymous = new_session(None, clock)
assert anonymous.display_name is None
assert anonymous.token != session.token
assert anonymous.created_at == 42.0
+144
View File
@@ -0,0 +1,144 @@
"""Unit: the session's privacy surface (phase 05, task 04, US-07).
The store is anonymous by design (A16): the identity is a 24-byte
random token and *nothing else*. These tests lock the surface so a PII
field can never join without a test failing first:
* **the `SessionRecord` field set** — exactly the task's nine fields
(identity, name, progression, counters, stamps),
* **a `repr()` scan** — a representative record (and a representative
store) never carries `ip`, `user_agent`, `authorization` or `cookie`
(case-insensitive),
* **`SessionStore` has no attribute holding raw headers** — neither the
record's fields nor the store's attributes name a `header` of any
kind, and no dict-valued store attribute holds a header-like key.
"""
from __future__ import annotations
import dataclasses
import pytest
from undercooked.server.clock import FakeClock
from undercooked.server.sessions import BestRun, SessionRecord, SessionStore
pytestmark = pytest.mark.unit
#: The only fields a session record may hold (task 04's list).
ALLOWED_RECORD_FIELDS = frozenset(
{
"token",
"display_name",
"created_at",
"unlocked_level",
"best",
"rounds_played",
"dishes_served",
"last_seen",
"endless_best",
}
)
#: The strings a session's repr must never contain (the PII markers).
FORBIDDEN_REPR_SUBSTRINGS = ("ip", "user_agent", "authorization", "cookie")
#: A request-header key that must never appear in the store's own data.
HEADER_LIKE_KEYS = frozenset(
{"host", "user-agent", "authorization", "cookie", "x-forwarded-for"}
)
def _benign_record() -> SessionRecord:
"""A representative record with fixed, benign values (a random
base64url token could incidentally contain a marker; the test needs
a *guaranteed* shape, so the token is fixed too)."""
return SessionRecord(
token="t" * 32,
display_name="Ada",
created_at=1.0,
unlocked_level=2,
best={"01_kitchen": BestRun(score=100, stars=3, completed_at=2.0)},
rounds_played=4,
dishes_served=9,
last_seen=3.0,
endless_best=None,
)
def _benign_store() -> SessionStore:
"""A representative store: a `FakeClock`, one campaign entry, and one
record inside (so a walk of its data covers the record's shape)."""
store = SessionStore(
max_entries=10, clock=FakeClock(start=1.0), campaign={"01_kitchen": 1}
)
record = _benign_record()
store.put(record)
return store
def test_the_record_field_set_is_exactly_the_task_list() -> None:
fields = {field.name for field in dataclasses.fields(SessionRecord)}
assert fields == ALLOWED_RECORD_FIELDS
def test_the_record_repr_carrys_no_pii_markers() -> None:
text = repr(_benign_record()).lower()
for marker in FORBIDDEN_REPR_SUBSTRINGS:
assert marker not in text, f"the record's repr carries {marker!r}"
def test_the_store_repr_carrys_no_pii_markers() -> None:
text = repr(_benign_store()).lower()
for marker in FORBIDDEN_REPR_SUBSTRINGS:
assert marker not in text, f"the store's repr carries {marker!r}"
def test_the_record_and_the_best_run_are_slots_dataclasses() -> None:
"""Slots (not a `__dict__` bag) is the shape that keeps the field
set auditable: a new field is a named, visible change."""
assert hasattr(SessionRecord, "__slots__")
assert hasattr(BestRun, "__slots__")
def test_neither_record_nor_store_names_a_header_attribute() -> None:
record_fields = {field.name for field in dataclasses.fields(SessionRecord)}
assert not any("header" in name.lower() for name in record_fields)
store = _benign_store()
assert not any("header" in name.lower() for name in dir(store))
assert not any("header" in name.lower() for name in vars(store))
def test_the_store_holds_no_request_header_mapping() -> None:
"""Every dict-valued attribute of the store is either `token ->
record` or `level id -> order`: neither may hold a header-like key."""
store = _benign_store()
for name, value in vars(store).items():
if isinstance(value, dict):
assert not (
set(value) & HEADER_LIKE_KEYS
), f"store attribute {name!r} holds header-like keys"
record = _benign_record()
for field in dataclasses.fields(record):
assert field.name.lower() not in HEADER_LIKE_KEYS, f"record field {field.name!r}"
value = getattr(record, field.name)
if isinstance(value, dict):
assert not (
set(value) & HEADER_LIKE_KEYS
), f"record field {field.name!r} holds header-like keys"
def test_the_store_never_minted_with_headers_keeps_none() -> None:
"""A store built the way the supervisor builds it (a clock and the
campaign mapping only) exposes exactly its own five attributes — no
header bag can hide in a constructor that does not take one."""
store = SessionStore(
clock=FakeClock(), campaign={"01_kitchen": 1}
)
assert set(vars(store)) == {
"_max_entries",
"_clock",
"_campaign",
"_records",
"_evictions",
}