""" Phase 2 Voice Stream tests. Covers: 1. _split_into_voice_chunks: sentence splitting logic 2. BFF endpoint contract: /api/voice/chat/stream response schema 3. Edge cases: empty text, single sentence, very long text, in output 4. Phase 2 regression: ensure old /api/voice/tts still works (no regression) """ import re import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest REPO_ROOT = Path(__file__).parent.parent BFF_PATH = REPO_ROOT / "services" / "sofiia-console" / "app" sys.path.insert(0, str(BFF_PATH)) # voice_utils is a standalone module — importable without FastAPI from voice_utils import split_into_voice_chunks as _split from voice_utils import MIN_CHUNK_CHARS as _MIN, MAX_CHUNK_CHARS as _MAX # ── 1. Sentence splitting ───────────────────────────────────────────────────── class TestSentenceSplitter: pass # splitter always available via voice_utils def test_single_sentence(self): result = _split("Привіт. Як справи?") assert len(result) >= 1 # All content preserved assert "Привіт" in " ".join(result) assert "справи" in " ".join(result) def test_two_sentences_split(self): text = "Перше речення. Друге речення." result = _split(text) assert len(result) >= 1 full = " ".join(result) assert "Перше" in full assert "Друге" in full def test_three_sentences(self): text = "Я Sofiia. Я архітектор DAARION. Мій дім — NODA2." result = _split(text) # Should produce 2-3 chunks (short sentences may merge) assert 1 <= len(result) <= 3 full = " ".join(result) assert "Sofiia" in full assert "NODA2" in full def test_no_chunk_exceeds_max_chars(self): long_text = "Слово " * 60 # ~360 chars result = _split(long_text) for chunk in result: assert len(chunk) <= _MAX + 10, f"Chunk too long ({len(chunk)}): {chunk[:50]}..." def test_empty_text(self): result = _split("") assert result == [] or result == [""] def test_single_very_long_sentence(self): text = "а" * 400 # single word no punctuation result = _split(text) assert len(result) >= 1 for chunk in result: assert len(chunk) <= _MAX + 10 def test_think_tags_not_present_in_chunks(self): text = "міркування Відповідь на питання. Це тест." # Splitter should work on pre-cleaned text (after _clean_think_blocks) import re cleaned = re.sub(r".*?", "", text, flags=re.DOTALL) result = _split(cleaned.strip()) combined = " ".join(result) assert "" not in combined assert "міркування" not in combined def test_preserves_ukrainian_punctuation(self): text = "Добрий день! Як я можу допомогти? Ось відповідь." result = _split(text) full = " ".join(result) assert "Добрий" in full assert "відповідь" in full def test_no_tiny_standalone_chunks(self): text = "Ок. Добре. Чудово! Так само. Зрозуміло." result = _split(text) # Short fragments should be merged for chunk in result: assert len(chunk) >= _MIN or len(result) == 1, ( f"Tiny standalone chunk found: '{chunk}'" ) def test_markdown_stripped_before_split(self): # The BFF strips markdown before TTS; splitting should handle it fine text = "**Відповідь**: перше речення. - Список: не для голосу. Кінець." result = _split(text) assert result # should not crash # ── 2. BFF endpoint contract (mock-based) ──────────────────────────────────── class TestVoiceChatStreamContract: """Tests the /api/voice/chat/stream response schema contract.""" @pytest.fixture def mock_ollama_response(self): """Mock Ollama returning a 2-sentence response.""" mock = AsyncMock() mock.status_code = 200 mock.raise_for_status = MagicMock() mock.json = MagicMock(return_value={ "message": {"content": "Перше речення про NODA2. Друге речення про архітектуру."} }) return mock @pytest.fixture def mock_tts_response(self): """Mock TTS returning MP3 bytes.""" import base64 mock = AsyncMock() mock.status_code = 200 mock.raise_for_status = MagicMock() # Minimal valid "audio" bytes (not real MP3 but enough for b64) mock.content = b"\xff\xfb\x90\x00" + b"\x00" * 100 mock.headers = {"content-type": "audio/mpeg", "X-TTS-Compute-MS": "450"} return mock def test_response_schema_keys(self): """Document expected keys in /api/voice/chat/stream response.""" required_keys = { "ok", "trace_id", "first_text", "first_audio_b64", "first_audio_mime", "rest_chunks", "full_text", "meta" } meta_keys = {"llm_ms", "tts_ms", "chunks_total", "voice", "model", "voice_profile"} # Contract assertion (documentation test) assert required_keys # always passes — documents the contract assert meta_keys def test_first_chunk_is_first_sentence(self): full = "Це перше речення. Це друге речення. Третє." chunks = _split(full) assert chunks[0] in full # first chunk is a prefix or early part of full text assert full.startswith(chunks[0]) or chunks[0] in full def test_rest_chunks_plus_first_equals_full(self): full = "Перше речення про NODA2. Друге речення. Третє речення для тесту." chunks = _split(full) combined = " ".join(chunks) # Normalize whitespace for comparison norm = lambda s: re.sub(r"\s+", " ", s).strip() assert norm(combined) == norm(full) def test_single_sentence_has_empty_rest_chunks(self): single = "Це єдине речення відповіді." chunks = _split(single) assert len(chunks) == 1 # BFF: rest_chunks = chunks[1:] = [] def test_trace_id_format(self): """trace_id must start with 'vs_'.""" import uuid sid = "sess_abc" trace_id = f"vs_{sid}_{uuid.uuid4().hex[:8]}" assert trace_id.startswith("vs_") assert len(trace_id) > 10 # ── 3. Policy regression: max_tokens ≤ 256 ──────────────────────────────────── class TestPhase2PolicyRegression: def test_bff_num_predict_quality_is_256(self): src = (BFF_PATH / "main.py").read_text() # voice_quality: num_predict=256 assert "num_predict\": 256 if _is_quality" in src or \ "num_predict': 256 if _is_quality" in src or \ '"num_predict": 256' in src, \ "BFF voice quality num_predict must be ≤256" def test_bff_stream_endpoint_exists(self): src = (BFF_PATH / "main.py").read_text() assert "/api/voice/chat/stream" in src, \ "Phase 2 endpoint /api/voice/chat/stream not found in BFF" def test_bff_stream_uses_voice_guardrails(self): src = (BFF_PATH / "main.py").read_text() # Check that stream endpoint uses SOFIIA_VOICE_PROMPT_SUFFIX assert "SOFIIA_VOICE_PROMPT_SUFFIX" in src, \ "voice stream must use SOFIIA_VOICE_PROMPT_SUFFIX" def test_bff_stream_cleans_think_tags(self): src = (BFF_PATH / "main.py").read_text() # _clean function defined in stream handler assert "re.sub" in src and "" in src, \ "voice stream endpoint must strip tags" def test_sentence_splitter_exported(self): src = (BFF_PATH / "main.py").read_text() assert "_split_into_voice_chunks" in src, \ "_split_into_voice_chunks function not found in BFF main.py" def test_ui_has_stream_mode_checkbox(self): ui_html = (REPO_ROOT / "services" / "sofiia-console" / "static" / "index.html").read_text() assert "streamMode" in ui_html, "streamMode checkbox not found in UI" assert "voiceChatStream" in ui_html, "voiceChatStream function not found in UI" assert "_audioQueue" in ui_html, "audio queue not found in UI" assert "_audioQueuePush" in ui_html, "_audioQueuePush not found in UI" def test_ui_stream_default_enabled(self): ui_html = (REPO_ROOT / "services" / "sofiia-console" / "static" / "index.html").read_text() # streamMode checkbox should be checked by default assert 'id="streamMode" checked' in ui_html, \ "streamMode should be checked by default for Phase 2"