Files
musicmouse/python-backend/tests/test_tippen_api.py
Martin Bauer b6342cc117 Drop the pearls currency
Pearls were earned every run and spent on nothing: the aquarium fills up by
finishing worlds, not by paying for it. A counter that only ever goes up is one
more stat competing for attention on the result sheet and the home screen, and
one more field to carry through the run payload, the progress file and both test
suites.

Stars and the animal ladder already say how a run went, so nothing is lost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:05:08 +02:00

246 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,
"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*")) == []