Files
musicmouse/python-backend/tests/test_tippen_progress.py
Martin Bauer b6342cc117 Drop the pearls currency
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>
2026-09-19 18:05:08 +02:00

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