Merge the typing game into the music player as a tab, with lock/unlock UI
Moves tippen from a standalone app into web/ as a third tab (audio player / smarthome / typing), replacing the old single room-toggle corner button with a vertical icon tab rail. Curriculum and progress now come from the backend (musicmouse/tippen/*) instead of a build-time YAML import and localStorage. Adds reward-driven lock rendering: Cover/BrowseView/AlbumModal show a question mark for locked albums/tracks with a hint on what unlocks them, and ResultSheet gets a new unlock-animation block alongside the existing lesson-unlock and aquarium-creature celebrations. CSS from the two apps is merged carefully: identical rules (bubble/card/ key-cap/view-enter/backdrop-enter and their keyframes) are shared as-is, while rules that bake in each app's own hue are kept separate under a `tp-` prefix and scoped to the typing tab's own .tp-stage wrapper, so neither app's look bleeds into the other's. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,17 @@ from pathlib import Path
|
||||
import pytest
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
from musicmouse.tippen.curriculum import CurriculumError, load_curriculum
|
||||
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": [
|
||||
@@ -150,3 +160,168 @@ def test_unlocks_is_carried_through_unresolved(tmp_path: Path) -> None:
|
||||
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_never_plays_the_same_arcade_game_twice_in_a_row(curriculum) -> None:
|
||||
arcade = [lesson for lesson in curriculum.lessons if lesson.kind == "letters"]
|
||||
for i in range(1, len(arcade)):
|
||||
where = f"{arcade[i].number}: {arcade[i].title}"
|
||||
assert arcade[i].primary_mode != arcade[i - 1].primary_mode, where
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user