from __future__ import annotations import asyncio from datetime import datetime, timezone from unittest.mock import MagicMock import pytest from app.config import Settings from app.services.consumable_vision_algorithm import ( PredictionCandidate, PredictionResult, ) 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, vision_algorithm=MagicMock(), 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, vision_algorithm=MagicMock(), 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, vision_algorithm=MagicMock(), 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) run = RunningSurgery(stop_event=asyncio.Event(), state=st, tasks=[]) mgr._registry._active["123456"] = run 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_closes_without_detail() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), 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="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=None, rejected=True ) assert st.details == [] assert pid not in st.pending_by_id @pytest.mark.asyncio async def test_handle_skips_when_candidate_list_empty() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=[]) res = PredictionResult( label="纱布", confidence=0.99, topk=[PredictionCandidate(label="纱布", confidence=0.99)], ) await mgr._handle_classification_result(state=state, cls_res=res) assert state.details == [] assert state.pending_fifo == [] @pytest.mark.asyncio async def test_archive_retry_loop_starts() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), 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_handle_skips_below_voice_floor() -> None: settings = Settings() settings.video_voice_confirm_min_confidence = 0.5 mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["纱布"]) res = PredictionResult( label="纱布", confidence=0.4, topk=[PredictionCandidate(label="纱布", confidence=0.4)], ) await mgr._handle_classification_result(state=state, cls_res=res) assert state.details == [] assert state.pending_fifo == [] @pytest.mark.asyncio async def test_handle_auto_vision_confirm() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["纱布"]) res = PredictionResult( label="纱布", confidence=0.99, topk=[PredictionCandidate(label="纱布", confidence=0.99)], ) await mgr._handle_classification_result(state=state, cls_res=res) assert len(state.details) == 1 assert state.details[0].source == "vision" assert state.details[0].item_id == "纱布" @pytest.mark.asyncio async def test_handle_high_conf_top1_not_in_candidates_enqueues_pending() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["缝线"]) res = PredictionResult( label="纱布", confidence=0.9, topk=[ PredictionCandidate(label="纱布", confidence=0.9), PredictionCandidate(label="缝线", confidence=0.2), ], ) await mgr._handle_classification_result(state=state, cls_res=res) assert len(state.details) == 1 assert state.details[0].item_name == "待确认" assert state.details[0].source == "pending_confirmation" assert len(state.pending_fifo) == 1 pid = state.pending_fifo[0] assert state.details[0].pending_confirmation_id == pid assert "缝线" in state.pending_by_id[pid].prompt_text @pytest.mark.asyncio async def test_handle_mid_confidence_enqueues_pending() -> None: settings = Settings() settings.video_auto_confirm_confidence = 0.8 settings.video_voice_confirm_min_confidence = 0.3 mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["纱布", "缝线"]) res = PredictionResult( label="纱布", confidence=0.5, topk=[ PredictionCandidate(label="纱布", confidence=0.5), PredictionCandidate(label="缝线", confidence=0.3), ], ) await mgr._handle_classification_result(state=state, cls_res=res) assert len(state.pending_fifo) == 1 assert len(state.details) == 1 assert state.details[0].item_name == "待确认" @pytest.mark.asyncio async def test_handle_voice_disabled_no_pending_for_mid_conf() -> None: settings = Settings() settings.voice_confirmation_enabled = False settings.video_auto_confirm_confidence = 0.8 mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["纱布"]) res = PredictionResult( label="纱布", confidence=0.5, topk=[PredictionCandidate(label="纱布", confidence=0.5)], ) await mgr._handle_classification_result(state=state, cls_res=res) assert state.pending_fifo == [] assert state.details == [] @pytest.mark.asyncio async def test_handle_vision_cooldown_skips_duplicate() -> None: settings = Settings() settings.video_detail_cooldown_sec = 3600.0 mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["纱布"]) res = PredictionResult( label="纱布", confidence=0.99, topk=[PredictionCandidate(label="纱布", confidence=0.99)], ) await mgr._handle_classification_result(state=state, cls_res=res) await mgr._handle_classification_result(state=state, cls_res=res) assert len(state.details) == 1 @pytest.mark.asyncio async def test_handle_pending_dedupe_cooldown() -> None: settings = Settings() settings.video_detail_cooldown_sec = 3600.0 mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) state = SurgerySessionState(candidate_consumables=["缝线"]) res = PredictionResult( label="纱布", confidence=0.9, topk=[ PredictionCandidate(label="纱布", confidence=0.9), PredictionCandidate(label="缝线", confidence=0.2), ], ) await mgr._handle_classification_result(state=state, cls_res=res) await mgr._handle_classification_result(state=state, cls_res=res) assert len(state.pending_fifo) == 1 @pytest.mark.asyncio async def test_resolve_invalid_chosen_label() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), 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, vision_algorithm=MagicMock(), 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, vision_algorithm=MagicMock(), 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, vision_algorithm=MagicMock(), 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"