"""Tests for VoiceConfirmationService branches and audit persistence.""" from __future__ import annotations import asyncio import io import wave from datetime import datetime, timezone from unittest.mock import MagicMock import pytest from sqlalchemy import func, select from app.config import Settings from app.db.models import VoiceConfirmationAudit from app.repositories.voice_audits import VoiceAuditRepository from app.services.minio_audio_storage import StoredAudio from app.services.video.session_manager import ( CameraSessionManager, PendingConsumableConfirmation, RunningSurgery, SurgerySessionState, ) from app.services.voice_resolution import VoiceConfirmationService from app.surgery_errors import SurgeryPipelineError def _minimal_wav_16k_mono() -> bytes: buf = io.BytesIO() with wave.open(buf, "wb") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(16000) wf.writeframes(b"\x00\x00" * 200) return buf.getvalue() def _make_service( *, settings: Settings, sessions: CameraSessionManager, minio: MagicMock, baidu: MagicMock, sqlite_factory, monkeypatch: pytest.MonkeyPatch, ) -> VoiceConfirmationService: monkeypatch.setattr( "app.services.voice_resolution.AsyncSessionLocal", sqlite_factory, ) audits = VoiceAuditRepository() return VoiceConfirmationService( settings=settings, sessions=sessions, baidu=baidu, minio=minio, audits=audits, ) def _active_session_with_pending( surgery_id: str = "123456", confirmation_id: str = "cid-a", ) -> tuple[CameraSessionManager, str]: settings = Settings() mgr = CameraSessionManager( settings=settings, consumable_classifier=MagicMock(), tear_action=MagicMock(), hikvision_runtime=None, result_repository=None, ) st = SurgerySessionState(candidate_consumables=["纱布", "缝线"]) st.pending_by_id[confirmation_id] = PendingConsumableConfirmation( id=confirmation_id, status="pending", options=[("纱布", 0.4), ("缝线", 0.3)], prompt_text="请确认", created_at=datetime.now(timezone.utc), model_top1_label="x", model_top1_confidence=0.41, ) st.pending_fifo.append(confirmation_id) mgr._active[surgery_id] = RunningSurgery( stop_event=asyncio.Event(), state=st, tasks=[] ) return mgr, confirmation_id async def _audit_count(sqlite_factory, *, surgery_id: str) -> int: async with sqlite_factory() as session: async with session.begin(): res = await session.execute( select(func.count()).select_from(VoiceConfirmationAudit).where( VoiceConfirmationAudit.surgery_id == surgery_id ) ) return int(res.scalar_one()) @pytest.mark.asyncio async def test_resolve_recognized_appends_voice_detail_and_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() settings.voice_upload_max_bytes = 10 * 1024 * 1024 sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock( return_value=StoredAudio( object_key="surgeries/123456/confirmations/cid-a/abc.wav", sha256_hex="b" * 64, size_bytes=100, ) ) baidu = MagicMock() baidu.configured = True baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["第一个"]}) svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) wav = _minimal_wav_16k_mono() result = await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=wav, filename="a.wav", content_type="audio/wav", ) assert result.rejected is False assert result.resolved_label == "纱布" assert result.asr_text == "第一个" assert result.audio_object_key is not None st = sessions._active["123456"].state assert len(st.details) == 1 assert st.details[0].source == "voice" assert await _audit_count(sqlite_session_factory, surgery_id="123456") == 1 @pytest.mark.asyncio async def test_resolve_rejected_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock( return_value=StoredAudio( object_key="k.wav", sha256_hex="c" * 64, size_bytes=10, ) ) baidu = MagicMock() baidu.configured = True baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["不是"]}) svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) result = await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=_minimal_wav_16k_mono(), filename="a.wav", content_type="audio/wav", ) assert result.rejected is True assert result.resolved_label is None assert len(sessions._active["123456"].state.details) == 0 async with sqlite_session_factory() as session: async with session.begin(): res = await session.execute(select(VoiceConfirmationAudit)) row = res.scalars().one() assert row.status == "rejected" @pytest.mark.asyncio async def test_audio_too_large_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() settings.voice_upload_max_bytes = 10 sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True baidu = MagicMock() svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) with pytest.raises(SurgeryPipelineError) as ei: await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=b"x" * 20, filename="a.wav", content_type="audio/wav", ) assert ei.value.code == "VOICE_AUDIO_INVALID" async with sqlite_session_factory() as session: async with session.begin(): res = await session.execute(select(VoiceConfirmationAudit)) row = res.scalars().one() assert row.status == "invalid_audio" @pytest.mark.asyncio async def test_minio_not_configured_no_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = False baidu = MagicMock() baidu.configured = True svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) with pytest.raises(SurgeryPipelineError) as ei: await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=_minimal_wav_16k_mono(), filename="a.wav", content_type="audio/wav", ) assert ei.value.code == "MINIO_NOT_CONFIGURED" assert await _audit_count(sqlite_session_factory, surgery_id="123456") == 0 @pytest.mark.asyncio async def test_upload_failed_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock(side_effect=RuntimeError("s3 down")) baidu = MagicMock() baidu.configured = True svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) with pytest.raises(SurgeryPipelineError) as ei: await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=_minimal_wav_16k_mono(), filename="a.wav", content_type="audio/wav", ) assert ei.value.code == "MINIO_UPLOAD_FAILED" async with sqlite_session_factory() as session: async with session.begin(): res = await session.execute(select(VoiceConfirmationAudit)) row = res.scalars().one() assert row.status == "upload_failed" @pytest.mark.asyncio async def test_asr_failed_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock( return_value=StoredAudio(object_key="k", sha256_hex="d" * 64, size_bytes=1) ) baidu = MagicMock() baidu.configured = True baidu.asr = MagicMock(return_value={"err_no": 3300, "err_msg": "bad"}) svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) with pytest.raises(SurgeryPipelineError) as ei: await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=_minimal_wav_16k_mono(), filename="a.wav", content_type="audio/wav", ) assert ei.value.code == "VOICE_ASR_FAILED" async with sqlite_session_factory() as session: async with session.begin(): res = await session.execute(select(VoiceConfirmationAudit)) row = res.scalars().one() assert row.status == "asr_failed" @pytest.mark.asyncio async def test_parse_failed_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock( return_value=StoredAudio(object_key="k", sha256_hex="e" * 64, size_bytes=1) ) baidu = MagicMock() baidu.configured = True # Avoid substrings like 「无」that trigger `is_rejection_phrase`. baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["西红柿土豆"]}) svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) with pytest.raises(SurgeryPipelineError) as ei: await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=_minimal_wav_16k_mono(), filename="a.wav", content_type="audio/wav", ) assert ei.value.code == "VOICE_PARSE_FAILED" async with sqlite_session_factory() as session: async with session.begin(): res = await session.execute(select(VoiceConfirmationAudit)) row = res.scalars().one() assert row.status == "parse_failed" @pytest.mark.asyncio async def test_invalid_wav_decode_audit( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock( return_value=StoredAudio(object_key="k", sha256_hex="f" * 64, size_bytes=1) ) baidu = MagicMock() baidu.configured = True svc = _make_service( settings=settings, sessions=sessions, minio=minio, baidu=baidu, sqlite_factory=sqlite_session_factory, monkeypatch=monkeypatch, ) with pytest.raises(SurgeryPipelineError) as ei: await svc.resolve_from_wav( surgery_id="123456", confirmation_id=cid, wav_bytes=b"not a wav", filename="a.wav", content_type="audio/wav", ) assert ei.value.code == "VOICE_AUDIO_INVALID" async with sqlite_session_factory() as session: async with session.begin(): res = await session.execute(select(VoiceConfirmationAudit)) row = res.scalars().one() assert row.status == "invalid_audio"