A lesson's `keys` was capped at two outright, which made it impossible to write "K, with the four keys before it still in play". The cap now counts only what is *new*; keys already taught can be listed freely, and the spotlight falls on the new ones alone, so a lesson titled "K" drills K instead of spreading itself evenly over all five keys it names. A round that introduces nothing new still spotlights its whole list - that is the "mixed" replay, unchanged. Also documents `unlocks:` paths as relative to the library root. Absolute and "~" paths still resolve, so an existing file keeps working, but a relative path survives moving the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
12 KiB
Python
329 lines
12 KiB
Python
"""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 (
|
|
ELIGIBLE_MODES,
|
|
CurriculumError,
|
|
first_lesson_id,
|
|
lesson_by_id,
|
|
load_curriculum,
|
|
next_lesson,
|
|
)
|
|
|
|
#: The real, shipped curriculum - see python-backend/tippen-curriculum.yml.example.
|
|
_EXAMPLE = Path(__file__).parent.parent / "tippen-curriculum.yml.example"
|
|
|
|
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 introducing
|
|
# too many keys at once - both should show up in a single error, not just the first
|
|
# one found. ("a" and "s" are already taught in world 1, so only the three unseen
|
|
# keys count against the cap.)
|
|
worlds[1] = {**worlds[1], "reward": "clownfish"}
|
|
worlds[2] = {
|
|
**worlds[2],
|
|
"lessons": [{"title": "Zu viel", "subtitle": "x", "keys": ["a", "s", "q", "w", "e"]}],
|
|
}
|
|
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 new 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
|
|
|
|
|
|
# ------------------------------------------------------------- the real curriculum
|
|
#
|
|
# Content-shape invariants the real, shipped curriculum must hold - ported from the
|
|
# frontend's old curriculum.test.ts, which checked these against the same YAML back
|
|
# when it was parsed client-side. The pedagogical content itself did not change in the
|
|
# move to the backend; what moved is the parsing/deriving logic these tests actually
|
|
# exercise (buildLessons's Python port, `_build_lessons`), so the coverage still earns
|
|
# its keep here. A few of the original checks needed real-keyboard finger/hand mapping
|
|
# (`fingers.ts`, never ported to Python, since nothing backend-side needs it) and are
|
|
# not repeated.
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def curriculum():
|
|
return load_curriculum(_EXAMPLE)
|
|
|
|
|
|
def test_has_unique_ids_and_consecutive_numbers(curriculum) -> None:
|
|
ids = [lesson.id for lesson in curriculum.lessons]
|
|
assert len(set(ids)) == len(ids)
|
|
for i, lesson in enumerate(curriculum.lessons):
|
|
assert lesson.number == i + 1
|
|
|
|
|
|
def test_starts_on_the_two_keys_with_the_tactile_bumps(curriculum) -> None:
|
|
assert curriculum.lessons[0].new_keys == ("f", "j")
|
|
assert first_lesson_id(curriculum) == curriculum.lessons[0].id
|
|
|
|
|
|
def test_eases_in_at_most_two_new_keys_per_lesson(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
assert len(lesson.new_keys) <= 2, f"{lesson.number}: {lesson.title}"
|
|
|
|
|
|
def test_makes_every_round_a_real_block_of_practice(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
where = f"{lesson.number}: {lesson.title}"
|
|
if lesson.kind == "sentences":
|
|
assert lesson.chunks >= 10, where
|
|
elif lesson.kind == "words":
|
|
assert lesson.chunks >= 25, where
|
|
else:
|
|
assert lesson.chunks * lesson.chunk_size >= 60, where
|
|
|
|
|
|
def test_has_more_sentences_to_draw_from_than_a_round_uses(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
if lesson.kind != "sentences":
|
|
continue
|
|
assert len(lesson.words) >= lesson.chunks, f"{lesson.number}: {lesson.title}"
|
|
|
|
|
|
def test_active_keys_grow_monotonically(curriculum) -> None:
|
|
previous: set[str] = set()
|
|
for lesson in curriculum.lessons:
|
|
active = set(lesson.active_keys)
|
|
assert previous <= active
|
|
previous = active
|
|
|
|
|
|
def test_every_new_key_enters_its_own_active_keys(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
for key in lesson.new_keys:
|
|
if key == "⇧": # Shift is not a character the generator can emit.
|
|
continue
|
|
assert key in lesson.active_keys, f"{lesson.number}: {lesson.title}"
|
|
|
|
|
|
def test_covers_the_whole_alphabet_plus_umlauts_by_the_end(curriculum) -> None:
|
|
final = set(curriculum.lessons[-1].active_keys)
|
|
for key in "abcdefghijklmnopqrstuvwxyzäöü":
|
|
assert key in final
|
|
|
|
|
|
def test_plays_a_mode_that_fits_its_kind(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
where = f"{lesson.number}: {lesson.title}"
|
|
assert lesson.primary_mode in ELIGIBLE_MODES[lesson.kind], where
|
|
for mode in lesson.bonus_modes:
|
|
assert mode in ELIGIBLE_MODES[lesson.kind], where
|
|
|
|
|
|
def test_letters_rounds_always_play_bubbles(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
if lesson.kind == "letters":
|
|
assert lesson.primary_mode == "bubbles", f"{lesson.number}: {lesson.title}"
|
|
|
|
|
|
def test_offers_every_eligible_mode_not_gated_on_as_a_bonus(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
expected = sorted(
|
|
mode
|
|
for mode in ELIGIBLE_MODES[lesson.kind]
|
|
if mode in ("feed", "race") and mode != lesson.primary_mode
|
|
)
|
|
assert sorted(lesson.bonus_modes) == expected
|
|
|
|
|
|
def test_mixes_games_instead_of_always_diving(curriculum) -> None:
|
|
words = [lesson for lesson in curriculum.lessons if lesson.kind == "words"]
|
|
sentences = [lesson for lesson in curriculum.lessons if lesson.kind == "sentences"]
|
|
assert any(lesson.primary_mode == "feed" for lesson in words)
|
|
assert any(lesson.primary_mode == "race" for lesson in words)
|
|
assert any(lesson.primary_mode == "race" for lesson in sentences)
|
|
|
|
|
|
def test_distinguishes_a_mixed_round_from_a_drill(curriculum) -> None:
|
|
mixed = [lesson for lesson in curriculum.lessons if lesson.emphasis == "mixed"]
|
|
assert len(mixed) > 0
|
|
for lesson in mixed:
|
|
assert lesson.new_keys == ()
|
|
assert lesson.is_drill is False
|
|
|
|
|
|
def test_every_isolated_round_has_something_new_to_drill(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
if lesson.emphasis == "isolated":
|
|
assert len(lesson.new_keys) > 0, f"{lesson.number}: {lesson.title}"
|
|
|
|
|
|
def test_teaches_capitals_only_once_shift_exists(curriculum) -> None:
|
|
for lesson in curriculum.lessons:
|
|
has_uppercase = any(word != word.lower() for word in lesson.words)
|
|
if has_uppercase:
|
|
assert lesson.world >= 4, f"{lesson.number}: {lesson.title}"
|
|
|
|
|
|
def test_follows_every_pair_of_new_keys_with_a_drill(curriculum) -> None:
|
|
drills = [lesson for lesson in curriculum.lessons if lesson.is_drill]
|
|
assert len(drills) >= 15
|
|
for lesson in drills:
|
|
assert lesson.new_keys == ()
|
|
assert len(lesson.active_keys) > 0
|
|
|
|
|
|
def test_is_a_real_duolingo_length_course(curriculum) -> None:
|
|
assert len(curriculum.lessons) >= 80
|
|
assert len(curriculum.worlds) == 5
|
|
|
|
|
|
def test_every_world_has_at_least_three_lessons(curriculum) -> None:
|
|
for world in curriculum.worlds:
|
|
count = sum(1 for lesson in curriculum.lessons if lesson.world == world.number)
|
|
assert count >= 3, world.title
|
|
|
|
|
|
def test_every_world_has_its_own_creature(curriculum) -> None:
|
|
rewards = [world.reward for world in curriculum.worlds]
|
|
assert len(set(rewards)) == len(rewards)
|
|
|
|
|
|
def test_navigation_chains_every_lesson_to_the_next(curriculum) -> None:
|
|
lessons = curriculum.lessons
|
|
for i in range(len(lessons) - 1):
|
|
assert next_lesson(curriculum, lessons[i].id).id == lessons[i + 1].id
|
|
assert next_lesson(curriculum, lessons[-1].id) is None
|
|
assert next_lesson(curriculum, "gibt-es-nicht") is None
|
|
|
|
|
|
def test_navigation_looks_lessons_up_by_id(curriculum) -> None:
|
|
assert lesson_by_id(curriculum, first_lesson_id(curriculum)).number == 1
|
|
assert lesson_by_id(curriculum, "gibt-es-nicht") is None
|