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