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