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:
152
python-backend/tests/test_tippen_curriculum.py
Normal file
152
python-backend/tests/test_tippen_curriculum.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Loading and validating the typing game's curriculum file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from musicmouse.tippen.curriculum import CurriculumError, load_curriculum
|
||||
|
||||
VALID: dict = {
|
||||
"worlds": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Riff",
|
||||
"emoji": "🐠",
|
||||
"reward": "clownfish",
|
||||
"lessons": [
|
||||
{"title": "A und S", "subtitle": "Die Startfinger", "keys": ["a", "s"]},
|
||||
{
|
||||
"title": "Wörter",
|
||||
"subtitle": "Kurze Wörter",
|
||||
"kind": "words",
|
||||
"words": ["as", "sass"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"title": "Höhle",
|
||||
"emoji": "🐙",
|
||||
"reward": "octopus",
|
||||
"lessons": [{"title": "D und F", "subtitle": "Weiter", "keys": ["d", "f"]}],
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"title": "Riff2",
|
||||
"emoji": "🦑",
|
||||
"reward": "seahorse",
|
||||
"lessons": [{"title": "J und K", "subtitle": "Weiter", "keys": ["j", "k"]}],
|
||||
},
|
||||
{
|
||||
"number": 4,
|
||||
"title": "Riff3",
|
||||
"emoji": "🐳",
|
||||
"reward": "turtle",
|
||||
"lessons": [{"title": "L", "subtitle": "Weiter", "keys": ["l"]}],
|
||||
},
|
||||
{
|
||||
"number": 5,
|
||||
"title": "Riff4",
|
||||
"emoji": "🧜",
|
||||
"reward": "pearlmussel",
|
||||
"lessons": [{"title": "Ö", "subtitle": "Weiter", "keys": ["ö"]}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _write(directory: Path, data: dict, name: str = "curriculum.yaml") -> Path:
|
||||
path = directory / name
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
YAML(typ="safe").dump(data, handle)
|
||||
return path
|
||||
|
||||
|
||||
def test_a_valid_curriculum_loads(tmp_path: Path) -> None:
|
||||
curriculum = load_curriculum(_write(tmp_path, VALID))
|
||||
assert [w.reward for w in curriculum.worlds] == [
|
||||
"clownfish",
|
||||
"octopus",
|
||||
"seahorse",
|
||||
"turtle",
|
||||
"pearlmussel",
|
||||
]
|
||||
assert len(curriculum.lessons) == 6
|
||||
assert curriculum.lessons[0].id == "l01"
|
||||
assert curriculum.lessons[0].new_keys == ("a", "s")
|
||||
assert curriculum.lessons[1].kind == "words"
|
||||
assert curriculum.lessons[1].words == ("as", "sass")
|
||||
|
||||
|
||||
def test_missing_file_is_a_curriculum_error(tmp_path: Path) -> None:
|
||||
with pytest.raises(CurriculumError, match="Cannot read"):
|
||||
load_curriculum(tmp_path / "no-such-file.yaml")
|
||||
|
||||
|
||||
def test_not_yaml_is_a_curriculum_error(tmp_path: Path) -> None:
|
||||
path = tmp_path / "curriculum.yaml"
|
||||
path.write_text("worlds: [this is not: valid: yaml", encoding="utf-8")
|
||||
with pytest.raises(CurriculumError, match="not valid YAML"):
|
||||
load_curriculum(path)
|
||||
|
||||
|
||||
def test_unknown_key_is_rejected(tmp_path: Path) -> None:
|
||||
data = {"worlds": VALID["worlds"], "extra_top_level_key": True}
|
||||
with pytest.raises(CurriculumError, match="extra_top_level_key"):
|
||||
load_curriculum(_write(tmp_path, data))
|
||||
|
||||
|
||||
def test_wrong_world_count_is_one_problem(tmp_path: Path) -> None:
|
||||
data = {"worlds": VALID["worlds"][:4]}
|
||||
with pytest.raises(CurriculumError, match="expected 5 worlds, found 4"):
|
||||
load_curriculum(_write(tmp_path, data))
|
||||
|
||||
|
||||
def test_multiple_problems_are_all_reported_at_once(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
# Two independent problems in one file: a reused reward, and a lesson with too many
|
||||
# keys - both should show up in a single error, not just the first one found.
|
||||
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
||||
worlds[2] = {
|
||||
**worlds[2],
|
||||
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "d"]}],
|
||||
}
|
||||
with pytest.raises(CurriculumError) as excinfo:
|
||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
message = str(excinfo.value)
|
||||
assert "2 problems" in message
|
||||
assert "reuses reward" in message
|
||||
assert "at most two keys" in message
|
||||
|
||||
|
||||
def test_letters_kind_cannot_have_words(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
worlds[0] = {
|
||||
**worlds[0],
|
||||
"lessons": [{"title": "x", "subtitle": "x", "words": ["hallo"]}],
|
||||
}
|
||||
with pytest.raises(CurriculumError, match="has words but no explicit kind"):
|
||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
|
||||
|
||||
def test_non_letters_kind_needs_words(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
worlds[0] = {
|
||||
**worlds[0],
|
||||
"lessons": [{"title": "x", "subtitle": "x", "kind": "words"}],
|
||||
}
|
||||
with pytest.raises(CurriculumError, match='kind "words" needs a non-empty words list'):
|
||||
load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
|
||||
|
||||
def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None:
|
||||
worlds = [dict(w) for w in VALID["worlds"]]
|
||||
lessons = [dict(lesson) for lesson in worlds[0]["lessons"]]
|
||||
lessons[0] = {**lessons[0], "unlocks": "~/Music/Musik/Album/05Track.mp3"}
|
||||
worlds[0] = {**worlds[0], "lessons": lessons}
|
||||
curriculum = load_curriculum(_write(tmp_path, {"worlds": worlds}))
|
||||
assert curriculum.lessons[0].unlocks == "~/Music/Musik/Album/05Track.mp3"
|
||||
assert curriculum.lessons[1].unlocks is None
|
||||
Reference in New Issue
Block a user