"""解耦测试:用 fake PendingConfirmationStore 验证 VoiceConfirmationService 对端口的依赖。 该用例不构造完整的 CameraSessionManager,验证 Phase 5 引入的协议可替换性。 """ from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from unittest.mock import MagicMock import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.config import Settings from app.repositories.voice_audits import VoiceAuditRepository from app.services.pending_confirmation_port import PendingConfirmationStore from app.services.video.session_manager import PendingConsumableConfirmation from app.services.voice_resolution import VoiceConfirmationService from app.surgery_errors import SurgeryPipelineError @dataclass class _FakePendingStore: """与 PendingConfirmationStore 协议等价的可控 fake;不依赖 CameraSessionManager。""" pendings: dict[tuple[str, str], PendingConsumableConfirmation] = field( default_factory=dict ) candidates: dict[str, list[str]] = field(default_factory=dict) resolved: list[tuple[str, str, str | None, bool]] = field(default_factory=list) traces: list[dict[str, str | None]] = field(default_factory=list) parse_failures: dict[str, int] = field(default_factory=dict) def get_pending_confirmation_by_id( self, surgery_id: str, confirmation_id: str ) -> PendingConsumableConfirmation | None: return self.pendings.get((surgery_id, confirmation_id)) def get_surgery_candidate_consumables(self, surgery_id: str) -> list[str]: return list(self.candidates.get(surgery_id, [])) async def record_voice_parse_failure( self, surgery_id: str, confirmation_id: str ) -> tuple[int, int]: key = f"{surgery_id}:{confirmation_id}" self.parse_failures[key] = self.parse_failures.get(key, 0) + 1 remaining = max(0, 2 - self.parse_failures[key]) return self.parse_failures[key], remaining async def resolve_pending_confirmation( self, surgery_id: str, confirmation_id: str, *, chosen_label: str | None, rejected: bool, ) -> None: self.resolved.append((surgery_id, confirmation_id, chosen_label, rejected)) def record_voice_trace( self, surgery_id: str, *, asr_text: str | None, error: str | None, ) -> None: self.traces.append( {"surgery_id": surgery_id, "asr_text": asr_text, "error": error} ) def test_fake_store_satisfies_protocol() -> None: """_FakePendingStore 必须符合 PendingConfirmationStore 协议(静态/运行时同时验证)。""" store = _FakePendingStore() assert isinstance(store, PendingConfirmationStore) @pytest.mark.asyncio async def test_resolve_from_recognized_text_with_fake_store( sqlite_session_factory: async_sessionmaker[AsyncSession], ) -> None: store = _FakePendingStore() surgery_id = "123456" confirmation_id = "cid-a" store.pendings[(surgery_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="纱布", model_top1_confidence=0.4, ) store.candidates[surgery_id] = ["纱布", "缝线"] svc = VoiceConfirmationService( settings=Settings(), sessions=store, baidu=MagicMock(), minio=MagicMock(), audits=VoiceAuditRepository(), session_factory=sqlite_session_factory, ) result = await svc.resolve_from_recognized_text( surgery_id=surgery_id, confirmation_id=confirmation_id, recognized_text="第一个", ) assert result.resolved_label == "纱布" assert result.rejected is False assert store.resolved == [(surgery_id, confirmation_id, "纱布", False)] @pytest.mark.asyncio async def test_resolve_from_recognized_text_not_found_branch( sqlite_session_factory: async_sessionmaker[AsyncSession], ) -> None: store = _FakePendingStore() svc = VoiceConfirmationService( settings=Settings(), sessions=store, baidu=MagicMock(), minio=MagicMock(), audits=VoiceAuditRepository(), session_factory=sqlite_session_factory, ) with pytest.raises(SurgeryPipelineError) as excinfo: await svc.resolve_from_recognized_text( surgery_id="000000", confirmation_id="missing", recognized_text="第一个", ) assert excinfo.value.code == "CONFIRMATION_NOT_FOUND"