| | |
| | | |
| | | from core.app_runtime.playback import ( |
| | | PLAY_SPEED_DEFAULT, |
| | | PLAYLIST_ITEM_MAX, |
| | | clamp_play_speed, |
| | | daily_base_speed, |
| | | expand_lesson_playlist, |
| | | lesson_day_index, |
| | | mark_lesson_played_today, |
| | | parse_playlist, |
| | | playlist_from_params, |
| | | speeds_for_repeats, |
| | | ) |
| | | from core.app_runtime.runtime import ( |
| | |
| | | |
| | | def test_lesson_day_index_counts_distinct_days(tmp_path, monkeypatch): |
| | | monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path)) |
| | | assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 0 |
| | | mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3") |
| | | assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1 |
| | | mark_lesson_played_today("mac1", "english_learning", "repeater", "unit01.mp3") |
| | | assert lesson_day_index("mac1", "english_learning", "repeater", "unit01.mp3") == 1 |
| | | assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 0 |
| | | mark_lesson_played_today("mac1", "english_learning", "repeater", "l1.001.mp3") |
| | | assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 1 |
| | | mark_lesson_played_today("mac1", "english_learning", "repeater", "l1.001.mp3") |
| | | assert lesson_day_index("mac1", "english_learning", "repeater", "l1.001.mp3") == 1 |
| | | |
| | | |
| | | def test_apply_playback_speed_queues_gradient(): |
| | |
| | | repeater_power_off_after=False, |
| | | ) |
| | | play = _apply_playback_speed( |
| | | conn, task, {"name": "/english_learning/repeater/lessons/unit01/full.mp3", "repeat": 5} |
| | | conn, task, {"name": "/english_learning/repeater/lessons/l1/001/cn/01.mp3", "repeat": 5} |
| | | ) |
| | | assert play["speed"] == 0.8 |
| | | assert play["repeat"] == 1 |
| | |
| | | assert items[1]["speed"] == 0.9 |
| | | |
| | | |
| | | def test_parse_playlist_safety_cap_is_256_not_a_lesson_limit(): |
| | | items = parse_playlist( |
| | | { |
| | | "items": [ |
| | | {"name": f"/english_learning/shadowing/lessons/l1/001/en/{i:03d}.mp3"} |
| | | for i in range(1, PLAYLIST_ITEM_MAX + 4) |
| | | ] |
| | | } |
| | | ) |
| | | assert PLAYLIST_ITEM_MAX == 256 |
| | | assert len(items) == PLAYLIST_ITEM_MAX |
| | | assert items[0]["name"].endswith("001.mp3") |
| | | assert items[-1]["name"].endswith("256.mp3") |
| | | |
| | | |
| | | def test_begin_repeater_queues_remaining_items(): |
| | | conn = SimpleNamespace( |
| | | device_id="mac", |
| | |
| | | assert asyncio.run(_continue_repeater_if_needed(conn)) is False |
| | | |
| | | |
| | | def test_begin_repeater_uses_temp_unit01_playlist(): |
| | | def test_begin_repeater_without_lesson_has_no_playlist(): |
| | | conn = SimpleNamespace( |
| | | device_id="mac", |
| | | active_app_id="english_learning", |
| | | current_task_id="repeater", |
| | | repeater_speed_queue=[], |
| | | repeater_play_name="", |
| | | repeater_power_off_after=False, |
| | | repeater_playlist=[], |
| | | ) |
| | | first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {}) |
| | | assert first.get("items") == [] |
| | | assert not first.get("name") |
| | | |
| | | |
| | | def test_begin_repeater_uses_catalog_lesson(): |
| | | from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST |
| | | |
| | | conn = SimpleNamespace( |
| | |
| | | repeater_power_off_after=False, |
| | | repeater_playlist=[], |
| | | ) |
| | | first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {}) |
| | | first = _begin_repeater_playlist(conn, {"playback": {"speed": 1.0}}, {"lesson_id": "l1.001"}) |
| | | assert first["name"] == TEMP_REPEATER_PLAYLIST[0]["name"] |
| | | assert first["repeat"] == 10 |
| | | assert first["repeat"] == 1 |
| | | assert first["speed"] == 1.0 |
| | | assert first.get("finish_task") is False |
| | | assert len(first["items"]) == 2 |
| | | assert first["items"][0]["repeat"] == 10 |
| | | assert len(first["items"]) == 4 |
| | | assert first["items"][0]["repeat"] == 1 |
| | | assert first["items"][1]["name"] == TEMP_REPEATER_PLAYLIST[1]["name"] |
| | | assert first["items"][1]["repeat"] == 5 |
| | | assert first["items"][1]["repeat"] == 1 |
| | | assert first["items"][1]["speed"] == 0.8 |
| | | assert first["items"][2]["speed"] == 0.9 |
| | | assert first["items"][3]["repeat"] == 5 |
| | | assert first["items"][3]["speed"] == 1.0 |
| | | assert conn.repeater_playlist == [] |
| | | |
| | | |
| | | def test_begin_shadowing_playlist_one_shot(): |
| | | def test_begin_shadowing_playlist_one_shot(tmp_path, monkeypatch): |
| | | from core.app_runtime.playback import TEMP_SHADOWING_PLAYLIST |
| | | |
| | | monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None) |
| | | |
| | | conn = SimpleNamespace( |
| | | device_id="mac", |
| | |
| | | shadowing_prompt="", |
| | | shadowing_awaiting_user=True, |
| | | ) |
| | | play = _begin_shadowing_playlist(conn, {}) |
| | | play = _begin_shadowing_playlist(conn, {"lesson_id": "l1.001"}) |
| | | assert play["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"] |
| | | assert play["repeat"] == 5 |
| | | assert play["repeat"] == 1 |
| | | assert play["speed"] == 0.8 |
| | | assert play.get("finish_task") is False |
| | | assert play.get("listen_ratio") == 1.2 |
| | | assert len(play["items"]) == 2 |
| | | assert play["items"][0]["repeat"] == 5 |
| | | assert play["items"][1]["name"] == TEMP_SHADOWING_PLAYLIST[1]["name"] |
| | | assert play["items"][1]["repeat"] == 10 |
| | | assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5 |
| | | assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10 |
| | | assert len(conn.shadowing_clips) == 15 |
| | | assert len(play["items"]) == 58 |
| | | assert play["items"][0]["repeat"] == 1 |
| | | assert play["items"][-1]["name"] == TEMP_SHADOWING_PLAYLIST[-1]["name"] |
| | | assert play["items"][-1]["speed"] == 0.8 |
| | | assert conn.shadowing_clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 1 |
| | | assert conn.shadowing_clips[-1] == TEMP_SHADOWING_PLAYLIST[-1]["name"] |
| | | assert len(conn.shadowing_clips) == 58 |
| | | assert conn.shadowing_awaiting_user is False |
| | | |
| | | |
| | | def test_expand_shadowing_playlist_one_clip_per_listen_window(): |
| | | def test_expand_shadowing_playlist_one_clip_per_listen_window(tmp_path, monkeypatch): |
| | | from core.app_runtime.playback import ( |
| | | TEMP_SHADOWING_PLAYLIST, |
| | | default_shadowing_playlist, |
| | | expand_playlist_plays, |
| | | ) |
| | | |
| | | clips = expand_playlist_plays(default_shadowing_playlist()) |
| | | assert clips.count(TEMP_SHADOWING_PLAYLIST[0]["name"]) == 5 |
| | | assert clips.count(TEMP_SHADOWING_PLAYLIST[1]["name"]) == 10 |
| | | assert len(clips) == 15 |
| | | monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None) |
| | | clips = expand_playlist_plays(expand_lesson_playlist("shadowing", "l1.001")) |
| | | assert clips[0] == TEMP_SHADOWING_PLAYLIST[0]["name"] |
| | | assert clips[-1] == TEMP_SHADOWING_PLAYLIST[-1]["name"] |
| | | assert len(clips) == 58 |
| | | |
| | | |
| | | def test_audio_completion_timeout_scales_with_queue(): |
| | |
| | | _execute_named_tool_play( |
| | | conn, |
| | | { |
| | | "name": "/english_learning/shadowing/lessons/unit01/02.mp3", |
| | | "name": "/english_learning/shadowing/lessons/l1/001/en/02.mp3", |
| | | "repeat": 5, |
| | | "speed": 1.0, |
| | | "finish_task": True, |
| | | "items": [ |
| | | { |
| | | "name": "/english_learning/shadowing/lessons/unit01/01.mp3", |
| | | "name": "/english_learning/shadowing/lessons/l1/001/en/01.mp3", |
| | | "repeat": 10, |
| | | "speed": 1.0, |
| | | }, |
| | | { |
| | | "name": "/english_learning/shadowing/lessons/unit01/02.mp3", |
| | | "name": "/english_learning/shadowing/lessons/l1/001/en/02.mp3", |
| | | "repeat": 5, |
| | | "speed": 1.0, |
| | | }, |
| | |
| | | _execute_named_tool_play( |
| | | conn, |
| | | { |
| | | "name": "/english_learning/shadowing/lessons/unit01/01.mp3", |
| | | "name": "/english_learning/shadowing/lessons/l1/001/en/01.mp3", |
| | | "repeat": 1, |
| | | "speed": 1.0, |
| | | "finish_task": False, |
| | |
| | | current_task_id="shadowing", |
| | | config={}, |
| | | ) |
| | | ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/unit01/01.mp3")) |
| | | ok = asyncio.run(_play_shadowing_clip(conn, "/english_learning/shadowing/lessons/l1/001/en/01.mp3")) |
| | | assert ok is True |
| | | assert captured["params"]["finish_task"] is False |
| | | assert captured["params"]["repeat"] == 1 |
| | |
| | | from core.app_runtime.runtime import _lesson_end_speech |
| | | |
| | | conn = SimpleNamespace(config={}) |
| | | assert _lesson_end_speech(conn, "复读结束") == "复读结束。我们来聊天吧。" |
| | | assert _lesson_end_speech(conn, "泛听结束") == "泛听结束。我们来聊天吧。" |
| | | assert _lesson_end_speech(conn, "跟读结束") == "跟读结束。我们来聊天吧。" |
| | | |
| | | |
| | | def test_expand_l1_001_repeater_and_shadowing(tmp_path, monkeypatch): |
| | | from core.app_runtime.playback import TEMP_REPEATER_PLAYLIST, TEMP_SHADOWING_PLAYLIST |
| | | |
| | | monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None) |
| | | repeater = expand_lesson_playlist("repeater", "l1.001") |
| | | assert [item["name"] for item in repeater] == [item["name"] for item in TEMP_REPEATER_PLAYLIST] |
| | | assert repeater[3]["repeat"] == 5 |
| | | assert "schedule" not in repeater[0] |
| | | |
| | | overridden = expand_lesson_playlist("repeater", "l1.001", repeat=1) |
| | | assert all(item["repeat"] == 1 for item in overridden) |
| | | |
| | | shadowing = expand_lesson_playlist("shadowing", "l1.001") |
| | | assert len(shadowing) == 58 |
| | | assert shadowing[0]["name"] == TEMP_SHADOWING_PLAYLIST[0]["name"] |
| | | assert shadowing[-1]["name"].endswith("/58.mp3") |
| | | assert all(item["speed"] == 0.8 for item in shadowing) |
| | | |
| | | |
| | | def test_expand_catalog_story_en_passes(): |
| | | from core.app_runtime.idiom_catalog import find_story |
| | | |
| | | story = find_story("l1.002") |
| | | assert story is not None |
| | | assert story["id"] == "002" |
| | | items = expand_lesson_playlist( |
| | | "repeater", |
| | | "l1.002", |
| | | en_passes=[ |
| | | {"speed": 0.8, "repeat": 1}, |
| | | {"speed": 0.9, "repeat": 2}, |
| | | {"speed": 1.0, "repeat": 5}, |
| | | ], |
| | | ) |
| | | assert items[0]["name"].endswith("/l1/002/cn/01.mp3") |
| | | assert items[0]["repeat"] == 1 |
| | | assert items[0]["speed"] == 1.0 |
| | | assert [item["speed"] for item in items[1:]] == [0.8, 0.9, 1.0] |
| | | assert [item["repeat"] for item in items[1:]] == [1, 2, 5] |
| | | assert all(item["name"].endswith("/l1/002/en/01.mp3") for item in items[1:]) |
| | | |
| | | |
| | | def test_expand_catalog_shadowing_sentence_files(): |
| | | items = expand_lesson_playlist( |
| | | "shadowing", |
| | | "l1.079", |
| | | language="en", |
| | | speed=0.8, |
| | | ) |
| | | assert len(items) == 30 |
| | | assert items[0]["name"].endswith("/l1/079/en/01.mp3") |
| | | assert items[-1]["name"].endswith("/l1/079/en/30.mp3") |
| | | assert all(item["speed"] == 0.8 for item in items) |
| | | |
| | | cn_items = expand_lesson_playlist( |
| | | "shadowing", |
| | | "l1.079", |
| | | language="cn", |
| | | speed=0.9, |
| | | ) |
| | | assert cn_items[0]["name"].endswith("/l1/079/cn/01.mp3") |
| | | assert cn_items[0]["speed"] == 0.9 |
| | | |
| | | |
| | | def test_expand_l1_001_custom_en_pass(tmp_path, monkeypatch): |
| | | monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None) |
| | | items = expand_lesson_playlist( |
| | | "repeater", |
| | | "l1.001", |
| | | en_passes=[{"speed": 0.8, "repeat": 1}], |
| | | ) |
| | | assert items[0]["name"].endswith("/l1/001/cn/01.mp3") |
| | | assert items[1]["name"].endswith("/l1/001/en/01.mp3") |
| | | |
| | | shadowing = expand_lesson_playlist( |
| | | "shadowing", |
| | | "l1.001", |
| | | language="en", |
| | | speed=0.8, |
| | | sentence_count_override=58, |
| | | ) |
| | | assert shadowing[0]["name"].endswith("/l1/001/en/01.mp3") |
| | | assert len(shadowing) == 58 |
| | | |
| | | |
| | | def test_idiom_catalog_lists_all_chapters(): |
| | | from core.app_runtime.idiom_catalog import find_story, load_catalog |
| | | |
| | | data = load_catalog() |
| | | assert len(data["systems"]) == 7 |
| | | assert len(data["stories"]) == 86 |
| | | assert find_story("l1.001")["name"] == "开天辟地" |
| | | assert find_story("l1.001")["id"] == "001" |
| | | assert find_story("l3.048")["nameEn"].startswith("An ugly girl") |
| | | assert find_story("l6.086")["id"] == "086" |
| | | |
| | | |
| | | def test_unknown_lesson_does_not_fallback_to_temp(): |
| | | assert expand_lesson_playlist("repeater", "l9.999") == [] |
| | | assert expand_lesson_playlist("shadowing", "missing") == [] |
| | | items = playlist_from_params( |
| | | {"default_playlist": [{"name": "/x.mp3", "repeat": 1}]}, |
| | | {"lesson_id": "l9.999"}, |
| | | "repeater", |
| | | lambda task: [{"name": "/fallback.mp3", "repeat": 1}], |
| | | ) |
| | | assert items == [] |
| | | |
| | | |
| | | def test_voice_without_lesson_does_not_use_temp_default(): |
| | | from core.app_runtime.playback import default_repeater_playlist |
| | | |
| | | items = playlist_from_params(None, {}, "repeater", default_repeater_playlist) |
| | | assert items == [] |
| | | |
| | | |
| | | def test_remember_and_load_last_lesson(tmp_path, monkeypatch): |
| | | from core.app_runtime.playback import load_last_lesson_params, remember_last_lesson_params |
| | | |
| | | monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path)) |
| | | remember_last_lesson_params("mac-1", "repeater", {"lesson_id": "l2.010", "en_passes": [{"speed": 0.8, "repeat": 1}]}) |
| | | remember_last_lesson_params("mac-1", "shadowing", {"lesson_id": "l3.048", "speed": 0.8, "language": "en"}) |
| | | loaded = load_last_lesson_params("mac-1", "repeater") |
| | | assert loaded["lesson_id"] == "l2.010" |
| | | assert load_last_lesson_params("mac-1", "shadowing")["lesson_id"] == "l3.048" |
| | | assert load_last_lesson_params("mac-1", "chat") == {} |
| | | assert load_last_lesson_params("other", "repeater") == {} |
| | | |
| | | |
| | | def test_begin_repeater_expands_lesson_id(): |
| | | conn = SimpleNamespace( |
| | | device_id="mac", |
| | | active_app_id="english_learning", |
| | | current_task_id="repeater", |
| | | repeater_speed_queue=[], |
| | | repeater_play_name="", |
| | | repeater_power_off_after=False, |
| | | repeater_playlist=[], |
| | | ) |
| | | play = _begin_repeater_playlist(conn, {}, {"lesson_id": "l1.001", "repeat": 1}) |
| | | assert play["name"].endswith("/l1/001/cn/01.mp3") |
| | | assert len(play["items"]) == 4 |
| | | assert all(item["repeat"] == 1 for item in play["items"]) |
| | | assert play.get("finish_task") is False |