Files
esp32-server/tests/test_app.py
kunthawat e96498b67e fix: resolve fresh-eyes review criticals + majors on the audio path
- C1: stop feeding the unconsumed barge-in TTSQueue in the reply path (deadlocked after 64 chunks on a real long reply)
- C2: encode reply Opus at the TTS/codec sample rate (24 kHz), not hardcoded 16 kHz; encoder now rate-generic
- M3: bound the mic PCM buffer to the 60 s max utterance
- M4: tts_sentence_start per speakable sentence, not per LLM token
- Minor: send tts.stop on a failed turn; constant-time token compare
- Sec: untrack + gitignore config.yaml; add config.example.yaml with placeholder secrets
- Tests: 23 pass (2 new regressions: long-reply deadlock, rate-generic encoder)
2026-10-03 21:55:32 +07:00

188 lines
6.2 KiB
Python

"""AC-002: app imports and the /ws/xiaozhi endpoint is live (handshake + turn)."""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from app.config import Config
from app.main import make_app
from app.security import DeviceAuth
TOKEN = "test-device-token"
def _cfg() -> Config:
return Config.from_dict(
{
"security": {"enabled": True, "devices": [
{"device_id": "d1", "token_hash": DeviceAuth.hash_token(TOKEN)}
]},
}
)
def test_health():
client = TestClient(make_app(_cfg()))
r = client.get("/health")
assert r.status_code == 200
assert r.json()["status"] == "ok"
def test_ota_endpoint_delivers_server():
"""FIRMWARE: device reads websocket.url/token from /ota POST."""
cfg = _cfg()
from app.config import OtaConfig
cfg.ota = OtaConfig(
server_ws_url="wss://zhi.moreminimore.com/ws/xiaozhi",
server_token=TOKEN,
)
client = TestClient(make_app(cfg))
r = client.post("/ota", json={"device_id": "d1"})
assert r.status_code == 200
body = r.json()
assert body["websocket"]["url"] == "wss://zhi.moreminimore.com/ws/xiaozhi"
assert body["websocket"]["token"] == TOKEN
assert "server_time" in body
def test_ws_auth_via_headers():
"""FIRMWARE: token in Authorization header, Device-Id = MAC."""
client = TestClient(make_app(_cfg()))
with client.websocket_connect(
"/ws/xiaozhi",
headers={
"Device-Id": "d1",
"Authorization": f"Bearer {TOKEN}",
},
) as ws:
# hello frame WITHOUT auth fields — auth came in headers
ws.send_text(json.dumps({"device_id": "d1"}))
reply = json.loads(ws.receive_text())
assert reply["type"] == "hello"
assert reply["transport"] == "websocket"
assert reply["session_id"]
def test_ws_header_auth_rejects_bad_token():
client = TestClient(make_app(_cfg()))
with client.websocket_connect(
"/ws/xiaozhi",
headers={"Device-Id": "d1", "Authorization": "Bearer wrong"},
) as ws:
ws.send_text(json.dumps({"device_id": "d1"}))
reply = json.loads(ws.receive_text())
assert reply["type"] == "error"
def test_long_reply_does_not_deadlock():
"""Critical 1 regression: a reply whose audio far exceeds the old
barge-in TTSQueue cap (64) must still complete. Previously the queue was
filled with no consumer, so a long reply deadlocked after 64 chunks and
``tts_stop`` was never sent. The reply path no longer feeds the queue.
"""
import asyncio
from app.audio.vad import make_vad
from app.metrics import LatencyLogger
from app.stt import STTResult
from app.tts import MockTTS
from app.xiaozhi.session import Session
class _LongClient:
def session(self):
class _S:
def push(self, u, a):
pass
return _S()
async def respond(self, text, *, session_search_fn=None):
for i in range(80): # 80 sentences -> 160 chunks > 64
yield f"ประโยค {i}."
class _STT:
async def transcribe(self, audio):
return STTResult(text="สวัสดีครับ")
session = Session(
client=_LongClient(),
stt=_STT(),
tts=MockTTS(),
vad=make_vad("energy"),
logger=LatencyLogger(),
)
async def _drain():
audio_chunks = 0
async for kind, payload in session.run_turn(b"\x00" * 640):
if kind == "audio":
audio_chunks += 1
return audio_chunks
# wait_for guards against the deadlock: if it were back, this raises
# TimeoutError (and the test fails) instead of hanging forever.
audio_chunks = asyncio.run(asyncio.wait_for(_drain(), timeout=5))
assert audio_chunks > 64, (
f"expected a long reply (>64 chunks), got {audio_chunks}"
)
def test_encoder_is_rate_generic():
"""Critical 2 regression: the reply encoder must be rate-generic, not
hardwired to 16 kHz. At 24 kHz (the TTS/codec output rate this board
uses) a 20 ms frame is 480 samples = 960 B — feeding that must yield
exactly one Opus packet."""
from app.audio.opus import OpusEncoder, _ensure_native
# opuslib raises at import when the native lib is not on PATH, so we
# must make it discoverable BEFORE importing it (mirrors the bridge).
_ensure_native()
try:
import opuslib # noqa: F401
except Exception:
pytest.skip("native opus not available on this host")
enc = OpusEncoder(sample_rate=24000, channels=1)
assert enc.FRAME_SAMPLES == 480
assert enc.FRAME_BYTES == 960
out = enc.feed(b"\x00" * 960)
assert len(out) == 1
assert isinstance(out[0], (bytes, bytearray))
def test_ws_handshake_and_turn():
"""One spoken utterance (burst of speech frames + trailing silence)
-> firmware-compatible reply: stt text, tts start/sentence/stop, audio."""
client = TestClient(make_app(_cfg()))
with client.websocket_connect("/ws/xiaozhi") as ws:
# hello frame
ws.send_text(json.dumps({"device_id": "d1", "authorization": TOKEN}))
reply = json.loads(ws.receive_text())
assert reply["type"] == "hello"
assert reply["session_id"]
# one utterance: 6 loud frames (16-bit, RMS >= 300), then silence
# frames that push the VAD past min_silence_frames.
loud = b"\x10\x0f" * 320 # 20 ms @ 16 kHz mono, amplitude 3856
silent = b"\x00\x00" * 320
for _ in range(6):
ws.send_bytes(loud)
for _ in range(12):
ws.send_bytes(silent)
got_stt = got_audio = False
tts_stop = False
deadline = 0
while not tts_stop and deadline < 256:
frame = ws.receive()
deadline += 1
if frame.get("text") is not None:
payload = json.loads(frame["text"])
if payload.get("type") == "stt":
got_stt = True
if payload.get("type") == "tts" and payload.get("state") == "stop":
tts_stop = True
else:
got_audio = True
assert got_stt and got_audio and tts_stop