first commit
This commit is contained in:
1
tests/tools/__init__.py
Normal file
1
tests/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for operational tooling."""
|
||||
258
tests/tools/test_local_stresstest.py
Normal file
258
tests/tools/test_local_stresstest.py
Normal file
@@ -0,0 +1,258 @@
|
||||
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 "" 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("<svg")
|
||||
|
||||
|
||||
def _stress_config(tmp_path: Path) -> 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
|
||||
119
tests/tools/test_oci_audio_download.py
Normal file
119
tests/tools/test_oci_audio_download.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tools.oci_audio_download import (
|
||||
_parse_date,
|
||||
_parse_session_id,
|
||||
download_prefix,
|
||||
entire_calls_prefix,
|
||||
segments_prefix,
|
||||
)
|
||||
from app.utils.stt_audio_upload import OCIUploadConfig
|
||||
|
||||
|
||||
class _Raw:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
def stream(self, _size: int, decode_content: bool = False):
|
||||
assert decode_content is False
|
||||
yield self.content
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
self.downloaded: list[str] = []
|
||||
|
||||
def list_objects(self, **kwargs):
|
||||
prefix = kwargs["prefix"]
|
||||
items = [
|
||||
SimpleNamespace(name=name, size=len(content), etag="etag")
|
||||
for name, content in self.objects.items()
|
||||
if name.startswith(prefix)
|
||||
]
|
||||
return SimpleNamespace(data=SimpleNamespace(objects=items, next_start_with=None))
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
name = kwargs["object_name"]
|
||||
self.downloaded.append(name)
|
||||
return SimpleNamespace(data=SimpleNamespace(raw=_Raw(self.objects[name])))
|
||||
|
||||
|
||||
def _config() -> OCIUploadConfig:
|
||||
return OCIUploadConfig("local", "sa-saopaulo-1", "tia-audio", "namespace")
|
||||
|
||||
|
||||
def test_prefixes_match_object_storage_layout() -> None:
|
||||
assert segments_prefix("2026-07-24", "session-123") == "2026-07-24/session-123/"
|
||||
assert entire_calls_prefix("2026-07-24") == "2026-07-24/entire_call/"
|
||||
|
||||
|
||||
def test_argument_validation() -> None:
|
||||
assert _parse_date("2026-07-24") == "2026-07-24"
|
||||
assert _parse_session_id("session_123") == "session_123"
|
||||
with pytest.raises(Exception):
|
||||
_parse_date("24/07/2026")
|
||||
with pytest.raises(Exception):
|
||||
_parse_session_id("../entire_call")
|
||||
|
||||
|
||||
def test_download_prefix_downloads_and_creates_zip(tmp_path: Path) -> None:
|
||||
prefix = "2026-07-24/entire_call/"
|
||||
client = _Client(
|
||||
{
|
||||
f"{prefix}call-a.wav": b"audio-a",
|
||||
f"{prefix}call-b.wav": b"audio-b",
|
||||
"2026-07-23/entire_call/old.wav": b"old",
|
||||
}
|
||||
)
|
||||
output_dir = tmp_path / "calls"
|
||||
zip_path = tmp_path / "calls.zip"
|
||||
result = download_prefix(
|
||||
client=client,
|
||||
config=_config(),
|
||||
prefix=prefix,
|
||||
output_dir=output_dir,
|
||||
zip_path=zip_path,
|
||||
)
|
||||
assert result.object_count == 2
|
||||
assert result.downloaded_count == 2
|
||||
assert (output_dir / "call-a.wav").read_bytes() == b"audio-a"
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
assert sorted(archive.namelist()) == ["call-a.wav", "call-b.wav"]
|
||||
assert archive.testzip() is None
|
||||
|
||||
|
||||
def test_download_prefix_reuses_complete_file(tmp_path: Path) -> None:
|
||||
prefix = "2026-07-24/session-123/"
|
||||
name = f"{prefix}message.wav"
|
||||
client = _Client({name: b"segment"})
|
||||
output_dir = tmp_path / "segments"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "message.wav").write_bytes(b"segment")
|
||||
result = download_prefix(
|
||||
client=client,
|
||||
config=_config(),
|
||||
prefix=prefix,
|
||||
output_dir=output_dir,
|
||||
zip_path=None,
|
||||
)
|
||||
assert result.cached_count == 1
|
||||
assert result.downloaded_count == 0
|
||||
assert client.downloaded == []
|
||||
|
||||
|
||||
def test_download_prefix_rejects_empty_prefix(tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
download_prefix(
|
||||
client=_Client({}),
|
||||
config=_config(),
|
||||
prefix="2026-07-24/entire_call/",
|
||||
output_dir=tmp_path,
|
||||
zip_path=None,
|
||||
)
|
||||
Reference in New Issue
Block a user