feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调
- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings) - VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify - 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS - 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长 - 新增/调整契约与绑定测试,文档与示例配置同步 Made-with: Cursor
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -10,7 +13,12 @@ from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api import router as api_router
|
||||
from app.dependencies import get_surgery_pipeline
|
||||
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
|
||||
from app.services.voice_terminal_hub import (
|
||||
VoiceTerminalHub,
|
||||
assign_voice_terminal_after_recording_started,
|
||||
)
|
||||
from app.config import Settings
|
||||
from app.schemas import (
|
||||
SurgeryConsumptionDetail,
|
||||
SurgeryPendingConfirmationResponse,
|
||||
@@ -36,6 +44,8 @@ def api_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(api_router)
|
||||
hub = VoiceTerminalHub(Settings())
|
||||
app.dependency_overrides[get_voice_terminal_hub] = lambda: hub
|
||||
return app
|
||||
|
||||
|
||||
@@ -66,6 +76,98 @@ def test_start_surgery_accepted(api_app: FastAPI, instant_sleep: None) -> None:
|
||||
pipeline.start_recording.assert_awaited_once()
|
||||
|
||||
|
||||
def test_assign_voice_terminal_helper_matches_start_surgery_behavior(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
site = {
|
||||
"video_rtsp_urls": {"cam1": "rtsp://x/c1"},
|
||||
"voice_or_room_bindings": [
|
||||
{
|
||||
"or_room_id": "R1",
|
||||
"camera_ids": ["cam1"],
|
||||
"voice_terminal_id": "TERM-X",
|
||||
}
|
||||
],
|
||||
}
|
||||
p = tmp_path / "site.json"
|
||||
p.write_text(json.dumps(site), encoding="utf-8")
|
||||
hub = VoiceTerminalHub(Settings(or_site_config_json_file=str(p)))
|
||||
set_vtid = MagicMock()
|
||||
|
||||
async def _run() -> None:
|
||||
await assign_voice_terminal_after_recording_started(
|
||||
hub,
|
||||
surgery_id="123456",
|
||||
camera_ids=["cam1"],
|
||||
set_voice_terminal_id=set_vtid,
|
||||
)
|
||||
|
||||
asyncio.run(_run())
|
||||
set_vtid.assert_called_once_with("123456", "TERM-X")
|
||||
assert hub.get_assignment("TERM-X") == "123456"
|
||||
|
||||
|
||||
def test_start_surgery_notifies_voice_terminal_when_binding_matches(
|
||||
api_app: FastAPI, instant_sleep: None, tmp_path: Path
|
||||
) -> None:
|
||||
site = {
|
||||
"video_rtsp_urls": {"cam1": "rtsp://x/c1", "cam2": "rtsp://x/c2"},
|
||||
"voice_or_room_bindings": [
|
||||
{
|
||||
"or_room_id": "R1",
|
||||
"camera_ids": ["cam1", "cam2"],
|
||||
"voice_terminal_id": "TERM-OR1",
|
||||
}
|
||||
],
|
||||
}
|
||||
p = tmp_path / "site.json"
|
||||
p.write_text(json.dumps(site), encoding="utf-8")
|
||||
hub = VoiceTerminalHub(Settings(or_site_config_json_file=str(p)))
|
||||
api_app.dependency_overrides[get_voice_terminal_hub] = lambda: hub
|
||||
pipeline = MagicMock()
|
||||
pipeline.start_recording = AsyncMock(return_value=None)
|
||||
pipeline.set_voice_terminal_id = MagicMock()
|
||||
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
||||
client = TestClient(api_app)
|
||||
r = client.post(
|
||||
"/client/surgeries/start",
|
||||
json={
|
||||
"surgery_id": "123456",
|
||||
"camera_ids": ["cam2", "cam1"],
|
||||
"candidate_consumables": ["纱布"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
pipeline.set_voice_terminal_id.assert_called_once_with("123456", "TERM-OR1")
|
||||
assert hub.get_assignment("TERM-OR1") == "123456"
|
||||
|
||||
|
||||
def test_voice_terminal_assignment_get(api_app: FastAPI) -> None:
|
||||
hub = api_app.dependency_overrides[get_voice_terminal_hub]()
|
||||
asyncio.run(hub.notify_start("t-assign", "999999"))
|
||||
client = TestClient(api_app)
|
||||
r = client.get("/client/voice-terminals/t-assign/assignment")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"voice_terminal_id": "t-assign",
|
||||
"active_surgery_id": "999999",
|
||||
}
|
||||
|
||||
|
||||
def test_end_surgery_notifies_voice_terminal(
|
||||
api_app: FastAPI, instant_sleep: None
|
||||
) -> None:
|
||||
hub = api_app.dependency_overrides[get_voice_terminal_hub]()
|
||||
asyncio.run(hub.notify_start("t-end", "123456"))
|
||||
pipeline = MagicMock()
|
||||
pipeline.stop_recording = AsyncMock(return_value="t-end")
|
||||
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
|
||||
client = TestClient(api_app)
|
||||
r = client.post("/client/surgeries/end", json={"surgery_id": "123456"})
|
||||
assert r.status_code == 200
|
||||
assert hub.get_assignment("t-end") is None
|
||||
|
||||
|
||||
def test_start_surgery_503_on_pipeline_error(
|
||||
api_app: FastAPI, instant_sleep: None
|
||||
) -> None:
|
||||
|
||||
@@ -72,9 +72,12 @@ class _StubCameraSessionManager:
|
||||
)
|
||||
await self._registry.register(surgery_id, run)
|
||||
|
||||
def set_voice_terminal_id(self, surgery_id: str, terminal_id: str | None) -> None:
|
||||
self._real.set_voice_terminal_id(surgery_id, terminal_id)
|
||||
|
||||
async def stop_surgery(
|
||||
self, surgery_id: str, *, require_active: bool = True
|
||||
) -> None:
|
||||
) -> str | None:
|
||||
run = await self._registry.unregister(surgery_id)
|
||||
if run is None:
|
||||
if require_active:
|
||||
@@ -84,9 +87,11 @@ class _StubCameraSessionManager:
|
||||
"RECORDING_NOT_STOPPED",
|
||||
"停录未能完成:当前没有该手术的活跃录制会话。",
|
||||
)
|
||||
return
|
||||
return None
|
||||
voice_tid = run.state.voice_terminal_id
|
||||
details = list(run.state.details)
|
||||
await self._archive.persist_or_archive(surgery_id, details)
|
||||
return voice_tid
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
return getattr(self._real, name)
|
||||
|
||||
134
tests/test_voice_terminal_binding.py
Normal file
134
tests/test_voice_terminal_binding.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""术间绑定与站点配置(OR_SITE_CONFIG_JSON)解析。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.or_site_config import (
|
||||
load_or_site_config_from_path,
|
||||
merge_video_rtsp_urls_into_file,
|
||||
parse_or_site_config_object,
|
||||
)
|
||||
from app.services.voice_terminal_binding import VoiceTerminalBindingIndex
|
||||
|
||||
|
||||
def test_parse_and_resolve() -> None:
|
||||
idx = VoiceTerminalBindingIndex.from_binding_list(
|
||||
[
|
||||
{
|
||||
"or_room_id": "OR-05",
|
||||
"camera_ids": ["or-cam-01", "or-cam-02"],
|
||||
"voice_terminal_id": "T-05",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert idx is not None
|
||||
assert idx.resolve_terminal(["or-cam-02", "or-cam-01"]) == "T-05"
|
||||
assert idx.resolve_terminal(["or-cam-01"]) == "T-05"
|
||||
|
||||
|
||||
def test_resolve_subset_uses_smallest_superset_when_ambiguous() -> None:
|
||||
idx = VoiceTerminalBindingIndex.from_binding_list(
|
||||
[
|
||||
{
|
||||
"or_room_id": "A",
|
||||
"camera_ids": ["c1", "c2"],
|
||||
"voice_terminal_id": "T-AB",
|
||||
},
|
||||
{
|
||||
"or_room_id": "B",
|
||||
"camera_ids": ["c1", "c2", "c3"],
|
||||
"voice_terminal_id": "T-ABC",
|
||||
},
|
||||
]
|
||||
)
|
||||
assert idx is not None
|
||||
assert idx.resolve_terminal(["c1"]) == "T-AB"
|
||||
|
||||
|
||||
def test_duplicate_terminal_rejected() -> None:
|
||||
assert (
|
||||
VoiceTerminalBindingIndex.from_binding_list(
|
||||
[
|
||||
{"or_room_id": "A", "camera_ids": ["c1"], "voice_terminal_id": "T"},
|
||||
{"or_room_id": "B", "camera_ids": ["c2"], "voice_terminal_id": "T"},
|
||||
]
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_camera_set_rejected() -> None:
|
||||
assert (
|
||||
VoiceTerminalBindingIndex.from_binding_list(
|
||||
[
|
||||
{"or_room_id": "A", "camera_ids": ["c1"], "voice_terminal_id": "T1"},
|
||||
{"or_room_id": "B", "camera_ids": ["c1"], "voice_terminal_id": "T2"},
|
||||
]
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_from_binding_list_empty() -> None:
|
||||
idx = VoiceTerminalBindingIndex.from_binding_list([])
|
||||
assert idx is not None
|
||||
assert idx.resolve_terminal(["x"]) is None
|
||||
|
||||
|
||||
def test_parse_site_config_rejects_unknown_keys() -> None:
|
||||
with pytest.raises(ValueError, match="unknown top-level"):
|
||||
parse_or_site_config_object(
|
||||
{
|
||||
"video_rtsp_urls": {},
|
||||
"voice_or_room_bindings": [],
|
||||
"extra": 1,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_site_config_requires_both_sections() -> None:
|
||||
with pytest.raises(ValueError, match="must include"):
|
||||
parse_or_site_config_object({"video_rtsp_urls": {}})
|
||||
|
||||
|
||||
def test_load_from_path_roundtrip(tmp_path: Path) -> None:
|
||||
p = tmp_path / "site.json"
|
||||
p.write_text(
|
||||
'{"video_rtsp_urls":{"c1":"rtsp://h"},"voice_or_room_bindings":['
|
||||
'{"or_room_id":"R","camera_ids":["a"],"voice_terminal_id":"T"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
cfg = load_or_site_config_from_path(p)
|
||||
assert cfg.video_rtsp_urls == {"c1": "rtsp://h"}
|
||||
assert cfg.voice_bindings.resolve_terminal(["a"]) == "T"
|
||||
|
||||
|
||||
def test_settings_video_map(tmp_path: Path) -> None:
|
||||
p = tmp_path / "site.json"
|
||||
p.write_text(
|
||||
'{"video_rtsp_urls":{"c1":"rtsp://x"},"voice_or_room_bindings":[]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
s = Settings(or_site_config_json_file=str(p))
|
||||
assert s.video_rtsp_url_map() == {"c1": "rtsp://x"}
|
||||
|
||||
|
||||
def test_merge_preserves_bindings(tmp_path: Path) -> None:
|
||||
p = tmp_path / "site.json"
|
||||
p.write_text(
|
||||
'{"video_rtsp_urls":{"old":"rtsp://old"},'
|
||||
'"voice_or_room_bindings":[{"or_room_id":"R","camera_ids":["x"],'
|
||||
'"voice_terminal_id":"T"}]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
merge_video_rtsp_urls_into_file(
|
||||
p, {"x": "rtsp://127.0.0.1:1/p"}, replace_host="127.0.0.1"
|
||||
)
|
||||
cfg = load_or_site_config_from_path(p)
|
||||
assert cfg.video_rtsp_urls == {"x": "rtsp://127.0.0.1:1/p"}
|
||||
assert cfg.voice_bindings.resolve_terminal(["x"]) == "T"
|
||||
Reference in New Issue
Block a user