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