From 220e4fab27b7f419b3e89e2f2d28ae789aac09d7 Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期四, 08 十月 2026 16:29:55 +0800
Subject: [PATCH] 5种场景模式家长端ok

---
 xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py |  255 ++++++++++++++++++++++++++++++++++++++++++++------
 1 files changed, 221 insertions(+), 34 deletions(-)

diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
index 527fc0f..2879388 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_playback.py
@@ -2,11 +2,14 @@
 
 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 (
@@ -54,11 +57,11 @@
 
 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():
@@ -72,7 +75,7 @@
         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
@@ -142,6 +145,21 @@
     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",
@@ -196,7 +214,22 @@
     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(
@@ -208,19 +241,26 @@
         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",
@@ -232,32 +272,33 @@
         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():
@@ -287,18 +328,18 @@
         _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,
                     },
@@ -338,7 +379,7 @@
         _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,
@@ -370,7 +411,7 @@
         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
@@ -380,5 +421,151 @@
     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

--
Gitblit v1.9.1