wangzhibo
2026-09-11 659c470eba542383987aad227b7f918100f131fa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
"""Phase 2 App Runtime unit tests. No websocket, no Opus."""
 
from types import SimpleNamespace
 
from core.app_runtime.runtime import (
    _plain_user_text,
    app_sd_layout_paths,
    is_echo_of_prompt,
    lesson_mp3_clips,
    match_voice_switch,
    repeater_lesson_mp3,
)
from core.app_runtime.session import (
    TaskSession,
    parse_skill_event,
    should_report_asr_now,
)
 
 
def test_match_voice_switch_shadowing():
    assert match_voice_switch("开始跟读") == ("english_learning", "shadowing")
 
 
def test_match_voice_switch_menu():
    assert match_voice_switch("切换模式") == ("__menu__", "")
    assert match_voice_switch("模式切换") == ("__menu__", "")
 
 
def test_match_voice_switch_chat_return():
    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")
    assert match_voice_switch("退出跟读") == ("chat", "conversation")
    assert match_voice_switch("退出复读") == ("chat", "conversation")
 
 
def test_enter_prompts_announce_mode_without_exit_echo():
    from core.app_runtime.registry import get_task
 
    _, repeater = get_task("english_learning", "repeater")
    _, 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 shadowing_prompt
    assert "退出跟读" not in shadowing_prompt
 
 
def test_voice_menu_text_uses_task_menu_names():
    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
 
 
def test_exit_phrases_beat_short_enter_phrases():
    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("我想退出跟读") == ("chat", "conversation")
    assert is_exit_to_chat("退出翻译") is True
    assert is_exit_to_chat("开始跟读") is False
    assert is_exit_to_chat("退出听读") is True
 
 
def test_new_task_phrases_come_from_registry_only(monkeypatch):
    from copy import deepcopy
    from core.app_runtime import registry as reg
 
    data = deepcopy(reg.get_registry())
    data["apps"]["story_retell"] = {
        "name": "故事复述",
        "default_task": "play",
        "tasks": {
            "play": {
                "menu_name": "故事复述",
                "voice_enter": ["开始故事复述", "故事复述"],
                "voice_exit": ["退出复述"],
                "enter_prompt": "现在已经切换至故事复述。",
            }
        },
    }
    monkeypatch.setattr(reg, "_REGISTRY", data)
    monkeypatch.setattr(reg, "_VOICE_INDEX", None)
    assert match_voice_switch("开始故事复述") == ("story_retell", "play")
    assert match_voice_switch("退出复述") == ("chat", "conversation")
    menu = reg.voice_menu_text()
    assert "故事复述" in menu
 
 
def test_match_voice_switch_select_after_menu():
    assert match_voice_switch("跟读") == ("english_learning", "shadowing")
    assert match_voice_switch("翻译") == ("english_translation", "live_translate")
 
 
def test_match_voice_switch_translate():
    assert match_voice_switch("打开翻译") == ("english_translation", "live_translate")
 
 
def test_reserved_voice_enter_stays_in_registry():
    assert match_voice_switch("成语接龙") == ("__reserved__", "")
    assert match_voice_switch("数字背诵") == ("__reserved__", "")
 
 
def test_is_echo_of_prompt_same_sentence():
    prompt = "Hello, how are you today?"
    assert is_echo_of_prompt(prompt, prompt) is True
    assert is_echo_of_prompt("unrelated park visit", prompt) is False
 
 
def test_plain_user_text_extracts_json_content():
    assert _plain_user_text('{"content":"hello world","language":"en"}') == "hello world"
    assert _plain_user_text("just text") == "just text"
 
 
def test_parse_skill_event_completed():
    event = parse_skill_event(
        "[skill_event]audio.completed|english_learning|repeater|1234"
    )
    assert event is not None
    assert event["name"] == "audio.completed"
    assert event["app_id"] == "english_learning"
    assert event["task_id"] == "repeater"
    assert event["duration_ms"] == "1234"
 
 
def test_parse_skill_event_stopped_and_reject_plain():
    event = parse_skill_event("[skill_event]audio.stopped|english_learning|repeater")
    assert event["name"] == "audio.stopped"
    assert parse_skill_event("我们来聊天吧") is None
    assert parse_skill_event("[app_task]chat|conversation|voice") is None
 
 
def test_task_session_save_load(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
    session = TaskSession(
        task_session_id="sess-demo",
        ws_session_id="ws-1",
        device_id="mac",
        app_id="english_learning",
        task_id="shadowing",
        source="voice",
        interaction_mode="SHADOWING",
        started_at=1,
        status="running",
    )
    session.add_turn(0, "Hello, how are you today?", "hello how are you today", False)
    session.add_turn(1, "I like to read English books.", "i like to read", False)
    session.finish("finished")
    session.save()
 
    loaded = TaskSession.load("sess-demo")
    assert loaded is not None
    assert loaded.status == "completed"
    assert loaded.end_reason == "finished"
    assert len(loaded.turns) == 2
    assert loaded.turns[0]["prompt_text"] == "Hello, how are you today?"
    assert loaded.turns[1]["asr_text"] == "i like to read"
 
 
def test_should_report_asr_now_defers_play_and_shadowing():
    assert should_report_asr_now(SimpleNamespace(interaction_mode="CHAT")) is True
    assert should_report_asr_now(SimpleNamespace(interaction_mode="PLAY_AUDIO")) is False
    assert should_report_asr_now(SimpleNamespace(interaction_mode="SHADOWING")) is False
    assert should_report_asr_now(SimpleNamespace(interaction_mode="TRANSLATION")) is True
 
 
def test_last_shadowing_turn_is_saved_before_chat(tmp_path, monkeypatch):
    from core.app_runtime.runtime import apply_shadowing_user_text
    from core.app_runtime.session import begin_session
 
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
 
    conn = SimpleNamespace(
        session_id="ws-1",
        device_id="mac",
        interaction_mode="SHADOWING",
        client_is_speaking=False,
        shadowing_index=0,
        shadowing_prompt="Hello, how are you today?",
        shadowing_clips=[],
        shadowing_awaiting_user=True,
        config={"app_runtime": {"shadowing_sentences": ["Hello, how are you today?"]}},
        last_asr_audio_snapshot=[],
        sentence_id=None,
        task_session=None,
        active_app_id="english_learning",
        current_task_id="shadowing",
    )
    begin_session(conn, "english_learning", "shadowing", "voice", "SHADOWING")
    outcome = apply_shadowing_user_text(conn, "I went to the park yesterday")
    assert outcome == "recorded"
    assert conn.shadowing_index == 0
    loaded = TaskSession.load(conn.task_session.task_session_id)
    assert loaded.status == "running"
    assert len(loaded.turns) == 1
    assert loaded.turns[0]["asr_text"] == "I went to the park yesterday"
 
 
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",
        "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",
    ]
    only_unit2 = lesson_mp3_clips(names, "english_learning", "shadowing", "unit02")
    assert only_unit2 == ["/english_learning/shadowing/lessons/unit02/02.mp3"]
    assert repeater_lesson_mp3(names, "english_learning", "repeater") == (
        "/english_learning/repeater/lessons/unit01/full.mp3"
    )
    assert repeater_lesson_mp3(names, "english_learning", "shadowing") == (
        "/english_learning/shadowing/lessons/unit01/full.mp3"
    )
 
 
def test_task_session_to_dict_has_phase3_fields():
    session = TaskSession(
        task_session_id="abc123",
        ws_session_id="ws-1",
        device_id="mac",
        agent_id="",
        app_id="english_learning",
        task_id="shadowing",
        source="voice",
        interaction_mode="SHADOWING",
        started_at=1,
        status="running",
    )
    session.add_turn(0, "/english_learning/shadowing/lessons/unit01/01.mp3", "hello", False)
    session.add_event("audio.completed", "english_learning", "shadowing")
    data = session.to_dict()
    assert data["task_session_id"] == "abc123"
    assert data["device_id"] == "mac"
    assert "agent_id" in data
    assert data["turns"][0]["prompt_text"].endswith("01.mp3")
    assert data["events"][0]["name"] == "audio.completed"
 
 
def test_save_survives_upsert_scheduler_error(tmp_path, monkeypatch):
    monkeypatch.setattr("core.app_runtime.session.session_dir", lambda: str(tmp_path))
 
    def boom(_payload):
        raise RuntimeError("manage_api down")
 
    monkeypatch.setattr("core.app_runtime.session._schedule_upsert", boom)
    session = TaskSession(
        task_session_id="sess-upsert-fail",
        ws_session_id="ws-1",
        device_id="mac",
        app_id="english_learning",
        task_id="shadowing",
        source="voice",
        interaction_mode="SHADOWING",
        started_at=1,
        status="running",
    )
    session.add_turn(0, "Hello", "hello", False)
    session.finish("finished")
    session.save()
    loaded = TaskSession.load("sess-upsert-fail")
    assert loaded is not None
    assert loaded.status == "completed"
    assert len(loaded.turns) == 1
 
 
def test_repeater_queues_play_until_listen_start(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
 
    plays = []
 
    async def fake_play(conn, params):
        plays.append(params)
        return None
 
    async def fake_dirs(conn, app_id):
        return None
 
    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",
        ]
 
    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)
    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]
    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].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 conn.repeater_playlist == []
    assert conn.pending_play_after_tts is None
 
 
def test_shadowing_queues_whole_playlist_until_listen_start(monkeypatch):
    import asyncio
    from core.app_runtime.runtime import maybe_flush_pending_play_after_tts, start_app_task
 
    plays = []
 
    async def fake_play(conn, params):
        plays.append(params)
        return None
 
    async def fake_dirs(conn, app_id):
        return None
 
    monkeypatch.setattr("core.app_runtime.runtime._execute_named_tool_play", fake_play)
    monkeypatch.setattr("core.app_runtime.runtime.ensure_app_sd_dirs", fake_dirs)
    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"))
    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
    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 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():
    import asyncio
    from core.app_runtime.runtime import _prepare_shadowing_sd
 
    conn = SimpleNamespace(
        config={},
        active_app_id="english_learning",
        current_task_id="shadowing",
    )
    ok = asyncio.run(_prepare_shadowing_sd(conn, {}))
    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
 
 
def test_app_sd_layout_paths_from_registry():
    from core.app_runtime.registry import get_app
 
    paths = app_sd_layout_paths("english_learning", get_app("english_learning"))
    assert set(paths) == {
        "/english_learning/repeater/lessons",
        "/english_learning/shadowing/lessons",
    }
    assert app_sd_layout_paths("chat", get_app("chat")) == []
    assert app_sd_layout_paths("english_translation", get_app("english_translation")) == []
 
 
def test_boot_default_chat_speaks_greeting(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))
 
    async def _ready(conn, timeout=8):
        return True
 
    monkeypatch.setattr("core.app_runtime.runtime.wait_for_tts_channels", _ready)
    conn = SimpleNamespace(
        config={
            "enable_proactive_greeting": True,
            "proactive_chat_greeting": "我们来聊天吧。",
        },
        prompt="",
        func_handler=None,
        just_woken_up=True,
        chat_greeted=False,
        active_app_id="chat",
        current_task_id="conversation",
        current_source="default_chat",
        interaction_mode="CHAT",
    )
    conn.change_system_prompt = lambda text: setattr(conn, "prompt", text)
    asyncio.run(start_app_task(conn, "chat", "conversation", {}, source="default_chat"))
    assert spoken == ["我们来聊天吧。"]
    assert conn.chat_greeted is True
 
 
def test_client_hello_is_recognized_before_bind():
    from core.websocket_handshake import is_client_hello
 
    assert is_client_hello('{"type":"hello","version":1}') is True
    assert is_client_hello('{"type":"listen","state":"detect"}') is False
    assert is_client_hello(b"\x00\x01") is False
 
 
def test_merge_hello_audio_params_keeps_server_output_rate():
    from core.websocket_handshake import merge_hello_audio_params
 
    merged = merge_hello_audio_params(
        {"format": "opus", "sample_rate": 24000, "channels": 1, "frame_duration": 60},
        {"format": "opus", "sample_rate": 16000, "channels": 1, "frame_duration": 60},
    )
    assert merged["sample_rate"] == 24000
    assert merged["frame_duration"] == 60
    assert merged["format"] == "opus"
 
 
def test_priority_listen_detect_is_not_dropped_during_bind():
    from core.websocket_handshake import is_priority_listen_detect
 
    assert is_priority_listen_detect(
        '{"type":"listen","state":"detect","text":"[app_task]chat|conversation|default_chat"}'
    ) is True
    assert is_priority_listen_detect(
        '{"type":"listen","state":"detect","text":"你好小智"}'
    ) is False
 
 
def test_websocket_upgrade_accepts_keep_alive_connection_header():
    from core.websocket_handshake import is_websocket_upgrade
 
    assert is_websocket_upgrade({"connection": "Upgrade", "upgrade": "websocket"}) is True
    assert is_websocket_upgrade({"connection": "keep-alive, Upgrade", "upgrade": "websocket"}) is True
    assert is_websocket_upgrade({"connection": "keep-alive"}) is False
 
 
def test_app_sd_layout_paths_custom_subscribed_app():
    app = {
        "tasks": {
            "listen": {
                "interaction_mode": "PLAY_AUDIO",
                "skills": [{"name": "self.mp3.play"}],
            },
            "chatty": {"interaction_mode": "CHAT", "skills": []},
        }
    }
    assert app_sd_layout_paths("story_time", app) == ["/story_time/listen/lessons"]