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