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>
227 lines
7.6 KiB
Python
227 lines
7.6 KiB
Python
"""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) -> RunResult:
|
|
return RunResult(
|
|
stars=stars, # type: ignore[arg-type]
|
|
animal="fish",
|
|
points=points,
|
|
passed=passed,
|
|
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
|
|
# 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_streak_accumulates() -> None:
|
|
curriculum = _curriculum()
|
|
progress = fresh_progress(curriculum)
|
|
day1 = record_run(
|
|
progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-01"
|
|
)
|
|
day2 = record_run(
|
|
day1.progress, "l01", _result(stars=1, passed=False), curriculum, day="2026-01-02"
|
|
)
|
|
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
|