Add backend support for the typing game: curriculum, progress, and reward unlocks
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>
This commit is contained in:
229
python-backend/tests/test_tippen_progress.py
Normal file
229
python-backend/tests/test_tippen_progress.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user