"""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 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 # ------------------------------------------------------------- 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