Reported: the pet was speaking in the offline pyttsx3 voice instead of the server-synthesized one. speak()'s fallback chain is silent by design (a TtsError just logs and falls through to pyttsx3), and the shallow doctor check only ever verified config presence, not that a real /desk/tts call actually succeeds — so "misconfigured" and "configured but the server rejects it" looked identical from the outside. check_tts(deep=True) now posts a couple of words to /desk/tts for real and reports the exact HTTP status and server error message (a 404 means the voice id isn't real or isn't owned by this key - the likely shape of a Voicebox-clone ownership mismatch; 402 means the account is out of credits; 401 means DESK_API_KEY is wrong). Wired through the same CHECKS/run() deep-parameter dance check_streaming_endpoint already uses. 5 new tests, including one pinning that run()'s TypeError-fallback actually reaches check_tts's deep parameter rather than silently running it shallow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CBvympe6SuHdhf9q1VQqS
188 lines
7.2 KiB
Python
188 lines
7.2 KiB
Python
"""The preflight. Its one job is to never be the thing that's broken.
|
|
|
|
A doctor that raises on a broken install diagnoses the wrong patient, so the
|
|
tests that matter here are the ugly-input ones: no config at all, a check that
|
|
throws, a dependency missing. The individual diagnoses are simple enough to
|
|
read; that they *run* on a machine missing everything is the property worth
|
|
pinning down.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from bolt_pet import config, doctor
|
|
from bolt_pet.doctor import FAIL, OK, WARN
|
|
|
|
|
|
def test_every_check_returns_a_verdict_on_a_bare_machine(monkeypatch):
|
|
"""Nothing configured, nothing installed — still a full report."""
|
|
for name in ("SERVER_URL", "API_KEY", "ELEVENLABS_API_KEY", "ELEVENLABS_VOICE_ID"):
|
|
monkeypatch.setattr(config, name, "")
|
|
monkeypatch.setattr(doctor, "_module", lambda _n: False)
|
|
|
|
results = doctor.run()
|
|
|
|
assert len(results) == len(doctor.CHECKS)
|
|
assert all(c.status in (OK, WARN, FAIL) for c in results)
|
|
assert all(c.name and c.detail for c in results)
|
|
|
|
|
|
def test_a_check_that_raises_does_not_hide_the_others():
|
|
"""One broken probe must not cost you the other eleven diagnoses."""
|
|
def explode(*_args):
|
|
raise RuntimeError("boom")
|
|
|
|
original = doctor.CHECKS
|
|
doctor.CHECKS = (("mic", explode),) + original[:2]
|
|
try:
|
|
results = doctor.run()
|
|
finally:
|
|
doctor.CHECKS = original
|
|
|
|
assert len(results) == 3
|
|
assert results[0].status == FAIL
|
|
assert "boom" in results[0].detail
|
|
|
|
|
|
def test_missing_server_config_is_a_failure_not_a_warning(monkeypatch):
|
|
"""Without it the controller exits its thread at startup — the pet looks
|
|
alive and simply never answers. That is the worst failure mode there is."""
|
|
monkeypatch.setattr(config, "missing_config", lambda: ["BOLT_SERVER_URL", "DESK_API_KEY"])
|
|
check = doctor.check_config()
|
|
assert check.status == FAIL
|
|
assert "BOLT_SERVER_URL" in check.detail
|
|
assert check.fix
|
|
|
|
|
|
def test_a_configured_server_passes_without_being_contacted(monkeypatch):
|
|
"""The shallow run must not need the network — it's the first thing you
|
|
reach for when the network is what's wrong."""
|
|
monkeypatch.setattr(config, "missing_config", lambda: [])
|
|
monkeypatch.setattr(config, "SERVER_URL", "http://bolt.local:8000")
|
|
assert doctor.check_config().status == OK
|
|
assert doctor.check_server(deep=False).status == OK
|
|
|
|
|
|
def test_every_problem_comes_with_something_to_do_about_it(monkeypatch):
|
|
""""screen reading: warn" is useless on its own; "apt install tesseract-ocr"
|
|
is the entire point of the tool."""
|
|
for name in ("SERVER_URL", "API_KEY"):
|
|
monkeypatch.setattr(config, name, "")
|
|
monkeypatch.setattr(doctor, "_module", lambda _n: False)
|
|
|
|
for check in doctor.run():
|
|
if check.status == FAIL:
|
|
assert check.fix, f"{check.name} says what's wrong but not what to do"
|
|
|
|
|
|
def test_the_exit_code_is_nonzero_only_for_real_failures(monkeypatch, capsys):
|
|
monkeypatch.setattr(doctor, "run", lambda deep=False: [
|
|
doctor.Check("a", OK, "fine"), doctor.Check("b", WARN, "degraded")])
|
|
assert doctor.main([]) == 0
|
|
|
|
monkeypatch.setattr(doctor, "run", lambda deep=False: [doctor.Check("a", FAIL, "broken")])
|
|
assert doctor.main([]) == 1
|
|
assert "Fix those first" in capsys.readouterr().out
|
|
|
|
|
|
def test_deep_is_off_unless_asked(monkeypatch):
|
|
seen = []
|
|
monkeypatch.setattr(doctor, "run", lambda deep=False: seen.append(deep) or [])
|
|
doctor.main([])
|
|
doctor.main(["--deep"])
|
|
assert seen == [False, True]
|
|
|
|
|
|
def test_a_slow_silence_timeout_is_flagged(monkeypatch):
|
|
"""The setting most likely to make it feel sluggish, and the least obvious
|
|
— it is pure dead air before anything at all starts happening."""
|
|
monkeypatch.setattr(config, "SILENCE_END_SEC", 2.0)
|
|
check = doctor.check_latency()
|
|
assert check.status == WARN
|
|
assert "2s" in check.detail or "2 " in check.detail
|
|
|
|
monkeypatch.setattr(config, "SILENCE_END_SEC", 0.9)
|
|
assert doctor.check_latency().status == OK
|
|
|
|
|
|
def test_a_check_line_renders_the_fix_only_when_there_is_a_problem():
|
|
assert "→" not in doctor.Check("x", OK, "all good", fix="unused").line()
|
|
assert "→ do the thing" in doctor.Check("x", WARN, "hmm", fix="do the thing").line()
|
|
|
|
|
|
# ── check_tts's --deep probe ────────────────────────────────────────────────
|
|
# check_streaming_endpoint already established the pattern: shallow only
|
|
# checks config (no network), --deep actually calls the server. TTS needs
|
|
# its own deep probe specifically because "configured but broken" (a
|
|
# rejected voice id, a dead credit balance) is otherwise indistinguishable
|
|
# from "working" until speak() silently falls back to the offline voice —
|
|
# which is exactly the bug report this was added to diagnose.
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _tts_configured(monkeypatch):
|
|
monkeypatch.setattr(config, "SERVER_URL", "http://test-server:5002")
|
|
monkeypatch.setattr(config, "API_KEY", "test-key")
|
|
monkeypatch.setattr(config, "SESSION_ID", "pet-test")
|
|
monkeypatch.setattr(config, "ELEVENLABS_VOICE_ID", "abc123voiceid")
|
|
|
|
|
|
def test_tts_shallow_does_not_touch_the_network():
|
|
with patch("requests.post") as post:
|
|
check = doctor.check_tts(deep=False)
|
|
post.assert_not_called()
|
|
assert check.status == OK
|
|
assert "not probed" in check.detail
|
|
|
|
|
|
def test_tts_deep_reports_success():
|
|
response = MagicMock(status_code=200, content=b"\x00\x01" * 100)
|
|
with patch("requests.post", return_value=response) as post:
|
|
check = doctor.check_tts(deep=True)
|
|
post.assert_called_once()
|
|
assert check.status == OK
|
|
assert "200 bytes" in check.detail
|
|
|
|
args, kwargs = post.call_args
|
|
assert args[0] == "http://test-server:5002/desk/tts"
|
|
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
|
assert kwargs["json"]["voice_id"] == "abc123voiceid"
|
|
|
|
|
|
def test_tts_deep_surfaces_the_servers_error_message():
|
|
"""A 404 with 'voice not found' is exactly the shape of the reported bug:
|
|
a vb: clone owned by someone else, or a stale/mistyped id."""
|
|
response = MagicMock(status_code=404)
|
|
response.json.return_value = {"error": "voice not found"}
|
|
with patch("requests.post", return_value=response):
|
|
check = doctor.check_tts(deep=True)
|
|
assert check.status == FAIL
|
|
assert "404" in check.detail
|
|
assert "voice not found" in check.detail
|
|
assert check.fix
|
|
|
|
|
|
def test_tts_deep_handles_a_network_failure_without_raising():
|
|
with patch("requests.post", side_effect=OSError("no route to host")):
|
|
check = doctor.check_tts(deep=True)
|
|
assert check.status == FAIL
|
|
assert "no route to host" in check.detail
|
|
|
|
|
|
def test_tts_run_wires_deep_through_automatically(monkeypatch):
|
|
"""run()'s TypeError-fallback dance (doctor.py's CHECKS loop) must
|
|
actually reach check_tts's deep parameter, not silently call it shallow."""
|
|
seen = []
|
|
|
|
def fake_check_tts(deep=False):
|
|
seen.append(deep)
|
|
return doctor.Check("text-to-speech", OK, "stub")
|
|
|
|
monkeypatch.setattr(doctor, "CHECKS", (("tts", fake_check_tts),))
|
|
doctor.run(deep=True)
|
|
assert seen == [True]
|