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>
248 lines
8.5 KiB
Python
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*")) == []
|