Files
musicmouse/python-backend/tests/test_tippen_api.py
Martin Bauer f7a5d24d8d Add backend support for the typing game: curriculum, progress, and reward unlocks
Moves the typing app's lesson plan and progress from client-side YAML/localStorage
into the backend, and adds a reward system that ties passing a lesson to unlocking
part of the music library. Lock state is always recomputed live from curriculum x
progress x the live library, never persisted separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 21:03:45 +02:00

248 lines
8.5 KiB
Python

"""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,
"pearls": 5,
"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
assert body["pearls"] == 0
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*")) == []