"""FastAPI → 算法子进程调用链单元测试。 覆盖两条生产路径: 1. ``POST /internal/demo/offline-batch`` → ``BatchAlgorithmService`` → ``subprocess.run``(reference bundle ``main.py``) 2. ``POST /client/surgeries/start`` → ``CameraSessionManager`` → ``asyncio.create_subprocess_exec``(``python -m app.algorithm_runner``) """ from __future__ import annotations import json import sys from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest import yaml from fastapi import FastAPI from fastapi.testclient import TestClient from httpx import ASGITransport, AsyncClient from app.api import router as api_router from app.config import Settings from app.dependencies import build_container, get_surgery_pipeline, get_voice_terminal_hub from app.routers import recording_demo from app.services.video.session_manager import CameraSessionManager from app.algo_host.batch_service import BatchAlgorithmService from app.algo_host.subprocess_runner import build_batch_main_command from tests.reference_bundle_fixtures import complete_result_tsv_body, write_minimal_reference_bundle def _fake_reference_subprocess_run(captured: list[dict[str, Any]]): class _Proc: returncode = 0 stdout = "" stderr = "" def _run(cmd: list[str], **kwargs: Any) -> _Proc: config_path = Path(cmd[cmd.index("--config") + 1]) config = yaml.safe_load(config_path.read_text(encoding="utf-8")) captured.append( { "cmd": list(cmd), "kwargs": dict(kwargs), "config": config, } ) output = Path(config["io"]["out"]) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(complete_result_tsv_body(), encoding="utf-8") return _Proc() return _run @pytest.fixture def reference_bundle(tmp_path: Path) -> Path: bundle = tmp_path / "algorithm_subprocesses" / "5.15" write_minimal_reference_bundle(bundle) return bundle def test_video_batch_endpoint_invokes_reference_bundle_subprocess( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, reference_bundle: Path, sqlite_session_factory, ) -> None: monkeypatch.setattr(recording_demo.settings, "demo_orchestrator_enabled", True) monkeypatch.setattr( "app.algo_host.bundle.resolve_reference_bundle_dir", lambda _override=None: reference_bundle.resolve(), ) monkeypatch.setattr( recording_demo, "BatchAlgorithmService", lambda: BatchAlgorithmService( bundle_dir=reference_bundle, root_dir=tmp_path / "video_batch", ), ) captured: list[dict[str, Any]] = [] monkeypatch.setattr( "app.algo_host.subprocess_runner.subprocess.run", _fake_reference_subprocess_run(captured), ) container = build_container(recording_demo.settings, session_factory=sqlite_session_factory) app = FastAPI() app.include_router(recording_demo.router) app.dependency_overrides[get_surgery_pipeline] = lambda: container.surgery_pipeline client = TestClient(app) res = client.post( "/internal/demo/offline-batch", data={ "surgery_id": "100001", "candidate_consumables_json": '["耗材1"]', "include_visualization": "false", }, files={"video1": ("case.mp4", b"fake-mp4-bytes", "video/mp4")}, ) assert res.status_code == 200, res.text assert len(captured) == 1, "expected exactly one reference bundle subprocess invocation" call = captured[0] cmd: list[str] = call["cmd"] kwargs: dict[str, Any] = call["kwargs"] expected = build_batch_main_command( bundle_dir=reference_bundle, config_path=Path(cmd[cmd.index("--config") + 1]), ) assert cmd == expected assert kwargs.get("cwd") == str(reference_bundle.resolve()) assert kwargs.get("env", {}).get("PYTHONFAULTHANDLER") == "1" config = call["config"] assert Path(config["io"]["video"]).name == "input.mp4" assert str(config["io"]["excel"]).endswith("商品信息表.xlsx") assert str(config["io"]["whitelist_json"]).endswith("whitelist.json") assert config["runtime"]["keep_work_dir"] is False @pytest.mark.asyncio async def test_start_surgery_endpoint_spawns_algorithm_runner_subprocess( monkeypatch: pytest.MonkeyPatch, sqlite_session_factory, tmp_path: Path, ) -> None: async def _check_db_ok() -> None: return None monkeypatch.setattr("app.api.check_database", _check_db_ok) monkeypatch.setattr( "app.services.video.session_manager.LOGS_DIR", tmp_path / "logs", ) settings = Settings( video_rtsp_url_template="rtsp://lab/{camera_id}/live", video_open_timeout_sec=5.0, ) container = build_container(settings, session_factory=sqlite_session_factory) async def _fake_resolve_rtsp( self, *, camera_id: str, kind: Any, ) -> tuple[str, int | None, bool]: return f"rtsp://unittest/{camera_id}/live", None, False monkeypatch.setattr(CameraSessionManager, "_resolve_rtsp_url", _fake_resolve_rtsp) captured_cmds: list[list[str]] = [] async def fake_create_subprocess_exec(*cmd: str, **kwargs: Any): captured_cmds.append(list(cmd)) events_idx = list(cmd).index("--events-jsonl") + 1 events_path = Path(cmd[events_idx]) events_path.parent.mkdir(parents=True, exist_ok=True) events_path.write_text( json.dumps({"type": "ready", "camera_id": "cam1"}) + "\n", encoding="utf-8", ) proc = MagicMock() proc.returncode = 0 proc.terminate = MagicMock() proc.kill = MagicMock() async def _wait() -> int: return 0 proc.wait = _wait return proc monkeypatch.setattr( "app.services.video.session_manager.asyncio.create_subprocess_exec", fake_create_subprocess_exec, ) async def _noop_tail(self, *args: Any, **kwargs: Any) -> None: return None monkeypatch.setattr(CameraSessionManager, "_tail_algo_events", _noop_tail) async def _instant_sleep(_delay: float) -> None: return None monkeypatch.setattr("app.services.video.session_manager.asyncio.sleep", _instant_sleep) monkeypatch.setattr("app.api.asyncio.sleep", _instant_sleep) async def _noop_voice_assign(*args: Any, **kwargs: Any) -> None: return None monkeypatch.setattr( "app.services.recording_live.assign_voice_terminal_after_recording_started", _noop_voice_assign, ) app = FastAPI() app.include_router(api_router) app.dependency_overrides[get_surgery_pipeline] = lambda: container.surgery_pipeline app.dependency_overrides[get_voice_terminal_hub] = lambda: container.voice_terminal_hub surgery_id = "123456" async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: res = await client.post( "/client/surgeries/start", json={ "surgery_id": surgery_id, "camera_ids": ["cam1"], "candidate_consumables": ["纱布"], }, ) assert res.status_code == 200, res.text assert res.json()["status"] == "accepted" end = await client.post("/client/surgeries/end", json={"surgery_id": surgery_id}) assert end.status_code == 200, end.text assert len(captured_cmds) == 1, "expected one algorithm_runner subprocess spawn" cmd = captured_cmds[0] assert cmd[0] == sys.executable assert cmd[1:3] == ["-m", "app.algorithm_runner"] assert cmd[cmd.index("--source") + 1] == "rtsp://unittest/or-cam-03/live" assert cmd[cmd.index("--source-mode") + 1] == "realtime" assert cmd[cmd.index("--surgery-id") + 1] == surgery_id assert cmd[cmd.index("--camera-id") + 1] == "or-cam-03" whitelist_path = Path(cmd[cmd.index("--whitelist-json") + 1]) assert whitelist_path.is_file() whitelist = json.loads(whitelist_path.read_text(encoding="utf-8")) assert whitelist["candidate_consumables"] == ["纱布"] events_path = Path(cmd[cmd.index("--events-jsonl") + 1]) assert events_path.is_file() assert '"type": "ready"' in events_path.read_text(encoding="utf-8")