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_app_runtime.py |  547 +++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 files changed, 497 insertions(+), 50 deletions(-)

diff --git a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
index 6e4d490..6dc14fa 100644
--- a/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
+++ b/xiaozhi-esp32-server/main/xiaozhi-server/tests/test_app_runtime.py
@@ -33,7 +33,7 @@
     assert match_voice_switch("閫�鍑虹炕璇�") == ("chat", "conversation")
     assert match_voice_switch("杩斿洖鑱婂ぉ") == ("chat", "conversation")
     assert match_voice_switch("閫�鍑鸿窡璇�") == ("chat", "conversation")
-    assert match_voice_switch("閫�鍑哄璇�") == ("chat", "conversation")
+    assert match_voice_switch("閫�鍑烘硾鍚�") == ("chat", "conversation")
 
 
 def test_enter_prompts_announce_mode_without_exit_echo():
@@ -43,8 +43,8 @@
     _, shadowing = get_task("english_learning", "shadowing")
     repeater_prompt = (repeater or {}).get("enter_prompt") or ""
     shadowing_prompt = (shadowing or {}).get("enter_prompt") or ""
-    assert "鐜板湪宸茬粡鍒囨崲鑷冲璇绘満妯″紡" in repeater_prompt
-    assert "閫�鍑哄璇�" not in repeater_prompt
+    assert "鐜板湪宸茬粡鍒囨崲鑷虫硾鍚ā寮�" in repeater_prompt
+    assert "閫�鍑烘硾鍚�" not in repeater_prompt
     assert "鐜板湪宸茬粡鍒囨崲鑷宠窡璇绘ā寮�" in shadowing_prompt
     assert "閫�鍑鸿窡璇�" not in shadowing_prompt
 
@@ -53,7 +53,7 @@
     from core.app_runtime.runtime import voice_menu_text
 
     menu = voice_menu_text()
-    assert "鍚璇绘満" in menu
+    assert "娉涘惉" in menu
     assert "璺熻" in menu
     assert "缈昏瘧" in menu
     assert "涓嫳瀵硅瘽" in menu
@@ -64,11 +64,11 @@
     from core.app_runtime.runtime import is_exit_to_chat
 
     assert match_voice_switch("璺熻") == ("english_learning", "shadowing")
-    assert match_voice_switch("鍚") == ("english_learning", "shadowing")
+    assert match_voice_switch("娉涘惉") == ("english_learning", "repeater")
     assert match_voice_switch("鎴戞兂閫�鍑鸿窡璇�") == ("chat", "conversation")
     assert is_exit_to_chat("閫�鍑虹炕璇�") is True
     assert is_exit_to_chat("寮�濮嬭窡璇�") is False
-    assert is_exit_to_chat("閫�鍑哄惉璇�") is True
+    assert is_exit_to_chat("閫�鍑烘硾鍚�") is True
 
 
 def test_new_task_phrases_come_from_registry_only(monkeypatch):
@@ -207,27 +207,27 @@
 
 def test_lesson_mp3_clips_picks_first_lesson_folder():
     names = [
-        "/english_learning/repeater/lessons/unit01/full.mp3",
-        "/english_learning/shadowing/lessons/unit02/02.mp3",
-        "/english_learning/shadowing/lessons/unit01/02.mp3",
-        "/english_learning/shadowing/lessons/unit01/01.mp3",
-        "/english_learning/shadowing/lessons/unit01/full.mp3",
-        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
+        "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+        "/english_learning/shadowing/lessons/l2/002/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
+        "/english_learning/shadowing/lessons/l1/001/full.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
         "loose.mp3",
     ]
     clips = lesson_mp3_clips(names, "english_learning", "shadowing")
     assert clips == [
-        "/english_learning/shadowing/lessons/unit01/01.mp3",
-        "/english_learning/shadowing/lessons/unit01/02.mp3",
-        "/english_learning/shadowing/lessons/unit01/audio/03.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/02.mp3",
+        "/english_learning/shadowing/lessons/l1/001/en/03.mp3",
     ]
-    only_unit2 = lesson_mp3_clips(names, "english_learning", "shadowing", "unit02")
-    assert only_unit2 == ["/english_learning/shadowing/lessons/unit02/02.mp3"]
+    only_l2 = lesson_mp3_clips(names, "english_learning", "shadowing", "l2")
+    assert only_l2 == ["/english_learning/shadowing/lessons/l2/002/en/02.mp3"]
     assert repeater_lesson_mp3(names, "english_learning", "repeater") == (
-        "/english_learning/repeater/lessons/unit01/full.mp3"
+        "/english_learning/repeater/lessons/l1/001/cn/01.mp3"
     )
     assert repeater_lesson_mp3(names, "english_learning", "shadowing") == (
-        "/english_learning/shadowing/lessons/unit01/full.mp3"
+        "/english_learning/shadowing/lessons/l1/001/full.mp3"
     )
 
 
@@ -244,7 +244,7 @@
         started_at=1,
         status="running",
     )
-    session.add_turn(0, "/english_learning/shadowing/lessons/unit01/01.mp3", "hello", False)
+    session.add_turn(0, "/english_learning/shadowing/lessons/l1/001/en/01.mp3", "hello", False)
     session.add_event("audio.completed", "english_learning", "shadowing")
     data = session.to_dict()
     assert data["task_session_id"] == "abc123"
@@ -252,6 +252,19 @@
     assert "agent_id" in data
     assert data["turns"][0]["prompt_text"].endswith("01.mp3")
     assert data["events"][0]["name"] == "audio.completed"
+
+
+def test_turn_audio_id_keeps_local_recording_path():
+    session = TaskSession(
+        task_session_id="abc123",
+        app_id="english_learning",
+        task_id="shadowing",
+        status="running",
+    )
+    long_path = "14-c1-9f-49-43-94/shadowing/2026-09-18/091728893_asr_b5a60ebacb9544bfba4782a5cc978fb9_0.wav"
+    session.upsert_turn(0, prompt_text="01.mp3", asr_text="hello", audio_id=long_path)
+    assert session.to_dict()["turns"][0]["audio_id"] == long_path
+    assert len(long_path) > 32
 
 
 def test_save_survives_upsert_scheduler_error(tmp_path, monkeypatch):
@@ -281,7 +294,7 @@
     assert len(loaded.turns) == 1
 
 
-def test_repeater_queues_play_until_listen_start(monkeypatch):
+def test_repeater_queues_play_until_listen_start(tmp_path, monkeypatch):
     import asyncio
     from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
 
@@ -296,34 +309,86 @@
 
     async def fake_list(conn):
         return [
-            "/english_learning/shadowing/lessons/unit01/full.mp3",
-            "/english_learning/repeater/lessons/unit01/full.mp3",
-            "/english_learning/shadowing/lessons/unit01/01.mp3",
+            "/english_learning/shadowing/lessons/l1/001/full.mp3",
+            "/english_learning/repeater/lessons/l1/001/cn/01.mp3",
+            "/english_learning/shadowing/lessons/l1/001/en/01.mp3",
         ]
 
     monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
     monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
     monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_ensure(*args, **kwargs):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
     spoken = []
     monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
-    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
-    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
-    assert spoken and "澶嶈鏈�" in spoken[0]
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-queue")
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {"lesson_id": "l1.001"}, source="voice"))
+    assert spoken and "娉涘惉" in spoken[0]
     assert plays == []
     assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "repeater"
     asyncio.run(maybe_flush_pending_play_after_tts(conn))
     assert len(plays) == 1
-    assert plays[0]["name"] == "/english_learning/shadowing/lessons/unit01/01.mp3"
-    assert plays[0]["repeat"] == 10
+    assert plays[0]["name"] == "/english_learning/repeater/lessons/l1/001/cn/01.mp3"
+    assert plays[0]["repeat"] == 1
+    assert plays[0]["speed"] == 1.0
     assert plays[0].get("finish_task") is False
-    assert len(plays[0]["items"]) == 2
-    assert plays[0]["items"][1]["name"].endswith("/02.mp3")
-    assert plays[0]["items"][1]["repeat"] == 5
+    assert len(plays[0]["items"]) == 4
+    assert plays[0]["items"][1]["name"].endswith("/l1/001/en/01.mp3")
+    assert plays[0]["items"][1]["speed"] == 0.8
+    assert plays[0]["items"][3]["repeat"] == 5
+    assert plays[0]["items"][3]["speed"] == 1.0
     assert conn.repeater_playlist == []
     assert conn.pending_play_after_tts is None
 
 
-def test_shadowing_queues_whole_playlist_until_listen_start(monkeypatch):
+def test_sd_ready_skips_sync_and_queued_play(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    plays = []
+    spoken = []
+    dirs = []
+    ensures = []
+
+    async def fake_play(conn, params):
+        plays.append(params)
+        return None
+
+    async def fake_dirs(conn, app_id):
+        dirs.append(app_id)
+        return None
+
+    async def fake_ensure(*args, **kwargs):
+        ensures.append(args)
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-sd-ready")
+    asyncio.run(start_app_task(
+        conn,
+        "english_learning",
+        "repeater",
+        {"lesson_id": "l1.001", "sd_ready": True},
+        source="mqtt_immediate",
+    ))
+    assert plays == []
+    assert dirs == []
+    assert ensures == []
+    assert getattr(conn, "pending_play_after_tts", None) is None
+    assert conn.interaction_mode == "PLAY_AUDIO"
+    assert spoken == ["寮�濮嬫硾鍚换鍔�"]
+    assert not any("鍚屾" in str(text) for text in spoken)
+
+
+def test_shadowing_queues_whole_playlist_until_listen_start(tmp_path, monkeypatch):
     import asyncio
     from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
 
@@ -336,47 +401,192 @@
     async def fake_dirs(conn, app_id):
         return None
 
+    async def fake_ensure(*args, **kwargs):
+        return True
+
     monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
     monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
     spoken = []
     monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
-    conn = SimpleNamespace(config={}, prompt="", func_handler=None)
-    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-shadow-queue")
+    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {"lesson_id": "l1.001"}, source="voice"))
     assert spoken and "璺熻" in spoken[0]
     assert plays == []
     assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
     queued = conn.pending_play_after_tts["params"]["items"]
-    assert len(queued) == 2
-    assert queued[0]["repeat"] == 5
-    assert queued[1]["name"].endswith("/02.mp3")
-    assert queued[1]["repeat"] == 10
+    assert len(queued) == 58
+    assert queued[0]["repeat"] == 1
+    assert queued[0]["speed"] == 0.8
+    assert queued[-1]["name"].endswith("/58.mp3")
+    assert queued[-1]["repeat"] == 1
     asyncio.run(maybe_flush_pending_play_after_tts(conn))
     assert len(plays) == 1
-    assert len(plays[0]["items"]) == 2
-    assert plays[0]["items"][0]["repeat"] == 5
-    assert plays[0]["items"][1]["repeat"] == 10
+    assert len(plays[0]["items"]) == 58
+    assert plays[0]["items"][0]["repeat"] == 1
+    assert plays[0]["items"][-1]["repeat"] == 1
     assert plays[0].get("finish_task") is False
     assert plays[0].get("listen_ratio") == 1.2
     assert conn.pending_play_after_tts is None
 
 
-def test_shadowing_expands_default_playlist_into_listen_turns():
+def test_shadowing_ensures_mp3s_even_when_sd_empty(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    ensure_calls = []
+
+    async def fake_play(conn, params):
+        return None
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    async def fake_list(conn):
+        return []
+
+    async def fake_ensure(conn, names, task_id, params):
+        ensure_calls.append((list(names), task_id, dict(params or {})))
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._list_sd_mp3_names", fake_list)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: None)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-shadow-sync")
+    asyncio.run(start_app_task(
+        conn,
+        "english_learning",
+        "shadowing",
+        {"lesson_id": "l1.001"},
+        source="mqtt_immediate",
+    ))
+    assert len(ensure_calls) == 1
+    names, task_id, params = ensure_calls[0]
+    assert task_id == "shadowing"
+    assert params.get("lesson_id") == "l1.001"
+    assert len(names) == 58
+    assert names[0].endswith("/en/01.mp3")
+    assert names[-1].endswith("/58.mp3")
+    assert conn.pending_play_after_tts and conn.pending_play_after_tts["kind"] == "shadowing"
+
+
+def test_shadowing_expands_default_playlist_into_listen_turns(tmp_path, monkeypatch):
     import asyncio
     from core.app_runtime.runtime import _prepare_shadowing_sd
 
+    monkeypatch.setattr("core.app_runtime.playback.list_lesson_clips", lambda *_args, **_kwargs: None)
     conn = SimpleNamespace(
         config={},
         active_app_id="english_learning",
         current_task_id="shadowing",
     )
-    ok = asyncio.run(_prepare_shadowing_sd(conn, {}))
+    ok = asyncio.run(_prepare_shadowing_sd(conn, {"lesson_id": "l1.001"}))
     assert ok is True
-    unit01_01 = "/english_learning/shadowing/lessons/unit01/01.mp3"
-    unit01_02 = "/english_learning/shadowing/lessons/unit01/02.mp3"
-    assert conn.shadowing_clips.count(unit01_01) == 5
-    assert conn.shadowing_clips.count(unit01_02) == 10
-    assert len(conn.shadowing_clips) == 15
-    assert conn.shadowing_prompt == unit01_01
+    clip_01 = "/english_learning/shadowing/lessons/l1/001/en/01.mp3"
+    clip_58 = "/english_learning/shadowing/lessons/l1/001/en/58.mp3"
+    assert conn.shadowing_clips.count(clip_01) == 1
+    assert conn.shadowing_clips[-1] == clip_58
+    assert len(conn.shadowing_clips) == 58
+    assert conn.shadowing_prompt == clip_01
+
+
+def test_voice_repeater_without_last_lesson_prompts_parent(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-empty")
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
+    assert spoken and "杩樻病鏈変笅鍙戣繃娉涘惉璇炬枃" in spoken[0]
+    assert getattr(conn, "pending_play_after_tts", None) is None
+
+
+def test_voice_repeater_replays_last_parent_task(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    async def fake_ensure(*args, **kwargs):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-last")
+    asyncio.run(
+        start_app_task(
+            conn,
+            "english_learning",
+            "repeater",
+            {"lesson_id": "l2.010", "en_passes": [{"speed": 0.8, "repeat": 1}]},
+            source="mqtt_immediate",
+        )
+    )
+    conn.active_app_id = "chat"
+    conn.current_task_id = "conversation"
+    conn.current_source = "default_chat"
+    conn.interaction_mode = "CHAT"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
+    pending = conn.pending_play_after_tts
+    assert pending and pending["kind"] == "repeater"
+    assert pending["params"]["name"].endswith("/l2/010/cn/01.mp3")
+    assert spoken and "娉涘惉" in spoken[0]
+
+
+def test_voice_shadowing_replays_last_parent_task(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.playback.get_project_dir", lambda: str(tmp_path))
+
+    async def fake_dirs(conn, app_id):
+        return None
+
+    async def fake_ensure(*args, **kwargs):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
+    monkeypatch.setattr("core.app_runtime.runtime._ensure_lesson_mp3s", fake_ensure)
+    conn = SimpleNamespace(config={}, prompt="", func_handler=None, device_id="mac-last-shd")
+    asyncio.run(
+        start_app_task(
+            conn,
+            "english_learning",
+            "shadowing",
+            {"lesson_id": "l1.001", "language": "en", "speed": 0.8},
+            source="schedule",
+        )
+    )
+    conn.active_app_id = "chat"
+    conn.current_task_id = "conversation"
+    conn.current_source = "default_chat"
+    conn.interaction_mode = "CHAT"
+    conn.shadowing_clips = []
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "english_learning", "shadowing", {}, source="voice"))
+    pending = conn.pending_play_after_tts
+    assert pending and pending["kind"] == "shadowing"
+    assert pending["params"]["items"][0]["name"].endswith("/l1/001/en/01.mp3")
+    assert spoken and "璺熻" in spoken[0] and "杩樻病鏈変笅鍙戣繃" not in spoken[0]
 
 
 def test_app_sd_layout_paths_from_registry():
@@ -422,6 +632,48 @@
     assert conn.chat_greeted is True
 
 
+def test_boot_unbound_skips_greeting_and_plays_bind_code(monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+    bind_codes = []
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+
+    async def _ready(conn, timeout=8):
+        return True
+
+    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _ready)
+
+    async def fake_bind(conn):
+        bind_codes.append(conn.bind_code)
+
+    monkeypatch.setattr("core.app_runtime.runtime._play_bind_prompt", fake_bind)
+    conn = SimpleNamespace(
+        config={
+            "enable_proactive_greeting": True,
+            "proactive_chat_greeting": "鎴戜滑鏉ヨ亰澶╁惂銆�",
+        },
+        prompt="",
+        func_handler=None,
+        just_woken_up=False,
+        chat_greeted=False,
+        active_app_id="chat",
+        current_task_id="conversation",
+        current_source="default_chat",
+        interaction_mode="CHAT",
+        need_bind=True,
+        bind_code="123456",
+        bind_completed_event=asyncio.Event(),
+    )
+    conn.bind_completed_event.set()
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="default_chat"))
+    assert spoken == []
+    assert bind_codes == ["123456"]
+    assert conn.chat_greeted is False
+
+
 def test_client_hello_is_recognized_before_bind():
     from core.websocket_handshake import is_client_hello
 
@@ -449,6 +701,9 @@
         '{"type":"listen","state":"detect","text":"[app_task]chat|conversation|default_chat"}'
     ) is True
     assert is_priority_listen_detect(
+        '{"type":"listen","state":"detect","text":"[lesson_sync]"}'
+    ) is True
+    assert is_priority_listen_detect(
         '{"type":"listen","state":"detect","text":"浣犲ソ灏忔櫤"}'
     ) is False
 
@@ -472,3 +727,195 @@
         }
     }
     assert app_sd_layout_paths("story_time", app) == ["/story_time/listen/lessons"]
+
+
+def test_language_pair_uses_bilingual_prompt_and_unknown_falls_back():
+    from core.app_runtime.runtime import BILINGUAL_PROMPT, _apply_prompt
+
+    conn = SimpleNamespace(prompt="base", original_system_prompt=None)
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    app = {"system_prompt_mode": "bilingual"}
+    _apply_prompt(conn, app, {"language_pair": "zh-en"})
+    assert conn.prompt == BILINGUAL_PROMPT
+    assert conn.language_pair_fallback is False
+
+    conn2 = SimpleNamespace(prompt="base", original_system_prompt=None)
+    conn2.change_system_prompt = lambda text: setattr(conn2, "prompt", text)
+    _apply_prompt(conn2, app, {"language_pair": "zh-ko"})
+    assert conn2.prompt == BILINGUAL_PROMPT
+    assert conn2.language_pair_fallback is True
+    assert conn2.language_pair == "zh-ko"
+
+
+def test_unknown_repeater_lesson_does_not_queue_play(monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _true(*args, **kwargs):
+        return True
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _true)
+    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", _true)
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+
+    conn = SimpleNamespace(
+        config={},
+        prompt="",
+        func_handler=None,
+        just_woken_up=False,
+        chat_greeted=True,
+        active_app_id="chat",
+        current_task_id="conversation",
+        current_source="default_chat",
+        interaction_mode="CHAT",
+        pending_play_after_tts=None,
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    asyncio.run(
+        start_app_task(
+            conn,
+            "english_learning",
+            "repeater",
+            {"lesson_id": "unit99"},
+            source="mqtt_immediate",
+        )
+    )
+    assert conn.pending_play_after_tts is None
+    assert any("SD鍗�" in text for text in spoken)
+
+
+def test_parent_translation_clears_abort_and_announces(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
+    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
+
+    conn = SimpleNamespace(
+        config={},
+        prompt="base",
+        func_handler=None,
+        device_id="mac-translate",
+        client_abort=True,
+        tts=SimpleNamespace(tts_stop_request=True),
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+    asyncio.run(start_app_task(
+        conn,
+        "english_translation",
+        "live_translate",
+        {"language_pair": "zh-en"},
+        source="mqtt_immediate",
+    ))
+    assert conn.client_abort is False
+    assert conn.tts.tts_stop_request is False
+    assert spoken and "缈昏瘧妯″紡" in spoken[0]
+    assert conn.interaction_mode == "TRANSLATION"
+
+
+def test_schedule_talk_announces_start_phrase(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
+    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
+
+    conn = SimpleNamespace(
+        config={"enable_proactive_greeting": True},
+        prompt="base",
+        func_handler=None,
+        device_id="mac-schedule-talk",
+        client_abort=False,
+        tts=SimpleNamespace(tts_stop_request=False),
+        active_app_id="chat",
+        current_task_id="conversation",
+        current_source="default_chat",
+        interaction_mode="CHAT",
+        chat_greeted=True,
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+
+    asyncio.run(start_app_task(conn, "english_translation", "live_translate", {}, source="schedule"))
+    assert spoken == ["寮�濮嬬炕璇戜换鍔�"]
+    assert conn.interaction_mode == "TRANSLATION"
+
+    spoken.clear()
+    conn.active_app_id = "chat"
+    conn.current_task_id = "conversation"
+    conn.interaction_mode = "CHAT"
+    asyncio.run(start_app_task(conn, "english_conversation", "bilingual_chat", {}, source="schedule"))
+    assert spoken == ["寮�濮嬩腑鑻卞璇濅换鍔�"]
+    assert conn.interaction_mode == "BILINGUAL"
+
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="schedule"))
+    assert spoken == ["寮�濮嬪璇濅换鍔�"]
+    assert conn.chat_greeted is True
+    assert conn.interaction_mode == "CHAT"
+
+
+def test_voice_exit_overrides_parent_bilingual_and_translation(tmp_path, monkeypatch):
+    import asyncio
+    from core.app_runtime.runtime import start_app_task
+
+    spoken = []
+
+    async def _false(*args, **kwargs):
+        return False
+
+    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
+    monkeypatch.setattr("core.app_runtime.runtime._speak", lambda conn, text: spoken.append(text))
+    monkeypatch.setattr("core.app_runtime.runtime.prompt_bind_if_needed", _false)
+    monkeypatch.setattr("core.app_runtime.runtime.schedule_close_parent_except", lambda *a, **k: None)
+    monkeypatch.setattr("core.app_runtime.session._schedule_close_parent", lambda *a, **k: None)
+
+    conn = SimpleNamespace(
+        config={},
+        prompt="base",
+        func_handler=None,
+        device_id="mac-exit",
+        client_abort=False,
+        tts=SimpleNamespace(tts_stop_request=False),
+    )
+    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
+
+    asyncio.run(start_app_task(conn, "english_conversation", "bilingual_chat", {}, source="schedule"))
+    assert conn.current_source == "schedule"
+    assert conn.interaction_mode == "BILINGUAL"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="voice"))
+    assert conn.interaction_mode == "CHAT"
+    assert conn.current_source == "voice"
+    assert spoken and "瀵硅瘽妯″紡" in spoken[0]
+
+    asyncio.run(start_app_task(conn, "english_translation", "live_translate", {}, source="mqtt_immediate"))
+    assert conn.interaction_mode == "TRANSLATION"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "english_learning", "repeater", {}, source="voice"))
+    assert conn.interaction_mode == "TRANSLATION"
+    spoken.clear()
+    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="voice"))
+    assert conn.interaction_mode == "CHAT"
+    assert spoken and "瀵硅瘽妯″紡" in spoken[0]

--
Gitblit v1.9.1