from __future__ import annotations import asyncio import json import math import wave from pathlib import Path from app.tools.local_stresstest.audio import ( AudioSample, audio_metrics, build_variations, read_wav_mono16, vad_proxy_metrics, write_wav, ) from app.tools.local_stresstest.report import render_markdown_report, write_csv, write_mermaid_files from app.tools.local_stresstest.runner import StressConfig, wait_for_local_services from app.tools.local_stresstest.scenarios import scenario_description from app.tools.local_stresstest.text import compare_text, normalize_text, word_error_rate from app.tools.local_stresstest.timeline import excerpt_timeline, read_timeline, timeline_has_error def _tone_pcm(*, sample_rate: int = 16_000, duration_ms: int = 300, hz: float = 440.0) -> bytes: samples = round(sample_rate * duration_ms / 1000) out = bytearray() for idx in range(samples): value = round(math.sin(2 * math.pi * hz * idx / sample_rate) * 9000) out.extend(int(value).to_bytes(2, byteorder="little", signed=True)) return bytes(out) def test_normalize_text_removes_accents_case_and_punctuation() -> None: assert normalize_text(" Teste UNITÁRIO, Sofya! ") == "teste unitario sofya" def test_word_error_rate_counts_insertions_deletions_and_substitutions() -> None: wer, substitutions, deletions, insertions = word_error_rate( "teste unitario do stt sofya", "teste unitario stt sofia agora", ) assert round(wer, 2) == 0.6 assert substitutions == 1 assert deletions == 1 assert insertions == 1 def test_compare_text_reports_missing_critical_terms() -> None: comparison = compare_text( expected="Teste unitario do STT Sofya", actual="Teste unitario do STT", critical_terms=["teste", "sofya"], ) assert comparison.terms_ok is False assert comparison.missing_terms == ("sofya",) def test_audio_variations_keep_expected_names_and_are_nonempty() -> None: sample = AudioSample(name="base", pcm=_tone_pcm()) variations = build_variations(sample) assert [item.name for item in variations] == [ "clean", "low_volume", "very_low_volume", "high_volume", "clipped_high_volume", "leading_trailing_silence", "short_leading_silence", "long_leading_silence", "noise_snr_20", "noise_snr_15", "noise_snr_10", "pre_noise_300ms", "pre_noise_800ms", "telephony_profile", "telephony_low_volume", "initial_fade_in_250ms", "initial_fade_in_500ms", "initial_fade_in_900ms", "initial_dip_300ms", "initial_dip_700ms", "prefix_300ms_15pct", "prefix_600ms_20pct", "prefix_900ms_25pct", "low_prefix_noise_600ms", "low_prefix_telephony_700ms", ] assert all(item.pcm for item in variations) assert len(variations[5].pcm) > len(sample.pcm) assert len(variations) == 25 def test_audio_metrics_detects_audible_tone_and_duration() -> None: metrics = audio_metrics(_tone_pcm(duration_ms=500)) assert metrics.duration_ms == 500 assert metrics.audible is True assert metrics.clipping_ratio == 0.0 def test_vad_proxy_metrics_flags_soft_start_risk() -> None: sample = AudioSample(name="base", pcm=(b"\x00" * 1600) + _tone_pcm(duration_ms=300)) metrics = vad_proxy_metrics(sample, threshold_dbfs=-45.0, prefix_padding_ms=20) assert metrics.first_voice_ms > 0 assert metrics.unrecovered_prefix_ms > 0 assert metrics.low_start_risk is True def test_wav_read_converts_to_16k_mono(tmp_path: Path) -> None: stereo_path = tmp_path / "stereo.wav" left = _tone_pcm(sample_rate=8_000, duration_ms=100) stereo = bytearray() for idx in range(0, len(left), 2): stereo.extend(left[idx : idx + 2]) stereo.extend(left[idx : idx + 2]) with wave.open(str(stereo_path), "wb") as handle: handle.setnchannels(2) handle.setsampwidth(2) handle.setframerate(8_000) handle.writeframes(bytes(stereo)) sample = read_wav_mono16(stereo_path) assert sample.sample_rate == 16_000 assert sample.channels == 1 assert audio_metrics(sample.pcm).duration_ms == 100 def test_write_wav_creates_parent_directory(tmp_path: Path) -> None: out = write_wav(tmp_path / "nested" / "tone.wav", AudioSample(name="tone", pcm=_tone_pcm())) assert out.exists() assert read_wav_mono16(out).pcm def test_render_markdown_report_contains_tables_and_mermaid() -> None: report = render_markdown_report( { "passed": True, "started_at": "2026-06-16T00:00:00Z", "duration_ms": 123, "expected_text": "Teste", "baseline": {"path": "baseline.wav", "synthetic": True}, "stt_results": [{"scenario": "clean", "passed": True, "wer": 0.0}], "tts_results": [{"scenario": "short", "passed": True, "duration_ms": 1000}], "e2e_results": [{"scenario": "clean", "passed": True, "ready_received": True}], "startup_checks": { "bridge": { "service": "bridge", "url": "http://127.0.0.1:8000/health", "status": "ready", "attempts": 1, "detail": "HTTP 200", } }, "artifacts": {"summary_json": "summary.json"}, } ) assert "Aviso: esta execucao usou um audio base sintetico" in report assert "## Prontidao Local" in report assert "Versao textual:" in report assert "![Fluxo STT](diagrams/stt_flow.svg)" in report assert "Codigo Mermaid:" in report assert "```mermaid" in report assert "| cenario | descricao | status | prefixo_ok |" in report assert "Audio base sem degradacao" in report def test_scenario_description_ignores_repeat_suffix() -> None: assert scenario_description("noise_snr_20_r2").startswith("Audio com ruido leve") def test_write_mermaid_files_creates_standalone_diagrams(tmp_path: Path) -> None: files = write_mermaid_files(tmp_path) assert Path(files["stt_mermaid"]).read_text(encoding="utf-8").startswith("flowchart LR") assert "sequenceDiagram" in Path(files["e2e_mermaid"]).read_text(encoding="utf-8") assert Path(files["stt_svg"]).read_text(encoding="utf-8").startswith(" StressConfig: return StressConfig( env_file=Path(".env.dev"), report_dir=tmp_path, expected_text="Teste", synthesis_text="Teste", critical_terms=("teste",), stt_wer_threshold=0.2, bridge_url="ws://127.0.0.1:8000/ws/agent", bridge_health_url="http://bridge.local/health", agent_health_url="http://agent.local/", startup_wait_s=5.0, startup_poll_s=0.01, skip_local_wait=False, repeat=1, concurrency=1, e2e_turns=1, e2e_timeout_s=5.0, stress_audio=None, prefix_text="Teste", prefix_words=1, vad_proxy_threshold_dbfs=-45.0, vad_proxy_prefix_padding_ms=1000, vad_proxy_min_speech_ms=100, ) def test_wait_for_local_services_retries_until_agent_is_ready(tmp_path: Path) -> None: config = _stress_config(tmp_path) attempts: dict[str, int] = {} async def probe(url: str) -> tuple[bool, str]: attempts[url] = attempts.get(url, 0) + 1 if "agent" in url and attempts[url] < 3: return False, "connection refused" return True, "HTTP 200" async def no_sleep(_: float) -> None: return None states = asyncio.run(wait_for_local_services(config, probe=probe, sleep=no_sleep)) assert states["bridge"]["ok"] is True assert states["bridge"]["attempts"] == 1 assert states["agent_runtime"]["ok"] is True assert states["agent_runtime"]["attempts"] == 3 def test_write_csv_handles_empty_rows(tmp_path: Path) -> None: out = write_csv(tmp_path / "empty.csv", []) assert out.read_text(encoding="utf-8").strip() == "" def test_timeline_helpers_parse_excerpt_and_errors(tmp_path: Path) -> None: path = tmp_path / "timeline.jsonl" records = [ {"event": "ready_sent"}, {"event": "noise"}, {"event": "user_transcript_final", "text": "ola"}, {"event": "bridge_failed"}, ] path.write_text("\n".join(json.dumps(item) for item in records), encoding="utf-8") parsed = read_timeline(path) assert parsed == records assert [item["event"] for item in excerpt_timeline(parsed)] == [ "ready_sent", "user_transcript_final", "bridge_failed", ] assert timeline_has_error(parsed) is True