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:
Kevin
2026-04-27 11:21:16 +08:00
parent 4c3f9a367b
commit 6b3adb4ad8
36 changed files with 1194 additions and 162 deletions

View 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"