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*")) == []
|
||||
152
python-backend/tests/test_tippen_curriculum.py
Normal file
152
python-backend/tests/test_tippen_curriculum.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Loading and validating the typing game's curriculum file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from musicmouse.tippen.curriculum import CurriculumError, load_curriculum
|
||||
|
||||
VALID: dict = {
|
||||
"worlds": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Riff",
|
||||
"emoji": "🐠",
|
||||
"reward": "clownfish",
|
||||
"lessons": [
|
||||
{"title": "A und S", "subtitle": "Die Startfinger", "keys": ["a", "s"]},
|
||||
{
|
||||
"title": "Wörter",
|
||||
"subtitle": "Kurze Wörter",
|
||||
"kind": "words",
|
||||
"words": ["as", "sass"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"title": "Höhle",
|
||||
"emoji": "🐙",
|
||||
"reward": "octopus",
|
||||
"lessons": [{"title": "D und F", "subtitle": "Weiter", "keys": ["d", "f"]}],
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"title": "Riff2",
|
||||
"emoji": "🦑",
|
||||
"reward": "seahorse",
|
||||
"lessons": [{"title": "J und K", "subtitle": "Weiter", "keys": ["j", "k"]}],
|
||||
},
|
||||
{
|
||||
"number": 4,
|
||||
"title": "Riff3",
|
||||
"emoji": "🐳",
|
||||
"reward": "turtle",
|
||||
"lessons": [{"title": "L", "subtitle": "Weiter", "keys": ["l"]}],
|
||||
},
|
||||
{
|
||||
"number": 5,
|
||||
"title": "Riff4",
|
||||
"emoji": "🧜",
|
||||
"reward": "pearlmussel",
|
||||
"lessons": [{"title": "Ö", "subtitle": "Weiter", "keys": ["ö"]}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _write(directory: Path, data: dict, name: str = "curriculum.yaml") -> Path:
|
||||
path = directory / name
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
YAML(typ="safe").dump(data, handle)
|
||||
return path
|
||||
|
||||
|
||||
def test_a_valid_curriculum_loads(tmp_path: Path) -> None:
|
||||
curriculum = load_curriculum(_write(tmp_path, VALID))
|
||||
assert [w.reward for w in curriculum.worlds] == [
|
||||
"clownfish",
|
||||
"octopus",
|
||||
"seahorse",
|
||||
"turtle",
|
||||
"pearlmussel",
|
||||
]
|
||||
assert len(curriculum.lessons) == 6
|
||||
assert curriculum.lessons[0].id == "l01"
|
||||
assert curriculum.lessons[0].new_keys == ("a", "s")
|
||||
assert curriculum.lessons[1].kind == "words"
|
||||
assert curriculum.lessons[1].words == ("as", "sass")
|
||||
|
||||
|
||||
def test_missing_file_is_a_curriculum_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CurriculumError, match="Cannot read"):
|
||||
load_curriculum(tmp_path / "no-such-file.yaml")
|
||||
|
||||
|
||||
def test_not_yaml_is_a_curriculum_error(tmp_path: Path) -> None:
|
||||
path = tmp_path / "curriculum.yaml"
|
||||
path.write_text("worlds: [this is not: valid: yaml", encoding="utf-8")
|
||||
with pytest.raises(CurriculumError, match="not valid YAML"):
|
||||
load_curriculum(path)
|
||||
|
||||
|
||||
def test_unknown_key_is_rejected(tmp_path: Path) -> None:
|
||||
data = {"worlds": VALID["worlds"], "extra_top_level_key": True}
|
||||
with pytest.raises(CurriculumError, match="extra_top_level_key"):
|
||||
load_curriculum(_write(tmp_path, data))
|
||||
|
||||
|
||||
def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None:
|
||||
data = {"worlds": VALID["worlds"][:4]}
|
||||
with pytest.raises(CurriculumError, match="expected 5 worlds, found 4"):
|
||||
load_curriculum(_write(tmp_path, data))
|
||||
|
||||
|
||||
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
# Two independent problems in one file: a reused reward, and a lesson with too many
|
||||
# keys - both should show up in a single error, not just the first one found.
|
||||
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
||||
worlds[2] = {
|
||||
**worlds[2],
|
||||
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}],
|
||||
}
|
||||
with pytest.raises(CurriculumError) as excinfo:
|
||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
message = str(excinfo.value)
|
||||
assert "2 problems" in message
|
||||
assert "reuses reward" in message
|
||||
assert "at most two keys" in message
|
||||
|
||||
|
||||
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
worlds[0] = {
|
||||
**worlds[0],
|
||||
"lessons": [{"title": "x", "subtitle": "x", "words": ["hallo"]}],
|
||||
}
|
||||
with pytest.raises(CurriculumError, match="has words but no explicit kind"):
|
||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
|
||||
|
||||
def test_non_letters_kind_needs_words(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
worlds[0] = {
|
||||
**worlds[0],
|
||||
"lessons": [{"title": "x", "subtitle": "x", "kind": "words"}],
|
||||
}
|
||||
with pytest.raises(CurriculumError, match='kind "words" needs a non-empty words list'):
|
||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
|
||||
|
||||
def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
lessons = [dict(lesson) for lesson in worlds[0]["lessons"]]
|
||||
lessons[0] = {**lessons[0], "unlocks": "~/Music/Musik/Album/05Track.mp3"}
|
||||
worlds[0] = {**worlds[0], "lessons": lessons}
|
||||
curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3"
|
||||
assert curriculum.lessons[1].unlocks is None
|
||||
229
python-backend/tests/test_tippen_progress.py
Normal file
229
python-backend/tests/test_tippen_progress.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""Typing-progress persistence and the ``record_run`` bookkeeping it drives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from musicmouse.tippen.curriculum import Curriculum, Lesson, World
|
||||
from musicmouse.tippen.progress import (
|
||||
DILIGENCE_ATTEMPTS,
|
||||
RunResult,
|
||||
Stroke,
|
||||
fresh_progress,
|
||||
load_progress,
|
||||
record_run,
|
||||
save_progress,
|
||||
)
|
||||
|
||||
|
||||
def _lesson(id: str, world: int, number: int, **overrides: object) -> Lesson:
|
||||
base: dict = {
|
||||
"id": id,
|
||||
"world": world,
|
||||
"number": number,
|
||||
"title": id,
|
||||
"subtitle": "",
|
||||
"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": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return Lesson(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _curriculum() -> Curriculum:
|
||||
lessons = (
|
||||
_lesson("l01", 1, 1),
|
||||
_lesson("l02", 1, 2, unlocks="Musik/Album/00.mp3"),
|
||||
_lesson("l03", 2, 1),
|
||||
)
|
||||
worlds = (
|
||||
World(number=1, title="Riff", emoji="🐠", reward="clownfish"),
|
||||
World(number=2, title="Höhle", emoji="🐙", reward="octopus"),
|
||||
)
|
||||
return Curriculum(worlds=worlds, lessons=lessons)
|
||||
|
||||
|
||||
def _result(*, stars: int, passed: bool, points: float = 10.0, pearls: int = 3) -> RunResult:
|
||||
return RunResult(
|
||||
stars=stars, # type: ignore[arg-type]
|
||||
animal="fish",
|
||||
points=points,
|
||||
passed=passed,
|
||||
pearls=pearls,
|
||||
strokes=(Stroke(key="a", expected="a", correct=True, at=0.0),),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------ persistence
|
||||
|
||||
|
||||
def test_fresh_progress_unlocks_only_the_first_lesson() -> None:
|
||||
progress = fresh_progress(_curriculum())
|
||||
assert progress.lessons["l01"].unlocked is True
|
||||
assert progress.lessons["l02"].unlocked is False
|
||||
assert progress.lessons["l03"].unlocked is False
|
||||
|
||||
|
||||
def test_a_missing_file_is_a_fresh_start(tmp_path: Path) -> None:
|
||||
progress = load_progress(tmp_path / "no-such-file.json", _curriculum())
|
||||
assert progress == fresh_progress(_curriculum())
|
||||
|
||||
|
||||
def test_a_corrupt_file_is_a_fresh_start_not_a_crash(tmp_path: Path) -> None:
|
||||
path = tmp_path / "progress.json"
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
progress = load_progress(path, _curriculum())
|
||||
assert progress == fresh_progress(_curriculum())
|
||||
|
||||
|
||||
def test_save_then_load_round_trips(tmp_path: Path) -> None:
|
||||
path = tmp_path / "progress.json"
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
outcome = record_run(
|
||||
progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01"
|
||||
)
|
||||
|
||||
save_progress(path, outcome.progress)
|
||||
reloaded = load_progress(path, curriculum)
|
||||
|
||||
assert reloaded.lessons["l01"].best_stars == 3
|
||||
assert reloaded.lessons["l02"].unlocked is True
|
||||
assert reloaded.pearls == 3
|
||||
# Atomic write leaves no temp file behind.
|
||||
assert list(tmp_path.glob("*.tmp*")) == []
|
||||
|
||||
|
||||
def test_a_lesson_added_to_the_curriculum_since_the_last_save_gets_an_entry(tmp_path: Path) -> None:
|
||||
path = tmp_path / "progress.json"
|
||||
small = Curriculum(
|
||||
worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),),
|
||||
lessons=(_lesson("l01", 1, 1),),
|
||||
)
|
||||
save_progress(path, fresh_progress(small))
|
||||
|
||||
grown = _curriculum()
|
||||
reloaded = load_progress(path, grown)
|
||||
assert "l02" in reloaded.lessons
|
||||
assert "l03" in reloaded.lessons
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- earning
|
||||
|
||||
|
||||
def test_two_stars_unlocks_the_next_lesson() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
outcome = record_run(
|
||||
progress, "l01", _result(stars=2, passed=True), curriculum, day="2026-01-01"
|
||||
)
|
||||
assert outcome.newly_earned is True
|
||||
assert outcome.unlocked_lesson_id == "l02"
|
||||
assert outcome.progress.lessons["l02"].unlocked is True
|
||||
|
||||
|
||||
def test_one_star_does_not_unlock_the_next_lesson() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
outcome = record_run(
|
||||
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
|
||||
)
|
||||
assert outcome.newly_earned is False
|
||||
assert outcome.unlocked_lesson_id is None
|
||||
|
||||
|
||||
def test_the_diligence_attempt_unlocks_regardless_of_score() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
for _ in range(DILIGENCE_ATTEMPTS - 1):
|
||||
outcome = record_run(
|
||||
progress, "l01", _result(stars=0, passed=False), curriculum, day="2026-01-01"
|
||||
)
|
||||
progress = outcome.progress
|
||||
assert outcome.newly_earned is False
|
||||
|
||||
outcome = record_run(
|
||||
progress, "l01", _result(stars=0, passed=False), curriculum, day="2026-01-01"
|
||||
)
|
||||
assert outcome.progress.lessons["l01"].runs == DILIGENCE_ATTEMPTS
|
||||
assert outcome.newly_earned is True
|
||||
assert outcome.unlocked_lesson_id == "l02"
|
||||
|
||||
|
||||
def test_a_replay_after_already_earned_does_not_re_unlock() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
first = record_run(progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01")
|
||||
assert first.unlocked_lesson_id == "l02"
|
||||
|
||||
again = record_run(
|
||||
first.progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-02"
|
||||
)
|
||||
assert again.newly_earned is False
|
||||
assert again.unlocked_lesson_id is None
|
||||
|
||||
|
||||
def test_best_score_only_improves() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
good = record_run(
|
||||
progress, "l01", _result(stars=3, passed=True, points=100), curriculum, day="2026-01-01"
|
||||
)
|
||||
worse = record_run(
|
||||
good.progress, "l01", _result(stars=1, passed=False, points=5), curriculum, day="2026-01-02"
|
||||
)
|
||||
assert worse.progress.lessons["l01"].best_stars == 3
|
||||
assert worse.progress.lessons["l01"].best_points == 100
|
||||
assert worse.is_new_best is False
|
||||
|
||||
|
||||
def test_finishing_a_world_awards_its_creature_exactly_once() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
# Pass l01 (does not cross a world boundary: l02 is still world 1).
|
||||
step1 = record_run(progress, "l01", _result(stars=3, passed=True), curriculum, day="2026-01-01")
|
||||
assert step1.new_creature is None
|
||||
|
||||
# Pass l02: the next lesson (l03) is world 2, so this crosses the boundary.
|
||||
step2 = record_run(
|
||||
step1.progress, "l02", _result(stars=3, passed=True), curriculum, day="2026-01-02"
|
||||
)
|
||||
assert step2.new_creature == "clownfish"
|
||||
assert step2.progress.aquarium == ["clownfish"]
|
||||
|
||||
# Replaying l02 after l03 is already unlocked must never award it twice.
|
||||
step3 = record_run(
|
||||
step2.progress, "l02", _result(stars=3, passed=True), curriculum, day="2026-01-03"
|
||||
)
|
||||
assert step3.new_creature is None
|
||||
assert step3.progress.aquarium == ["clownfish"]
|
||||
|
||||
|
||||
def test_pearls_and_streak_accumulate() -> None:
|
||||
curriculum = _curriculum()
|
||||
progress = fresh_progress(curriculum)
|
||||
day1 = record_run(
|
||||
progress, "l01", _result(stars=1, passed=False, pearls=3), curriculum, day="2026-01-01"
|
||||
)
|
||||
day2 = record_run(
|
||||
day1.progress, "l01", _result(stars=1, passed=False, pearls=4), curriculum, day="2026-01-02"
|
||||
)
|
||||
assert day2.progress.pearls == 7
|
||||
assert day2.progress.streak.days == 2
|
||||
assert day2.progress.streak.last_played == "2026-01-02"
|
||||
|
||||
# A missed day (skip to 2026-01-05) restarts the streak at 1, not 0.
|
||||
day5 = record_run(
|
||||
day2.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-05"
|
||||
)
|
||||
assert day5.progress.streak.days == 1
|
||||
219
python-backend/tests/test_tippen_rewards.py
Normal file
219
python-backend/tests/test_tippen_rewards.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Resolving a lesson's ``unlocks:`` against the library, and the lock state it drives.
|
||||
|
||||
Music/audiobook albums are one folder with many tracks; podcasts are one album *per
|
||||
episode file*, grouped by show - see ``library/sections.py``. The two need different
|
||||
math (an ordinal track index vs. a chronological episode index across many albums), so
|
||||
both are exercised here against a real scanned library, not a hand-built one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.library import MusicLibrary
|
||||
from musicmouse.tippen.curriculum import Curriculum, Lesson, World
|
||||
from musicmouse.tippen.progress import LessonProgress, TypingProgress
|
||||
from musicmouse.tippen.rewards import compute_lock_state, resolve_lesson_reward
|
||||
from tests.conftest import write_track
|
||||
|
||||
|
||||
def _lesson(id: str, *, unlocks: str | None) -> Lesson:
|
||||
return Lesson(
|
||||
id=id,
|
||||
world=1,
|
||||
number=1,
|
||||
title=id,
|
||||
subtitle="",
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def library(tmp_path: Path) -> MusicLibrary:
|
||||
root = tmp_path / "music"
|
||||
|
||||
for index in range(6):
|
||||
write_track(
|
||||
root / "Musik" / "Kinderparty - Kinderparty Lieder" / f"{index:02d} - lied.mp3",
|
||||
title=f"Lied {index}",
|
||||
album="Kinderparty Lieder",
|
||||
albumartist="Kinderparty",
|
||||
)
|
||||
|
||||
# An album no lesson ever targets - proves "never referenced, never locked".
|
||||
for index in range(2):
|
||||
write_track(
|
||||
root / "Hörbücher" / "Conni - Conni in den Bergen" / f"{index:02d} - teil.mp3",
|
||||
title=f"Teil {index}",
|
||||
album="Conni in den Bergen",
|
||||
albumartist="Conni",
|
||||
)
|
||||
|
||||
show = root / "Kinderpodcasts" / "Wissen macht Ah"
|
||||
# Created out of chronological order on purpose, to prove the *filename date*, not
|
||||
# creation order or directory listing order, decides "chronological".
|
||||
for date, title in (("20260301", "Dritte"), ("20260101", "Erste"), ("20260201", "Zweite")):
|
||||
write_track(
|
||||
show / f"{date} - {title}.mp3",
|
||||
title=title,
|
||||
album="Wissen macht Ah! - Podcast",
|
||||
albumartist="Various",
|
||||
)
|
||||
|
||||
return await MusicLibrary.build(root, tmp_path / ".cache", frozenset({".mp3"}))
|
||||
|
||||
|
||||
def _album(library: MusicLibrary, title: str):
|
||||
return next(a for a in library.albums if a.title == title)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- resolving
|
||||
|
||||
|
||||
async def test_a_music_track_resolves_to_its_ordinal_index(library: MusicLibrary) -> None:
|
||||
album = _album(library, "Kinderparty Lieder")
|
||||
target = album.tracks[2].path
|
||||
lesson = _lesson("l1", unlocks=str(target))
|
||||
|
||||
reward = resolve_lesson_reward(lesson, library)
|
||||
|
||||
assert reward is not None
|
||||
assert reward.kind == "tracks"
|
||||
assert reward.album_id == album.id
|
||||
assert reward.until_index == 2
|
||||
|
||||
|
||||
async def test_a_relative_path_resolves_against_the_library_root(library: MusicLibrary) -> None:
|
||||
lesson = _lesson("l1", unlocks="Musik/Kinderparty - Kinderparty Lieder/02 - lied.mp3")
|
||||
reward = resolve_lesson_reward(lesson, library)
|
||||
assert reward is not None
|
||||
assert reward.until_index == 2
|
||||
|
||||
|
||||
async def test_an_unresolvable_path_is_none_not_a_crash(
|
||||
library: MusicLibrary, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
lesson = _lesson("l1", unlocks="Musik/Nope/Nothing.mp3")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
reward = resolve_lesson_reward(lesson, library)
|
||||
assert reward is None
|
||||
assert "matches no track" in caplog.text
|
||||
|
||||
|
||||
async def test_a_lesson_with_no_unlocks_resolves_to_none(library: MusicLibrary) -> None:
|
||||
assert resolve_lesson_reward(_lesson("l1", unlocks=None), library) is None
|
||||
|
||||
|
||||
async def test_a_podcast_episode_resolves_to_its_chronological_index(library: MusicLibrary) -> None:
|
||||
zweite = next(t for t in library.albums if t.title == "Zweite")
|
||||
lesson = _lesson("l1", unlocks=str(zweite.tracks[0].path))
|
||||
|
||||
reward = resolve_lesson_reward(lesson, library)
|
||||
|
||||
assert reward is not None
|
||||
assert reward.kind == "episode"
|
||||
assert reward.series == "Wissen macht Ah"
|
||||
# Erste (2026-01-01) = 0, Zweite (2026-02-01) = 1, Dritte (2026-03-01) = 2 -
|
||||
# chronological by filename date, regardless of the creation order above.
|
||||
assert reward.until_index == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- locking
|
||||
|
||||
|
||||
def _curriculum(*lessons: Lesson) -> Curriculum:
|
||||
return Curriculum(
|
||||
worlds=(World(number=1, title="Riff", emoji="🐠", reward="clownfish"),), lessons=lessons
|
||||
)
|
||||
|
||||
|
||||
def _progress(**earned: bool) -> TypingProgress:
|
||||
"""`earned["l1"] = True` etc - built via best_stars, since `.earned` is derived."""
|
||||
lessons = {
|
||||
lesson_id: LessonProgress(unlocked=True, runs=1, best_stars=3 if is_earned else 0)
|
||||
for lesson_id, is_earned in earned.items()
|
||||
}
|
||||
return TypingProgress(lessons=lessons)
|
||||
|
||||
|
||||
async def test_an_album_no_lesson_targets_is_never_locked(library: MusicLibrary) -> None:
|
||||
curriculum = _curriculum()
|
||||
state = compute_lock_state(curriculum, library, _progress())
|
||||
conni = _album(library, "Conni in den Bergen")
|
||||
assert state.get(conni.id) is None
|
||||
|
||||
|
||||
async def test_an_unearned_music_lesson_locks_the_whole_album(library: MusicLibrary) -> None:
|
||||
album = _album(library, "Kinderparty Lieder")
|
||||
lesson = _lesson("l1", unlocks=str(album.tracks[2].path))
|
||||
state = compute_lock_state(_curriculum(lesson), library, _progress(l1=False))
|
||||
|
||||
lock = state.get(album.id)
|
||||
assert lock is not None
|
||||
assert lock.locked is True
|
||||
assert all(track.locked for track in lock.tracks)
|
||||
assert lock.tracks[0].hint is not None
|
||||
assert lock.tracks[0].hint.lesson_id == "l1"
|
||||
|
||||
|
||||
async def test_an_earned_music_lesson_partially_unlocks_the_album(library: MusicLibrary) -> None:
|
||||
album = _album(library, "Kinderparty Lieder")
|
||||
# l1 is passed and covers tracks 0-2; l2 covers further (0-4) but is not passed yet -
|
||||
# so track 5 is locked with no covering lesson at all, and tracks 3-4 are locked but
|
||||
# do have a hint, from the still-unearned l2.
|
||||
l1 = _lesson("l1", unlocks=str(album.tracks[2].path))
|
||||
l2 = _lesson("l2", unlocks=str(album.tracks[4].path))
|
||||
state = compute_lock_state(_curriculum(l1, l2), library, _progress(l1=True, l2=False))
|
||||
|
||||
lock = state.get(album.id)
|
||||
assert lock is not None
|
||||
assert lock.locked is False # some tracks are unlocked, so the cover shows
|
||||
assert [track.locked for track in lock.tracks] == [False, False, False, True, True, True]
|
||||
assert lock.tracks[0].hint is None # already unlocked, no hint needed
|
||||
assert lock.tracks[3].hint is not None
|
||||
assert lock.tracks[3].hint.lesson_id == "l2"
|
||||
assert lock.tracks[5].hint is None # nothing configured unlocks track 5 at all
|
||||
|
||||
|
||||
async def test_two_lessons_targeting_the_same_album_take_the_max_earned_range(
|
||||
library: MusicLibrary,
|
||||
) -> None:
|
||||
album = _album(library, "Kinderparty Lieder")
|
||||
early = _lesson("l1", unlocks=str(album.tracks[1].path))
|
||||
late = _lesson("l2", unlocks=str(album.tracks[4].path))
|
||||
# Only the earlier lesson passed: unlocked through track 1.
|
||||
state = compute_lock_state(_curriculum(early, late), library, _progress(l1=True, l2=False))
|
||||
assert [t.locked for t in state.get(album.id).tracks] == [False, False, True, True, True, True] # type: ignore[union-attr]
|
||||
|
||||
# Both passed: unlocked through track 4 (the later lesson's own range wins).
|
||||
state = compute_lock_state(_curriculum(early, late), library, _progress(l1=True, l2=True))
|
||||
locks = [t.locked for t in state.get(album.id).tracks] # type: ignore[union-attr]
|
||||
assert locks == [False, False, False, False, False, True]
|
||||
|
||||
|
||||
async def test_podcast_unlock_is_chronological_not_per_episode(library: MusicLibrary) -> None:
|
||||
zweite = _album(library, "Zweite")
|
||||
dritte = _album(library, "Dritte")
|
||||
l1 = _lesson("l1", unlocks=str(zweite.tracks[0].path)) # earned: unlocks through Zweite
|
||||
l2 = _lesson("l2", unlocks=str(dritte.tracks[0].path)) # not earned yet: covers Dritte
|
||||
state = compute_lock_state(_curriculum(l1, l2), library, _progress(l1=True, l2=False))
|
||||
|
||||
erste = _album(library, "Erste")
|
||||
assert state.get(erste.id).locked is False # type: ignore[union-attr]
|
||||
assert state.get(zweite.id).locked is False # type: ignore[union-attr]
|
||||
assert state.get(dritte.id).locked is True # type: ignore[union-attr]
|
||||
assert state.get(dritte.id).tracks[0].hint.lesson_id == "l2" # type: ignore[union-attr]
|
||||
Reference in New Issue
Block a user