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`.
756 lines
28 KiB
Python
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
|