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:
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