"""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, ) -> VoiceConfirmationService: audits = VoiceAuditRepository() return VoiceConfirmationService( settings=settings, sessions=sessions, baidu=baidu, minio=minio, audits=audits, session_factory=sqlite_factory, ) def _active_session_with_pending( surgery_id: str = "123456", confirmation_id: str = "cid-a", *, candidate_consumables: list[str] | None = None, pending_options: list[tuple[str, float]] | None = None, ) -> tuple[CameraSessionManager, str]: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) cands = candidate_consumables or ["纱布", "缝线"] opts = pending_options or [("纱布", 0.4), ("缝线", 0.3)] st = SurgerySessionState(candidate_consumables=cands) st.pending_by_id[confirmation_id] = PendingConsumableConfirmation( id=confirmation_id, status="pending", options=opts, prompt_text="请确认", created_at=datetime.now(timezone.utc), model_top1_label="x", model_top1_confidence=0.41, ) st.pending_fifo.append(confirmation_id) mgr._registry._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, ) 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._registry._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, ) 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._registry._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_resolve_recognizes_label_not_in_topk_but_in_surgery_candidates( sqlite_session_factory, monkeypatch: pytest.MonkeyPatch, ) -> None: """医生说出候选清单中的耗材(未出现在本次 pending 的模型 topk 里)也应记账。""" settings = Settings() sessions, cid = _active_session_with_pending( candidate_consumables=["纱布", "缝线", "止血钳"], pending_options=[("纱布", 0.4), ("缝线", 0.3)], ) minio = MagicMock() minio.configured = True minio.ensure_bucket = MagicMock() minio.upload_voice_wav = MagicMock( return_value=StoredAudio( object_key="k2.wav", sha256_hex="d" * 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, ) 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 False assert result.resolved_label == "止血钳" st = sessions._registry._active["123456"].state assert len(st.details) == 1 assert st.details[0].item_name == "止血钳" assert st.details[0].source == "voice" @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, ) 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, ) 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, ) 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, ) 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, ) 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, ) 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"