This commit is contained in:
Kevin
2026-04-28 10:41:48 +08:00
parent 482b016872
commit 15884bd68e
60 changed files with 2092 additions and 1994 deletions

View File

@@ -6,7 +6,7 @@ import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
@@ -107,6 +107,43 @@ def test_assign_voice_terminal_helper_matches_start_surgery_behavior(
assert hub.get_assignment("TERM-X") == "123456"
async def test_voice_terminal_hub_notify_pending_head_payload() -> None:
payload = SurgeryPendingConfirmationResponse(
surgery_id="123456",
confirmation_id="cid-a",
pending_queue_length=1,
pending_queue_position=1,
pending_cumulative_ordinal=1,
prompt_text="请确认",
prompt_audio_mp3_base64="QQ==",
options=[],
model_top1_label="x",
model_top1_confidence=0.5,
created_at=datetime.now(timezone.utc),
)
fetcher = AsyncMock(return_value=payload)
hub = VoiceTerminalHub(Settings(), pending_head_fetcher=fetcher)
with patch.object(hub, "_broadcast", new_callable=AsyncMock) as bc:
await hub.notify_pending_head("TERM-1", "123456")
fetcher.assert_awaited_once_with("123456")
sent = bc.await_args[0][1]
assert sent["type"] == "voice_pending"
assert sent["confirmation_id"] == "cid-a"
assert sent["surgery_id"] == "123456"
async def test_voice_terminal_hub_notify_pending_head_empty() -> None:
fetcher = AsyncMock(return_value=None)
hub = VoiceTerminalHub(Settings(), pending_head_fetcher=fetcher)
with patch.object(hub, "_broadcast", new_callable=AsyncMock) as bc:
await hub.notify_pending_head("TERM-1", "123456")
fetcher.assert_awaited_once_with("123456")
assert bc.await_args[0][1] == {
"type": "voice_pending_empty",
"surgery_id": "123456",
}
def test_start_surgery_notifies_voice_terminal_when_binding_matches(
api_app: FastAPI, instant_sleep: None, tmp_path: Path
) -> None:
@@ -265,6 +302,9 @@ def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
payload = SurgeryPendingConfirmationResponse(
surgery_id="123456",
confirmation_id="cid",
pending_queue_length=1,
pending_queue_position=1,
pending_cumulative_ordinal=1,
prompt_text="请确认",
prompt_audio_mp3_base64="//uQ",
options=[],
@@ -280,6 +320,9 @@ def test_pending_confirmation_200_and_404(api_app: FastAPI) -> None:
assert r.status_code == 200
body_ok = r.json()
assert body_ok["confirmation_id"] == "cid"
assert body_ok["pending_queue_length"] == 1
assert body_ok["pending_queue_position"] == 1
assert body_ok["pending_cumulative_ordinal"] == 1
assert body_ok["prompt_audio_mp3_base64"] == "//uQ"
pipeline_none = MagicMock()
@@ -333,11 +376,34 @@ def test_resolve_200(api_app: FastAPI) -> None:
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "accepted"
assert body["error_code"] is None
assert body["resolved_label"] == "纱布"
assert body["rejected"] is False
assert body["asr_text"] == "第一个"
def test_resolve_voice_recoverable_error_returns_200_failed(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(
side_effect=SurgeryPipelineError(
"VOICE_ASR_FAILED",
"asr_err_3301: speech quality error.",
)
)
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/123456/pending-confirmation/cid/resolve",
files={"audio": ("a.wav", b"RIFF", "audio/wav")},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "failed"
assert body["error_code"] == "VOICE_ASR_FAILED"
assert "3301" in body["message"]
def test_resolve_maps_surgery_pipeline_error_to_http(api_app: FastAPI) -> None:
pipeline = MagicMock()
pipeline.resolve_pending_confirmation_from_audio = AsyncMock(

View File

@@ -259,6 +259,7 @@ def test_full_flow_start_pending_resolve_end_result(
assert r3.status_code == 200, r3.text
body3 = r3.json()
assert body3["confirmation_id"] == cid
assert body3["pending_queue_length"] == 1
import base64
decoded = base64.b64decode(body3["prompt_audio_mp3_base64"].encode("ascii"))
@@ -270,6 +271,7 @@ def test_full_flow_start_pending_resolve_end_result(
)
assert r4.status_code == 200, r4.text
body4 = r4.json()
assert body4["status"] == "accepted"
assert body4["resolved_label"] == "纱布"
assert body4["rejected"] is False

View File

@@ -0,0 +1,45 @@
"""PCM 归一化与 WAV 封装。"""
from __future__ import annotations
import array
import pytest
from app.services.audio_wav import (
WavDecodeError,
normalize_pcm_s16le_for_baidu,
pcm_s16le_to_wav_bytes,
wav_bytes_to_pcm16k_mono_s16le,
)
def test_normalize_boosts_quiet_pcm() -> None:
samples = array.array("h", [50, -80, 30] * 500)
pcm = samples.tobytes()
out = normalize_pcm_s16le_for_baidu(pcm)
arr = array.array("h")
arr.frombytes(out)
assert max(abs(x) for x in arr) > max(abs(x) for x in samples)
def test_normalize_skips_loud_pcm() -> None:
samples = array.array("h", [15000, -14000])
pcm = samples.tobytes()
out = normalize_pcm_s16le_for_baidu(pcm)
assert out == pcm
def test_pcm_wav_roundtrip_is_valid_wav() -> None:
pcm = array.array("h", [100, -200, 300] * 100).tobytes()
wav = pcm_s16le_to_wav_bytes(pcm)
back = wav_bytes_to_pcm16k_mono_s16le(wav)
assert len(back) == len(pcm)
assert max(abs(x) for x in array.array("h", back)) >= max(
abs(x) for x in array.array("h", pcm)
)
def test_pcm_s16le_to_wav_empty_raises() -> None:
with pytest.raises(WavDecodeError):
pcm_s16le_to_wav_bytes(b"")

View File

@@ -0,0 +1,29 @@
"""百度 ASR3301 时 WAV 重试。"""
from __future__ import annotations
import array
from unittest.mock import MagicMock
from app.config import Settings
from app.services.baidu_speech import BaiduSpeechService
def test_asr_pcm_or_wav_fallback_retries_on_3301() -> None:
ok = {"err_no": 0, "result": [""]}
pcm = array.array("h", [100] * 800).tobytes()
client = MagicMock()
client.asr = MagicMock(side_effect=[{"err_no": 3301, "err_msg": "q"}, ok])
svc = BaiduSpeechService(
app_settings=Settings(
BAIDU_APP_ID="1",
BAIDU_API_KEY="k",
BAIDU_SECRET_KEY="s",
)
)
svc._client = client # type: ignore[attr-defined]
r = svc.asr_16k_mono_pcm_or_wav_fallback(pcm)
assert r == ok
assert client.asr.call_count == 2
assert client.asr.call_args_list[0][0][1] == "pcm"
assert client.asr.call_args_list[1][0][1] == "wav"

View File

@@ -0,0 +1,45 @@
"""resolve 路径中 confirmation_id 的 URL 编码(与浏览器 / 历史客户端行为一致)。"""
from __future__ import annotations
from urllib.parse import quote, urljoin
import httpx
def test_post_resolve_url_encodes_confirmation_id() -> None:
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={"status": "accepted"})
transport = httpx.MockTransport(handler)
base = "http://example.test:8080/"
cid = "c/id+here"
path = f"client/surgeries/123456/pending-confirmation/{quote(cid, safe='')}/resolve"
url = urljoin(base, path)
with httpx.Client(transport=transport) as client:
r = client.post(url, files={"audio": ("voice.wav", b"RIFF....", "audio/wav")})
assert r.status_code == 200
assert captured["url"].endswith(
"/client/surgeries/123456/pending-confirmation/c%2Fid%2Bhere/resolve"
)
def test_pending_payload_field_names_match_contract() -> None:
raw = {
"surgery_id": "123456",
"confirmation_id": "abc",
"pending_queue_length": 1,
"pending_queue_position": 1,
"pending_cumulative_ordinal": 1,
"prompt_text": "请确认",
"prompt_audio_mp3_base64": "AA",
"options": [{"label": "纱布", "confidence": 0.4}],
"model_top1_label": "x",
"model_top1_confidence": 0.41,
"created_at": "2026-01-01T00:00:00+00:00",
}
assert raw["confirmation_id"] == "abc"
assert raw["prompt_text"] == "请确认"

View File

@@ -463,3 +463,29 @@ async def test_resolve_already_resolved_status() -> None:
"123456", pid, chosen_label="纱布", rejected=False
)
assert excinfo.value.code == "CONFIRMATION_ALREADY_RESOLVED"
def test_pending_queue_pending_count_fifo() -> None:
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
vision_algorithm=MagicMock(),
hikvision_runtime=None,
result_repository=None,
)
st = SurgerySessionState(candidate_consumables=["纱布"])
for pid in ("p1", "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=[]
)
assert mgr.pending_queue_pending_count("123456") == 2

View File

@@ -1,96 +0,0 @@
"""语音客户端配置:系统级 + 用户级合并与保存。"""
from __future__ import annotations
import json
import pytest
from voice_confirmation_client.core.machine_config import (
http_base_url_from_config,
load_voice_client_config,
machine_config_file_path,
save_user_voice_client_config,
user_voice_client_config_path,
voice_terminal_id_from_config,
)
def test_fields_from_system_file_only(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
cfg = tmp_path / "voice_client.json"
cfg.write_text(
json.dumps(
{"voice_terminal_id": "t-1", "http_base_url": "http://api.example:38080"},
),
encoding="utf-8",
)
monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(cfg))
monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(tmp_path / "none.json"))
(tmp_path / "none.json").write_text("{}", encoding="utf-8")
data = load_voice_client_config()
assert voice_terminal_id_from_config(data) == "t-1"
assert http_base_url_from_config(data) == "http://api.example:38080"
def test_user_file_overrides_system(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
sys_f = tmp_path / "sys.json"
sys_f.write_text(
json.dumps({"voice_terminal_id": "sys", "http_base_url": "http://sys:1"}),
encoding="utf-8",
)
usr_f = tmp_path / "usr.json"
usr_f.write_text(
json.dumps({"voice_terminal_id": "usr", "http_base_url": "http://usr:2"}),
encoding="utf-8",
)
monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(sys_f))
monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(usr_f))
data = load_voice_client_config()
assert voice_terminal_id_from_config(data) == "usr"
assert http_base_url_from_config(data) == "http://usr:2"
def test_http_base_default_when_missing_key(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
cfg = tmp_path / "voice_client.json"
cfg.write_text(json.dumps({"voice_terminal_id": "x"}), encoding="utf-8")
monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(cfg))
monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(tmp_path / "empty.json"))
(tmp_path / "empty.json").write_text("{}", encoding="utf-8")
data = load_voice_client_config()
assert http_base_url_from_config(data) == "http://127.0.0.1:38080"
def test_machine_config_file_path_respects_override(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
p = tmp_path / "custom.json"
monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", str(p))
assert machine_config_file_path() == p
def test_user_config_file_path_respects_override(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
p = tmp_path / "u.json"
monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(p))
assert user_voice_client_config_path() == p
def test_missing_files_return_empty_merge(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("VOICE_CLIENT_MACHINE_CONFIG_FILE", "/nonexistent/a.json")
monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", "/nonexistent/b.json")
assert load_voice_client_config() == {}
def test_save_user_voice_client_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
out = tmp_path / "out.json"
monkeypatch.setenv("VOICE_CLIENT_USER_CONFIG_FILE", str(out))
save_user_voice_client_config(
voice_terminal_id=" t99 ",
http_base_url="http://host:38080/",
)
assert out.is_file()
data = json.loads(out.read_text(encoding="utf-8"))
assert data == {"voice_terminal_id": "t99", "http_base_url": "http://host:38080"}

View File

@@ -34,8 +34,9 @@ def test_parse_voice_choice_negative() -> None:
def test_build_prompt_contains_options() -> None:
text = build_prompt_text([("纱布", 0.4), ("缝线", 0.3)])
assert "纱布" in text
assert "缝线" in text
assert text.startswith("请确认以下耗材。")
assert "第1个纱布。" in text
assert "第2个缝线。" in text
def test_match_voice_against_full_candidate_list() -> None:

View File

@@ -1,48 +0,0 @@
"""Core HTTP client tests (no PySide6)."""
from __future__ import annotations
import httpx
import pytest
from voice_confirmation_client.core.api import ConfirmationApiClient
def test_post_resolve_url_encoding(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
return httpx.Response(200, json={"status": "accepted"})
transport = httpx.MockTransport(handler)
client = ConfirmationApiClient("http://example.test:8080")
client._client = httpx.Client(transport=transport) # noqa: SLF001
st, body = client.post_resolve("123456", "c/id+here", b"RIFF....", "voice.wav")
assert st == 200
assert isinstance(body, dict)
assert body.get("status") == "accepted"
assert captured["url"].endswith(
"/client/surgeries/123456/pending-confirmation/c%2Fid%2Bhere/resolve"
)
client.close()
def test_parse_pending() -> None:
client = ConfirmationApiClient("http://localhost")
raw = {
"surgery_id": "123456",
"confirmation_id": "abc",
"prompt_text": "请确认",
"prompt_audio_mp3_base64": "AA",
"options": [{"label": "纱布", "confidence": 0.4}],
"model_top1_label": "x",
"model_top1_confidence": 0.41,
"created_at": "2026-01-01T00:00:00+00:00",
}
p = client.parse_pending(raw)
assert p.confirmation_id == "abc"
assert p.prompt_text == "请确认"
client.close()

View File

@@ -120,7 +120,7 @@ async def test_resolve_recognized_appends_voice_detail_and_audit(
)
baidu = MagicMock()
baidu.configured = True
baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["第一个"]})
baidu.asr_16k_mono_pcm_or_wav_fallback = MagicMock(return_value={"err_no": 0, "result": ["第一个"]})
svc = _make_service(
settings=settings,
@@ -166,7 +166,7 @@ async def test_resolve_rejected_audit(
)
baidu = MagicMock()
baidu.configured = True
baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["不是"]})
baidu.asr_16k_mono_pcm_or_wav_fallback = MagicMock(return_value={"err_no": 0, "result": ["不是"]})
svc = _make_service(
settings=settings,
@@ -215,7 +215,7 @@ async def test_resolve_recognizes_label_not_in_topk_but_in_surgery_candidates(
)
baidu = MagicMock()
baidu.configured = True
baidu.asr = MagicMock(
baidu.asr_16k_mono_pcm_or_wav_fallback = MagicMock(
return_value={"err_no": 0, "result": ["刚才用的是止血钳"]}
)
svc = _make_service(
@@ -355,7 +355,7 @@ async def test_asr_failed_audit(
)
baidu = MagicMock()
baidu.configured = True
baidu.asr = MagicMock(return_value={"err_no": 3300, "err_msg": "bad"})
baidu.asr_16k_mono_pcm_or_wav_fallback = MagicMock(return_value={"err_no": 3300, "err_msg": "bad"})
svc = _make_service(
settings=settings,
sessions=sessions,
@@ -395,7 +395,7 @@ async def test_parse_failed_audit(
baidu = MagicMock()
baidu.configured = True
# Avoid substrings like 「无」that trigger `is_rejection_phrase`.
baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["西红柿土豆"]})
baidu.asr_16k_mono_pcm_or_wav_fallback = MagicMock(return_value={"err_no": 0, "result": ["西红柿土豆"]})
svc = _make_service(
settings=settings,
sessions=sessions,