Files
operating-room-monitor-server/tests/test_voice_resolution_service.py

470 lines
14 KiB
Python
Raw Normal View History

"""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",
*,
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._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_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,
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 False
assert result.resolved_label == "止血钳"
st = sessions._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,
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"