实现 video batch 自动清理与按需标注视频,并补充子进程调用测试。
batch 完成后仅保留数据库文本结果,勾选时才生成临时标注视频(24h TTL);新增 FastAPI 到 reference bundle 与 algorithm_runner 的单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
240
backend/tests/test_fastapi_algorithm_subprocess.py
Normal file
240
backend/tests/test_fastapi_algorithm_subprocess.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""FastAPI → 算法子进程调用链单元测试。
|
||||
|
||||
覆盖两条生产路径:
|
||||
1. ``POST /internal/demo/video-batch-surgery`` → ``VideoBatchRunner`` → ``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 demo_orch
|
||||
from app.services.video.session_manager import CameraSessionManager
|
||||
from app.services.video_batch_runner import VideoBatchRunner, build_reference_command
|
||||
from tests.test_video_batch_runner 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(demo_orch.settings, "demo_orchestrator_enabled", True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.video_batch_runner.resolve_reference_bundle_dir",
|
||||
lambda _override=None: reference_bundle.resolve(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.video_batch_runner.ensure_reference_actionformer_nms_patch",
|
||||
lambda _bundle: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
demo_orch,
|
||||
"VideoBatchRunner",
|
||||
lambda: VideoBatchRunner(
|
||||
bundle_dir=reference_bundle,
|
||||
root_dir=tmp_path / "video_batch",
|
||||
),
|
||||
)
|
||||
|
||||
captured: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(
|
||||
"app.services.video_batch_runner.subprocess.run",
|
||||
_fake_reference_subprocess_run(captured),
|
||||
)
|
||||
|
||||
container = build_container(demo_orch.settings, session_factory=sqlite_session_factory)
|
||||
app = FastAPI()
|
||||
app.include_router(demo_orch.router)
|
||||
app.dependency_overrides[get_surgery_pipeline] = lambda: container.surgery_pipeline
|
||||
|
||||
client = TestClient(app)
|
||||
res = client.post(
|
||||
"/internal/demo/video-batch-surgery",
|
||||
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_reference_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.api.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")
|
||||
Reference in New Issue
Block a user