Files
undercooked/tests/integration/test_sessions.py
T
ducoterra f51ba919af 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`.
2026-10-08 05:17:21 -04:00

756 lines
28 KiB
Python

"""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