ver0.1
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
45
tests/test_audio_wav_normalize.py
Normal file
45
tests/test_audio_wav_normalize.py
Normal 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"")
|
||||
29
tests/test_baidu_asr_fallback.py
Normal file
29
tests/test_baidu_asr_fallback.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""百度 ASR:3301 时 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"
|
||||
45
tests/test_pending_resolve_url_encoding.py
Normal file
45
tests/test_pending_resolve_url_encoding.py
Normal 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"] == "请确认"
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user