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>
385 lines
12 KiB
Python
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
|
|
|