136 lines
4.6 KiB
Python
136 lines
4.6 KiB
Python
|
|
"""解耦测试:用 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"
|