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>
This commit is contained in:
247
python-backend/tests/test_tippen_api.py
Normal file
247
python-backend/tests/test_tippen_api.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""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*")) == []
|
||||
Reference in New Issue
Block a user