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>
230 lines
7.7 KiB
Python
230 lines
7.7 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, 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
|