"""The typing game's REST surface: curriculum, progress, settings, and run recording.""" from __future__ import annotations from collections.abc import AsyncIterator from pathlib import Path import httpx2 import pytest from musicmouse.config import WebConfig, load_config from musicmouse.services.web.service import build_app from musicmouse.simulator.harness import Simulation, build_simulation from musicmouse.tippen.curriculum import Curriculum, Lesson, World from musicmouse.tippen.progress import fresh_progress from musicmouse.tippen.runtime import TippenRuntime from tests.conftest import VALID_CONFIG, write_config def _lesson(id: str, *, unlocks: str | None = None) -> Lesson: return Lesson( id=id, world=1, number=1, title=f"Lektion {id}", subtitle="x", kind="letters", new_keys=(), spotlight_keys=(), emphasis=None, active_keys=(), primary_mode="dive", bonus_modes=(), words=(), is_drill=False, chunks=1, chunk_size=1, unlocks=unlocks, ) #: l02 unlocks the whole (two-track) "Kinderparty Lieder" album, via its last track. _CURRICULUM = Curriculum( worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),), lessons=( _lesson("l01"), _lesson("l02", unlocks="Musik/Kinderparty - Kinderparty Lieder/01 - lied.mp3"), ), ) def _run_body(lesson_id: str, *, stars: int = 3, passed: bool = True) -> dict: return { "lesson_id": lesson_id, "stars": stars, "animal": "fish", "points": 42.0, "passed": passed, "strokes": [{"key": "a", "expected": "a", "correct": True, "at": 0.0}], } @pytest.fixture async def sim(config_dir: Path, tmp_path: Path) -> AsyncIterator[Simulation]: config = load_config(write_config(config_dir, VALID_CONFIG)) tippen = TippenRuntime( curriculum=_CURRICULUM, progress_path=tmp_path / "tippen-progress.json", progress=fresh_progress(_CURRICULUM), ) simulation = await build_simulation(config, tippen=tippen) try: yield simulation finally: await simulation.aclose() @pytest.fixture async def sim_without_tippen(config_dir: Path) -> AsyncIterator[Simulation]: config = load_config(write_config(config_dir, VALID_CONFIG)) simulation = await build_simulation(config) try: yield simulation finally: await simulation.aclose() async def _client(simulation: Simulation, config_path: Path) -> httpx2.AsyncClient: api, hub = build_app(simulation.app, WebConfig(), config_path) hub.start() transport = httpx2.ASGITransport(app=api) http = httpx2.AsyncClient(transport=transport, base_url="http://mouse") http._musicmouse_hub = hub # type: ignore[attr-defined] http._musicmouse_api = api # type: ignore[attr-defined] return http @pytest.fixture async def client(sim: Simulation, config_dir: Path) -> AsyncIterator[httpx2.AsyncClient]: http = await _client(sim, config_dir / "config.yml") try: yield http finally: http._musicmouse_hub.stop() # type: ignore[attr-defined] await http._musicmouse_api.state.ha_client.aclose() # type: ignore[attr-defined] await http.aclose() @pytest.fixture async def client_without_tippen( sim_without_tippen: Simulation, config_dir: Path ) -> AsyncIterator[httpx2.AsyncClient]: http = await _client(sim_without_tippen, config_dir / "config.yml") try: yield http finally: http._musicmouse_hub.stop() # type: ignore[attr-defined] await http._musicmouse_api.state.ha_client.aclose() # type: ignore[attr-defined] await http.aclose() def _album_id(sim: Simulation, title: str) -> str: return next(a.id for a in sim.app.library.albums if a.title == title) # ----------------------------------------------------------------- unconfigured async def test_tippen_routes_are_a_404_when_unconfigured( client_without_tippen: httpx2.AsyncClient, ) -> None: assert (await client_without_tippen.get("/api/tippen/curriculum")).status_code == 404 assert (await client_without_tippen.get("/api/tippen/progress")).status_code == 404 response = await client_without_tippen.post("/api/tippen/runs", json=_run_body("l01")) assert response.status_code == 404 async def test_library_has_no_lock_fields_when_unconfigured( client_without_tippen: httpx2.AsyncClient, ) -> None: body = (await client_without_tippen.get("/api/library")).json() assert all(album["locked"] is False for album in body["albums"]) assert all(not track["locked"] for album in body["albums"] for track in album["tracks"]) # --------------------------------------------------------------------- curriculum async def test_get_curriculum_reports_resolved_and_unresolved_rewards( client: httpx2.AsyncClient, ) -> None: body = (await client.get("/api/tippen/curriculum")).json() lessons = {lesson["id"]: lesson for lesson in body["lessons"]} assert lessons["l01"]["reward"] == { "resolved": False, "album_id": None, "has_cover": False, "kind": None, } assert lessons["l02"]["reward"]["resolved"] is True assert lessons["l02"]["reward"]["kind"] == "tracks" # ------------------------------------------------------------------------ progress async def test_get_progress_is_fresh_with_only_the_first_lesson_unlocked( client: httpx2.AsyncClient, ) -> None: body = (await client.get("/api/tippen/progress")).json() assert body["lessons"]["l01"]["unlocked"] is True assert body["lessons"]["l02"]["unlocked"] is False async def test_put_settings_round_trips(client: httpx2.AsyncClient) -> None: response = await client.put( "/api/tippen/settings", json={"sound": False, "keyboard_hint": "off"} ) assert response.status_code == 200 assert response.json() == {"sound": False, "keyboard_hint": "off"} progress = (await client.get("/api/tippen/progress")).json() assert progress["settings"] == {"sound": False, "keyboard_hint": "off"} # ----------------------------------------------------------------------- run recording async def test_an_unknown_lesson_id_is_rejected(client: httpx2.AsyncClient) -> None: response = await client.post("/api/tippen/runs", json=_run_body("no-such-lesson")) assert response.status_code == 422 async def test_passing_a_lesson_unlocks_the_next_one(client: httpx2.AsyncClient) -> None: response = await client.post("/api/tippen/runs", json=_run_body("l01")) assert response.status_code == 200 body = response.json() assert body["unlocked_lesson_id"] == "l02" assert body["unlocked_lesson_title"] == "Lektion l02" assert body["progress"]["lessons"]["l02"]["unlocked"] is True # l01 carries no reward of its own. assert body["unlocked_reward"] is None async def test_passing_a_lesson_with_a_reward_reveals_it_and_unlocks_the_album( client: httpx2.AsyncClient, sim: Simulation ) -> None: response = await client.post("/api/tippen/runs", json=_run_body("l02")) assert response.status_code == 200 body = response.json() reward = body["unlocked_reward"] assert reward is not None assert reward["kind"] == "album" assert reward["title"] == "Lied 1" assert reward["album_id"] == _album_id(sim, "Kinderparty Lieder") # And the album is now fully unlocked in the library snapshot. library = (await client.get("/api/library")).json() album = next(a for a in library["albums"] if a["id"] == reward["album_id"]) assert album["locked"] is False assert all(not track["locked"] for track in album["tracks"]) async def test_a_locked_album_is_reported_as_locked_before_the_reward_is_earned( client: httpx2.AsyncClient, sim: Simulation ) -> None: library = (await client.get("/api/library")).json() album = next(a for a in library["albums"] if a["id"] == _album_id(sim, "Kinderparty Lieder")) assert album["locked"] is True assert all(track["locked"] for track in album["tracks"]) assert album["tracks"][0]["unlock_hint"]["lesson_id"] == "l02" async def test_progress_survives_a_rebuilt_runtime( client: httpx2.AsyncClient, sim: Simulation, tmp_path: Path ) -> None: await client.post("/api/tippen/runs", json=_run_body("l01")) assert sim.app.tippen is not None from musicmouse.tippen.progress import load_progress reloaded = load_progress(sim.app.tippen.progress_path, _CURRICULUM) assert reloaded.lessons["l02"].unlocked is True assert list(tmp_path.glob("*.tmp*")) == []