"""Phase 2 tests: DashScope WebSocket protocol — callback bridge and event formatting. Covers: - DashScopeCallback sync→async queue bridge - Transcription text event formatting (partial) - Transcription completed event formatting (final) """ import asyncio import json from unittest.mock import AsyncMock, MagicMock import pytest class TestDashScopeCallback: def test_puts_events_on_queue(self): """on_event should push parsed JSON onto the asyncio queue.""" from app.routers.ws_asr import DashScopeCallback queue: asyncio.Queue = asyncio.Queue() loop = asyncio.new_event_loop() callback = DashScopeCallback(queue, loop) test_event = json.dumps({"type": "test", "data": "hello"}) callback.on_event(test_event) # Give the loop a chance to process call_soon_threadsafe loop.run_until_complete(asyncio.sleep(0.05)) assert not queue.empty() event = queue.get_nowait() assert event["type"] == "test" assert event["data"] == "hello" loop.close() def test_handles_dict_event(self): """on_event should accept dict as well as string.""" from app.routers.ws_asr import DashScopeCallback queue: asyncio.Queue = asyncio.Queue() loop = asyncio.new_event_loop() callback = DashScopeCallback(queue, loop) callback.on_event({"type": "test_dict", "key": "value"}) loop.run_until_complete(asyncio.sleep(0.05)) assert not queue.empty() event = queue.get_nowait() assert event["type"] == "test_dict" loop.close() def test_handles_invalid_json_gracefully(self): """on_event should not crash on invalid JSON.""" from app.routers.ws_asr import DashScopeCallback queue: asyncio.Queue = asyncio.Queue() loop = asyncio.new_event_loop() callback = DashScopeCallback(queue, loop) # Should not raise callback.on_event("not-valid-json{{{") loop.close() def test_on_open_and_close(self): """on_open and on_close should not crash.""" from app.routers.ws_asr import DashScopeCallback queue: asyncio.Queue = asyncio.Queue() loop = asyncio.new_event_loop() callback = DashScopeCallback(queue, loop) callback.on_open() callback.on_close(1000, "normal") loop.close() class TestTextFieldFormatting: """Verify text field (stable cumulative) replaces stash merge logic. DashScope partial events contain: - text: monotonically growing stable transcription (never shrinks) - stash: sliding window of latest chars (raw, unstable) The production code uses text for delta computation and passes stash through for frontend's trailing-char completion on pause. _merge_stash has been removed — text is already cumulative. """ def test_text_field_present_and_distinct_from_stash(self): """text field is the stable prefix; stash is the trailing window.""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.text", "text": "多謝主席咁啊亦都多謝", "stash": "邱主任", "language": "yue", } result = format_transcription_event(event, "") assert result is not None assert not result["is_final"] assert result["text"] == "多謝主席咁啊亦都多謝" assert result["stash"] == "邱主任" def test_text_grows_monotonically_across_events(self): """text field should never lose characters between successive events.""" from app.routers.ws_asr import format_transcription_event events = [ {"type": "conversation.item.input_audio_transcription.text", "text": "多謝主席", "stash": "席咁啊", "language": "yue"}, {"type": "conversation.item.input_audio_transcription.text", "text": "多謝主席咁啊", "stash": "啊亦都", "language": "yue"}, {"type": "conversation.item.input_audio_transcription.text", "text": "多謝主席咁啊亦都多謝", "stash": "邱主任", "language": "yue"}, ] prev_text = "" for event in events: result = format_transcription_event(event, "") assert result is not None current_text = result["text"] assert current_text.startswith(prev_text) if prev_text else True, ( f"text regressed: '{current_text}' does not start with '{prev_text}'" ) prev_text = current_text def test_text_empty_early_on(self): """Early in an utterance, text may be empty while stash has content.""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.text", "text": "", "stash": "多謝主席", "language": "yue", } result = format_transcription_event(event, "") assert result is not None assert result["text"] == "" assert result["stash"] == "多謝主席" def test_text_empty_stash_only_is_still_valid(self): """Both fields empty is a valid transient state.""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.text", "text": "", "stash": "", "language": "yue", } result = format_transcription_event(event, "") assert result is not None assert result["text"] == "" assert result["stash"] == "" class TestProxyFormatsTranscriptionTextEvent: def test_partial_event_returns_text_and_stash_fields(self): """Partial event returns both text (stable prefix) and stash (trailing).""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.text", "text": "多謝主席", "stash": "席咁啊", "language": "yue", } result = format_transcription_event(event, "") assert result is not None assert result["is_final"] is False assert result["language"] == "yue" assert result["delta"] == "" assert result["text"] == "多謝主席" assert result["stash"] == "席咁啊" def test_partial_event_ignores_accumulated(self): """Partial event returns fields unchanged regardless of accumulated.""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.text", "text": "世界", "stash": "界大同", "language": "yue", } result = format_transcription_event(event, "你好") assert result["text"] == "世界" assert result["stash"] == "界大同" class TestProxyFormatsTranscriptionCompletedEvent: def test_completed_event_format(self): """Completed event should format as ASRTranscriptEvent with is_final=True.""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.completed", "transcript": "你好世界", "language": "yue", } result = format_transcription_event(event, "") assert result is not None assert result["is_final"] is True assert result["language"] == "yue" assert "你好" in result["full_text"] def test_completed_updates_accumulated(self): """Completed event appends transcript to accumulated text.""" from app.routers.ws_asr import format_transcription_event event = { "type": "conversation.item.input_audio_transcription.completed", "transcript": "世界", "language": "yue", } result = format_transcription_event(event, "你好") assert "你好" in result["full_text"] assert "世界" in result["full_text"] def test_unknown_event_type_returns_none(self): """Unknown event types should return None.""" from app.routers.ws_asr import format_transcription_event result = format_transcription_event({"type": "unknown.event"}, "") assert result is None