Files
operating-room-monitor-server/backend/tests/test_session_manager_unit.py
Kevin 5bbc3903cb Fix Docker log permissions and harden live surgery operations.
Map bind-mounted logs to host UID/GID via entrypoint, expose RTSP prewarm in compose, suppress health-check access noise, and return 409 when another surgery is active with orphan auto-end sweep.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:36:09 +08:00

385 lines
12 KiB
Python

from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.baked import pipeline as bp
from app.config import Settings
from app.domain.consumption import SurgeryConsumptionStored
from app.surgery_errors import SurgeryPipelineError
from app.services.video.session_manager import (
CameraSessionManager,
PendingConsumableConfirmation,
RunningSurgery,
SurgerySessionState,
)
def test_live_consumption_requires_non_empty_details() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
run = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
mgr._registry._active["123456"] = run
st.ready.set()
assert mgr.live_consumption_if_active("123456") is None
@pytest.mark.asyncio
async def test_resolve_voice_accepts_label_on_surgery_list_not_in_topk_options() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(
candidate_consumables=["纱布", "止血钳"],
name_to_code={"纱布": "P1", "止血钳": "P2"},
)
pid = "test-confirm-id"
st.pending_by_id[pid] = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4)],
prompt_text="请确认",
created_at=datetime.now(timezone.utc),
model_top1_label="unknown",
model_top1_confidence=0.41,
)
st.pending_fifo.append(pid)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
await mgr.resolve_pending_confirmation("123456", pid, chosen_label="止血钳", rejected=False)
assert len(st.details) == 1
assert st.details[0].item_id == "P2"
assert st.details[0].item_name == "止血钳"
assert st.details[0].source == "voice"
@pytest.mark.asyncio
async def test_resolve_pending_appends_voice_detail() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布", "缝线"])
pid = "test-confirm-id"
st.pending_by_id[pid] = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4), ("缝线", 0.3)],
prompt_text="请确认",
created_at=datetime.now(timezone.utc),
model_top1_label="unknown",
model_top1_confidence=0.41,
)
st.pending_fifo.append(pid)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
await mgr.resolve_pending_confirmation("123456", pid, chosen_label="纱布", rejected=False)
assert len(st.details) == 1
assert st.details[0].item_name == "纱布"
assert st.details[0].source == "voice"
assert pid not in st.pending_by_id
assert st.pending_fifo == []
@pytest.mark.asyncio
async def test_resolve_reject_keeps_pending_detail_and_queue() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
pid = "r1"
st.pending_by_id[pid] = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4)],
prompt_text="x",
created_at=datetime.now(timezone.utc),
model_top1_label="纱布",
model_top1_confidence=0.4,
)
st.pending_fifo.append(pid)
st.details.append(
SurgeryConsumptionStored(
item_id=f"pending:{pid}",
item_name="纱布(待确认)",
qty=1,
doctor_id="x",
timestamp=datetime.now(timezone.utc),
source="pending_confirmation",
pending_confirmation_id=pid,
),
)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
await mgr.resolve_pending_confirmation("123456", pid, chosen_label=None, rejected=True)
assert len(st.details) == 1
assert st.details[0].item_id == f"pending:{pid}"
assert st.details[0].item_name == "纱布(待确认)"
assert pid in st.pending_by_id
assert st.pending_fifo == [pid]
@pytest.mark.asyncio
async def test_archive_retry_loop_starts() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
await mgr.start_archive_retry_loop()
persister = mgr._archive
assert persister._retry_task is not None
await mgr.shutdown()
assert persister._retry_task is None
@pytest.mark.asyncio
async def test_resolve_invalid_chosen_label() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
pid = "p1"
st.pending_by_id[pid] = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4)],
prompt_text="x",
created_at=datetime.now(timezone.utc),
model_top1_label="x",
model_top1_confidence=0.4,
)
st.pending_fifo.append(pid)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
with pytest.raises(SurgeryPipelineError) as excinfo:
await mgr.resolve_pending_confirmation("123456", pid, chosen_label="止血钳", rejected=False)
assert excinfo.value.code == "CONFIRMATION_INVALID"
@pytest.mark.asyncio
async def test_resolve_not_active() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
with pytest.raises(SurgeryPipelineError) as excinfo:
await mgr.resolve_pending_confirmation("999999", "p1", chosen_label="纱布", rejected=False)
assert excinfo.value.code == "CONFIRMATION_NOT_ACTIVE"
@pytest.mark.asyncio
async def test_resolve_second_time_not_found() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
pid = "p2"
st.pending_by_id[pid] = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4)],
prompt_text="x",
created_at=datetime.now(timezone.utc),
model_top1_label="x",
model_top1_confidence=0.4,
)
st.pending_fifo.append(pid)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
await mgr.resolve_pending_confirmation("123456", pid, chosen_label="纱布", rejected=False)
with pytest.raises(SurgeryPipelineError) as excinfo:
await mgr.resolve_pending_confirmation("123456", pid, chosen_label="纱布", rejected=False)
assert excinfo.value.code == "CONFIRMATION_NOT_FOUND"
@pytest.mark.asyncio
async def test_resolve_already_resolved_status() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
pid = "p3"
pending = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4)],
prompt_text="x",
created_at=datetime.now(timezone.utc),
model_top1_label="x",
model_top1_confidence=0.4,
)
st.pending_by_id[pid] = pending
st.pending_fifo.append(pid)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
pending.status = "confirmed"
with pytest.raises(SurgeryPipelineError) as excinfo:
await mgr.resolve_pending_confirmation("123456", pid, chosen_label="纱布", rejected=False)
assert excinfo.value.code == "CONFIRMATION_ALREADY_RESOLVED"
def test_pending_queue_pending_count_fifo() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
for pid in ("p1", "p2"):
st.pending_by_id[pid] = PendingConsumableConfirmation(
id=pid,
status="pending",
options=[("纱布", 0.4)],
prompt_text="x",
created_at=datetime.now(timezone.utc),
model_top1_label="x",
model_top1_confidence=0.4,
)
st.pending_fifo.append(pid)
mgr._registry._active["123456"] = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[])
assert mgr.pending_queue_pending_count("123456") == 2
@pytest.mark.asyncio
async def test_start_surgery_pauses_prewarm_and_resumes_on_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
settings = Settings(video_open_timeout_sec=5.0)
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
prewarm = AsyncMock()
prewarm.was_warm = MagicMock(return_value=True)
mgr.set_rtsp_prewarm_service(prewarm)
async def fake_recorder_run(_self: object, stop_event: asyncio.Event) -> None:
await asyncio.sleep(3600)
monkeypatch.setattr(
"app.services.video.session_manager.RtspSegmentRecorder.run",
fake_recorder_run,
)
monkeypatch.setattr(
"app.services.video.session_manager.resolve_recording_cameras",
lambda *_a, **_k: ["or-cam-03"],
)
monkeypatch.setattr(
mgr,
"_resolve_rtsp_url",
AsyncMock(return_value=("rtsp://example/stream", None, False)),
)
with pytest.raises(SurgeryPipelineError):
await mgr.start_surgery("123456", ["or-cam-03"], ["纱布"])
prewarm.pause.assert_awaited_once_with("or-cam-03")
prewarm.resume.assert_awaited_once_with("or-cam-03")
@pytest.mark.asyncio
async def test_stop_surgery_resumes_prewarm_for_recorded_cameras() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
prewarm = AsyncMock()
mgr.set_rtsp_prewarm_service(prewarm)
st = SurgerySessionState(candidate_consumables=["纱布"])
st.ready.set()
run = RunningSurgery(
stop_event=asyncio.Event(),
state=st,
tasks=[],
record_camera_ids=["or-cam-03"],
)
mgr._registry._active["123456"] = run
await mgr.stop_surgery("123456")
prewarm.resume.assert_awaited_once_with("or-cam-03")
@pytest.mark.asyncio
async def test_start_surgery_rejects_when_another_surgery_active() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
other = SurgerySessionState(candidate_consumables=["纱布"], surgery_started_wall=0.0)
other.ready.set()
mgr._registry._active["111111"] = RunningSurgery(
stop_event=asyncio.Event(),
state=other,
tasks=[],
)
with pytest.raises(SurgeryPipelineError) as ei:
await mgr.start_surgery("222222", ["or-cam-03"], ["纱布"])
assert ei.value.code == "SURGERY_IN_PROGRESS"
assert ei.value.extra == {"active_surgery_id": "111111"}
assert "111111" in ei.value.message
@pytest.mark.asyncio
async def test_orphan_sweep_auto_ends_stale_active_surgery() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
hikvision_runtime=None,
result_repository=None,
)
mgr._slice_batch.drain = AsyncMock(return_value=None)
st = SurgerySessionState(
candidate_consumables=["纱布"],
surgery_started_wall=0.0,
)
st.ready.set()
mgr._registry._active["123456"] = RunningSurgery(
stop_event=asyncio.Event(),
state=st,
tasks=[],
)
await mgr._sweep_orphan_surgeries_once()
assert "123456" not in mgr._registry._active