"""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]