- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测 Made-with: Cursor
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"
|