Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:
- server_client.list_chats() / create_chat(), same shape and error
handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
SESSION_ID every call reads, persisted to session.json. The window
refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
populated on open, with Refresh and New Chat. Network calls run as
QRunnables on the global QThreadPool and come back through Signals,
never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
fallback).
This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.
Build output (build/, dist/, Release/, *.spec) is now ignored.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
"""Barge-in detection, driven by a fake mic stream and a fake wake model
|
||||
(no audio hardware, no ONNX runtime)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio.barge_in import BargeInDetector, WakeWordBargeIn, make_detector
|
||||
|
||||
|
||||
class FakeStream:
|
||||
"""Yields frames of a given amplitude, mimicking sounddevice's
|
||||
(data, overflowed) 2-D int16 return shape."""
|
||||
|
||||
def __init__(self, amplitudes):
|
||||
self._amplitudes = list(amplitudes)
|
||||
|
||||
def read(self, frames):
|
||||
amplitude = self._amplitudes.pop(0) if self._amplitudes else 0
|
||||
data = np.full((frames, 1), amplitude, dtype=np.int16)
|
||||
return data, False
|
||||
|
||||
|
||||
def test_silence_never_interrupts():
|
||||
detector = BargeInDetector(FakeStream([0] * 20), threshold=1000, required_frames=3)
|
||||
assert not any(detector.check() for _ in range(20))
|
||||
|
||||
|
||||
def test_sustained_speech_interrupts_after_the_required_frames():
|
||||
detector = BargeInDetector(FakeStream([2000] * 5), threshold=1000, required_frames=3)
|
||||
assert detector.check() is False
|
||||
assert detector.check() is False
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_a_single_thump_does_not_interrupt():
|
||||
# loud, quiet, loud, quiet ... never three in a row
|
||||
detector = BargeInDetector(FakeStream([2000, 0, 2000, 0, 2000, 0]), threshold=1000, required_frames=3)
|
||||
assert not any(detector.check() for _ in range(6))
|
||||
|
||||
|
||||
def test_counter_resets_after_a_quiet_frame():
|
||||
detector = BargeInDetector(FakeStream([2000, 2000, 0, 2000, 2000, 2000]), threshold=1000, required_frames=3)
|
||||
results = [detector.check() for _ in range(6)]
|
||||
assert results == [False, False, False, False, False, True]
|
||||
|
||||
|
||||
def test_reset_clears_progress():
|
||||
detector = BargeInDetector(FakeStream([2000] * 6), threshold=1000, required_frames=3)
|
||||
detector.check()
|
||||
detector.check()
|
||||
detector.reset()
|
||||
assert detector.check() is False
|
||||
assert detector.loud_frames == 1
|
||||
|
||||
|
||||
def test_a_mic_error_mid_playback_is_not_fatal():
|
||||
class BrokenStream:
|
||||
def read(self, frames):
|
||||
raise OSError("device disappeared")
|
||||
|
||||
detector = BargeInDetector(BrokenStream(), threshold=1000, required_frames=1)
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
# ── wake-word mode ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeModel:
|
||||
"""Scores frames from a canned list, mimicking openWakeWord's
|
||||
{class_name: score} return. Records reset() calls."""
|
||||
|
||||
def __init__(self, scores):
|
||||
self._scores = list(scores)
|
||||
self.resets = 0
|
||||
|
||||
def predict(self, frame):
|
||||
score = self._scores.pop(0) if self._scores else 0.0
|
||||
return {"thunderbolt": score}
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
|
||||
|
||||
def _wake_detector(scores, threshold=0.5, amplitudes=None):
|
||||
stream = FakeStream(amplitudes if amplitudes is not None else [500] * len(scores))
|
||||
return WakeWordBargeIn(stream, model=FakeModel(scores), threshold=threshold), stream
|
||||
|
||||
|
||||
def test_loud_noise_alone_does_not_interrupt_in_wake_mode():
|
||||
"""The whole point of wake mode: a slammed door is deafening and scores
|
||||
nothing, so the pet keeps talking."""
|
||||
detector, _ = _wake_detector([0.01] * 6, amplitudes=[30000] * 6)
|
||||
assert not any(detector.check() for _ in range(6))
|
||||
|
||||
|
||||
def test_the_wake_word_interrupts():
|
||||
detector, _ = _wake_detector([0.1, 0.2, 0.9])
|
||||
assert [detector.check() for _ in range(3)] == [False, False, True]
|
||||
|
||||
|
||||
def test_a_single_frame_is_enough_when_it_clears_the_threshold():
|
||||
detector, _ = _wake_detector([0.55])
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_scores_just_under_the_threshold_do_not_fire():
|
||||
detector, _ = _wake_detector([0.49, 0.499], threshold=0.5)
|
||||
assert not any(detector.check() for _ in range(2))
|
||||
|
||||
|
||||
def test_detecting_resets_the_model_so_the_tail_is_not_reused():
|
||||
model = FakeModel([0.9])
|
||||
detector = WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5)
|
||||
assert detector.check() is True
|
||||
assert model.resets == 1
|
||||
|
||||
|
||||
def test_reset_clears_the_models_audio_window_not_just_predictions():
|
||||
"""The regression that made the pet interrupt itself a word into every
|
||||
reply: openwakeword's reset() clears only the prediction buffer, so the
|
||||
"thunderbolt" that started the turn was still in the preprocessor's
|
||||
rolling window when playback began, and the first frame fed to the model
|
||||
re-fired on it."""
|
||||
|
||||
class FakePreprocessor:
|
||||
def __init__(self):
|
||||
self.raw_data_buffer = [1, 2, 3]
|
||||
self.feature_buffer = np.ones((120, 96))
|
||||
self.melspectrogram_buffer = np.zeros((76, 32))
|
||||
self.accumulated_samples = 4096
|
||||
|
||||
def _get_embeddings(self, audio):
|
||||
return np.zeros((120, 96))
|
||||
|
||||
model = FakeModel([0.9])
|
||||
model.preprocessor = FakePreprocessor()
|
||||
|
||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
||||
|
||||
assert model.preprocessor.raw_data_buffer == []
|
||||
assert model.preprocessor.accumulated_samples == 0
|
||||
assert not model.preprocessor.feature_buffer.any() # blank, not the old audio
|
||||
assert model.preprocessor.melspectrogram_buffer.all() # restored to ones
|
||||
|
||||
|
||||
def test_the_lazy_wrapper_exposes_its_preprocessor():
|
||||
"""The pet holds _default_model — a lazy *wrapper* around openwakeword's
|
||||
Model. If the wrapper stops proxying .preprocessor, hard_reset() finds
|
||||
nothing to clear and silently degrades to the shallow reset that leaves
|
||||
the last detection in the audio window. That failure is invisible: no
|
||||
exception, no log, the pet just interrupts itself again."""
|
||||
from bolt_pet.audio.wake_word import _OpenWakeWordModel
|
||||
|
||||
wrapper = _OpenWakeWordModel()
|
||||
assert hasattr(wrapper, "preprocessor")
|
||||
assert wrapper.preprocessor is None # not loaded yet: a no-op, not a load
|
||||
|
||||
class FakeInner:
|
||||
preprocessor = object()
|
||||
|
||||
def reset(self):
|
||||
pass
|
||||
|
||||
wrapper._model = FakeInner()
|
||||
assert wrapper.preprocessor is FakeInner.preprocessor
|
||||
|
||||
|
||||
def test_a_model_without_a_preprocessor_still_resets():
|
||||
"""Fakes in tests, and any future openwakeword whose internals moved."""
|
||||
model = FakeModel([0.0])
|
||||
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
|
||||
assert model.resets == 1
|
||||
|
||||
|
||||
def test_a_callable_threshold_is_read_every_frame():
|
||||
"""The tray tuner's slider has to apply mid-playback, not just mid-idle."""
|
||||
threshold = {"value": 0.9}
|
||||
detector = WakeWordBargeIn(
|
||||
FakeStream([500] * 2), model=FakeModel([0.6, 0.6]),
|
||||
threshold=lambda: threshold["value"],
|
||||
)
|
||||
assert detector.check() is False
|
||||
threshold["value"] = 0.5
|
||||
assert detector.check() is True
|
||||
|
||||
|
||||
def test_a_model_that_blows_up_mid_playback_is_not_fatal():
|
||||
class BrokenModel:
|
||||
def predict(self, frame):
|
||||
raise RuntimeError("onnx session died")
|
||||
|
||||
def reset(self):
|
||||
raise RuntimeError("still dead")
|
||||
|
||||
detector = WakeWordBargeIn(FakeStream([500]), model=BrokenModel(), threshold=0.5)
|
||||
assert detector.check() is False
|
||||
detector.reset() # must not raise either
|
||||
|
||||
|
||||
def test_a_mic_error_is_not_fatal_in_wake_mode():
|
||||
class BrokenStream:
|
||||
def read(self, frames):
|
||||
raise OSError("device disappeared")
|
||||
|
||||
detector = WakeWordBargeIn(BrokenStream(), model=FakeModel([0.9]), threshold=0.5)
|
||||
assert detector.check() is False
|
||||
|
||||
|
||||
def test_make_detector_picks_the_mode():
|
||||
stream = FakeStream([0])
|
||||
assert isinstance(make_detector(stream, mode="wake"), WakeWordBargeIn)
|
||||
assert isinstance(make_detector(stream, mode="energy"), BargeInDetector)
|
||||
# A typo in .env shouldn't stop the pet from starting.
|
||||
assert isinstance(make_detector(stream, mode="waek"), BargeInDetector)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""The Chats dialog, the header New chat, /chats in the terminal, and the
|
||||
row helpers. No network: list/create are injected fakes, and they still run
|
||||
on the real QThreadPool, so the off-UI-thread hand-off is exercised."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from bolt_console import chats, config, console, server_client
|
||||
from bolt_console.tools import Tools
|
||||
from bolt_console.ui import Terminal
|
||||
|
||||
CHATS = [
|
||||
{"session_id": "chat-new1", "label": "Fix the build",
|
||||
"last_message": "try again [Bolt Console: a text terminal on Windows 10; working directory C:/x. To act",
|
||||
"last_active": "2026-09-25T23:46:31+00:00"},
|
||||
{"session_id": "pet-console-test", "label": "console DESKTOP",
|
||||
"last_message": "what is in this folder", "last_active": "2026-09-25T21:26:20+00:00"},
|
||||
{"session_id": "android-SM-S928U", "label": "android SM S928U",
|
||||
"last_message": "wifi networks", "last_active": "2026-09-20T22:33:34+00:00"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _session(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "SESSION_ID", "pet-console-test")
|
||||
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json")
|
||||
monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9")
|
||||
monkeypatch.setattr(config, "API_KEY", "k")
|
||||
monkeypatch.delenv("DESK_SESSION_ID", raising=False)
|
||||
|
||||
|
||||
class Calls:
|
||||
"""Fake list/create that record which thread they ran on."""
|
||||
|
||||
def __init__(self, chat_list=CHATS, new_id="chat-fresh", fail=None):
|
||||
self.chat_list, self.new_id, self.fail, self.threads = chat_list, new_id, fail, []
|
||||
|
||||
def list(self):
|
||||
self.threads.append(threading.current_thread())
|
||||
if self.fail:
|
||||
raise server_client.ServerError(self.fail)
|
||||
return self.chat_list
|
||||
|
||||
def create(self):
|
||||
self.threads.append(threading.current_thread())
|
||||
if self.fail:
|
||||
raise server_client.ServerError(self.fail)
|
||||
return self.new_id
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_preview_drops_client_context_notes_even_when_cut_off():
|
||||
assert chats.clean_preview(CHATS[0]["last_message"]) == "try again"
|
||||
assert chats.clean_preview("hi [on screen right now: VS Code] there") == "hi there"
|
||||
assert chats.clean_preview("x" * 200, 20).endswith("…")
|
||||
|
||||
|
||||
def test_relative_time():
|
||||
now = datetime(2026, 9, 26, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def ago(**delta):
|
||||
return (now - timedelta(**delta)).isoformat()
|
||||
|
||||
assert chats.relative_time(ago(seconds=10), now) == "just now"
|
||||
assert chats.relative_time(ago(minutes=12), now) == "12 min ago"
|
||||
assert chats.relative_time(ago(hours=3), now) == "3 h ago"
|
||||
assert chats.relative_time(ago(days=1, hours=2), now) == "yesterday"
|
||||
assert chats.relative_time(ago(days=4), now) == "4 days ago"
|
||||
assert chats.relative_time("garbage", now) == "garbage"
|
||||
|
||||
|
||||
# ── terminal ────────────────────────────────────────────────────────────────
|
||||
|
||||
def terminal_session(calls):
|
||||
out = io.StringIO()
|
||||
ui = Terminal(out=out, color=False, input_fn=lambda _p: "", animate=False)
|
||||
session = console.Session(ui, tools=Tools(ui), list_chats=calls.list,
|
||||
create_chat=calls.create, list_files=lambda: [])
|
||||
return session, out
|
||||
|
||||
|
||||
def test_chats_command_lists_and_marks_the_current_one():
|
||||
session, out = terminal_session(Calls())
|
||||
session.handle("/chats")
|
||||
text = out.getvalue()
|
||||
assert "Fix the build" in text and "try again" in text and "Bolt Console:" not in text
|
||||
assert "* 2 console DESKTOP" in text # the current chat is starred
|
||||
|
||||
|
||||
def test_chat_command_switches_by_number_and_remembers_it():
|
||||
session, _ = terminal_session(Calls())
|
||||
session.handle("/chats")
|
||||
session.handle("/chat 3")
|
||||
assert config.SESSION_ID == "android-SM-S928U"
|
||||
assert config._remembered_session() == "android-SM-S928U"
|
||||
|
||||
|
||||
def test_new_uses_the_server_minted_id_and_falls_back_locally():
|
||||
session, _ = terminal_session(Calls(new_id="chat-abc123"))
|
||||
session.handle("/new")
|
||||
assert config.SESSION_ID == "chat-abc123"
|
||||
session, out = terminal_session(Calls(fail="HTTP 401"))
|
||||
session.handle("/new")
|
||||
assert config.SESSION_ID.startswith("pet-console-") and "made locally" in out.getvalue()
|
||||
|
||||
|
||||
# ── window ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def pump(app, predicate, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window():
|
||||
from bolt_console.gui.window import MainWindow, make_app
|
||||
|
||||
app = make_app(["test"])
|
||||
calls = Calls()
|
||||
win = MainWindow(list_chats=calls.list, create_chat=calls.create)
|
||||
win.calls, win.app = calls, app
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
|
||||
def test_dialog_lists_chats_off_the_ui_thread(window):
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||
assert window.calls.threads and all(t is not threading.main_thread() for t in window.calls.threads)
|
||||
assert dialog.list.currentItem().data(Qt.UserRole) == "pet-console-test" # current one selected
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_picking_a_chat_switches_session_and_clears_the_transcript(window):
|
||||
from bolt_console.gui.widgets import UserMessage
|
||||
|
||||
window._add(UserMessage("old conversation"))
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||
dialog.list.setCurrentRow(0)
|
||||
dialog.open_button.click()
|
||||
window.app.processEvents()
|
||||
assert config.SESSION_ID == "chat-new1"
|
||||
assert not window.empty.isHidden()
|
||||
assert "Fix the build" in window.hint.text()
|
||||
|
||||
|
||||
def test_new_chat_in_the_dialog_switches_to_the_minted_id(window):
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||
dialog.new_button.click()
|
||||
assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh")
|
||||
assert not dialog.isVisible()
|
||||
|
||||
|
||||
def test_header_new_chat_mints_off_thread_and_clears(window):
|
||||
from bolt_console.gui.widgets import UserMessage
|
||||
|
||||
window._add(UserMessage("old"))
|
||||
window.start_new_chat()
|
||||
assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh")
|
||||
assert window.calls.threads[0] is not threading.main_thread()
|
||||
assert not window.empty.isHidden() and window.new_chat.isEnabled()
|
||||
|
||||
|
||||
def test_header_new_chat_failure_keeps_the_current_chat(window):
|
||||
window.calls.fail = "HTTP 401 unauthorized"
|
||||
window.start_new_chat()
|
||||
assert pump(window.app, lambda: "Couldn't start a new chat" in window.hint.text())
|
||||
assert config.SESSION_ID == "pet-console-test"
|
||||
assert window.new_chat.isEnabled()
|
||||
|
||||
|
||||
def test_dialog_shows_a_load_failure(window):
|
||||
window.calls.fail = "HTTP 401"
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: "Couldn't load chats" in dialog.status.text())
|
||||
assert dialog.refresh_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_switching_is_refused_while_bolt_is_working(window):
|
||||
window._set_busy(True)
|
||||
window.open_chats()
|
||||
assert "Wait for Bolt" in window.hint.text()
|
||||
window.switch_chat("chat-other", "Other")
|
||||
assert config.SESSION_ID == "pet-console-test"
|
||||
window._set_busy(False)
|
||||
@@ -1,62 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config
|
||||
|
||||
|
||||
def test_set_env_value_uncomments_existing_line(tmp_path, monkeypatch):
|
||||
(tmp_path / ".env").write_text("#MIC_DEVICE=\nOTHER=1\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "HERE", tmp_path)
|
||||
|
||||
config.set_env_value("MIC_DEVICE", "USB Mic")
|
||||
|
||||
lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
|
||||
assert lines == ["MIC_DEVICE=USB Mic", "OTHER=1"]
|
||||
assert config.os.environ["MIC_DEVICE"] == "USB Mic"
|
||||
|
||||
|
||||
def test_set_env_value_overwrites_existing_uncommented_line(tmp_path, monkeypatch):
|
||||
(tmp_path / ".env").write_text("MIC_DEVICE=Old Mic\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "HERE", tmp_path)
|
||||
|
||||
config.set_env_value("MIC_DEVICE", "New Mic")
|
||||
|
||||
lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
|
||||
assert lines == ["MIC_DEVICE=New Mic"]
|
||||
|
||||
|
||||
def test_set_env_value_appends_when_key_is_missing(tmp_path, monkeypatch):
|
||||
(tmp_path / ".env").write_text("OTHER=1\n", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "HERE", tmp_path)
|
||||
|
||||
config.set_env_value("MIC_DEVICE", "New Mic")
|
||||
|
||||
lines = (tmp_path / ".env").read_text(encoding="utf-8").splitlines()
|
||||
assert lines == ["OTHER=1", "MIC_DEVICE=New Mic"]
|
||||
|
||||
|
||||
def test_set_env_value_creates_file_if_missing(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "HERE", tmp_path)
|
||||
|
||||
config.set_env_value("MIC_DEVICE", "New Mic")
|
||||
|
||||
assert (tmp_path / ".env").read_text(encoding="utf-8") == "MIC_DEVICE=New Mic\n"
|
||||
|
||||
|
||||
def test_mic_device_parses_a_numeric_index_as_int():
|
||||
# sounddevice only takes an int index as-is; a string (even "1") goes
|
||||
# through substring matching against every device name instead, which is
|
||||
# not what a saved index means — see the tray's mic picker.
|
||||
assert config._mic_device("1") == 1
|
||||
assert isinstance(config._mic_device("1"), int)
|
||||
|
||||
|
||||
def test_mic_device_keeps_a_name_as_a_string():
|
||||
assert config._mic_device("USB Mic") == "USB Mic"
|
||||
|
||||
|
||||
def test_mic_device_blank_is_none():
|
||||
assert config._mic_device("") is None
|
||||
assert config._mic_device(" ") is None
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Whole turns over real HTTP: the real server_client, the real Tools and the
|
||||
real Terminal (writing to a buffer), against a threaded server on a loopback
|
||||
port that speaks the desk protocol. Only the keyboard is faked."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_console import config, console
|
||||
from bolt_console.console import Session
|
||||
from bolt_console.tools import Tools
|
||||
from bolt_console.ui import Terminal
|
||||
|
||||
|
||||
class FakeDesk:
|
||||
"""Scripted responses, real HTTP. Set `.script` per test."""
|
||||
|
||||
def __init__(self):
|
||||
self.script = {}
|
||||
self.requests = []
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
|
||||
threading.Thread(target=self._server.serve_forever, daemon=True).start()
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
host, port = self._server.server_address[:2]
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
def stop(self) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
|
||||
def _handler(self):
|
||||
desk = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
desk.requests.append(("GET", path))
|
||||
response = desk.script.get(path, {"files": []})
|
||||
if isinstance(response, bytes):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Length", str(len(response)))
|
||||
self.end_headers()
|
||||
self.wfile.write(response)
|
||||
else:
|
||||
self._json(response)
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
path = self.path.split("?")[0]
|
||||
desk.requests.append(("POST", path, body))
|
||||
response = desk.script.get(path)
|
||||
if callable(response):
|
||||
response = response(body)
|
||||
if path.endswith("converse_stream"):
|
||||
if response is None:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/x-ndjson")
|
||||
self.end_headers()
|
||||
for event in response:
|
||||
self.wfile.write((json.dumps(event) + "\n").encode())
|
||||
self.wfile.flush()
|
||||
else:
|
||||
self._json(response if response is not None else {"type": "reply", "text": "ok"})
|
||||
|
||||
def _json(self, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def desk(monkeypatch, tmp_path):
|
||||
server = FakeDesk()
|
||||
monkeypatch.setattr(config, "SERVER_URL", server.url)
|
||||
monkeypatch.setattr(config, "API_KEY", "k")
|
||||
monkeypatch.setattr(config, "SESSION_ID", "pet-console-test")
|
||||
monkeypatch.setattr(config, "DELIVERED_FILES_DIR", tmp_path / "delivered")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
yield server
|
||||
server.stop()
|
||||
|
||||
|
||||
def make_session(answers=(), mode="ask"):
|
||||
answers = list(answers)
|
||||
out = io.StringIO()
|
||||
ui = Terminal(out=out, color=False, input_fn=lambda _p: answers.pop(0), animate=False)
|
||||
return Session(ui, tools=Tools(ui, mode=mode)), out
|
||||
|
||||
|
||||
def converse_bodies(desk):
|
||||
return [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]]
|
||||
|
||||
|
||||
def test_a_plain_reply_is_printed_once(desk):
|
||||
desk.script["/desk/converse"] = {"type": "reply", "text": "Hi there."}
|
||||
session, out = make_session()
|
||||
session.handle("hello")
|
||||
assert out.getvalue().count("Hi there.") == 1
|
||||
|
||||
|
||||
def test_streamed_sentences_print_as_one_block_and_are_not_repeated(desk):
|
||||
desk.script["/desk/converse_stream"] = [
|
||||
{"type": "say", "text": "First."},
|
||||
{"type": "say", "text": "Second."},
|
||||
{"type": "reply", "text": "First. Second.", "already_spoken": True},
|
||||
]
|
||||
session, out = make_session()
|
||||
session.handle("hello")
|
||||
text = out.getvalue()
|
||||
assert text.count("First.") == 1 and text.count("Second.") == 1
|
||||
assert "* First.\n Second." in text
|
||||
|
||||
|
||||
def test_the_message_carries_the_working_directory(desk, tmp_path):
|
||||
session, _ = make_session()
|
||||
session.handle("what's here?")
|
||||
body = converse_bodies(desk)[-1]
|
||||
assert body["session_id"] == "pet-console-test"
|
||||
assert body["text"].startswith("what's here?")
|
||||
assert str(tmp_path) in body["text"]
|
||||
|
||||
|
||||
def test_an_approved_edit_lands_on_disk_and_the_result_goes_back_up(desk, tmp_path):
|
||||
target = tmp_path / "app.py"
|
||||
target.write_text("DEBUG = True\n", encoding="utf-8")
|
||||
edit = "filectl " + json.dumps(
|
||||
{"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"})
|
||||
desk.script["/desk/converse"] = {"type": "command", "command": edit, "token": "t1"}
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Turned debug off."}
|
||||
|
||||
session, out = make_session(answers=["y"])
|
||||
session.handle("turn debug off in app.py")
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "DEBUG = False\n"
|
||||
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
|
||||
assert posted[0]["token"] == "t1" and "edited" in posted[0]["output"]
|
||||
text = out.getvalue()
|
||||
assert "Edit(" in text and "- DEBUG = True" in text and "+ DEBUG = False" in text
|
||||
assert "Turned debug off." in text
|
||||
|
||||
|
||||
def test_a_declined_shell_command_reports_back_and_the_turn_still_ends(desk, tmp_path):
|
||||
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"}
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."}
|
||||
session, out = make_session(answers=["n"])
|
||||
session.handle("make a file")
|
||||
assert not (tmp_path / "made.txt").exists()
|
||||
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
|
||||
assert "declined" in posted[0]["output"]
|
||||
assert "Okay, I won't." in out.getvalue()
|
||||
|
||||
|
||||
def test_delivered_files_are_saved_after_the_turn(desk, tmp_path):
|
||||
desk.script["/desk/files"] = {"files": [{"id": "f1", "name": "report.txt", "size": 5}]}
|
||||
desk.script["/desk/files/f1"] = b"hello"
|
||||
session, out = make_session()
|
||||
session.handle("send me the report")
|
||||
assert (tmp_path / "delivered" / "report.txt").read_bytes() == b"hello"
|
||||
assert "Received report.txt" in out.getvalue()
|
||||
|
||||
|
||||
def test_server_errors_are_shown_not_raised(desk):
|
||||
desk.script["/desk/converse"] = {"type": "error", "error": "unauthorized"}
|
||||
session, out = make_session()
|
||||
session.handle("hi")
|
||||
assert "Error: unauthorized" in out.getvalue()
|
||||
|
||||
|
||||
def test_slash_commands(desk, tmp_path):
|
||||
(tmp_path / "sub").mkdir()
|
||||
session, out = make_session()
|
||||
assert session.handle("/cd sub") is True
|
||||
assert Path.cwd() == tmp_path / "sub"
|
||||
session.handle("/auto")
|
||||
assert session.tools.mode == "auto"
|
||||
session.handle("/ask")
|
||||
assert session.tools.mode == "ask"
|
||||
assert session.handle("/exit") is False
|
||||
assert converse_bodies(desk) == [] # none of that went to Bolt
|
||||
|
||||
|
||||
def test_loop_exits_cleanly_on_eof(desk):
|
||||
out = io.StringIO()
|
||||
|
||||
def no_more_input(_prompt):
|
||||
raise EOFError
|
||||
|
||||
ui = Terminal(out=out, color=False, input_fn=no_more_input, animate=False)
|
||||
assert Session(ui).loop() == 0
|
||||
assert "Bolt Console" in out.getvalue()
|
||||
|
||||
|
||||
def test_backslash_continues_a_message_onto_the_next_line(desk):
|
||||
lines = ["first line \\", "second line"]
|
||||
ui = Terminal(out=io.StringIO(), color=False, input_fn=lambda _p: lines.pop(0), animate=False)
|
||||
assert ui.read_message() == "first line \nsecond line"
|
||||
|
||||
|
||||
def test_annotate_names_the_directory_and_says_there_is_no_pet():
|
||||
note = console.annotate("hi", "/work", "Linux 6.1")
|
||||
assert note.startswith("hi\n\n")
|
||||
assert "/work" in note and "Linux 6.1" in note and "no pet body" in note
|
||||
|
||||
|
||||
def test_a_reply_that_invents_command_results_is_flagged_and_corrected(desk):
|
||||
"""Seen live: 'wrote 1200 chars to …' three turns running, no command sent,
|
||||
no file on disk."""
|
||||
desk.script["/desk/converse_stream"] = [
|
||||
{"type": "say", "text": "Let me check that."},
|
||||
{"type": "say", "text": "→ Command result: [filectl] wrote 1200 chars to C:/x/main.py"},
|
||||
{"type": "reply", "text": "", "already_spoken": True},
|
||||
]
|
||||
session, out = make_session()
|
||||
session.handle("write it")
|
||||
assert console.FABRICATION_WARNING in out.getvalue()
|
||||
|
||||
desk.script["/desk/converse_stream"] = [{"type": "reply", "text": "Sorry.", "already_spoken": False}]
|
||||
session.handle("did you write it?")
|
||||
corrected = converse_bodies(desk)[-1]["text"]
|
||||
assert corrected.startswith(console.CORRECTION)
|
||||
assert "did you write it?" in corrected
|
||||
|
||||
session.handle("thanks") # the correction goes out once, not forever
|
||||
assert not converse_bodies(desk)[-1]["text"].startswith(console.CORRECTION)
|
||||
|
||||
|
||||
def test_real_command_output_quoted_in_a_reply_is_not_flagged(desk, tmp_path):
|
||||
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"}
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Got [exit 0] and hi."}
|
||||
session, out = make_session(answers=["y"])
|
||||
session.handle("say hi")
|
||||
assert console.FABRICATION_WARNING not in out.getvalue()
|
||||
|
||||
|
||||
def test_fabricated_matches_the_shapes_seen_live():
|
||||
assert console.fabricated("→ Command result: [filectl] {...}", 0)
|
||||
assert console.fabricated("-> Deliver files result: Files delivered to C:/x", 0)
|
||||
assert console.fabricated("[filectl] wrote 1200 chars to C:/x", 0)
|
||||
assert not console.fabricated("I wrote the plan below.", 0)
|
||||
assert not console.fabricated("→ Command result: [filectl] wrote 3 chars", 1)
|
||||
|
||||
|
||||
def test_the_context_note_spells_out_the_command_marker():
|
||||
"""'use filectl and shell commands' made the live model write filectl as
|
||||
prose; the explicit `command:` form got real relayed commands."""
|
||||
note = console.annotate("hi", "/work", "Windows 10")
|
||||
assert "`command: filectl {" in note
|
||||
assert "`command: <shell command>`" in note
|
||||
assert "Never write a command's output yourself" in note
|
||||
|
||||
|
||||
def test_new_conversation_switches_and_remembers_the_session(desk, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "state" / "session.json")
|
||||
monkeypatch.delenv("DESK_SESSION_ID", raising=False)
|
||||
session, out = make_session()
|
||||
session._correct_next = True
|
||||
session.handle("/new")
|
||||
new_id = config.SESSION_ID
|
||||
assert new_id.startswith("pet-console-") and new_id != "pet-console-test"
|
||||
assert new_id in out.getvalue()
|
||||
assert not session._correct_next # nothing to correct in an empty history
|
||||
assert config._remembered_session() == new_id # survives a restart
|
||||
|
||||
session.handle("hello")
|
||||
assert converse_bodies(desk)[-1]["session_id"] == new_id
|
||||
|
||||
|
||||
def test_a_corrupt_state_file_means_the_default_session(tmp_path, monkeypatch):
|
||||
state = tmp_path / "session.json"
|
||||
state.write_text("{not json", encoding="utf-8")
|
||||
monkeypatch.setattr(config, "STATE_FILE", state)
|
||||
assert config._remembered_session() == ""
|
||||
@@ -1,203 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet import controller as controller_mod
|
||||
from bolt_pet.state import PetState
|
||||
|
||||
# A QApplication is required before any QObject with signals can be built.
|
||||
_app = QApplication.instance() or QApplication(["test"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_screen_probes(monkeypatch):
|
||||
# The real ones shell out to xprop/osascript — irrelevant here, and slow
|
||||
# (or hung) on a headless box.
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_streaming(monkeypatch):
|
||||
"""Most tests drive the plain request/response path; leaving streaming on
|
||||
would have them attempt a real HTTP call, fail, and fall back — passing,
|
||||
slowly, for the wrong reason. The streaming path has its own tests."""
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctrl():
|
||||
return controller_mod.PetController()
|
||||
|
||||
|
||||
def _capture(signal):
|
||||
events = []
|
||||
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
|
||||
return events
|
||||
|
||||
|
||||
# ── conversation turn ────────────────────────────────────────────────────────
|
||||
|
||||
def test_full_turn_happy_path(monkeypatch, ctrl):
|
||||
states = _capture(ctrl.state_changed)
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's the weather")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", lambda text, on_command=None, on_say=None: controller_mod.server_client.Reply("sunny and 72"))
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: (spoken.append(text), True)[1])
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert states == ["listening", "thinking", "talking", "idle"]
|
||||
assert said == ["sunny and 72"]
|
||||
assert spoken == ["sunny and 72"]
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_turn_with_nothing_heard_returns_to_idle_without_calling_server(monkeypatch, ctrl):
|
||||
states = _capture(ctrl.state_changed)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: None)
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1))
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert states == ["listening", "idle"]
|
||||
assert called["n"] == 0
|
||||
|
||||
|
||||
def test_turn_with_empty_transcript_returns_to_idle(monkeypatch, ctrl):
|
||||
states = _capture(ctrl.state_changed)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "")
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert states == ["listening", "thinking", "idle"]
|
||||
|
||||
|
||||
def test_turn_with_stt_error_flashes_error_then_idle(monkeypatch, ctrl):
|
||||
states = _capture(ctrl.state_changed)
|
||||
logs = _capture(ctrl.log)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
|
||||
def boom(pcm):
|
||||
raise controller_mod.stt.SttError("deepgram is down")
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", boom)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert states == ["listening", "thinking", "error", "idle"]
|
||||
assert any("deepgram is down" in msg for msg in logs)
|
||||
|
||||
|
||||
def test_turn_with_server_error_flashes_error_then_idle(monkeypatch, ctrl):
|
||||
states = _capture(ctrl.state_changed)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance", lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "hello")
|
||||
|
||||
def boom(text, on_command=None, on_say=None):
|
||||
raise controller_mod.server_client.ServerError("server is down")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", boom)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert states == ["listening", "thinking", "error", "idle"]
|
||||
|
||||
|
||||
# ── mute / talk-now / wake-or-click disambiguation ──────────────────────────
|
||||
|
||||
def test_toggle_mute_flips_and_returns_new_state(ctrl):
|
||||
assert ctrl.toggle_mute() is True
|
||||
assert ctrl.toggle_mute() is False
|
||||
|
||||
|
||||
def test_wait_for_wake_or_click_true_on_phrase_detection(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", lambda *a, **k: True)
|
||||
ctrl._stream = object()
|
||||
assert ctrl._wait_for_wake_or_click() is True
|
||||
|
||||
|
||||
def test_wait_for_wake_or_click_true_on_manual_trigger(monkeypatch, ctrl):
|
||||
# listen_for_wake_word returns False because should_continue() went
|
||||
# false (the talk_now flag got set) — controller must still recognize
|
||||
# this as "proceed", not "spurious wakeup".
|
||||
def fake_listen(stream, should_continue, on_tick=None, **kwargs):
|
||||
ctrl.request_talk_now()
|
||||
should_continue() # simulate the loop noticing the flag
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", fake_listen)
|
||||
ctrl._stream = object()
|
||||
assert ctrl._wait_for_wake_or_click() is True
|
||||
assert not ctrl._talk_now.is_set() # cleared after being consumed
|
||||
|
||||
|
||||
def test_wait_for_wake_or_click_false_on_shutdown(monkeypatch, ctrl):
|
||||
def fake_listen(stream, should_continue, on_tick=None, **kwargs):
|
||||
ctrl.stop()
|
||||
return False
|
||||
monkeypatch.setattr(controller_mod.wake_word, "listen_for_wake_word", fake_listen)
|
||||
ctrl._stream = object()
|
||||
assert ctrl._wait_for_wake_or_click() is False
|
||||
|
||||
|
||||
# ── heartbeat / proactive announcements ─────────────────────────────────────
|
||||
|
||||
def test_heartbeat_speaks_a_pending_announcement_when_idle(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: "don't forget your 3pm")
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: (spoken.append(text), True)[1])
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert spoken == ["don't forget your 3pm"]
|
||||
assert said == ["don't forget your 3pm"]
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_heartbeat_does_nothing_when_not_idle(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status",
|
||||
lambda: called.__setitem__("n", called["n"] + 1))
|
||||
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert called["n"] == 0
|
||||
|
||||
|
||||
def test_heartbeat_respects_the_interval(monkeypatch, ctrl):
|
||||
# Deterministic fake clock — time.monotonic()'s absolute value is
|
||||
# arbitrary (often system uptime), so asserting behavior relative to it
|
||||
# without controlling it would be flaky.
|
||||
fake_now = {"t": 1000.0}
|
||||
monkeypatch.setattr(controller_mod.time, "monotonic", lambda: fake_now["t"])
|
||||
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 60)
|
||||
called = {"n": 0}
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status",
|
||||
lambda: called.__setitem__("n", called["n"] + 1))
|
||||
|
||||
ctrl._maybe_heartbeat() # last_heartbeat starts at 0.0 -> elapsed is huge -> runs
|
||||
assert called["n"] == 1
|
||||
|
||||
fake_now["t"] += 10 # only 10s later — inside the 60s interval
|
||||
ctrl._maybe_heartbeat()
|
||||
assert called["n"] == 1 # skipped
|
||||
|
||||
fake_now["t"] += 60 # now well past the interval
|
||||
ctrl._maybe_heartbeat()
|
||||
assert called["n"] == 2
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,236 +0,0 @@
|
||||
"""`dialoguectl` — multi-voice scene parsing, voice resolution, API limits,
|
||||
and the request the ElevenLabs Text to Dialogue endpoint actually gets.
|
||||
|
||||
Pure logic plus one mocked HTTP call: no audio device, no network, no display.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import dialogue
|
||||
from bolt_pet.audio import tts
|
||||
|
||||
SELF_ID = "aaorr6ZHIL88gEexu7dC"
|
||||
NARRATOR_ID = "9BWtsMINqrJLrRacOk9x"
|
||||
VILLAIN_ID = "IKne3meq5aSn9XLyUdCD"
|
||||
VOICES = {"narrator": NARRATOR_ID, "villain": VILLAIN_ID}
|
||||
|
||||
|
||||
def _scene(*lines):
|
||||
return '{"lines": [' + ", ".join(lines) + "]}"
|
||||
|
||||
|
||||
# ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_non_dialogue_commands_are_left_alone():
|
||||
assert dialogue.parse("ls -la") is None
|
||||
assert dialogue.parse('filectl {"op": "list"}') is None
|
||||
assert dialogue.parse("") is None
|
||||
# "dialogues" must not be mistaken for the "dialogue" prefix
|
||||
assert dialogue.parse("dialogues --list") is None
|
||||
|
||||
|
||||
def test_a_scene_parses_into_lines():
|
||||
action = dialogue.parse(
|
||||
'dialoguectl ' + _scene(
|
||||
'{"voice": "self", "text": "[cheerfully] Hello, how are you?"}',
|
||||
'{"voice": "villain", "text": "[stuttering] I am... fine."}',
|
||||
)
|
||||
)
|
||||
assert action["action"] == "dialogue"
|
||||
assert [line["voice"] for line in action["lines"]] == ["self", "villain"]
|
||||
assert action["lines"][0]["text"].startswith("[cheerfully]")
|
||||
|
||||
|
||||
def test_the_elevenlabs_field_names_are_accepted_too():
|
||||
"""The model has read that API; copying its shape is the obvious thing to
|
||||
try, so 'inputs'/'voice_id' work as well as 'lines'/'voice'."""
|
||||
action = dialogue.parse(
|
||||
'dialoguectl {"inputs": [{"voice_id": "%s", "text": "hi"}]}' % NARRATOR_ID
|
||||
)
|
||||
assert action["lines"] == [{"voice": NARRATOR_ID, "text": "hi"}]
|
||||
|
||||
|
||||
def test_a_line_with_no_voice_defaults_to_the_pet_itself():
|
||||
action = dialogue.parse('dialoguectl {"lines": [{"text": "just me talking"}]}')
|
||||
assert action["lines"][0]["voice"] == "self"
|
||||
|
||||
|
||||
def test_truncated_json_explains_the_one_line_rule():
|
||||
"""The real failure mode: the server's command extractor stops at the
|
||||
first newline, so a multi-line payload arrives cut in half. The error has
|
||||
to name the cause, since Bolt is the one who has to fix it."""
|
||||
with pytest.raises(dialogue.DialogueError, match="one line"):
|
||||
dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"')
|
||||
|
||||
|
||||
def test_an_empty_or_shapeless_payload_is_rejected():
|
||||
with pytest.raises(dialogue.DialogueError, match="needs a JSON argument"):
|
||||
dialogue.parse("dialoguectl")
|
||||
with pytest.raises(dialogue.DialogueError, match="non-empty"):
|
||||
dialogue.parse('dialoguectl {"lines": []}')
|
||||
with pytest.raises(dialogue.DialogueError, match="no text"):
|
||||
dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": " "}]}')
|
||||
|
||||
|
||||
def test_optional_model_and_stability_ride_along():
|
||||
action = dialogue.parse(
|
||||
'dialoguectl {"model_id": "eleven_v3", "stability": 0.8, '
|
||||
'"lines": [{"text": "hi"}]}'
|
||||
)
|
||||
assert action["model"] == "eleven_v3"
|
||||
assert action["stability"] == 0.8
|
||||
|
||||
|
||||
# ── voice resolution ────────────────────────────────────────────────────────
|
||||
|
||||
def test_named_voices_resolve_from_the_configured_cast():
|
||||
action = dialogue.parse('dialoguectl ' + _scene(
|
||||
'{"voice": "narrator", "text": "Once upon a time."}',
|
||||
'{"voice": "villain", "text": "Not this again."}',
|
||||
))
|
||||
inputs = dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID)
|
||||
assert [entry["voice_id"] for entry in inputs] == [NARRATOR_ID, VILLAIN_ID]
|
||||
|
||||
|
||||
def test_self_tracks_the_voice_the_pet_is_currently_using():
|
||||
"""A scene featuring Bolt should sound like whoever Bolt currently is —
|
||||
including a voice the server picked mid-conversation with speak_as."""
|
||||
action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}')
|
||||
picked = "VOICEfromSPEAKas1234"
|
||||
assert dialogue.resolve(action, self_voice=picked)[0]["voice_id"] == picked
|
||||
|
||||
|
||||
def test_a_raw_voice_id_passes_straight_through():
|
||||
action = dialogue.parse('dialoguectl {"lines": [{"voice": "%s", "text": "hi"}]}' % NARRATOR_ID)
|
||||
assert dialogue.resolve(action, self_voice=SELF_ID)[0]["voice_id"] == NARRATOR_ID
|
||||
|
||||
|
||||
def test_an_unknown_name_lists_what_is_available():
|
||||
action = dialogue.parse('dialoguectl {"lines": [{"voice": "wizard", "text": "hi"}]}')
|
||||
with pytest.raises(dialogue.DialogueError) as excinfo:
|
||||
dialogue.resolve(action, voices=VOICES, self_voice=SELF_ID)
|
||||
message = str(excinfo.value)
|
||||
assert "wizard" in message and "narrator" in message and "villain" in message
|
||||
|
||||
|
||||
def test_self_without_a_configured_voice_says_so():
|
||||
action = dialogue.parse('dialoguectl {"lines": [{"voice": "self", "text": "hi"}]}')
|
||||
with pytest.raises(dialogue.DialogueError, match="ELEVENLABS_VOICE_ID"):
|
||||
dialogue.resolve(action, self_voice="")
|
||||
|
||||
|
||||
def test_the_voice_map_parser_skips_typos_instead_of_dying():
|
||||
voices = dialogue.parse_voice_map(f"narrator:{NARRATOR_ID}, broken-entry, villain:{VILLAIN_ID}")
|
||||
assert voices == {"narrator": NARRATOR_ID, "villain": VILLAIN_ID}
|
||||
assert dialogue.parse_voice_map("") == {}
|
||||
|
||||
|
||||
# ── API limits, enforced before the request goes out ────────────────────────
|
||||
|
||||
def test_too_many_distinct_voices_is_refused_locally():
|
||||
inputs = [{"text": "hi", "voice_id": f"voice{index:015d}"} for index in range(11)]
|
||||
with pytest.raises(dialogue.DialogueError, match="limit is 10"):
|
||||
dialogue.check_limits(inputs)
|
||||
|
||||
|
||||
def test_an_over_long_scene_is_refused_with_advice():
|
||||
inputs = [{"text": "x" * 1100, "voice_id": SELF_ID} for _ in range(2)]
|
||||
with pytest.raises(dialogue.DialogueError) as excinfo:
|
||||
dialogue.check_limits(inputs)
|
||||
assert "Split it" in str(excinfo.value) # actionable, since Bolt reads this
|
||||
|
||||
|
||||
# ── display / reporting ─────────────────────────────────────────────────────
|
||||
|
||||
def test_delivery_tags_are_stripped_from_what_the_bubble_shows():
|
||||
action = dialogue.parse('dialoguectl ' + _scene(
|
||||
'{"voice": "self", "text": "[cheerfully] Hello there!"}',
|
||||
'{"voice": "narrator", "text": "[whispering] He is lying."}',
|
||||
))
|
||||
assert dialogue.spoken_text(action) == "Hello there! He is lying."
|
||||
|
||||
|
||||
def test_the_relay_report_names_the_cast():
|
||||
action = dialogue.parse('dialoguectl ' + _scene(
|
||||
'{"voice": "self", "text": "one"}', '{"voice": "narrator", "text": "two"}',
|
||||
))
|
||||
assert dialogue.describe(action).startswith(
|
||||
"[dialogue] played 2 lines in 2 voices: narrator, self")
|
||||
|
||||
|
||||
def test_the_report_says_the_scene_was_already_heard():
|
||||
"""Observed 2026-07-31: the scene played, then the final reply summarised
|
||||
it, so the pet said the same thing twice with nothing in between. The
|
||||
model cannot know the audio already happened unless it is told."""
|
||||
action = dialogue.parse('dialoguectl {"lines": [{"text": "hello"}]}')
|
||||
report = dialogue.describe(action)
|
||||
assert "HEARD this already" in report
|
||||
assert "Do not repeat" in report
|
||||
|
||||
|
||||
# ── the HTTP request ────────────────────────────────────────────────────────
|
||||
|
||||
def _pcm_response(samples=(1, 2, 3, 4)):
|
||||
response = MagicMock()
|
||||
response.content = np.array(samples, dtype=np.int16).tobytes()
|
||||
response.raise_for_status = MagicMock()
|
||||
return response
|
||||
|
||||
|
||||
def test_the_request_matches_the_text_to_dialogue_api(monkeypatch):
|
||||
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
|
||||
monkeypatch.setattr(tts.config, "TTS_SAMPLE_RATE", 24000)
|
||||
monkeypatch.setattr(tts.config, "DIALOGUE_MODEL_ID", "eleven_v3")
|
||||
inputs = [
|
||||
{"text": "[cheerfully] Hello", "voice_id": NARRATOR_ID},
|
||||
{"text": "[stuttering] H-hi", "voice_id": VILLAIN_ID},
|
||||
]
|
||||
with patch.object(tts.requests, "post", return_value=_pcm_response()) as post:
|
||||
pcm, rate = tts.synthesize_dialogue(inputs)
|
||||
|
||||
assert rate == 24000 and pcm.tolist() == [1, 2, 3, 4]
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "https://api.elevenlabs.io/v1/text-to-dialogue"
|
||||
assert kwargs["params"] == {"output_format": "pcm_24000"}
|
||||
assert kwargs["headers"] == {"xi-api-key": "test-key"}
|
||||
assert kwargs["json"]["inputs"] == inputs
|
||||
assert kwargs["json"]["model_id"] == "eleven_v3"
|
||||
assert "settings" not in kwargs["json"] # omitted unless asked for
|
||||
|
||||
|
||||
def test_stability_is_only_sent_when_given(monkeypatch):
|
||||
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
|
||||
with patch.object(tts.requests, "post", return_value=_pcm_response()) as post:
|
||||
tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}], stability=0.3)
|
||||
assert post.call_args.kwargs["json"]["settings"] == {"stability": 0.3}
|
||||
|
||||
|
||||
def test_a_rejected_request_surfaces_what_the_api_said(monkeypatch):
|
||||
"""The API explains refusals in the body; Bolt reads this through the tool
|
||||
relay, so it has to reach him rather than being flattened to '422'."""
|
||||
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "test-key")
|
||||
failure = MagicMock()
|
||||
failure.text = '{"detail": "voice_id not found"}'
|
||||
error = Exception("422 Client Error")
|
||||
error.response = failure
|
||||
response = MagicMock()
|
||||
response.raise_for_status = MagicMock(side_effect=error)
|
||||
|
||||
with patch.object(tts.requests, "post", return_value=response):
|
||||
with pytest.raises(tts.TtsError, match="voice_id not found"):
|
||||
tts.synthesize_dialogue([{"text": "hi", "voice_id": "nope"}])
|
||||
|
||||
|
||||
def test_no_api_key_fails_before_the_request(monkeypatch):
|
||||
monkeypatch.setattr(tts.config, "ELEVENLABS_API_KEY", "")
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
with pytest.raises(tts.TtsError):
|
||||
tts.synthesize_dialogue([{"text": "hi", "voice_id": SELF_ID}])
|
||||
post.assert_not_called()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""Dictation without a mic or a server: a fake PortAudio stream we push
|
||||
frames into, and a fake /desk/stt socket that answers like Deepgram."""
|
||||
|
||||
import array
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_console import dictation
|
||||
|
||||
|
||||
def frame(level: int, samples: int = 1280) -> bytes:
|
||||
return array.array("h", [level, -level] * (samples // 2)).tobytes()
|
||||
|
||||
|
||||
def result(text, final):
|
||||
return json.dumps({"type": "Results", "is_final": final,
|
||||
"channel": {"alternatives": [{"transcript": text}]}})
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
"""Answers each audio chunk with the next scripted message; on CloseStream
|
||||
sends whatever is left, then closes."""
|
||||
|
||||
def __init__(self, replies=()):
|
||||
self.replies = list(replies)
|
||||
self.audio = []
|
||||
self.closed_stream = False
|
||||
self._inbox = queue.Queue()
|
||||
|
||||
def send_binary(self, data):
|
||||
self.audio.append(data)
|
||||
if self.replies and len(self.audio) % 2 == 0:
|
||||
self._inbox.put(self.replies.pop(0))
|
||||
|
||||
def send(self, text):
|
||||
if json.loads(text).get("type") == "CloseStream":
|
||||
self.closed_stream = True
|
||||
for reply in self.replies:
|
||||
self._inbox.put(reply)
|
||||
self._inbox.put("")
|
||||
|
||||
def recv(self):
|
||||
return self._inbox.get(timeout=5)
|
||||
|
||||
def close(self):
|
||||
self._inbox.put("")
|
||||
|
||||
|
||||
class FakeStream:
|
||||
def __init__(self):
|
||||
self.callback = None
|
||||
self.stopped = False
|
||||
|
||||
def open(self, callback, _rate):
|
||||
self.callback = callback
|
||||
return self
|
||||
|
||||
def push(self, *chunks):
|
||||
for chunk in chunks:
|
||||
self.callback(chunk)
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Recorder:
|
||||
def __init__(self):
|
||||
self.updates, self.levels, self.done = [], [], threading.Event()
|
||||
self.result = None
|
||||
|
||||
def kwargs(self):
|
||||
return dict(on_update=lambda f, i: self.updates.append((f, i)),
|
||||
on_level=self.levels.append,
|
||||
on_done=lambda text, err: (setattr(self, "result", (text, err)), self.done.set()))
|
||||
|
||||
|
||||
def make(sockets, stream, rec, **extra):
|
||||
sockets = list(sockets)
|
||||
|
||||
def connect(_rate):
|
||||
item = sockets.pop(0)
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
return item
|
||||
|
||||
return dictation.Dictation(**rec.kwargs(), connect_fn=connect, open_stream_fn=stream.open, **extra)
|
||||
|
||||
|
||||
def test_live_words_arrive_while_talking_and_stop_returns_the_text():
|
||||
sock = FakeSocket([result("open the", False), result("open the readme", True),
|
||||
result("please", True)])
|
||||
stream, rec = FakeStream(), Recorder()
|
||||
d = make([sock], stream, rec)
|
||||
d.start()
|
||||
assert d.live
|
||||
stream.push(*[frame(3000)] * 4)
|
||||
deadline = time.monotonic() + 3
|
||||
while len(rec.updates) < 2 and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert rec.updates[0] == ("", "open the")
|
||||
assert rec.updates[1] == ("open the readme", "")
|
||||
d.stop()
|
||||
assert rec.done.wait(5)
|
||||
assert rec.result == ("open the readme please", None)
|
||||
assert sock.closed_stream and stream.stopped
|
||||
assert max(rec.levels) > 0 and rec.levels[-1] == 0.0
|
||||
|
||||
|
||||
def test_no_live_socket_means_the_whole_recording_is_sent_on_stop():
|
||||
late = FakeSocket([result("hello bolt", True)])
|
||||
stream, rec = FakeStream(), Recorder()
|
||||
d = make([ConnectionError("down"), late], stream, rec)
|
||||
d.start()
|
||||
assert not d.live
|
||||
stream.push(frame(3000), frame(3000), frame(0))
|
||||
time.sleep(0.3)
|
||||
d.stop()
|
||||
assert rec.done.wait(5)
|
||||
assert rec.result == ("hello bolt", None)
|
||||
assert b"".join(late.audio) == frame(3000) * 2 + frame(0)
|
||||
|
||||
|
||||
def test_a_socket_dying_mid_recording_does_not_duplicate_words():
|
||||
class Dying(FakeSocket):
|
||||
def send_binary(self, data):
|
||||
if len(self.audio) >= 1:
|
||||
raise OSError("connection reset")
|
||||
super().send_binary(data)
|
||||
self._inbox.put(result("half", True))
|
||||
|
||||
retry = FakeSocket([result("the whole thing", True)])
|
||||
stream, rec = FakeStream(), Recorder()
|
||||
d = make([Dying(), retry], stream, rec)
|
||||
d.start()
|
||||
stream.push(frame(3000), frame(3000), frame(3000))
|
||||
time.sleep(0.3)
|
||||
d.stop()
|
||||
assert rec.done.wait(5)
|
||||
assert rec.result == ("the whole thing", None) # not "half the whole thing"
|
||||
|
||||
|
||||
def test_pausing_after_speech_stops_on_its_own():
|
||||
sock = FakeSocket([result("done talking", True)])
|
||||
stream, rec = FakeStream(), Recorder()
|
||||
stopping = threading.Event()
|
||||
d = make([sock], stream, rec, silence_seconds=0.3, on_stopping=stopping.set)
|
||||
d.start()
|
||||
stream.push(frame(3000), frame(3000))
|
||||
for _ in range(8):
|
||||
stream.push(frame(10))
|
||||
time.sleep(0.05)
|
||||
assert rec.done.wait(5) # nobody called stop()
|
||||
assert stopping.is_set()
|
||||
assert rec.result == ("done talking", None)
|
||||
|
||||
|
||||
def test_silence_alone_never_auto_stops():
|
||||
stream, rec = FakeStream(), Recorder()
|
||||
d = make([FakeSocket()], stream, rec, silence_seconds=0.2)
|
||||
d.start()
|
||||
for _ in range(8):
|
||||
stream.push(frame(10))
|
||||
time.sleep(0.05)
|
||||
assert not rec.done.is_set() # still waiting for you to start talking
|
||||
d.stop()
|
||||
assert rec.done.wait(5)
|
||||
assert rec.result[0] == "" and "Didn't catch anything" in rec.result[1]
|
||||
|
||||
|
||||
def test_cancel_throws_the_result_away_without_a_fallback_upload():
|
||||
stream, rec = FakeStream(), Recorder()
|
||||
d = make([ConnectionError("down")], stream, rec) # a fallback would need a 2nd socket
|
||||
d.start()
|
||||
stream.push(frame(3000))
|
||||
d.cancel()
|
||||
assert rec.done.wait(5)
|
||||
assert rec.result == ("", None)
|
||||
|
||||
|
||||
def test_mic_failure_raises_from_start_and_closes_the_socket():
|
||||
sock = FakeSocket()
|
||||
rec = Recorder()
|
||||
|
||||
def broken(_cb, _rate):
|
||||
raise OSError("no input device")
|
||||
|
||||
d = dictation.Dictation(**rec.kwargs(), connect_fn=lambda _r: sock, open_stream_fn=broken)
|
||||
with pytest.raises(OSError):
|
||||
d.start()
|
||||
assert sock._inbox.get(timeout=1) == "" # closed
|
||||
|
||||
|
||||
def test_parse_result_is_tolerant():
|
||||
assert dictation.parse_result(result("hi", True)) == ("hi", True)
|
||||
assert dictation.parse_result(json.dumps({"type": "Metadata"})) == ("", False)
|
||||
assert dictation.parse_result(b"not json") == ("", False)
|
||||
assert dictation.parse_result(json.dumps({"type": "Results", "speech_final": True,
|
||||
"channel": {"alternatives": [{"transcript": "x"}]}})) == ("x", True)
|
||||
|
||||
|
||||
def test_connect_url_carries_the_session_and_audio_format(monkeypatch):
|
||||
import types
|
||||
|
||||
seen = {}
|
||||
fake_ws = types.SimpleNamespace(create_connection=lambda url, header, timeout: seen.update(url=url, header=header))
|
||||
monkeypatch.setitem(sys.modules, "websocket", fake_ws)
|
||||
monkeypatch.setattr(dictation.config, "SERVER_URL", "https://bolt.example")
|
||||
monkeypatch.setattr(dictation.config, "API_KEY", "k")
|
||||
monkeypatch.setattr(dictation.config, "SESSION_ID", "pet-console-x y")
|
||||
dictation.connect()
|
||||
assert seen["url"].startswith("wss://bolt.example/desk/stt?")
|
||||
assert "session_id=pet-console-x+y" in seen["url"] and "sample_rate=16000" in seen["url"]
|
||||
assert seen["header"] == ["X-Desk-Api-Key: k"]
|
||||
|
||||
|
||||
# ── the window ──────────────────────────────────────────────────────────────
|
||||
|
||||
class ScriptedDictation:
|
||||
"""Stands in for Dictation in the window: the test drives its callbacks."""
|
||||
|
||||
last = None
|
||||
|
||||
def __init__(self, on_update, on_level, on_done, silence_seconds=3.0, on_stopping=None):
|
||||
self.on_update, self.on_level, self.on_done = on_update, on_level, on_done
|
||||
self.stopped = self.cancelled = False
|
||||
self.live = True
|
||||
ScriptedDictation.last = self
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
self.stopped = True
|
||||
|
||||
def cancel(self):
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
def test_window_mic_fills_the_composer_and_blocks_send_until_done(monkeypatch):
|
||||
from bolt_console import config
|
||||
from bolt_console.gui.window import MainWindow, make_app
|
||||
|
||||
monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9")
|
||||
monkeypatch.setattr(config, "API_KEY", "k")
|
||||
monkeypatch.setattr(dictation, "unavailable_reason", lambda: None)
|
||||
app = make_app(["test"])
|
||||
window = MainWindow(dictation_factory=ScriptedDictation)
|
||||
try:
|
||||
window.composer.edit.setPlainText("Please")
|
||||
window.toggle_dictation()
|
||||
rec = ScriptedDictation.last
|
||||
assert window.composer.mic.state == "recording"
|
||||
rec.on_update("open the readme", "and")
|
||||
app.processEvents()
|
||||
assert window.composer.edit.toPlainText() == "Please open the readme and"
|
||||
assert window.composer.edit.isReadOnly() and not window.composer.send.isEnabled()
|
||||
|
||||
window.toggle_dictation() # click again: stop
|
||||
assert rec.stopped and window.composer.mic.state == "finishing"
|
||||
rec.on_done("open the readme and summarise it", None)
|
||||
app.processEvents()
|
||||
assert window.composer.edit.toPlainText() == "Please open the readme and summarise it"
|
||||
assert not window.composer.edit.isReadOnly() and window.composer.send.isEnabled()
|
||||
assert window.composer.mic.state == "idle"
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
|
||||
def test_window_shows_why_the_mic_is_unavailable(monkeypatch):
|
||||
from bolt_console.gui.window import MainWindow, make_app
|
||||
|
||||
monkeypatch.setattr(dictation, "unavailable_reason", lambda: "Dictation needs sounddevice")
|
||||
make_app(["test"])
|
||||
window = MainWindow(dictation_factory=ScriptedDictation)
|
||||
try:
|
||||
ScriptedDictation.last = None
|
||||
window.toggle_dictation()
|
||||
assert ScriptedDictation.last is None
|
||||
assert window.composer.mic.state == "unavailable"
|
||||
assert "sounddevice" in window.hint.text()
|
||||
finally:
|
||||
window.close()
|
||||
@@ -1,187 +0,0 @@
|
||||
"""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]
|
||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import file_delivery
|
||||
from bolt_console import file_delivery
|
||||
|
||||
|
||||
def test_sanitize_filename_strips_directory_components():
|
||||
|
||||
+18
-4
@@ -4,7 +4,7 @@ read/write/edit local files instead of a raw shell heredoc.
|
||||
filectl {"op": ...} is a single-line JSON envelope (not a multi-line
|
||||
marker block) because it rides the "command" tool marker, which the main
|
||||
repo's tool-call extractor only captures up to the next newline — see the
|
||||
module docstring in bolt_pet/file_ops.py."""
|
||||
module docstring in bolt_console/file_ops.py."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
@@ -14,7 +14,7 @@ import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import file_ops
|
||||
from bolt_console import file_ops
|
||||
|
||||
|
||||
def _cmd(payload: dict) -> str:
|
||||
@@ -305,7 +305,7 @@ def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path):
|
||||
tomorrow — the model has to be told while it still has the turn."""
|
||||
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||
action = file_ops.parse(
|
||||
'filectl {"op":"list","path":"%s","recursive":false"}' % tmp_path
|
||||
'filectl {"op":"list","path":%s,"recursive":false"}' % json.dumps(str(tmp_path))
|
||||
)
|
||||
output = file_ops.execute(action)
|
||||
assert "a.txt" in output
|
||||
@@ -315,7 +315,7 @@ def test_a_repair_is_reported_in_the_output_never_hidden(tmp_path):
|
||||
|
||||
def test_valid_json_gets_no_repair_note(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hello", encoding="utf-8")
|
||||
action = file_ops.parse('filectl {"op": "list", "path": "%s"}' % tmp_path)
|
||||
action = file_ops.parse('filectl {"op": "list", "path": %s}' % json.dumps(str(tmp_path)))
|
||||
assert "_repairs" not in action
|
||||
assert "malformed" not in file_ops.execute(action)
|
||||
|
||||
@@ -327,3 +327,17 @@ def test_genuinely_unparseable_json_points_at_the_character():
|
||||
message = str(excinfo.value)
|
||||
assert "^" in message # caret under the offending character
|
||||
assert '"extra"' in message # ...and the fragment around it
|
||||
|
||||
|
||||
def test_a_raw_windows_path_is_repaired_and_reported():
|
||||
r"""Models type Windows paths with single backslashes. `\U` isn't a JSON
|
||||
escape, so strict parsing would reject the whole call."""
|
||||
action = file_ops.parse(r'filectl {"op": "read", "path": "C:\Users\sam\data.txt"}')
|
||||
assert action["path"] == r"C:\Users\sam\data.txt"
|
||||
assert any("backslash" in repair for repair in action["_repairs"])
|
||||
|
||||
|
||||
def test_the_backslash_repair_leaves_valid_escapes_alone():
|
||||
# The escaped pair and the valid \n survive; only the lone \U is doubled.
|
||||
action = file_ops.parse(r'filectl {"op": "read", "path": "C:\\ok\Users\n.txt"}')
|
||||
assert action["path"] == "C:\\ok\\Users\n.txt"
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""The window: a whole turn through the real MainWindow, worker thread and
|
||||
Bridge against the loopback desk server from test_console — only the mouse is
|
||||
faked (by clicking the approval button programmatically)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel
|
||||
|
||||
from bolt_console.gui import widgets
|
||||
from bolt_console.gui.bridge import Bridge
|
||||
from bolt_console.gui.window import MainWindow, make_app
|
||||
|
||||
from tests.test_console import desk # noqa: F401 (fixture)
|
||||
|
||||
app = make_app(["test"])
|
||||
|
||||
|
||||
def pump_until(predicate, timeout=10.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def entries(window, kind):
|
||||
layout = window.entries
|
||||
return [layout.itemAt(i).widget() for i in range(layout.count())
|
||||
if isinstance(layout.itemAt(i).widget(), kind)]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window(desk, monkeypatch): # noqa: F811
|
||||
from bolt_console import config
|
||||
|
||||
monkeypatch.setattr(config, "STREAMING_REPLIES", True)
|
||||
win = MainWindow(mode="ask")
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
|
||||
def test_confirm_blocks_the_worker_until_the_window_answers():
|
||||
bridge = Bridge()
|
||||
asked = []
|
||||
bridge.approval_needed.connect(lambda tool_id, q: asked.append(q))
|
||||
result = {}
|
||||
worker = threading.Thread(target=lambda: result.setdefault("a", bridge.confirm("Run it?")))
|
||||
worker.start()
|
||||
assert pump_until(lambda: asked == ["Run it?"])
|
||||
assert worker.is_alive() # still waiting on the user
|
||||
bridge.answer("always")
|
||||
worker.join(2)
|
||||
assert result["a"] == "always"
|
||||
|
||||
|
||||
def test_close_releases_a_pending_approval_as_no():
|
||||
bridge = Bridge()
|
||||
result = {}
|
||||
worker = threading.Thread(target=lambda: result.setdefault("a", bridge.confirm("Run it?")))
|
||||
worker.start()
|
||||
time.sleep(0.05)
|
||||
bridge.close()
|
||||
worker.join(2)
|
||||
assert result["a"] == "no"
|
||||
assert bridge.confirm("again?") == "no" # and never blocks after close
|
||||
|
||||
|
||||
def test_a_turn_with_an_approved_edit(window, desk, tmp_path): # noqa: F811
|
||||
target = tmp_path / "app.py"
|
||||
target.write_text("DEBUG = True\n", encoding="utf-8")
|
||||
edit = "filectl " + json.dumps(
|
||||
{"op": "edit", "path": str(target), "old": "DEBUG = True", "new": "DEBUG = False"})
|
||||
desk.script["/desk/converse_stream"] = [
|
||||
{"type": "say", "text": "On it."},
|
||||
{"type": "command", "command": edit, "token": "t1"},
|
||||
]
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Debug is **off** now."}
|
||||
|
||||
window._send("turn debug off")
|
||||
assert pump_until(lambda: entries(window, widgets.ToolCard)
|
||||
and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window))
|
||||
card = entries(window, widgets.ToolCard)[0]
|
||||
assert card.pill.property("kind") == "waiting"
|
||||
assert "DEBUG = True" in card.diff.text()
|
||||
|
||||
card.allow_button.click()
|
||||
assert pump_until(lambda: not window._busy)
|
||||
|
||||
assert target.read_text(encoding="utf-8") == "DEBUG = False\n"
|
||||
assert card.pill.property("kind") == "ok"
|
||||
texts = [m.text() for m in entries(window, widgets.AssistantMessage)]
|
||||
assert texts == ["On it.", "Debug is **off** now."]
|
||||
assert len(entries(window, widgets.UserMessage)) == 1
|
||||
|
||||
|
||||
def test_deny_reports_back_and_the_turn_finishes(window, desk, tmp_path): # noqa: F811
|
||||
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi > made.txt", "token": "t"}
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Okay, I won't."}
|
||||
window._send("make a file")
|
||||
assert pump_until(lambda: entries(window, widgets.ToolCard)
|
||||
and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window))
|
||||
card = entries(window, widgets.ToolCard)[0]
|
||||
card._answer("no")
|
||||
assert pump_until(lambda: not window._busy)
|
||||
assert not (tmp_path / "made.txt").exists()
|
||||
assert card.pill.property("kind") == "declined"
|
||||
posted = [r[2] for r in desk.requests if r[1] == "/desk/tool_result"]
|
||||
assert "declined" in posted[0]["output"]
|
||||
|
||||
|
||||
def test_always_allow_flips_the_header_switch_to_auto(window, desk, tmp_path): # noqa: F811
|
||||
desk.script["/desk/converse"] = {"type": "command", "command": "echo hi", "token": "t"}
|
||||
window._send("say hi")
|
||||
assert pump_until(lambda: entries(window, widgets.ToolCard)
|
||||
and entries(window, widgets.ToolCard)[0].approval.isVisibleTo(window))
|
||||
entries(window, widgets.ToolCard)[0]._answer("always")
|
||||
assert pump_until(lambda: not window._busy)
|
||||
assert window.session.tools.mode == "auto"
|
||||
assert window.mode.buttons["auto"].isChecked()
|
||||
|
||||
|
||||
def test_clear_empties_the_transcript_but_keeps_the_scaffolding(window, desk): # noqa: F811
|
||||
window._send("hello")
|
||||
assert pump_until(lambda: not window._busy)
|
||||
window.clear_transcript()
|
||||
app.processEvents()
|
||||
assert entries(window, widgets.UserMessage) == []
|
||||
assert window.entries.indexOf(window.thinking) != -1
|
||||
assert not window.empty.isHidden()
|
||||
|
||||
|
||||
def test_markdown_code_uses_the_app_mono_font():
|
||||
html = widgets.markdown_html("run `ls` now", QLabel().font())
|
||||
assert "Courier New" not in html
|
||||
assert widgets.theme.CODE in html
|
||||
|
||||
|
||||
def test_align_columns_pads_tab_separated_rows():
|
||||
rows = widgets.align_columns(["dir/", "a.txt\t5B", "longer.txt\t12B"])
|
||||
assert rows[0] == "dir/"
|
||||
assert rows[1].index("5B") == rows[2].index("12B")
|
||||
|
||||
|
||||
def test_new_chat_clears_the_window_and_starts_a_fresh_session(window, desk, tmp_path, monkeypatch): # noqa: F811
|
||||
from bolt_console import config
|
||||
|
||||
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json")
|
||||
desk.script["/desk/chats"] = {"session_id": "chat-3f9a2b1c9e04"} # POST mints the id
|
||||
window._send("hello")
|
||||
assert pump_until(lambda: not window._busy)
|
||||
window.start_new_chat()
|
||||
assert pump_until(lambda: config.SESSION_ID == "chat-3f9a2b1c9e04")
|
||||
assert ("POST", "/desk/chats", {}) in desk.requests # over real HTTP, off the UI thread
|
||||
assert entries(window, widgets.UserMessage) == []
|
||||
assert not window.empty.isHidden()
|
||||
|
||||
window._send("next")
|
||||
assert pump_until(lambda: not window._busy)
|
||||
converse = [r[2] for r in desk.requests if r[0] == "POST" and "converse" in r[1]]
|
||||
assert converse[-1]["session_id"] == "chat-3f9a2b1c9e04" # the next turn uses the new chat
|
||||
|
||||
|
||||
def test_write_ico_holds_every_size_the_exe_needs(tmp_path):
|
||||
from PySide6.QtGui import QImageReader
|
||||
from bolt_console.gui import icons
|
||||
|
||||
path = tmp_path / "bolt.ico"
|
||||
icons.write_ico(str(path))
|
||||
reader = QImageReader(str(path))
|
||||
sizes = []
|
||||
while True:
|
||||
image = reader.read()
|
||||
if image.isNull():
|
||||
break
|
||||
sizes.append(image.width())
|
||||
if not reader.jumpToNextImage():
|
||||
break
|
||||
assert sizes == list(icons.APP_ICON_SIZES)
|
||||
@@ -1,92 +0,0 @@
|
||||
"""Conversation scrollback + push-to-talk hotkey parsing."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import history as history_mod
|
||||
from bolt_pet.hotkey import GlobalHotkey, HotkeyError, to_pynput_spec
|
||||
|
||||
|
||||
# ── history ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_keeps_entries_in_order():
|
||||
log = history_mod.ConversationHistory(limit=10)
|
||||
log.add(history_mod.USER, "what's the weather")
|
||||
log.add(history_mod.PET, "sunny and 72")
|
||||
assert [e.text for e in log.entries()] == ["what's the weather", "sunny and 72"]
|
||||
|
||||
|
||||
def test_drops_the_oldest_past_the_limit():
|
||||
log = history_mod.ConversationHistory(limit=2)
|
||||
for i in range(5):
|
||||
log.add(history_mod.PET, f"line {i}")
|
||||
assert [e.text for e in log.entries()] == ["line 3", "line 4"]
|
||||
|
||||
|
||||
def test_blank_entries_are_ignored():
|
||||
log = history_mod.ConversationHistory()
|
||||
assert log.add(history_mod.PET, " ") is None
|
||||
assert len(log) == 0
|
||||
|
||||
|
||||
def test_last_can_filter_by_role():
|
||||
log = history_mod.ConversationHistory()
|
||||
log.add(history_mod.USER, "hello")
|
||||
log.add(history_mod.PET, "hi there")
|
||||
log.add(history_mod.USER, "still there?")
|
||||
assert log.last().text == "still there?"
|
||||
assert log.last(history_mod.PET).text == "hi there"
|
||||
|
||||
|
||||
def test_as_text_is_copyable_transcript():
|
||||
log = history_mod.ConversationHistory()
|
||||
log.add(history_mod.USER, "ping")
|
||||
log.add(history_mod.PET, "pong")
|
||||
assert log.as_text() == "You: ping\nBolt: pong"
|
||||
|
||||
|
||||
def test_timestamps_are_rendered_when_present():
|
||||
log = history_mod.ConversationHistory()
|
||||
log.add(history_mod.PET, "pong", timestamp=1710000000.0)
|
||||
assert log.as_text(clock=lambda t: "12:00:00") == "[12:00:00] Bolt: pong"
|
||||
|
||||
|
||||
def test_clear_empties_the_log():
|
||||
log = history_mod.ConversationHistory()
|
||||
log.add(history_mod.PET, "pong")
|
||||
log.clear()
|
||||
assert len(log) == 0
|
||||
|
||||
|
||||
# ── hotkey ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_translates_a_readable_spec_to_pynput_syntax():
|
||||
assert to_pynput_spec("ctrl+alt+space") == "<ctrl>+<alt>+<space>"
|
||||
assert to_pynput_spec("ctrl+shift+b") == "<ctrl>+<shift>+b"
|
||||
|
||||
|
||||
def test_accepts_the_names_people_actually_type():
|
||||
assert to_pynput_spec("Control+Option+Space") == "<ctrl>+<alt>+<space>"
|
||||
assert to_pynput_spec("super+k") == "<cmd>+k"
|
||||
|
||||
|
||||
def test_empty_spec_is_an_error_at_parse_time():
|
||||
with pytest.raises(HotkeyError):
|
||||
to_pynput_spec("")
|
||||
|
||||
|
||||
def test_disabled_hotkey_starts_cleanly_and_reports_nothing():
|
||||
hotkey = GlobalHotkey("", lambda: None)
|
||||
assert hotkey.start() is None
|
||||
assert hotkey.running is False
|
||||
|
||||
|
||||
def test_invalid_hotkey_reports_instead_of_raising():
|
||||
hotkey = GlobalHotkey("+++", lambda: None)
|
||||
problem = hotkey.start()
|
||||
assert problem and "invalid" in problem
|
||||
assert hotkey.running is False
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Amplitude → mouth openness, from PCM samples to the drawn frame.
|
||||
|
||||
Two halves, tested separately because only one of them needs a display:
|
||||
`tts.level_of`/`envelope` turn samples into a 0..1 loudness, and
|
||||
`PetWindow._mouth_frame` turns that loudness into a frame index. They meet at
|
||||
the `mouth` signal, which the pipeline smoke test covers end to end.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio import tts
|
||||
|
||||
|
||||
def frame(amplitude: int, samples: int = 480) -> np.ndarray:
|
||||
return np.full(samples, amplitude, dtype=np.int16)
|
||||
|
||||
|
||||
# ── loudness ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_silence_closes_the_mouth():
|
||||
assert tts.level_of(np.zeros(480, dtype=np.int16)) == 0.0
|
||||
|
||||
|
||||
def test_a_loud_frame_opens_it_fully():
|
||||
assert tts.level_of(frame(30000)) == 1.0
|
||||
|
||||
|
||||
def test_the_level_never_leaves_zero_to_one():
|
||||
"""It indexes a frame list; out of range is an IndexError on the UI thread."""
|
||||
for amplitude in (0, 1, 500, 6000, 20000, 32767):
|
||||
assert 0.0 <= tts.level_of(frame(amplitude)) <= 1.0
|
||||
|
||||
|
||||
def test_it_rises_with_amplitude():
|
||||
quiet, middling, loud = (tts.level_of(frame(a)) for a in (800, 4000, 12000))
|
||||
assert quiet < middling < loud
|
||||
|
||||
|
||||
def test_quiet_speech_still_moves_the_mouth_visibly():
|
||||
"""The sqrt curve is the whole point. Speech spends most of its time well
|
||||
below peak, so a linear map leaves the mouth barely open for normal talking
|
||||
and the pet looks like it's mumbling."""
|
||||
assert tts.level_of(frame(1500)) > 0.15
|
||||
|
||||
|
||||
def test_an_empty_frame_is_silence_not_a_crash():
|
||||
assert tts.level_of(np.zeros(0, dtype=np.int16)) == 0.0
|
||||
|
||||
|
||||
# ── the envelope of a whole clip ────────────────────────────────────────────
|
||||
|
||||
def test_an_envelope_has_one_value_per_frame_at_the_requested_rate():
|
||||
one_second = np.zeros(16000, dtype=np.int16)
|
||||
assert len(tts.envelope(one_second, 16000, fps=30)) == pytest.approx(30, abs=1)
|
||||
|
||||
|
||||
def test_an_envelope_tracks_loud_and_quiet_stretches():
|
||||
pcm = np.concatenate([np.zeros(8000, dtype=np.int16), frame(20000, 8000)])
|
||||
levels = tts.envelope(pcm, 16000, fps=10)
|
||||
assert max(levels[:4]) == 0.0 # the silent half
|
||||
assert min(levels[-4:]) > 0.5 # the loud half
|
||||
|
||||
|
||||
def test_an_empty_clip_has_an_empty_envelope():
|
||||
assert tts.envelope(np.zeros(0, dtype=np.int16), 16000) == []
|
||||
|
||||
|
||||
# ── the drawn frame ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def window():
|
||||
"""Needs a QApplication; run with QT_QPA_PLATFORM=offscreen."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet.ui.pet_window import PetWindow
|
||||
|
||||
_app = QApplication.instance() or QApplication(["test"])
|
||||
win = PetWindow()
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
|
||||
class FakeAnimation:
|
||||
"""Stands in for sprite.Animation — _mouth_frame only wants `.frames`."""
|
||||
|
||||
def __init__(self, frames):
|
||||
self.frames = list(frames)
|
||||
|
||||
|
||||
def test_the_talking_frame_follows_the_level(window):
|
||||
"""The talking frames are an openness ramp — closed first, widest last —
|
||||
specifically so loudness can index them directly."""
|
||||
animation = FakeAnimation(["closed", "a", "b", "c", "d", "open"])
|
||||
|
||||
assert window._mouth_frame(animation) is None # no level set yet
|
||||
|
||||
window.set_mouth(0.0)
|
||||
assert window._mouth_frame(animation) == "closed"
|
||||
|
||||
window.set_mouth(1.0)
|
||||
assert window._mouth_frame(animation) == "open"
|
||||
|
||||
window.set_mouth(0.5)
|
||||
assert window._mouth_frame(animation) not in ("closed", "open")
|
||||
|
||||
|
||||
def test_a_stale_level_gives_the_animation_back(window):
|
||||
"""If the audio thread stops sending levels — TTS died, the clip ended
|
||||
without a final zero — the mouth must not freeze half-open forever. After a
|
||||
moment it falls back to the ordinary looping animation."""
|
||||
animation = FakeAnimation(["a", "b", "c"])
|
||||
window.set_mouth(0.9)
|
||||
assert window._mouth_frame(animation) is not None
|
||||
|
||||
window._mouth_at = time.monotonic() - 5.0
|
||||
assert window._mouth_frame(animation) is None
|
||||
|
||||
|
||||
def test_a_single_frame_animation_falls_back_instead_of_indexing(window):
|
||||
"""Art with one talking frame can't lip-sync; it must not try."""
|
||||
window.set_mouth(1.0)
|
||||
assert window._mouth_frame(FakeAnimation(["only"])) is None
|
||||
assert window._mouth_frame(FakeAnimation([])) is None
|
||||
|
||||
|
||||
def test_no_animation_at_all_is_handled(window):
|
||||
window.set_mouth(0.5)
|
||||
assert window._mouth_frame(None) is None
|
||||
@@ -1,140 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bolt_pet.audio import mic
|
||||
|
||||
FRAME_LEN = 320 # small for fast tests
|
||||
SAMPLE_RATE = 8000
|
||||
|
||||
|
||||
class _ScriptedStream:
|
||||
"""Replays a fixed list of frames, then quiet forever."""
|
||||
|
||||
def __init__(self, frames):
|
||||
self._frames = list(frames)
|
||||
|
||||
def read(self, frames):
|
||||
if self._frames:
|
||||
frame = self._frames.pop(0)
|
||||
else:
|
||||
frame = np.zeros(FRAME_LEN, dtype=np.int16)
|
||||
return frame.reshape(-1, 1), False
|
||||
|
||||
|
||||
def _loud(n=1):
|
||||
return [np.full(FRAME_LEN, 5000, dtype=np.int16) for _ in range(n)]
|
||||
|
||||
|
||||
def _quiet(n=1):
|
||||
return [np.zeros(FRAME_LEN, dtype=np.int16) for _ in range(n)]
|
||||
|
||||
|
||||
def test_returns_none_when_nothing_ever_gets_loud():
|
||||
stream = _ScriptedStream(_quiet(50))
|
||||
calls = {"i": 0}
|
||||
|
||||
def should_continue():
|
||||
calls["i"] += 1
|
||||
return calls["i"] <= 50
|
||||
|
||||
result = mic.record_utterance(
|
||||
stream, should_continue=should_continue,
|
||||
rms_threshold=300, silence_end_sec=0.5, max_utterance_s=5, min_utterance_s=0.1,
|
||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_captures_speech_and_stops_after_trailing_silence():
|
||||
# speech, then enough silence to cross the silence_end_sec threshold
|
||||
silence_end_sec = 0.5
|
||||
silence_limit_frames = int(silence_end_sec * SAMPLE_RATE / FRAME_LEN)
|
||||
frames = _loud(5) + _quiet(silence_limit_frames + 2)
|
||||
stream = _ScriptedStream(frames)
|
||||
|
||||
result = mic.record_utterance(
|
||||
stream, rms_threshold=300, silence_end_sec=silence_end_sec,
|
||||
max_utterance_s=5, min_utterance_s=0.05,
|
||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
||||
)
|
||||
assert result is not None
|
||||
# captured the loud frames plus the silence up to (and including) the
|
||||
# frame that crossed the silence-end threshold, but not endless silence
|
||||
assert len(result) < len(frames) * FRAME_LEN
|
||||
|
||||
|
||||
def test_returns_none_if_utterance_shorter_than_minimum():
|
||||
frames = _loud(1) + _quiet(2) # crosses silence limit almost immediately
|
||||
stream = _ScriptedStream(frames)
|
||||
result = mic.record_utterance(
|
||||
stream, rms_threshold=300, silence_end_sec=0.05,
|
||||
max_utterance_s=5, min_utterance_s=5.0, # impossible to satisfy
|
||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_stops_at_max_utterance_even_without_silence():
|
||||
max_utterance_s = 0.5
|
||||
max_frames = int(max_utterance_s * SAMPLE_RATE / FRAME_LEN)
|
||||
stream = _ScriptedStream(_loud(max_frames + 20)) # never goes quiet
|
||||
|
||||
result = mic.record_utterance(
|
||||
stream, rms_threshold=300, silence_end_sec=10.0, # would never trigger
|
||||
max_utterance_s=max_utterance_s, min_utterance_s=0.01,
|
||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE,
|
||||
)
|
||||
assert result is not None
|
||||
assert len(result) == max_frames * FRAME_LEN
|
||||
|
||||
|
||||
def test_returns_none_when_should_continue_stops_before_speech():
|
||||
stream = _ScriptedStream(_quiet(100))
|
||||
result = mic.record_utterance(stream, should_continue=lambda: False,
|
||||
frame_len=FRAME_LEN, sample_rate=SAMPLE_RATE)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_list_input_devices_filters_to_input_capable(monkeypatch):
|
||||
fake_devices = [
|
||||
{"name": "Speakers", "max_input_channels": 0, "hostapi": 0},
|
||||
{"name": "USB Mic", "max_input_channels": 2, "hostapi": 0},
|
||||
{"name": "Line In", "max_input_channels": 1, "hostapi": 1},
|
||||
]
|
||||
fake_hostapis = [{"name": "MME"}, {"name": "WASAPI"}]
|
||||
|
||||
class _FakeSoundDevice:
|
||||
@staticmethod
|
||||
def query_devices():
|
||||
return fake_devices
|
||||
|
||||
@staticmethod
|
||||
def query_hostapis(index):
|
||||
return fake_hostapis[index]
|
||||
|
||||
monkeypatch.setitem(sys.modules, "sounddevice", _FakeSoundDevice())
|
||||
|
||||
devices = mic.list_input_devices()
|
||||
assert devices == [
|
||||
{"index": 1, "name": "USB Mic", "hostapi": "MME"},
|
||||
{"index": 2, "name": "Line In", "hostapi": "WASAPI"},
|
||||
]
|
||||
|
||||
|
||||
def test_pcm_to_wav_bytes_round_trips_via_wave_module():
|
||||
import wave
|
||||
import io
|
||||
|
||||
pcm = np.array([0, 100, -100, 32767, -32768], dtype=np.int16)
|
||||
wav_bytes = mic.pcm_to_wav_bytes(pcm, sample_rate=16000)
|
||||
with wave.open(io.BytesIO(wav_bytes), "rb") as wf:
|
||||
assert wf.getnchannels() == 1
|
||||
assert wf.getsampwidth() == 2
|
||||
assert wf.getframerate() == 16000
|
||||
frames = wf.readframes(wf.getnframes())
|
||||
assert np.frombuffer(frames, dtype=np.int16).tolist() == pcm.tolist()
|
||||
@@ -1,169 +0,0 @@
|
||||
"""Screen layout logic — resolving `petctl jump` targets and describing the
|
||||
setup. Pure: the monitor list is normally published by the UI, so none of this
|
||||
needs a display."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import monitors as m
|
||||
|
||||
|
||||
def grid():
|
||||
"""The 2x2 setup this was built against: four 1080p screens.
|
||||
|
||||
[1 HDMI-0] [2 HDMI-1]
|
||||
[3 DP-0 ] [4 DP-2 ]
|
||||
"""
|
||||
return [
|
||||
m.Monitor(0, "HDMI-0", 0, 0, 1920, 1080, primary=False),
|
||||
m.Monitor(1, "HDMI-1", 1920, 0, 1920, 1080, primary=False),
|
||||
m.Monitor(2, "DP-0", 0, 1080, 1920, 1080, primary=True),
|
||||
m.Monitor(3, "DP-2", 1920, 1080, 1920, 1080, primary=False),
|
||||
]
|
||||
|
||||
|
||||
def two():
|
||||
return [
|
||||
m.Monitor(0, "eDP-1", 0, 0, 1920, 1080, primary=True),
|
||||
m.Monitor(1, "HDMI-1", 1920, 0, 2560, 1440),
|
||||
]
|
||||
|
||||
|
||||
def test_numbers_shown_to_humans_are_one_based():
|
||||
left, right = two()
|
||||
assert left.index == 0 and left.number == 1
|
||||
assert right.index == 1 and right.number == 2
|
||||
assert "1: eDP-1 1920x1080 (primary)" == left.label
|
||||
|
||||
|
||||
def test_geometry_helpers():
|
||||
screen = m.Monitor(1, "HDMI-1", 1920, 0, 1920, 1080)
|
||||
assert screen.right == 3840 and screen.bottom == 1080
|
||||
assert screen.center == (2880, 540)
|
||||
assert screen.contains(1920, 0)
|
||||
assert screen.contains(3839, 1079)
|
||||
assert not screen.contains(3840, 0) # right edge is exclusive
|
||||
assert not screen.contains(1919, 0)
|
||||
|
||||
|
||||
def test_monitor_containing_and_nearest():
|
||||
screens = grid()
|
||||
assert m.monitor_containing(screens, 100, 100).name == "HDMI-0"
|
||||
assert m.monitor_containing(screens, 2000, 1500).name == "DP-2"
|
||||
assert m.monitor_containing(screens, -50, -50) is None
|
||||
# off the desktop entirely still resolves to something
|
||||
assert m.nearest_monitor(screens, -500, -500).name == "HDMI-0"
|
||||
|
||||
|
||||
def test_resolve_by_number():
|
||||
screens = grid()
|
||||
assert m.resolve(screens, "3").name == "DP-0"
|
||||
with pytest.raises(ValueError, match="no monitor 9"):
|
||||
m.resolve(screens, "9")
|
||||
with pytest.raises(ValueError):
|
||||
m.resolve(screens, "0")
|
||||
|
||||
|
||||
def test_resolve_next_and_prev_wrap():
|
||||
screens = grid()
|
||||
assert m.resolve(screens, "next", current=3).number == 1
|
||||
assert m.resolve(screens, "prev", current=0).number == 4
|
||||
assert m.resolve(screens, "next", current=0).number == 2
|
||||
|
||||
|
||||
def test_resolve_primary_and_other():
|
||||
screens = grid()
|
||||
assert m.resolve(screens, "primary", current=0).name == "DP-0"
|
||||
# "other" on a two-screen setup is genuinely the other one
|
||||
pair = two()
|
||||
assert m.resolve(pair, "other", current=0).number == 2
|
||||
assert m.resolve(pair, "other", current=1).number == 1
|
||||
|
||||
|
||||
def test_resolve_directions_on_a_grid():
|
||||
screens = grid()
|
||||
# from top-left (HDMI-0)
|
||||
assert m.resolve(screens, "right", current=0).name == "HDMI-1"
|
||||
assert m.resolve(screens, "down", current=0).name == "DP-0"
|
||||
# from bottom-right (DP-2)
|
||||
assert m.resolve(screens, "left", current=3).name == "DP-0"
|
||||
assert m.resolve(screens, "up", current=3).name == "HDMI-1"
|
||||
|
||||
|
||||
def test_direction_prefers_the_best_aligned_screen():
|
||||
screens = grid()
|
||||
# "right" from DP-0 (bottom-left) must pick DP-2 (same row), not HDMI-1,
|
||||
# even though both are to the right.
|
||||
assert m.resolve(screens, "right", current=2).name == "DP-2"
|
||||
|
||||
|
||||
def test_resolve_direction_with_nothing_there():
|
||||
screens = grid()
|
||||
with pytest.raises(ValueError, match="no monitor to the left"):
|
||||
m.resolve(screens, "left", current=0)
|
||||
|
||||
|
||||
def test_resolve_by_name_is_fuzzy_but_refuses_ambiguity():
|
||||
screens = grid()
|
||||
assert m.resolve(screens, "dp-2").name == "DP-2"
|
||||
assert m.resolve(screens, "HDMI-0").name == "HDMI-0"
|
||||
with pytest.raises(ValueError, match="matches several"):
|
||||
m.resolve(screens, "hdmi")
|
||||
|
||||
|
||||
def test_resolve_unknown_spec_lists_the_options():
|
||||
screens = two()
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
m.resolve(screens, "the big one")
|
||||
assert "eDP-1" in str(excinfo.value) and "HDMI-1" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_resolve_without_a_current_screen_falls_back_to_primary():
|
||||
screens = grid()
|
||||
# primary is index 2, so "next" from nowhere is index 3
|
||||
assert m.resolve(screens, "next", current=None).number == 4
|
||||
# an out-of-range current is treated the same way rather than exploding
|
||||
assert m.resolve(screens, "next", current=99).number == 4
|
||||
|
||||
|
||||
def test_resolve_needs_monitors():
|
||||
with pytest.raises(ValueError, match="no monitors"):
|
||||
m.resolve([], "next")
|
||||
with pytest.raises(ValueError, match="needs a target"):
|
||||
m.resolve(grid(), "")
|
||||
|
||||
|
||||
def test_random_always_moves_somewhere_else():
|
||||
screens = grid()
|
||||
for current in range(4):
|
||||
assert m.resolve(screens, "random", current=current).index != current
|
||||
|
||||
|
||||
def test_summary_is_one_line_and_marks_where_the_pet_is():
|
||||
line = m.summary(grid(), current=1)
|
||||
assert "\n" not in line
|
||||
assert line.startswith("4 monitors:")
|
||||
assert "Bolt is on 2" in line
|
||||
assert m.summary([]) is None
|
||||
assert "Bolt is on" not in m.summary(grid(), current=None)
|
||||
|
||||
|
||||
def test_annotate_matches_the_screen_context_style():
|
||||
out = m.annotate("what's on the other screen?", grid(), current=0)
|
||||
assert out.startswith("what's on the other screen?")
|
||||
assert "[4 monitors:" in out
|
||||
# nothing to say, nothing added
|
||||
assert m.annotate("hello", [], None) == "hello"
|
||||
assert m.annotate("", grid(), 0) == ""
|
||||
|
||||
|
||||
def test_describe_lists_every_screen_and_flags_the_pet():
|
||||
text = m.describe(grid(), current=2)
|
||||
assert text.count("\n") == 4 # header + 4 screens
|
||||
assert "DP-0" in text and "+0+1080" in text
|
||||
assert text.count("Bolt is here") == 1
|
||||
assert m.describe([]) == "[pet] no monitor information available"
|
||||
@@ -1,90 +0,0 @@
|
||||
"""dbus-monitor parsing + the forward/rate-limit gate. No session bus
|
||||
needed — the parser is fed canned dbus-monitor output."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.notifications import Notification, NotificationGate, iter_notifications
|
||||
|
||||
SAMPLE = '''signal time=1710000000.1 sender=org.freedesktop.DBus -> destination=:1.7 serial=2 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameAcquired
|
||||
string ":1.7"
|
||||
method call time=1710000001.2 sender=:1.72 -> destination=org.freedesktop.Notifications serial=88 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
|
||||
string "Firefox"
|
||||
uint32 0
|
||||
string ""
|
||||
string "Build finished"
|
||||
string "All 42 tests passed"
|
||||
array [
|
||||
]
|
||||
int32 -1
|
||||
method call time=1710000002.3 sender=:1.80 -> destination=org.freedesktop.Notifications serial=91 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
|
||||
string "Calendar"
|
||||
uint32 0
|
||||
string "calendar-icon"
|
||||
string "Standup in 5 minutes"
|
||||
string ""
|
||||
array [
|
||||
]
|
||||
'''
|
||||
|
||||
|
||||
def _parse(text=SAMPLE):
|
||||
return list(iter_notifications(text.splitlines()))
|
||||
|
||||
|
||||
def test_parses_each_notify_call():
|
||||
parsed = _parse()
|
||||
assert parsed == [
|
||||
Notification(app="Firefox", summary="Build finished", body="All 42 tests passed"),
|
||||
Notification(app="Calendar", summary="Standup in 5 minutes", body=""),
|
||||
]
|
||||
|
||||
|
||||
def test_unrelated_dbus_traffic_is_ignored():
|
||||
noise = SAMPLE.split("method call")[0]
|
||||
assert _parse(noise) == []
|
||||
|
||||
|
||||
def test_trailing_notification_without_a_following_block_is_still_emitted():
|
||||
assert _parse()[-1].summary == "Standup in 5 minutes"
|
||||
|
||||
|
||||
def test_as_text_is_what_gets_sent_to_the_server():
|
||||
assert _parse()[0].as_text() == "Firefox: Build finished — All 42 tests passed"
|
||||
assert _parse()[1].as_text() == "Calendar: Standup in 5 minutes"
|
||||
|
||||
|
||||
# ── gate ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _notification(summary="Build finished", app="Firefox"):
|
||||
return Notification(app=app, summary=summary, body="")
|
||||
|
||||
|
||||
def test_empty_filter_forwards_everything():
|
||||
gate = NotificationGate("", min_interval=0)
|
||||
assert gate.should_forward(_notification(), now=0) is True
|
||||
|
||||
|
||||
def test_filter_regex_selects_what_is_worth_a_round_trip():
|
||||
gate = NotificationGate(r"build|deploy", min_interval=0)
|
||||
assert gate.should_forward(_notification("Build finished"), now=0) is True
|
||||
assert gate.should_forward(_notification("New message from Dave"), now=1) is False
|
||||
|
||||
|
||||
def test_rate_limit_drops_a_burst():
|
||||
gate = NotificationGate("", min_interval=60)
|
||||
assert gate.should_forward(_notification(), now=100) is True
|
||||
assert gate.should_forward(_notification(), now=120) is False
|
||||
assert gate.should_forward(_notification(), now=161) is True
|
||||
|
||||
|
||||
def test_a_broken_regex_does_not_silence_the_bridge():
|
||||
gate = NotificationGate("(unclosed", min_interval=0)
|
||||
assert gate.should_forward(_notification(), now=0) is True
|
||||
|
||||
|
||||
def test_empty_notifications_are_dropped():
|
||||
gate = NotificationGate("", min_interval=0)
|
||||
assert gate.should_forward(Notification(app="", summary="", body=""), now=0) is False
|
||||
@@ -1,85 +0,0 @@
|
||||
"""petctl parsing — the pseudo-commands the server can relay to drive the
|
||||
pet's body instead of a shell."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import pet_actions
|
||||
|
||||
|
||||
def test_non_pet_commands_are_left_alone():
|
||||
assert pet_actions.parse("ls -la") is None
|
||||
assert pet_actions.parse("systemctl restart nginx") is None
|
||||
assert pet_actions.parse("") is None
|
||||
# "petstore" must not be mistaken for the "pet" prefix
|
||||
assert pet_actions.parse("petstore --list") is None
|
||||
|
||||
|
||||
def test_move_to_an_anchor():
|
||||
assert pet_actions.parse("petctl move top-left") == {"action": "move", "anchor": "top-left"}
|
||||
assert pet_actions.parse("petctl move bottom_right") == {"action": "move", "anchor": "bottom-right"}
|
||||
|
||||
|
||||
def test_move_to_coordinates():
|
||||
assert pet_actions.parse("petctl move 300 120") == {"action": "move", "x": 300, "y": 120}
|
||||
|
||||
|
||||
def test_move_rejects_nonsense_targets():
|
||||
with pytest.raises(pet_actions.ActionError):
|
||||
pet_actions.parse("petctl move sideways")
|
||||
with pytest.raises(pet_actions.ActionError):
|
||||
pet_actions.parse("petctl move")
|
||||
|
||||
|
||||
def test_emotes():
|
||||
assert pet_actions.parse("petctl emote wave") == {"action": "emote", "emote": "wave"}
|
||||
with pytest.raises(pet_actions.ActionError):
|
||||
pet_actions.parse("petctl emote moonwalk")
|
||||
|
||||
|
||||
def test_say_keeps_the_whole_sentence():
|
||||
assert pet_actions.parse('petctl say "build is green"') == {
|
||||
"action": "say", "text": "build is green"
|
||||
}
|
||||
assert pet_actions.parse("petctl say build is green")["text"] == "build is green"
|
||||
|
||||
|
||||
def test_wander_and_nap_toggles():
|
||||
assert pet_actions.parse("petctl wander off") == {"action": "wander", "enabled": False}
|
||||
assert pet_actions.parse("petctl nap on") == {"action": "nap", "enabled": True}
|
||||
with pytest.raises(pet_actions.ActionError):
|
||||
pet_actions.parse("petctl wander maybe")
|
||||
|
||||
|
||||
def test_alternate_prefixes_and_verbs():
|
||||
assert pet_actions.parse("bolt-pet goto center")["anchor"] == "center"
|
||||
assert pet_actions.parse("pet do hop")["emote"] == "hop"
|
||||
|
||||
|
||||
def test_unknown_verb_is_an_error_not_a_shell_command():
|
||||
with pytest.raises(pet_actions.ActionError):
|
||||
pet_actions.parse("petctl explode")
|
||||
|
||||
|
||||
def test_describe_is_reported_back_to_the_server():
|
||||
assert "top-left" in pet_actions.describe({"action": "move", "anchor": "top-left"})
|
||||
assert "wave" in pet_actions.describe({"action": "emote", "emote": "wave"})
|
||||
assert pet_actions.describe({"action": "help"}) == pet_actions.HELP
|
||||
|
||||
|
||||
def test_voice_reset_parses_with_or_without_the_word_reset():
|
||||
assert pet_actions.parse("petctl voice reset") == {"action": "voice", "voice": "default"}
|
||||
assert pet_actions.parse("petctl voice default") == {"action": "voice", "voice": "default"}
|
||||
assert pet_actions.parse("petctl voice") == {"action": "voice", "voice": "default"}
|
||||
|
||||
|
||||
def test_petctl_cannot_be_used_to_pick_a_voice():
|
||||
"""Choosing a voice is the server's job (speak_as) — it has the voice
|
||||
library. petctl only ever undoes one, so an attempt to set a voice here
|
||||
is pointed back at the marker that works."""
|
||||
with pytest.raises(pet_actions.ActionError, match="speak_as"):
|
||||
pet_actions.parse("petctl voice Terence")
|
||||
@@ -1,334 +0,0 @@
|
||||
"""Window-side behaviour for the newer features: emote curves, petctl
|
||||
actions, edge snapping, napping, click-through.
|
||||
|
||||
Needs a QApplication — run with QT_QPA_PLATFORM=offscreen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QPoint
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config
|
||||
from bolt_pet.state import PetState
|
||||
from bolt_pet.ui.pet_window import (
|
||||
_EMOTE_TICKS, _WALK_PIXELS_PER_FRAME, PetWindow, emote_transform,
|
||||
)
|
||||
from bolt_pet.ui.sprite import WALK
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
yield QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pet(qt_app):
|
||||
window = PetWindow()
|
||||
yield window
|
||||
window.close()
|
||||
|
||||
|
||||
# ── emote curves (pure maths) ───────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("emote", ["wave", "hop", "bounce", "spin", "nod", "shake", "wiggle"])
|
||||
def test_every_emote_returns_the_sprite_to_rest(emote):
|
||||
# Anything that doesn't land back at the identity transform leaves the pet
|
||||
# permanently askew. (approx: the sine curves land on ~1e-16, not 0.0.)
|
||||
rest = pytest.approx((0.0, 0.0, 0.0, 1.0), abs=1e-9)
|
||||
assert emote_transform(emote, 1.0) == rest
|
||||
assert emote_transform(emote, 0.0) == rest
|
||||
|
||||
|
||||
def test_emotes_actually_move_the_sprite_mid_animation():
|
||||
for emote in ("wave", "hop", "spin", "nod", "shake"):
|
||||
samples = [emote_transform(emote, i / 20) for i in range(1, 20)]
|
||||
assert any(sample != (0.0, 0.0, 0.0, 1.0) for sample in samples), emote
|
||||
|
||||
|
||||
def test_an_unknown_emote_is_a_no_op_not_a_crash():
|
||||
assert emote_transform("moonwalk", 0.5) == (0.0, 0.0, 0.0, 1.0)
|
||||
|
||||
|
||||
def test_progress_is_clamped():
|
||||
assert emote_transform("hop", 5.0) == emote_transform("hop", 1.0)
|
||||
assert emote_transform("hop", -3.0) == emote_transform("hop", 0.0)
|
||||
|
||||
|
||||
def test_an_emote_finishes_and_clears_itself(pet):
|
||||
pet.start_emote("spin")
|
||||
assert pet._emote == "spin"
|
||||
for _ in range(_EMOTE_TICKS + 2):
|
||||
pet._advance_emote()
|
||||
assert pet._emote is None
|
||||
|
||||
|
||||
# ── petctl actions ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_move_action_sets_a_walk_target(pet):
|
||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
||||
assert pet._wander_target is not None
|
||||
assert pet._commanded_move is True
|
||||
|
||||
|
||||
def test_commanded_moves_happen_even_while_talking(pet, monkeypatch):
|
||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", False) # snapping would move it again on arrival
|
||||
pet.set_state(PetState.TALKING)
|
||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
||||
target = pet._wander_target
|
||||
if target is None:
|
||||
pytest.skip("no usable screen geometry on this host")
|
||||
for _ in range(2000):
|
||||
pet._wander_tick()
|
||||
if pet._wander_target is None:
|
||||
break
|
||||
assert pet.pos() == target
|
||||
|
||||
|
||||
def test_a_commanded_move_survives_the_reply_arriving(pet):
|
||||
# Real ordering: `petctl move` comes back as a tool call mid-turn, then
|
||||
# the reply flips the pet to TALKING a moment later. That must not cancel
|
||||
# the walk it was just told to make.
|
||||
pet.apply_action({"action": "move", "anchor": "center"})
|
||||
pet.set_state(PetState.TALKING)
|
||||
assert pet._wander_target is not None
|
||||
|
||||
|
||||
def test_move_to_explicit_coordinates_is_clamped_on_screen(pet):
|
||||
pet.apply_action({"action": "move", "x": -5000, "y": -5000})
|
||||
geo = pet._screen_geometry()
|
||||
if geo is None:
|
||||
pytest.skip("no usable screen geometry on this host")
|
||||
assert pet._wander_target.x() >= geo.left()
|
||||
assert pet._wander_target.y() >= geo.top()
|
||||
|
||||
|
||||
def test_say_action_shows_the_bubble(pet):
|
||||
pet.apply_action({"action": "say", "text": "build is green"})
|
||||
assert pet._bubble.text == "build is green"
|
||||
|
||||
|
||||
def test_wander_and_nap_actions(pet):
|
||||
pet.apply_action({"action": "wander", "enabled": False})
|
||||
assert pet._wander_enabled is False
|
||||
pet.apply_action({"action": "nap", "enabled": True})
|
||||
assert pet.napping is True
|
||||
|
||||
|
||||
def test_emote_action_starts_the_emote(pet):
|
||||
pet.apply_action({"action": "emote", "emote": "wave"})
|
||||
assert pet._emote == "wave"
|
||||
|
||||
|
||||
# ── napping ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_napping_dims_the_pet_and_stops_it_wandering(pet):
|
||||
pet.set_napping(True)
|
||||
assert pet.windowOpacity() < 1.0
|
||||
start = pet.pos()
|
||||
pet.wander_now()
|
||||
for _ in range(60):
|
||||
pet._wander_tick()
|
||||
assert pet.pos() == start
|
||||
|
||||
pet.set_napping(False)
|
||||
assert pet.windowOpacity() == 1.0
|
||||
|
||||
|
||||
# ── edge snapping ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_snaps_flush_when_parked_near_an_edge(pet, monkeypatch):
|
||||
geo = pet._screen_geometry()
|
||||
if geo is None:
|
||||
pytest.skip("no usable screen geometry on this host")
|
||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
|
||||
pet.move(geo.left() + 10, geo.top() + 10)
|
||||
assert pet.snap_to_edge() is True
|
||||
assert pet.pos() == QPoint(geo.left(), geo.top())
|
||||
|
||||
|
||||
def test_does_not_snap_from_the_middle_of_the_screen(pet, monkeypatch):
|
||||
geo = pet._screen_geometry()
|
||||
if geo is None:
|
||||
pytest.skip("no usable screen geometry on this host")
|
||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
|
||||
middle = QPoint(geo.left() + geo.width() // 2, geo.top() + geo.height() // 2)
|
||||
pet.move(middle)
|
||||
assert pet.snap_to_edge() is False
|
||||
assert pet.pos() == middle
|
||||
|
||||
|
||||
def test_snapping_can_be_turned_off(pet, monkeypatch):
|
||||
geo = pet._screen_geometry()
|
||||
if geo is None:
|
||||
pytest.skip("no usable screen geometry on this host")
|
||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", False)
|
||||
pet.move(geo.left() + 10, geo.top() + 10)
|
||||
assert pet.snap_to_edge() is False
|
||||
|
||||
|
||||
# ── click-through ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_click_through_toggles_mouse_transparency(pet):
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
pet.set_click_through(True)
|
||||
assert pet.click_through is True
|
||||
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is True
|
||||
|
||||
pet.set_click_through(False)
|
||||
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is False
|
||||
|
||||
|
||||
# ── monitors ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_window_publishes_a_monitor_list(pet):
|
||||
"""Whatever the test host's screen setup is, the window must describe it
|
||||
in the shape the controller expects."""
|
||||
monitors = pet.monitors()
|
||||
assert monitors, "offscreen Qt still reports at least one screen"
|
||||
assert [m.index for m in monitors] == list(range(len(monitors)))
|
||||
assert all(m.width > 0 and m.height > 0 for m in monitors)
|
||||
assert all(m.name for m in monitors)
|
||||
assert sum(1 for m in monitors if m.primary) <= 1
|
||||
|
||||
|
||||
def test_publish_monitors_re_emits_when_forced(pet):
|
||||
"""ui/app.py relies on this: the window is built before the controller
|
||||
exists, so its constructor's publish reaches nobody and has to be redone."""
|
||||
seen = []
|
||||
pet.monitors_changed.connect(seen.append)
|
||||
pet.publish_monitors() # force defaults to True
|
||||
assert len(seen) == 1
|
||||
pet.publish_monitors(force=False) # nothing changed -> stays quiet
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_pet_reports_which_monitor_it_is_on(pet):
|
||||
seen = []
|
||||
pet.pet_monitor_changed.connect(seen.append)
|
||||
pet.publish_monitors()
|
||||
assert seen and seen[-1] == pet.current_monitor_index()
|
||||
assert 0 <= seen[-1] < len(pet.monitors())
|
||||
|
||||
|
||||
def test_jump_moves_the_window_onto_the_target_screen(pet):
|
||||
monitors = pet.monitors()
|
||||
target = len(monitors) - 1
|
||||
pet.apply_action({"action": "jump", "monitor": target})
|
||||
assert pet.current_monitor_index() == target
|
||||
# a jump lands with a hop rather than sliding there
|
||||
assert pet._emote == "hop"
|
||||
|
||||
|
||||
def test_jump_cancels_a_stroll_so_it_does_not_walk_back(pet):
|
||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
||||
assert pet._wander_target is not None
|
||||
pet.apply_action({"action": "jump", "monitor": 0})
|
||||
assert pet._wander_target is None
|
||||
assert pet._commanded_move is False
|
||||
|
||||
|
||||
def test_jump_to_a_bogus_index_is_a_no_op(pet):
|
||||
before = pet.pos()
|
||||
pet.apply_action({"action": "jump", "monitor": 99})
|
||||
pet.apply_action({"action": "jump", "monitor": -1})
|
||||
assert pet.pos() == before
|
||||
|
||||
|
||||
# ── walk cycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_walk_art_loads_as_a_non_state_animation(pet):
|
||||
"""Walking is a property of movement, not a PetState, so it lives outside
|
||||
the state machine but still loads like any other animation."""
|
||||
assert pet.sprites.has(WALK)
|
||||
assert len(pet.sprites.get(WALK).frames) == 8
|
||||
assert pet.sprites.get("nonsense") is pet.sprites.get(PetState.IDLE)
|
||||
assert not pet.sprites.has("nonsense")
|
||||
|
||||
|
||||
def test_walking_overrides_the_state_animation(pet):
|
||||
assert pet._animation_key() == pet._current_state
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
assert pet._animation_key() == WALK
|
||||
|
||||
|
||||
def test_walk_cycle_advances_by_distance_not_by_the_clock(pet):
|
||||
"""The planted paw tracks backwards at the speed the window moves
|
||||
forwards; drive it off the animation timer instead and the feet skate."""
|
||||
anim = pet.sprites.get(WALK)
|
||||
anim.reset()
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 3)
|
||||
assert anim._index == 3
|
||||
# a step too small to cross the threshold banks the distance instead
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
|
||||
assert anim._index == 3
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
|
||||
assert anim._index == 4
|
||||
|
||||
|
||||
def test_the_animation_timer_does_not_double_step_the_walk(pet):
|
||||
anim = pet.sprites.get(WALK)
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
anim.reset()
|
||||
pet._advance_frame()
|
||||
assert anim._index == 0
|
||||
|
||||
|
||||
def test_facing_follows_horizontal_travel(pet):
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
assert pet._facing == 1
|
||||
pet._advance_walk(-50, 0, 1.0)
|
||||
assert pet._facing == -1
|
||||
|
||||
|
||||
def test_a_near_vertical_stroll_does_not_flip_him(pet):
|
||||
"""Rounding noise on dx would otherwise flip him back and forth every
|
||||
tick on a straight-up walk."""
|
||||
pet._facing = 1
|
||||
pet._advance_walk(0.4, 60, 1.0)
|
||||
assert pet._facing == 1
|
||||
|
||||
|
||||
def test_walking_left_paints_a_mirrored_frame(pet):
|
||||
frame = pet.sprites.get(WALK).current()
|
||||
pet._facing = 1
|
||||
assert pet._oriented(frame) is frame # art is drawn facing right
|
||||
pet._facing = -1
|
||||
flipped = pet._oriented(frame)
|
||||
assert flipped is not frame
|
||||
assert flipped.size() == frame.size()
|
||||
assert pet._oriented(frame) is flipped # cached, not re-flipped per paint
|
||||
|
||||
|
||||
def test_stopping_resets_the_cycle_to_a_standing_frame(pet):
|
||||
pet._advance_walk(50, 0, _WALK_PIXELS_PER_FRAME * 2)
|
||||
assert pet._walking
|
||||
pet._stop_walking()
|
||||
assert not pet._walking
|
||||
assert pet._walk_distance == 0.0
|
||||
assert pet.sprites.get(WALK)._index == 0
|
||||
assert pet._animation_key() == pet._current_state
|
||||
|
||||
|
||||
def test_walk_art_suppresses_the_hard_coded_bob(pet):
|
||||
"""The frames carry their own weight shift — bobbing the window as well
|
||||
would double it up."""
|
||||
pet._advance_walk(50, 0, 5.0)
|
||||
assert pet._bob_offset == 0
|
||||
|
||||
|
||||
def test_without_walk_art_it_falls_back_to_the_old_bob(qt_app, tmp_path):
|
||||
window = PetWindow(sprite_dir=tmp_path)
|
||||
try:
|
||||
assert not window.sprites.has(WALK)
|
||||
window._advance_walk(50, 0, 5.0)
|
||||
assert window._walking
|
||||
assert window._animation_key() == window._current_state
|
||||
assert window._bob_offset < 0 # still visibly moving
|
||||
finally:
|
||||
window.close()
|
||||
@@ -1,289 +0,0 @@
|
||||
"""End-to-end: microphone in, speech out, over real HTTP.
|
||||
|
||||
Every other test in this suite injects a fake at the seam it cares about, and
|
||||
every one of them passed all week while these got through to production:
|
||||
|
||||
- notifications sitting unspoken for minutes (a clock stamped in the wrong
|
||||
order, two correct units)
|
||||
- the pet saying the same thing twice (server discarded prose, model repeated
|
||||
it — both sides behaving as written)
|
||||
- `[laughing]` read out loud (a tag that means something to one model and
|
||||
nothing to the next one down the pipe)
|
||||
- a device command written as prose (extractor fine, prompt fine, no marker)
|
||||
|
||||
They were all *interaction* bugs. So this one runs the actual pipeline against
|
||||
a real socket: a threaded HTTP server that speaks the desk protocol, the real
|
||||
`server_client` doing real requests (including NDJSON streaming), the real
|
||||
controller loop and state machine. The only fakes are where the hardware is —
|
||||
the mic stream and the speakers — because those are the two things a test
|
||||
genuinely cannot have.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import controller as controller_mod
|
||||
from bolt_pet.notifications import Notification
|
||||
from bolt_pet.state import PetState
|
||||
|
||||
_app = QApplication.instance() or QApplication(["test"])
|
||||
|
||||
|
||||
# ── a desk server that actually listens on a port ───────────────────────────
|
||||
|
||||
class FakeDesk:
|
||||
"""Scripted responses, real HTTP. Set `.script` per test."""
|
||||
|
||||
def __init__(self):
|
||||
self.script = {}
|
||||
self.requests = []
|
||||
self._server = ThreadingHTTPServer(("127.0.0.1", 0), self._handler())
|
||||
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
host, port = self._server.server_address[:2]
|
||||
return f"http://{host}:{port}"
|
||||
|
||||
def stop(self) -> None:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
|
||||
def _handler(self):
|
||||
desk = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass # the test output is not an access log
|
||||
|
||||
def do_GET(self):
|
||||
path = self.path.split("?")[0]
|
||||
desk.requests.append(("GET", path))
|
||||
self._json(desk.script.get(path, {"files": []}))
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
path = self.path.split("?")[0]
|
||||
desk.requests.append(("POST", path, body))
|
||||
response = desk.script.get(path)
|
||||
if callable(response):
|
||||
response = response(body)
|
||||
if path.endswith("converse_stream"):
|
||||
self._ndjson(response or [])
|
||||
else:
|
||||
self._json(response if response is not None else {"type": "reply", "text": "ok"})
|
||||
|
||||
def _json(self, payload):
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _ndjson(self, events):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/x-ndjson")
|
||||
self.end_headers()
|
||||
for event in events:
|
||||
self.wfile.write((json.dumps(event) + "\n").encode())
|
||||
self.wfile.flush()
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
class FakeMic:
|
||||
"""Loud frames then quiet ones, so the VAD ends the utterance on its own."""
|
||||
|
||||
def __init__(self, loud=8, quiet=60):
|
||||
self.frames = ([np.full((320, 1), 4000, dtype=np.int16)] * loud
|
||||
+ [np.zeros((320, 1), dtype=np.int16)] * quiet)
|
||||
|
||||
def read(self, _n):
|
||||
return (self.frames.pop(0) if self.frames
|
||||
else np.zeros((320, 1), dtype=np.int16)), None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_a):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(monkeypatch):
|
||||
"""A controller wired to a real local server, with fake ears and mouth."""
|
||||
desk = FakeDesk()
|
||||
spoken: list[str] = []
|
||||
levels: list[float] = []
|
||||
|
||||
monkeypatch.setattr(controller_mod.config, "SERVER_URL", desk.url)
|
||||
monkeypatch.setattr(controller_mod.config, "API_KEY", "test-key")
|
||||
monkeypatch.setattr(controller_mod.config, "SESSION_ID", "pet-smoke")
|
||||
monkeypatch.setattr(controller_mod.config, "SILENCE_END_SEC", 0.2)
|
||||
monkeypatch.setattr(controller_mod.config, "MIN_UTTERANCE_S", 0.0)
|
||||
monkeypatch.setattr(controller_mod.config, "RECEIVE_FILES", False)
|
||||
# Off by default so each test picks its own path; the streaming tests opt in.
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
||||
# Deepgram and the speakers are the two things a test can't have.
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "what's on my disk?")
|
||||
monkeypatch.setattr(controller_mod.stt_stream.StreamingTranscriber, "open",
|
||||
classmethod(lambda cls, **kw: None))
|
||||
|
||||
def fake_speak(text, on_error=None, should_stop=None, voice_id=None, on_level=None):
|
||||
spoken.append(text)
|
||||
if on_level is not None:
|
||||
on_level(0.8) # the mouth opens while a word plays...
|
||||
levels.append(0.8)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "speak", fake_speak)
|
||||
|
||||
ctrl = controller_mod.PetController()
|
||||
ctrl._stream = FakeMic()
|
||||
try:
|
||||
yield ctrl, desk, spoken, levels
|
||||
finally:
|
||||
desk.stop()
|
||||
|
||||
|
||||
# ── the whole path ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_a_plain_turn_goes_mic_to_speaker(pipeline):
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
desk.script["/desk/converse"] = {"type": "reply", "text": "About sixty percent full."}
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["About sixty percent full."]
|
||||
assert ctrl._state.state == PetState.IDLE
|
||||
posted = [r for r in desk.requests if r[0] == "POST"]
|
||||
assert posted[0][1] == "/desk/converse"
|
||||
assert "what's on my disk?" in posted[0][2]["text"]
|
||||
assert ctrl.history.entries()[-1].text == "About sixty percent full."
|
||||
|
||||
|
||||
def test_a_streamed_turn_speaks_each_sentence_as_it_lands(pipeline, monkeypatch):
|
||||
"""The NDJSON is parsed by the real client over a real socket — the layer
|
||||
that a mocked `converse` can never exercise."""
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
desk.script["/desk/converse_stream"] = [
|
||||
{"type": "say", "text": "The disk is fine."},
|
||||
{"type": "say", "text": "About sixty percent used."},
|
||||
{"type": "reply", "text": "The disk is fine. About sixty percent used.",
|
||||
"already_spoken": True},
|
||||
]
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["The disk is fine.", "About sixty percent used."]
|
||||
# The final reply must not be spoken a third time.
|
||||
assert len(spoken) == 2
|
||||
|
||||
|
||||
def test_a_tool_turn_says_give_me_a_sec_then_the_answer(pipeline, monkeypatch):
|
||||
"""Holding line, relayed command, and the real answer — in that order."""
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
||||
lambda cmd: ran.append(cmd) or "[exit 0]\n60% used")
|
||||
desk.script["/desk/converse"] = {
|
||||
"type": "command", "command": "df -h /", "token": "tok",
|
||||
"say": "Let me check that for you.",
|
||||
}
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "Sixty percent used."}
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["Let me check that for you.", "Sixty percent used."]
|
||||
assert ran == ["df -h /"]
|
||||
relayed = next(r for r in desk.requests if r[0] == "POST" and r[1] == "/desk/tool_result")
|
||||
assert relayed[2]["output"].endswith("60% used")
|
||||
|
||||
|
||||
def test_a_device_command_never_reaches_the_shell(pipeline, monkeypatch):
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
|
||||
actions = []
|
||||
ctrl.action.connect(actions.append)
|
||||
desk.script["/desk/converse"] = {
|
||||
"type": "command", "command": "petctl emote wave", "token": "tok"}
|
||||
desk.script["/desk/tool_result"] = {"type": "reply", "text": "There you go."}
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert ran == []
|
||||
assert actions == [{"action": "emote", "emote": "wave"}]
|
||||
assert spoken == ["There you go."]
|
||||
|
||||
|
||||
def test_the_mouth_moves_with_the_audio(pipeline):
|
||||
"""Lip-sync is only real if the level actually reaches the window."""
|
||||
ctrl, desk, _spoken, levels = pipeline
|
||||
mouth = []
|
||||
ctrl.mouth.connect(mouth.append)
|
||||
desk.script["/desk/converse"] = {"type": "reply", "text": "Talking now."}
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert levels, "TTS was never given a level callback"
|
||||
assert 0.8 in mouth # opened while speaking...
|
||||
assert mouth[-1] == 0.0 # ...and closed at the end
|
||||
|
||||
|
||||
def test_a_notification_is_forwarded_and_spoken(pipeline):
|
||||
"""The path that was silently sitting for five to ten minutes."""
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
desk.script["/desk/converse"] = {"type": "reply", "text": "Harry says he's around."}
|
||||
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert spoken == ["Harry says he's around."]
|
||||
forwarded = next(r for r in desk.requests if r[0] == "POST" and r[1] == "/desk/converse")
|
||||
assert "Harry" in forwarded[2]["text"]
|
||||
|
||||
|
||||
def test_streaming_falls_back_when_the_server_is_older(pipeline, monkeypatch):
|
||||
"""A server without /desk/converse_stream (or one that answers with nothing)
|
||||
must not cost a turn — the client drops to the plain endpoint. This is how
|
||||
the pet keeps working against a container that hasn't been updated yet."""
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
desk.script["/desk/converse_stream"] = [] # nothing streamed back
|
||||
desk.script["/desk/converse"] = {"type": "reply", "text": "Still here."}
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["Still here."]
|
||||
paths = [r[1] for r in desk.requests if r[0] == "POST"]
|
||||
assert paths == ["/desk/converse_stream", "/desk/converse"]
|
||||
|
||||
|
||||
def test_a_dead_server_leaves_the_pet_usable(pipeline):
|
||||
"""It should flash an error and go back to listening, not wedge."""
|
||||
ctrl, desk, spoken, _levels = pipeline
|
||||
desk.stop() # the server disappears mid-session
|
||||
states = []
|
||||
ctrl.state_changed.connect(states.append)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert states[-2:] == ["error", "idle"]
|
||||
assert spoken == []
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Quiet-hours parsing and matching, without waiting for 11pm."""
|
||||
|
||||
import sys
|
||||
from datetime import time as dtime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import quiet
|
||||
|
||||
|
||||
def test_no_spec_means_never_quiet():
|
||||
assert quiet.is_quiet("") is False
|
||||
assert quiet.is_quiet(" ") is False
|
||||
|
||||
|
||||
def test_simple_daytime_range():
|
||||
ranges = quiet.parse_ranges("13:00-14:00")
|
||||
assert quiet.in_ranges(dtime(13, 30), ranges) is True
|
||||
assert quiet.in_ranges(dtime(12, 59), ranges) is False
|
||||
assert quiet.in_ranges(dtime(14, 0), ranges) is False # end is exclusive
|
||||
|
||||
|
||||
def test_range_wrapping_past_midnight():
|
||||
ranges = quiet.parse_ranges("23:00-08:00")
|
||||
for moment in (dtime(23, 0), dtime(23, 59), dtime(0, 0), dtime(7, 59)):
|
||||
assert quiet.in_ranges(moment, ranges) is True, moment
|
||||
for moment in (dtime(8, 0), dtime(12, 0), dtime(22, 59)):
|
||||
assert quiet.in_ranges(moment, ranges) is False, moment
|
||||
|
||||
|
||||
def test_multiple_ranges():
|
||||
ranges = quiet.parse_ranges("23:00-08:00, 13:00-14:00")
|
||||
assert quiet.in_ranges(dtime(13, 15), ranges) is True
|
||||
assert quiet.in_ranges(dtime(2, 0), ranges) is True
|
||||
assert quiet.in_ranges(dtime(16, 0), ranges) is False
|
||||
|
||||
|
||||
def test_zero_length_range_is_not_all_day():
|
||||
assert quiet.in_ranges(dtime(12, 0), quiet.parse_ranges("09:00-09:00")) is False
|
||||
|
||||
|
||||
def test_malformed_specs_raise_when_parsed_directly():
|
||||
for spec in ("nonsense", "25:00-26:00", "13:00", "13:60-14:00"):
|
||||
with pytest.raises(quiet.QuietHoursError):
|
||||
quiet.parse_ranges(spec)
|
||||
|
||||
|
||||
def test_malformed_spec_is_reported_but_never_mutes_the_pet():
|
||||
seen = []
|
||||
assert quiet.is_quiet("nonsense", now=dtime(3, 0), on_error=seen.append) is False
|
||||
assert len(seen) == 1
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Screen-context parsing/annotation. The subprocess probes are platform
|
||||
specific; the parsing they feed is not, so that's what's tested here."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import screen_context
|
||||
|
||||
|
||||
def test_parses_the_active_window_id():
|
||||
output = "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00007\n"
|
||||
assert screen_context.parse_xprop_window_id(output) == "0x3c00007"
|
||||
|
||||
|
||||
def test_no_active_window_id_when_nothing_is_focused():
|
||||
assert screen_context.parse_xprop_window_id("_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0") is None
|
||||
assert screen_context.parse_xprop_window_id("") is None
|
||||
|
||||
|
||||
def test_parses_the_window_title():
|
||||
output = '_NET_WM_NAME(UTF8_STRING) = "bolt_pet/controller.py - Cursor"\n'
|
||||
assert screen_context.parse_xprop_window_name(output) == "bolt_pet/controller.py - Cursor"
|
||||
|
||||
|
||||
def test_unset_title_property():
|
||||
assert screen_context.parse_xprop_window_name("_NET_WM_NAME: not found") is None
|
||||
|
||||
|
||||
def test_fullscreen_state_detection():
|
||||
assert screen_context.parse_xprop_fullscreen(
|
||||
"_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN, _NET_WM_STATE_FOCUSED") is True
|
||||
assert screen_context.parse_xprop_fullscreen("_NET_WM_STATE(ATOM) = _NET_WM_STATE_FOCUSED") is False
|
||||
|
||||
|
||||
def test_desktop_titles_are_treated_as_no_context():
|
||||
assert screen_context.clean_title("Desktop") is None
|
||||
assert screen_context.clean_title(" ") is None
|
||||
|
||||
|
||||
def test_long_titles_are_truncated():
|
||||
cleaned = screen_context.clean_title("x" * 500)
|
||||
assert len(cleaned) <= 160 and cleaned.endswith("…")
|
||||
|
||||
|
||||
def test_annotate_appends_context_as_an_aside():
|
||||
annotated = screen_context.annotate("what's this error?", "app.py — Traceback")
|
||||
assert annotated.startswith("what's this error?")
|
||||
assert "[on screen right now: app.py — Traceback]" in annotated
|
||||
|
||||
|
||||
def test_annotate_is_a_no_op_without_a_title_or_text():
|
||||
assert screen_context.annotate("hello", None) == "hello"
|
||||
assert screen_context.annotate("hello", "Desktop") == "hello"
|
||||
assert screen_context.annotate("", "Firefox") == ""
|
||||
@@ -1,168 +0,0 @@
|
||||
"""OCR plumbing for `petctl read` — engine selection and output cleanup.
|
||||
|
||||
Only the pure half is covered, per the testing conventions: capture and the
|
||||
OCR call itself need a real screen and a real engine. Engine probes are
|
||||
injected so these pass on a machine with a different set installed (or none).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import screen_text
|
||||
from bolt_pet.monitors import Monitor
|
||||
|
||||
|
||||
def probes(modules=(), binaries=()):
|
||||
return (lambda name: name in modules), (
|
||||
lambda name: f"/usr/bin/{name}" if name in binaries else None
|
||||
)
|
||||
|
||||
|
||||
def test_prefers_tesseract_when_fully_installed():
|
||||
has_module, which = probes({"pytesseract"}, {"tesseract"})
|
||||
assert screen_text.resolve_engine(has_module, which) == ("pytesseract", "")
|
||||
|
||||
|
||||
def test_falls_back_to_rapidocr_when_tesseract_binary_is_absent():
|
||||
has_module, which = probes({"pytesseract", "rapidocr_onnxruntime"}, set())
|
||||
engine, reason = screen_text.resolve_engine(has_module, which)
|
||||
assert engine == "rapidocr"
|
||||
assert reason == ""
|
||||
|
||||
|
||||
def test_pytesseract_without_the_binary_says_which_half_is_missing():
|
||||
"""The commonest broken setup: `pip install pytesseract` and stop, not
|
||||
realising the actual engine is a system package."""
|
||||
has_module, which = probes({"pytesseract"}, set())
|
||||
engine, reason = screen_text.resolve_engine(has_module, which)
|
||||
assert engine is None
|
||||
assert "tesseract binary" in reason
|
||||
assert "apt install tesseract-ocr" in reason
|
||||
|
||||
|
||||
def test_nothing_installed_explains_how_to_fix_it():
|
||||
has_module, which = probes(set(), set())
|
||||
engine, reason = screen_text.resolve_engine(has_module, which)
|
||||
assert engine is None
|
||||
assert "pip install" in reason
|
||||
|
||||
|
||||
def test_capture_availability_follows_mss():
|
||||
assert screen_text.capture_available(lambda name: name == "mss")
|
||||
assert not screen_text.capture_available(lambda name: False)
|
||||
|
||||
|
||||
def test_clean_drops_ocr_noise_and_blank_runs():
|
||||
raw = "Firefox\n\n\n |\n .\nBuild failed\n~\n"
|
||||
assert screen_text.clean_ocr_text(raw) == "Firefox\nBuild failed"
|
||||
|
||||
|
||||
def test_clean_collapses_whitespace_but_keeps_line_structure():
|
||||
raw = " File edit view \nline\ttwo "
|
||||
assert screen_text.clean_ocr_text(raw) == "File edit view\nline two"
|
||||
|
||||
|
||||
def test_clean_drops_consecutive_duplicates_only():
|
||||
raw = "Terminal\nTerminal\nEditor\nTerminal"
|
||||
assert screen_text.clean_ocr_text(raw) == "Terminal\nEditor\nTerminal"
|
||||
|
||||
|
||||
def test_clean_keeps_short_but_real_tokens():
|
||||
# two alphanumerics is the bar — "ok" and "42" survive, "-" doesn't
|
||||
assert screen_text.clean_ocr_text("ok\n-\n42") == "ok\n42"
|
||||
|
||||
|
||||
def test_clean_truncates_and_says_so():
|
||||
out = screen_text.clean_ocr_text("word " * 500, max_chars=100)
|
||||
assert out.endswith("[truncated]")
|
||||
# the cap applies to the text, before the marker is appended
|
||||
assert len(out.split("\n[truncated]")[0]) <= 100
|
||||
|
||||
|
||||
def test_clean_handles_empty_input():
|
||||
assert screen_text.clean_ocr_text("") == ""
|
||||
assert screen_text.clean_ocr_text(None) == ""
|
||||
|
||||
|
||||
def test_format_reading_names_the_monitor():
|
||||
monitor = Monitor(1, "HDMI-1", 1920, 0, 1920, 1080)
|
||||
out = screen_text.format_reading(monitor, "Build failed")
|
||||
assert "monitor 2 (HDMI-1)" in out
|
||||
assert out.endswith("Build failed")
|
||||
|
||||
|
||||
def test_format_reading_when_nothing_was_recognised():
|
||||
monitor = Monitor(0, "DP-0", 0, 0, 1920, 1080)
|
||||
assert "no text recognised" in screen_text.format_reading(monitor, " ")
|
||||
|
||||
|
||||
def test_read_monitor_never_raises_without_an_engine(monkeypatch):
|
||||
"""Its return value goes back to the server as command output, so every
|
||||
failure has to come back as a sentence rather than an exception."""
|
||||
monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True)
|
||||
monkeypatch.setattr(
|
||||
screen_text, "resolve_engine", lambda *a, **k: (None, "no engine here")
|
||||
)
|
||||
out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080))
|
||||
assert out.startswith("[pet]")
|
||||
assert "no engine here" in out
|
||||
|
||||
|
||||
def test_read_monitor_reports_a_failed_capture(monkeypatch):
|
||||
monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True)
|
||||
monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", ""))
|
||||
monkeypatch.setattr(screen_text, "capture", lambda monitor: None)
|
||||
out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080))
|
||||
assert "couldn't capture" in out and "Wayland" in out
|
||||
|
||||
|
||||
def test_read_monitor_survives_an_exploding_engine(monkeypatch):
|
||||
monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True)
|
||||
monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", ""))
|
||||
monkeypatch.setattr(screen_text, "capture", lambda monitor: object())
|
||||
monkeypatch.setattr(
|
||||
screen_text, "_ocr", lambda image, engine: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080))
|
||||
assert "OCR failed" in out and "boom" in out
|
||||
|
||||
|
||||
def test_read_monitors_splits_the_budget(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_read(monitor, max_chars):
|
||||
seen.append((monitor.number, max_chars))
|
||||
return f"screen {monitor.number}"
|
||||
|
||||
monkeypatch.setattr(screen_text, "read_monitor", fake_read)
|
||||
screens = [
|
||||
Monitor(0, "A", 0, 0, 100, 100),
|
||||
Monitor(1, "B", 100, 0, 100, 100),
|
||||
Monitor(2, "C", 200, 0, 100, 100),
|
||||
]
|
||||
out = screen_text.read_monitors(screens, 3000)
|
||||
assert [n for n, _ in seen] == [1, 2, 3]
|
||||
assert all(limit == 1000 for _, limit in seen)
|
||||
assert out.count("screen ") == 3
|
||||
|
||||
|
||||
def test_read_monitors_keeps_a_floor_on_the_budget(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars)
|
||||
)
|
||||
screens = [Monitor(i, str(i), 0, 0, 10, 10) for i in range(20)]
|
||||
# 100/20 would be 5 characters per screen, which is useless — floor wins
|
||||
assert "400" in screen_text.read_monitors(screens, 100)
|
||||
|
||||
|
||||
def test_read_monitors_with_one_screen_uses_the_whole_budget(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars)
|
||||
)
|
||||
assert screen_text.read_monitors([Monitor(0, "A", 0, 0, 10, 10)], 4000) == "4000"
|
||||
|
||||
|
||||
def test_read_monitors_with_no_screens():
|
||||
assert "no monitor information" in screen_text.read_monitors([], 4000)
|
||||
@@ -1,233 +0,0 @@
|
||||
"""End-to-end wiring for the multi-monitor features: `petctl jump`, `petctl
|
||||
monitors`, `petctl read`, and the screen-layout note that rides along with
|
||||
each utterance.
|
||||
|
||||
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet import controller as controller_mod
|
||||
from bolt_pet import pet_actions
|
||||
from bolt_pet.monitors import Monitor
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_app = QApplication.instance() or QApplication(["test"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_screen_probes(monkeypatch):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctrl():
|
||||
controller = controller_mod.PetController()
|
||||
controller.set_monitors(
|
||||
[
|
||||
Monitor(0, "HDMI-0", 0, 0, 1920, 1080),
|
||||
Monitor(1, "HDMI-1", 1920, 0, 1920, 1080),
|
||||
Monitor(2, "DP-0", 0, 1080, 1920, 1080, primary=True),
|
||||
]
|
||||
)
|
||||
controller.set_pet_monitor(0)
|
||||
return controller
|
||||
|
||||
|
||||
def _capture(signal):
|
||||
events = []
|
||||
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
|
||||
return events
|
||||
|
||||
|
||||
# ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_jump_parses_without_validating_the_target():
|
||||
"""Which monitors exist is a runtime fact, so the pure parser passes the
|
||||
spec through and monitors.resolve() judges it later."""
|
||||
assert pet_actions.parse("petctl jump 2") == {"action": "jump", "target": "2"}
|
||||
assert pet_actions.parse("petctl jump next") == {"action": "jump", "target": "next"}
|
||||
assert pet_actions.parse("petctl monitor left") == {"action": "jump", "target": "left"}
|
||||
assert pet_actions.parse("petctl screen HDMI-1") == {"action": "jump", "target": "HDMI-1"}
|
||||
# a nonsense target is still parsed — it fails at resolve time, with a
|
||||
# message listing the real monitors
|
||||
assert pet_actions.parse("petctl jump sideways") == {
|
||||
"action": "jump", "target": "sideways",
|
||||
}
|
||||
|
||||
|
||||
def test_jump_needs_a_target():
|
||||
with pytest.raises(pet_actions.ActionError, match="needs a monitor"):
|
||||
pet_actions.parse("petctl jump")
|
||||
|
||||
|
||||
def test_read_defaults_to_the_current_screen():
|
||||
assert pet_actions.parse("petctl read") == {"action": "read", "target": "here"}
|
||||
assert pet_actions.parse("petctl read all") == {"action": "read", "target": "all"}
|
||||
assert pet_actions.parse("petctl look 2") == {"action": "read", "target": "2"}
|
||||
assert pet_actions.parse("petctl see here") == {"action": "read", "target": "here"}
|
||||
|
||||
|
||||
def test_monitors_verb():
|
||||
for spelling in ("monitors", "screens", "displays"):
|
||||
assert pet_actions.parse(f"petctl {spelling}") == {"action": "monitors"}
|
||||
|
||||
|
||||
def test_help_mentions_the_new_verbs():
|
||||
assert "petctl jump" in pet_actions.HELP
|
||||
assert "petctl read" in pet_actions.HELP
|
||||
assert "petctl monitors" in pet_actions.HELP
|
||||
|
||||
|
||||
# ── jump ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_jump_resolves_to_an_index_before_reaching_the_window(ctrl):
|
||||
"""The controller resolves and emits a concrete index, so the window can't
|
||||
re-resolve the spec against a different screen ordering."""
|
||||
actions = _capture(ctrl.action)
|
||||
out = ctrl._handle_command("petctl jump next")
|
||||
assert actions == [{"action": "jump", "monitor": 1}]
|
||||
assert "monitor 2: HDMI-1" in out
|
||||
|
||||
|
||||
def test_jump_by_direction_uses_the_published_layout(ctrl):
|
||||
actions = _capture(ctrl.action)
|
||||
ctrl._handle_command("petctl jump down")
|
||||
assert actions == [{"action": "jump", "monitor": 2}]
|
||||
|
||||
|
||||
def test_jump_tracks_where_the_pet_actually_is(ctrl):
|
||||
ctrl.set_pet_monitor(1)
|
||||
actions = _capture(ctrl.action)
|
||||
ctrl._handle_command("petctl jump left")
|
||||
assert actions == [{"action": "jump", "monitor": 0}]
|
||||
|
||||
|
||||
def test_jump_to_a_nonexistent_monitor_reports_back_and_moves_nothing(ctrl):
|
||||
actions = _capture(ctrl.action)
|
||||
out = ctrl._handle_command("petctl jump 7")
|
||||
assert actions == []
|
||||
assert "no monitor 7" in out
|
||||
assert "you have 3" in out
|
||||
|
||||
|
||||
def test_jump_never_reaches_the_shell(monkeypatch, ctrl):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", ran.append)
|
||||
ctrl._handle_command("petctl jump 2")
|
||||
ctrl._handle_command("petctl monitors")
|
||||
ctrl._handle_command("petctl read")
|
||||
assert ran == []
|
||||
|
||||
|
||||
# ── monitors ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_monitors_lists_the_layout_and_where_the_pet_is(ctrl):
|
||||
ctrl.set_pet_monitor(2)
|
||||
out = ctrl._handle_command("petctl monitors")
|
||||
assert "3 monitor(s)" in out
|
||||
assert "HDMI-0" in out and "DP-0" in out
|
||||
assert out.count("Bolt is here") == 1
|
||||
|
||||
|
||||
def test_monitors_before_the_ui_has_published_anything():
|
||||
fresh = controller_mod.PetController()
|
||||
assert "no monitor information" in fresh._handle_command("petctl monitors")
|
||||
|
||||
|
||||
# ── read ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_read_here_uses_the_pets_own_screen(monkeypatch, ctrl):
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda monitor, limit: seen.append(monitor.number) or "text",
|
||||
)
|
||||
ctrl.set_pet_monitor(1)
|
||||
assert ctrl._handle_command("petctl read") == "text"
|
||||
assert seen == [2]
|
||||
|
||||
|
||||
def test_read_a_named_screen(monkeypatch, ctrl):
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda monitor, limit: seen.append(monitor.name) or "text",
|
||||
)
|
||||
ctrl._handle_command("petctl read DP-0")
|
||||
assert seen == ["DP-0"]
|
||||
|
||||
|
||||
def test_read_all_goes_through_the_multi_screen_path(monkeypatch, ctrl):
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitors",
|
||||
lambda monitors, limit: seen.append(len(monitors)) or "everything",
|
||||
)
|
||||
assert ctrl._handle_command("petctl read all") == "everything"
|
||||
assert seen == [3]
|
||||
|
||||
|
||||
def test_read_an_unknown_screen_explains_rather_than_raising(ctrl):
|
||||
out = ctrl._handle_command("petctl read 9")
|
||||
assert out.startswith("[pet]")
|
||||
assert "no monitor 9" in out
|
||||
|
||||
|
||||
def test_read_respects_the_kill_switch(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "SCREEN_TEXT", False)
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda *a, **k: called.append(1) or "text",
|
||||
)
|
||||
out = ctrl._handle_command("petctl read")
|
||||
assert called == []
|
||||
assert "disabled" in out and "SCREEN_TEXT" in out
|
||||
|
||||
|
||||
def test_read_passes_the_character_cap_through(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "SCREEN_TEXT_MAX_CHARS", 123)
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda monitor, limit: seen.append(limit) or "text",
|
||||
)
|
||||
ctrl._handle_command("petctl read")
|
||||
assert seen == [123]
|
||||
|
||||
|
||||
# ── per-turn context ────────────────────────────────────────────────────────
|
||||
|
||||
def test_layout_rides_along_with_each_utterance(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", True)
|
||||
out = ctrl._with_context("what's on the other screen?")
|
||||
assert out.startswith("what's on the other screen?")
|
||||
assert "3 monitors:" in out
|
||||
assert "Bolt is on 1" in out
|
||||
|
||||
|
||||
def test_layout_context_can_be_switched_off(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", False)
|
||||
assert ctrl._with_context("hello") == "hello"
|
||||
|
||||
|
||||
def test_screen_text_never_rides_along_automatically(monkeypatch, ctrl):
|
||||
"""The layout is free; the *contents* cost an OCR pass and a lot of
|
||||
privacy, so they only ever move on an explicit petctl read."""
|
||||
monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", True)
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor", lambda *a, **k: called.append(1)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitors", lambda *a, **k: called.append(1)
|
||||
)
|
||||
ctrl._with_context("hello")
|
||||
assert called == []
|
||||
@@ -1,158 +0,0 @@
|
||||
"""`petctl self_restart` — the pet restarting itself and remembering why.
|
||||
|
||||
Everything here runs against a temp context file and a fake subprocess runner,
|
||||
so the tests exercise the arming/preflight/report logic without any process
|
||||
actually dying.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import pet_actions, self_restart
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(tmp_path):
|
||||
return tmp_path / "restart_context.json"
|
||||
|
||||
|
||||
def _ok_run(*args, **kwargs):
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
|
||||
def _broken_run(*args, **kwargs):
|
||||
return SimpleNamespace(
|
||||
returncode=1, stdout="",
|
||||
stderr=' File "bolt_pet/controller.py", line 42\n def _speak(\nSyntaxError: invalid syntax',
|
||||
)
|
||||
|
||||
|
||||
# ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_self_restart_parses_with_a_free_text_reason():
|
||||
action = pet_actions.parse("petctl self_restart check the new walk cycle loads")
|
||||
assert action == {
|
||||
"action": "self_restart", "reason": "check the new walk cycle loads",
|
||||
}
|
||||
|
||||
|
||||
def test_self_restart_needs_no_reason_and_accepts_aliases():
|
||||
assert pet_actions.parse("petctl self_restart")["reason"] == ""
|
||||
assert pet_actions.parse("petctl restart")["action"] == "self_restart"
|
||||
assert pet_actions.parse("petctl reboot")["action"] == "self_restart"
|
||||
|
||||
|
||||
def test_self_restart_is_listed_in_the_help():
|
||||
assert "self_restart" in pet_actions.HELP
|
||||
|
||||
|
||||
# ── preflight ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_preflight_passes_when_the_code_imports():
|
||||
self_restart.preflight(run=_ok_run) # no exception
|
||||
|
||||
|
||||
def test_preflight_hands_back_the_traceback_instead_of_dying(state):
|
||||
"""The whole point: a syntax error Bolt just introduced comes back as
|
||||
something he can read and fix, in the same turn, with the pet still up."""
|
||||
with pytest.raises(self_restart.RestartError) as excinfo:
|
||||
self_restart.preflight(run=_broken_run)
|
||||
message = str(excinfo.value)
|
||||
assert "does not import" in message
|
||||
assert "SyntaxError" in message and "controller.py" in message
|
||||
|
||||
|
||||
def test_preflight_runs_the_import_in_a_subprocess_not_here():
|
||||
"""This process holds the *old* modules, so an in-process import would
|
||||
pass on a file that no longer parses."""
|
||||
seen = {}
|
||||
|
||||
def capture(cmd, **kwargs):
|
||||
seen["cmd"], seen["kwargs"] = cmd, kwargs
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
self_restart.preflight(run=capture)
|
||||
assert seen["cmd"][0] == sys.executable
|
||||
assert "import bolt_pet" in seen["cmd"][2]
|
||||
assert seen["kwargs"]["env"]["QT_QPA_PLATFORM"] == "offscreen" # imports need no display
|
||||
|
||||
|
||||
def test_a_subprocess_that_cannot_even_run_is_reported(monkeypatch):
|
||||
def explode(*args, **kwargs):
|
||||
raise OSError("no python here")
|
||||
|
||||
with pytest.raises(self_restart.RestartError, match="couldn't run the preflight"):
|
||||
self_restart.preflight(run=explode)
|
||||
|
||||
|
||||
# ── context across the restart ──────────────────────────────────────────────
|
||||
|
||||
def test_arming_persists_the_reason_for_the_next_process(state):
|
||||
self_restart.arm("check the sprite frames load", version="0.2.3",
|
||||
session="pet-desktop", recent=["you: reload the sprites"],
|
||||
path=state, now=1000.0)
|
||||
revived = self_restart.load(state)
|
||||
assert revived.reason == "check the sprite frames load"
|
||||
assert revived.version == "0.2.3"
|
||||
assert revived.recent == ["you: reload the sprites"]
|
||||
assert revived.restarts == [1000.0]
|
||||
|
||||
|
||||
def test_no_context_means_a_normal_start(state):
|
||||
assert self_restart.load(state) is None
|
||||
|
||||
|
||||
def test_a_corrupt_context_file_is_ignored_not_fatal(state):
|
||||
state.write_text("{not json at all", encoding="utf-8")
|
||||
assert self_restart.load(state) is None
|
||||
|
||||
|
||||
def test_clearing_the_context_stops_it_being_re_announced(state):
|
||||
self_restart.arm("once", path=state, now=1000.0)
|
||||
self_restart.clear(state)
|
||||
assert self_restart.load(state) is None
|
||||
self_restart.clear(state) # clearing twice is not an error
|
||||
|
||||
|
||||
def test_the_report_says_what_happened_and_what_to_check(state):
|
||||
context = self_restart.arm(
|
||||
"verify the dialogue command works", verify="verify the dialogue command works",
|
||||
version="0.2.3", recent=["you: try a scene"], path=state, now=1000.0,
|
||||
)
|
||||
text = self_restart.report(context, version="0.2.4", now=1004.5)
|
||||
assert "I restarted myself" in text
|
||||
assert "verify the dialogue command works" in text
|
||||
assert "4.5s" in text
|
||||
assert "0.2.4" in text and "was 0.2.3" in text
|
||||
assert "you: try a scene" in text
|
||||
|
||||
|
||||
# ── loop guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_restart_history_accumulates_across_restarts(state):
|
||||
self_restart.arm("one", path=state, now=1000.0)
|
||||
self_restart.arm("two", path=state, now=1100.0)
|
||||
assert self_restart.load(state).restarts == [1000.0, 1100.0]
|
||||
|
||||
|
||||
def test_too_many_restarts_in_the_window_is_refused(state):
|
||||
now = 1000.0
|
||||
for index in range(self_restart.MAX_RESTARTS):
|
||||
self_restart.arm(f"attempt {index}", path=state, now=now + index)
|
||||
with pytest.raises(self_restart.RestartError, match="looping"):
|
||||
self_restart.check_loop_guard(self_restart.load(state), now=now + 10)
|
||||
|
||||
|
||||
def test_old_restarts_fall_out_of_the_window(state):
|
||||
now = 1000.0
|
||||
for index in range(self_restart.MAX_RESTARTS):
|
||||
self_restart.arm(f"attempt {index}", path=state, now=now + index)
|
||||
later = now + self_restart.WINDOW_SECONDS + 60
|
||||
self_restart.check_loop_guard(self_restart.load(state), now=later) # no exception
|
||||
assert self_restart.recent_restarts(self_restart.load(state), now=later) == []
|
||||
+136
-33
@@ -6,14 +6,14 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_pet import server_client
|
||||
from bolt_console import server_client
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configure(monkeypatch):
|
||||
monkeypatch.setattr(server_client.config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(server_client.config, "API_KEY", "test-key")
|
||||
monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-test")
|
||||
monkeypatch.setattr(server_client.config, "SESSION_ID", "pet-console-test")
|
||||
|
||||
|
||||
def _mock_response(json_data, ok=True):
|
||||
@@ -28,11 +28,10 @@ def test_converse_returns_reply_directly():
|
||||
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
|
||||
result = server_client.converse("hi")
|
||||
assert result.text == "hello there"
|
||||
assert result.voice_id == "" # no speak_as on this reply
|
||||
post.assert_called_once()
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/converse"
|
||||
assert kwargs["json"] == {"session_id": "pet-test", "text": "hi"}
|
||||
assert kwargs["json"] == {"session_id": "pet-console-test", "text": "hi"}
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
|
||||
|
||||
@@ -50,23 +49,10 @@ def test_converse_relays_a_command_then_returns_reply():
|
||||
second_call = post.call_args_list[1]
|
||||
assert second_call.args[0] == "http://test-server:5002/desk/tool_result"
|
||||
assert second_call.kwargs["json"] == {
|
||||
"session_id": "pet-test", "token": "tok1", "output": "[exit 0]\nhi",
|
||||
"session_id": "pet-console-test", "token": "tok1", "output": "[exit 0]\nhi",
|
||||
}
|
||||
|
||||
|
||||
def test_converse_carries_a_speak_as_voice_back_with_the_reply():
|
||||
"""The server tags a reply with the voice it picked (speak_as); this
|
||||
client is what actually speaks in it, so the id has to survive the
|
||||
return trip rather than being dropped with the rest of the payload."""
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({
|
||||
"type": "reply", "text": "Ahoy there.",
|
||||
"voice_id": "hnhGxwvHP8fc469w51rM", "voice_name": "Terence",
|
||||
})
|
||||
result = server_client.converse("talk like a pirate")
|
||||
assert result == server_client.Reply("Ahoy there.", "hnhGxwvHP8fc469w51rM", "Terence")
|
||||
|
||||
|
||||
def test_converse_raises_server_error_on_error_payload():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"type": "error", "error": "unauthorized"})
|
||||
@@ -80,19 +66,6 @@ def test_converse_raises_server_error_when_unreachable():
|
||||
server_client.converse("hi")
|
||||
|
||||
|
||||
def test_report_status_returns_reply_text_when_present():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"reply": "don't forget your 3pm"})
|
||||
result = server_client.report_status()
|
||||
assert result == "don't forget your 3pm"
|
||||
|
||||
|
||||
def test_report_status_returns_none_when_nothing_pending():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"ok": True})
|
||||
assert server_client.report_status() is None
|
||||
|
||||
|
||||
def test_check_health_returns_parsed_json():
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response({"ok": True, "service": "bolt-desk-api"})
|
||||
@@ -109,7 +82,7 @@ def test_list_outbox_files_returns_the_queue():
|
||||
assert result == [{"id": "abc", "name": "report.pdf", "size": 9}]
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/files"
|
||||
assert kwargs["params"] == {"session_id": "pet-test"}
|
||||
assert kwargs["params"] == {"session_id": "pet-console-test"}
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
|
||||
|
||||
@@ -134,7 +107,7 @@ def test_download_outbox_file_returns_raw_bytes():
|
||||
assert result == b"%PDF fake bytes"
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/files/abc"
|
||||
assert kwargs["params"] == {"session_id": "pet-test"}
|
||||
assert kwargs["params"] == {"session_id": "pet-console-test"}
|
||||
|
||||
|
||||
def test_download_outbox_file_raises_server_error_on_http_failure():
|
||||
@@ -142,3 +115,133 @@ def test_download_outbox_file_raises_server_error_on_http_failure():
|
||||
get.return_value = _mock_response({}, ok=False)
|
||||
with pytest.raises(server_client.ServerError, match="abc"):
|
||||
server_client.download_outbox_file("abc")
|
||||
|
||||
|
||||
class _StreamResponse:
|
||||
def __init__(self, events):
|
||||
self._lines = [__import__("json").dumps(e) for e in events]
|
||||
self.closed = False
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def iter_lines(self, decode_unicode=True):
|
||||
return iter(self._lines)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_converse_stream_hands_each_sentence_over_and_marks_the_reply_streamed():
|
||||
said = []
|
||||
events = [
|
||||
{"type": "say", "text": "First."},
|
||||
{"type": "say", "text": "Second."},
|
||||
{"type": "reply", "text": "First. Second.", "already_spoken": True},
|
||||
]
|
||||
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||
reply = server_client.converse_stream("hi", on_say=said.append)
|
||||
assert said == ["First.", "Second."]
|
||||
assert reply == server_client.Reply("First. Second.", streamed=True)
|
||||
|
||||
|
||||
def test_converse_stream_finishes_a_tool_call_through_the_plain_relay():
|
||||
events = [{"type": "command", "command": "filectl {}", "token": "t1"}]
|
||||
posts = [_StreamResponse(events), _mock_response({"type": "reply", "text": "done"})]
|
||||
on_command = MagicMock(return_value="out")
|
||||
with patch.object(server_client.requests, "post", side_effect=posts) as post:
|
||||
reply = server_client.converse_stream("hi", on_say=lambda _s: None, on_command=on_command)
|
||||
on_command.assert_called_once_with("filectl {}")
|
||||
assert post.call_args_list[1].args[0].endswith("/desk/tool_result")
|
||||
assert reply == server_client.Reply("done", streamed=False)
|
||||
|
||||
|
||||
def test_converse_stream_failing_before_any_text_raises_for_the_fallback():
|
||||
with patch.object(server_client.requests, "post", side_effect=ConnectionError("down")):
|
||||
with pytest.raises(server_client.ServerError):
|
||||
server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||
|
||||
|
||||
def test_run_local_command_runs_in_the_current_directory(tmp_path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "marker.txt").write_text("x")
|
||||
output = server_client.run_local_command("dir /b" if sys.platform == "win32" else "ls")
|
||||
assert output.startswith("[exit 0]")
|
||||
assert "marker.txt" in output
|
||||
|
||||
|
||||
def test_a_final_reply_repeating_the_streamed_sentences_is_not_shown_twice():
|
||||
"""Seen live: sentences streamed, then the same text again as the reply,
|
||||
without already_spoken — with line breaks the stream had dropped."""
|
||||
events = [
|
||||
{"type": "say", "text": "Let me check that."},
|
||||
{"type": "say", "text": "The folder contains:"},
|
||||
{"type": "reply", "text": "Let me check that.\n\nThe folder contains:\n- a\n- b"},
|
||||
]
|
||||
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||
reply = server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||
assert reply == server_client.Reply("- a\n- b", streamed=False)
|
||||
|
||||
|
||||
def test_an_exact_repeat_counts_as_streamed():
|
||||
events = [{"type": "say", "text": "Done."}, {"type": "reply", "text": "Done."}]
|
||||
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||
reply = server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||
assert reply.streamed and reply.text == ""
|
||||
|
||||
|
||||
def test_a_different_final_reply_is_shown_in_full():
|
||||
events = [{"type": "say", "text": "Checking."}, {"type": "reply", "text": "All good."}]
|
||||
with patch.object(server_client.requests, "post", return_value=_StreamResponse(events)):
|
||||
reply = server_client.converse_stream("hi", on_say=lambda _s: None)
|
||||
assert reply == server_client.Reply("All good.", streamed=False)
|
||||
|
||||
|
||||
def test_list_chats_returns_the_list_with_the_api_key():
|
||||
chats = [{"session_id": "chat-3f9a2b1c9e04", "label": "Fix the build",
|
||||
"last_message": "try again", "last_active": "2026-09-25T23:46:31+00:00"}]
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response({"chats": chats})
|
||||
assert server_client.list_chats() == chats
|
||||
args, kwargs = get.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/chats"
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
|
||||
|
||||
def test_list_chats_defaults_to_empty_and_wraps_failures():
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response({})
|
||||
assert server_client.list_chats() == []
|
||||
with patch.object(server_client.requests, "get") as get:
|
||||
get.return_value = _mock_response({}, ok=False) # e.g. a 401
|
||||
with pytest.raises(server_client.ServerError, match="couldn't list chats"):
|
||||
server_client.list_chats()
|
||||
|
||||
|
||||
def test_create_chat_posts_and_returns_the_new_session_id():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"session_id": "chat-3f9a2b1c9e04"})
|
||||
assert server_client.create_chat() == "chat-3f9a2b1c9e04"
|
||||
args, kwargs = post.call_args
|
||||
assert args[0] == "http://test-server:5002/desk/chats"
|
||||
assert kwargs["headers"] == {"X-Desk-Api-Key": "test-key"}
|
||||
|
||||
|
||||
def test_create_chat_without_a_session_id_is_an_error():
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({})
|
||||
with pytest.raises(server_client.ServerError, match="session_id"):
|
||||
server_client.create_chat()
|
||||
with patch.object(server_client.requests, "post", side_effect=ConnectionError("down")):
|
||||
with pytest.raises(server_client.ServerError):
|
||||
server_client.create_chat()
|
||||
|
||||
|
||||
def test_every_call_follows_a_reassigned_session_id(monkeypatch):
|
||||
"""Switching chats is just `config.SESSION_ID = new_id`; the next request
|
||||
must carry it, with no call site holding on to the old one."""
|
||||
monkeypatch.setattr(server_client.config, "SESSION_ID", "chat-new")
|
||||
with patch.object(server_client.requests, "post") as post:
|
||||
post.return_value = _mock_response({"type": "reply", "text": "hi"})
|
||||
server_client.converse("hi")
|
||||
assert post.call_args.kwargs["json"]["session_id"] == "chat-new"
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
"""The rules about talking — the four kinds of utterance and the mic policy.
|
||||
|
||||
These were four near-copies in the controller before, and the copies had
|
||||
drifted: one didn't arm barge-in, one skipped the follow-up rule. The value of
|
||||
having one `Speaker` is only real if the differences between the kinds stay
|
||||
*visible*, so this asserts on the differences rather than on the machinery.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config, speech
|
||||
from bolt_pet.speech import Speaker, Utterance
|
||||
from bolt_pet.state import PetState, PetStateMachine
|
||||
|
||||
|
||||
class FakeTts:
|
||||
def __init__(self, completed=True):
|
||||
self.completed = completed
|
||||
self.calls = []
|
||||
|
||||
def speak(self, text, on_error=None, should_stop=None, voice_id=None, on_level=None):
|
||||
self.calls.append({"text": text, "voice_id": voice_id,
|
||||
"interruptible": should_stop is not None})
|
||||
if on_level is not None:
|
||||
on_level(0.7)
|
||||
return self.completed
|
||||
|
||||
def play_pcm(self, pcm, sample_rate, should_stop=None, on_level=None):
|
||||
self.calls.append({"pcm": pcm, "rate": sample_rate,
|
||||
"interruptible": should_stop is not None})
|
||||
return self.completed
|
||||
|
||||
|
||||
class FakeBargeIn:
|
||||
def __init__(self):
|
||||
self.resets = 0
|
||||
|
||||
def reset(self):
|
||||
self.resets += 1
|
||||
|
||||
def check(self, _frame=None):
|
||||
return False
|
||||
|
||||
|
||||
def build(**kwargs):
|
||||
"""A speaker plus the things worth asserting on."""
|
||||
state = PetStateMachine()
|
||||
tts = kwargs.pop("tts", None) or FakeTts()
|
||||
said, logged, recorded, levels = [], [], [], []
|
||||
speaker = Speaker(
|
||||
state=state, tts=tts,
|
||||
history=recorded.append,
|
||||
on_said=said.append,
|
||||
on_log=logged.append,
|
||||
on_level=levels.append,
|
||||
**kwargs,
|
||||
)
|
||||
return speaker, state, tts, said, logged, recorded, levels
|
||||
|
||||
|
||||
# ── what distinguishes the four kinds ───────────────────────────────────────
|
||||
|
||||
def test_a_reply_is_recorded_but_a_holding_line_is_not():
|
||||
"""Filler must not push the actual answer out of the transcript."""
|
||||
speaker, state, _tts, _said, _logged, recorded, _levels = build()
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.holding("Give me a sec."))
|
||||
speaker.say(Utterance.reply("Sixty percent used."))
|
||||
|
||||
assert recorded == ["Sixty percent used."]
|
||||
|
||||
|
||||
def test_a_holding_line_resumes_the_turn_it_interrupted():
|
||||
"""The turn isn't over — a tool is still running — so it must go back to
|
||||
THINKING, not drop to IDLE and end the turn."""
|
||||
speaker, state, _tts, *_ = build()
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.holding("Let me check."))
|
||||
|
||||
assert state.state == PetState.THINKING
|
||||
|
||||
|
||||
def test_a_holding_line_cannot_be_talked_over():
|
||||
"""Cutting off "give me a sec" strands the tool that's already running."""
|
||||
detector = FakeBargeIn()
|
||||
speaker, state, tts, *_ = build(barge_in=lambda: detector)
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.holding("One sec."))
|
||||
assert tts.calls[-1]["interruptible"] is False
|
||||
|
||||
speaker.say(Utterance.reply("Done."))
|
||||
assert tts.calls[-1]["interruptible"] is True
|
||||
|
||||
|
||||
def test_a_streamed_sentence_stays_talking_between_sentences():
|
||||
"""Otherwise the sprite flickers idle-talking-idle down a long answer."""
|
||||
speaker, state, *_ = build()
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.stream_chunk("The disk is fine."))
|
||||
|
||||
assert state.state == PetState.TALKING
|
||||
|
||||
|
||||
def test_a_scene_is_recorded_because_the_user_heard_it():
|
||||
speaker, state, _tts, _said, _logged, recorded, _levels = build()
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say_pcm(Utterance.scene("Once upon a time."), pcm=b"\x00\x00", sample_rate=44100)
|
||||
|
||||
assert recorded == ["Once upon a time."]
|
||||
assert state.state == PetState.THINKING
|
||||
|
||||
|
||||
# ── barge-in ordering, which is what the copies got wrong ───────────────────
|
||||
|
||||
def test_the_detector_is_reset_after_playback_not_only_before():
|
||||
"""Playback fed the pet's own voice into the wake model's window. If it
|
||||
isn't cleared afterwards, the idle listener re-hears the last sentence and
|
||||
the pet answers itself."""
|
||||
detector = FakeBargeIn()
|
||||
speaker, state, *_ = build(barge_in=lambda: detector)
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.reply("Hello there."))
|
||||
|
||||
assert detector.resets == 2 # armed before, cleared after
|
||||
|
||||
|
||||
def test_the_barge_in_detail_is_captured_before_the_reset():
|
||||
"""Read it after the reset and every interruption reports zeroed counters —
|
||||
which reads like hard evidence and is nothing of the sort."""
|
||||
detector = FakeBargeIn()
|
||||
details = ["score 0.81 at frame 12", "score 0.000 at frame 0"]
|
||||
speaker, state, *_ = build(barge_in=lambda: detector,
|
||||
detail_of=lambda: details.pop(0))
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.reply("Hello there."))
|
||||
|
||||
assert speaker.last_detail == "score 0.81 at frame 12"
|
||||
|
||||
|
||||
def test_a_detector_that_appears_late_is_still_used():
|
||||
"""The detector is built after the speaker — it needs the mic stream — so
|
||||
it's read through a callable. Holding a copy is how the two drift apart."""
|
||||
detector = None
|
||||
speaker, state, tts, *_ = build(barge_in=lambda: detector)
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.reply("Before."))
|
||||
assert tts.calls[-1]["interruptible"] is False
|
||||
|
||||
detector = FakeBargeIn()
|
||||
speaker.say(Utterance.reply("After."))
|
||||
assert tts.calls[-1]["interruptible"] is True
|
||||
|
||||
|
||||
# ── the mouth ───────────────────────────────────────────────────────────────
|
||||
|
||||
def test_the_mouth_is_closed_when_the_line_ends():
|
||||
"""A pet left mid-vowel after the audio stops looks broken."""
|
||||
speaker, state, _tts, _said, _logged, _recorded, levels = build()
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.reply("Talking."))
|
||||
|
||||
assert levels[0] > 0
|
||||
assert levels[-1] == 0.0
|
||||
|
||||
|
||||
# ── text handling ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_an_empty_line_is_not_spoken_at_all():
|
||||
"""A reply that is nothing but an audio tag reduces to '' — and a silent
|
||||
bubble with no audio is better than the pet announcing "laughing"."""
|
||||
speaker, state, tts, said, *_ = build()
|
||||
|
||||
assert speaker.say(Utterance.reply("[laughing]")) is True
|
||||
assert tts.calls == []
|
||||
assert said == []
|
||||
assert state.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_the_bubble_gets_display_text_and_tts_gets_the_original():
|
||||
"""The bubble keeps emoji and drops markdown; TTS does its own stripping
|
||||
(inside speak(), so every path is covered) and needs the real text."""
|
||||
speaker, state, tts, said, *_ = build()
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.reply("**Sixty** percent 🎉"))
|
||||
|
||||
assert said == ["Sixty percent 🎉"]
|
||||
assert tts.calls[-1]["text"] == "**Sixty** percent 🎉"
|
||||
|
||||
|
||||
def test_a_voice_override_reaches_tts():
|
||||
speaker, state, tts, *_ = build(voice_id=lambda: "voice-abc")
|
||||
state.transition(PetState.LISTENING)
|
||||
state.transition(PetState.THINKING)
|
||||
|
||||
speaker.say(Utterance.reply("In character."))
|
||||
|
||||
assert tts.calls[-1]["voice_id"] == "voice-abc"
|
||||
|
||||
|
||||
# ── the follow-up rule ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def follow_ups_on(monkeypatch):
|
||||
monkeypatch.setattr(config, "FOLLOW_UP_LISTEN", True)
|
||||
monkeypatch.setattr(config, "FOLLOW_UP_MAX_TURNS", 3)
|
||||
|
||||
|
||||
def test_an_interruption_always_reopens_the_mic(follow_ups_on):
|
||||
"""You talked over it — you are mid-sentence, so it has to listen."""
|
||||
keep, why = speech.follow_up_decision("Anything else?", completed=False, follow_ups=99)
|
||||
assert keep is True
|
||||
assert why == "interrupted"
|
||||
|
||||
|
||||
def test_a_question_keeps_the_mic_open_without_the_wake_word(follow_ups_on):
|
||||
keep, why = speech.follow_up_decision("Want me to check?", completed=True, follow_ups=0)
|
||||
assert (keep, why) == (True, "question")
|
||||
|
||||
|
||||
def test_a_statement_ends_the_turn(follow_ups_on):
|
||||
keep, _why = speech.follow_up_decision("Sixty percent used.", completed=True, follow_ups=0)
|
||||
assert keep is False
|
||||
|
||||
|
||||
def test_the_cap_stops_a_server_that_ends_every_reply_with_a_question(follow_ups_on):
|
||||
"""Otherwise mic noise loops it forever."""
|
||||
assert speech.follow_up_decision("Ok?", completed=True, follow_ups=2)[0] is True
|
||||
keep, why = speech.follow_up_decision("Ok?", completed=True, follow_ups=3)
|
||||
assert keep is False
|
||||
assert "cap" in why
|
||||
|
||||
|
||||
def test_the_rule_can_be_switched_off(monkeypatch):
|
||||
monkeypatch.setattr(config, "FOLLOW_UP_LISTEN", False)
|
||||
assert speech.follow_up_decision("Ok?", completed=True, follow_ups=0)[0] is False
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Sanitizing chat-formatted replies into speakable prose. Pure string logic
|
||||
— no audio hardware, no Qt (see the testing conventions in CLAUDE.md)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.speech_text import for_display, for_speech, is_question
|
||||
|
||||
|
||||
def test_bold_markers_are_not_spoken():
|
||||
assert "*" not in for_speech("Here's the **maji-desktop** snapshot")
|
||||
assert for_speech("Here's the **maji-desktop** snapshot") == "Here's the maji-desktop snapshot"
|
||||
|
||||
|
||||
def test_italics_and_underscores_dropped():
|
||||
assert for_speech("that is _really_ odd") == "that is really odd"
|
||||
assert for_speech("***everything*** is fine") == "everything is fine"
|
||||
|
||||
|
||||
def test_bullet_list_becomes_sentences():
|
||||
spoken = for_speech(
|
||||
"System status:\n"
|
||||
"* **OS:** Linux 7.0.0\n"
|
||||
"* **Uptime:** 1 day\n"
|
||||
)
|
||||
assert "*" not in spoken
|
||||
assert spoken == "System status: OS: Linux 7.0.0. Uptime: 1 day."
|
||||
|
||||
|
||||
def test_emoji_and_symbols_removed():
|
||||
assert for_speech("✅ Done 🚀 — all good") == "Done all good"
|
||||
assert for_speech("→ next step") == "to next step"
|
||||
|
||||
|
||||
def test_urls_and_code_are_not_read_out_character_by_character():
|
||||
assert for_speech("see https://example.com/x?y=1 for docs") == "see link for docs"
|
||||
assert for_speech("run `sudo reboot` now") == "run sudo reboot now"
|
||||
assert for_speech("here:\n```\nls -la\n```\n") == "here: (code)."
|
||||
|
||||
|
||||
def test_headings_quotes_and_rules_stripped():
|
||||
assert for_speech("## Summary\n---\n> quoted bit") == "Summary. quoted bit."
|
||||
|
||||
|
||||
def test_ampersand_and_percent_are_spoken_as_words():
|
||||
assert for_speech("R&D at 50% capacity") == "R and D at 50 percent capacity"
|
||||
|
||||
|
||||
def test_blank_and_symbol_only_input():
|
||||
assert for_speech("") == ""
|
||||
assert for_speech(None) == ""
|
||||
assert for_speech("***") == ""
|
||||
|
||||
|
||||
def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("**Done** ✅") == "Done ✅"
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
assert is_question("Ready to go? 🚀")
|
||||
assert is_question('Shall I continue?"')
|
||||
assert is_question("Want me to fix it? **")
|
||||
|
||||
|
||||
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
||||
# The '?' here is inside a URL query string, which for_speech strips.
|
||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
||||
assert not is_question("")
|
||||
assert not is_question(None)
|
||||
|
||||
|
||||
# ── ElevenLabs v3 delivery tags (observed live 2026-07-31) ──────────────────
|
||||
# "[laughing] That one came through clean" was spoken as "laughing That one
|
||||
# came through clean": the tags mean something to the dialogue model inside a
|
||||
# dialoguectl scene, and nothing at all to the ordinary reply voice.
|
||||
|
||||
def test_delivery_tags_are_never_spoken_aloud():
|
||||
spoken = for_speech("[laughing] That one came through clean.")
|
||||
assert "laughing" not in spoken
|
||||
assert spoken.startswith("That one came through clean")
|
||||
|
||||
|
||||
def test_the_bubble_does_not_caption_a_laugh_nobody_heard():
|
||||
assert "[laughing]" not in for_display("[laughing] All good.")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", [
|
||||
"[whispering]", "[cheerfully]", "[sighs]", "[laughs]", "[clears throat]",
|
||||
"[nervously]", "[excited]", "[pause]", "[shouting]",
|
||||
])
|
||||
def test_the_common_tags_are_all_covered(tag):
|
||||
assert "" == for_speech(tag).strip()
|
||||
|
||||
|
||||
def test_ordinary_bracketed_text_survives():
|
||||
"""The tags are matched narrowly on purpose — real bracketed content is
|
||||
part of what the user asked to hear."""
|
||||
assert "1" in for_speech("See reference [1] for details.")
|
||||
assert "docs" in for_speech("It's in [the docs] somewhere.")
|
||||
@@ -1,73 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
|
||||
from bolt_pet.state import InvalidTransition, PetState, PetStateMachine
|
||||
|
||||
|
||||
def test_starts_idle():
|
||||
sm = PetStateMachine()
|
||||
assert sm.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_happy_path_transitions():
|
||||
sm = PetStateMachine()
|
||||
sm.transition(PetState.LISTENING)
|
||||
sm.transition(PetState.THINKING)
|
||||
sm.transition(PetState.TALKING)
|
||||
sm.transition(PetState.IDLE)
|
||||
assert sm.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_idle_to_talking_is_allowed_for_proactive_announcements():
|
||||
# The heartbeat poll can make the pet speak unprompted (a reminder
|
||||
# firing, a nudge from the server) with no preceding listen/think leg.
|
||||
sm = PetStateMachine()
|
||||
sm.transition(PetState.TALKING)
|
||||
assert sm.state == PetState.TALKING
|
||||
|
||||
|
||||
def test_invalid_transition_raises():
|
||||
sm = PetStateMachine()
|
||||
with pytest.raises(InvalidTransition):
|
||||
sm.transition(PetState.THINKING) # can't skip straight to thinking with no utterance
|
||||
|
||||
|
||||
def test_same_state_transition_is_a_noop():
|
||||
calls = []
|
||||
sm = PetStateMachine(on_change=lambda old, new: calls.append((old, new)))
|
||||
sm.transition(PetState.IDLE) # already idle
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_on_change_callback_fires_with_old_and_new():
|
||||
calls = []
|
||||
sm = PetStateMachine(on_change=lambda old, new: calls.append((old, new)))
|
||||
sm.transition(PetState.LISTENING)
|
||||
assert calls == [(PetState.IDLE, PetState.LISTENING)]
|
||||
|
||||
|
||||
def test_every_state_can_reach_error_and_recover():
|
||||
for path in (
|
||||
[PetState.LISTENING],
|
||||
[PetState.LISTENING, PetState.THINKING],
|
||||
[PetState.LISTENING, PetState.THINKING, PetState.TALKING],
|
||||
):
|
||||
sm = PetStateMachine()
|
||||
for step in path:
|
||||
sm.transition(step)
|
||||
sm.transition(PetState.ERROR)
|
||||
sm.transition(PetState.IDLE)
|
||||
assert sm.state == PetState.IDLE
|
||||
|
||||
|
||||
def test_force_recovers_from_talking_directly_to_idle_without_validation():
|
||||
sm = PetStateMachine()
|
||||
sm.transition(PetState.LISTENING)
|
||||
sm.transition(PetState.THINKING)
|
||||
sm.transition(PetState.TALKING)
|
||||
sm.force(PetState.LISTENING) # not in TALKING's allowed set, but force skips the check
|
||||
assert sm.state == PetState.LISTENING
|
||||
@@ -1,103 +0,0 @@
|
||||
"""stt.transcribe() — the "guaranteed" one-shot fallback.
|
||||
|
||||
There is no separate REST endpoint server-side any more: /desk/stt is a
|
||||
websocket relay only, so this connects the exact same way stt_stream.py's
|
||||
opportunistic streaming path does (via stt_stream.connect(), unconditionally
|
||||
— not gated by STT_STREAMING, since there's nothing left to fall back to).
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio import stt, stt_stream
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, messages=(), fail_to_connect=False):
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
self._fail_to_connect = fail_to_connect
|
||||
self._messages = list(messages)
|
||||
|
||||
def send_binary(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
def send(self, text):
|
||||
self.sent.append(text)
|
||||
|
||||
def recv(self):
|
||||
if self._messages:
|
||||
return self._messages.pop(0)
|
||||
time.sleep(0.01)
|
||||
raise ConnectionError("closed")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _results(transcript, is_final=True):
|
||||
return json.dumps({
|
||||
"type": "Results", "is_final": is_final,
|
||||
"channel": {"alternatives": [{"transcript": transcript}]},
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch):
|
||||
monkeypatch.setattr(stt.config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(stt.config, "API_KEY", "test-key")
|
||||
|
||||
|
||||
def test_transcribe_raises_when_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(stt.config, "API_KEY", "")
|
||||
with pytest.raises(stt.SttError, match="BOLT_SERVER_URL"):
|
||||
stt.transcribe(np.zeros(320, dtype=np.int16))
|
||||
|
||||
|
||||
def test_transcribe_connects_and_feeds_the_whole_utterance(monkeypatch):
|
||||
socket = FakeSocket([_results("turn on the lights")])
|
||||
monkeypatch.setattr(stt_stream, "connect", lambda: socket)
|
||||
|
||||
pcm = np.full(3200, 500, dtype=np.int16)
|
||||
text = stt.transcribe(pcm)
|
||||
|
||||
assert text == "turn on the lights"
|
||||
# one binary frame (the whole utterance) plus the close message
|
||||
assert len(socket.sent) == 2
|
||||
assert socket.closed
|
||||
|
||||
|
||||
def test_transcribe_raises_when_the_server_is_unreachable(monkeypatch):
|
||||
def refuse():
|
||||
raise OSError("no route to host")
|
||||
|
||||
monkeypatch.setattr(stt_stream, "connect", refuse)
|
||||
|
||||
with pytest.raises(stt.SttError, match="couldn't reach"):
|
||||
stt.transcribe(np.zeros(320, dtype=np.int16))
|
||||
|
||||
|
||||
def test_transcribe_returns_empty_string_for_silence_not_an_error(monkeypatch):
|
||||
"""No speech recognized is a legitimate outcome, not a failure — callers
|
||||
(controller.py) treat "" as "say nothing" rather than logging an error."""
|
||||
socket = FakeSocket([]) # never says anything back
|
||||
monkeypatch.setattr(stt_stream, "connect", lambda: socket)
|
||||
|
||||
assert stt.transcribe(np.zeros(320, dtype=np.int16)) == ""
|
||||
|
||||
|
||||
def test_transcribe_is_unaffected_by_stt_streaming_being_off(monkeypatch):
|
||||
"""The opportunistic accelerator and the guaranteed fallback share a
|
||||
connector, but STT_STREAMING must only gate the former."""
|
||||
monkeypatch.setattr(stt.config, "STT_STREAMING", False)
|
||||
socket = FakeSocket([_results("still works")])
|
||||
monkeypatch.setattr(stt_stream, "connect", lambda: socket)
|
||||
|
||||
assert stt.transcribe(np.zeros(320, dtype=np.int16)) == "still works"
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Streaming speech-to-text: the protocol, and the fallback that makes it safe
|
||||
to switch on at all.
|
||||
|
||||
A fake websocket throughout — no network, no Deepgram account, no audio.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.audio import mic
|
||||
from bolt_pet.audio.stt_stream import StreamingTranscriber
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
"""Records what was sent; replays scripted Deepgram frames."""
|
||||
|
||||
def __init__(self, messages=(), fail_on_send=False):
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
self.fail_on_send = fail_on_send
|
||||
self._messages = list(messages)
|
||||
|
||||
def send_binary(self, data):
|
||||
if self.fail_on_send:
|
||||
raise ConnectionError("socket died")
|
||||
self.sent.append(data)
|
||||
|
||||
def send(self, text):
|
||||
self.sent.append(text)
|
||||
|
||||
def recv(self):
|
||||
if self._messages:
|
||||
return self._messages.pop(0)
|
||||
time.sleep(0.01)
|
||||
raise ConnectionError("closed")
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _results(transcript, is_final=True):
|
||||
return json.dumps({
|
||||
"type": "Results", "is_final": is_final,
|
||||
"channel": {"alternatives": [{"transcript": transcript}]},
|
||||
})
|
||||
|
||||
|
||||
def _frame(value=1000):
|
||||
return np.full(320, value, dtype=np.int16)
|
||||
|
||||
|
||||
# ── the protocol ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_frames_go_up_as_they_are_captured():
|
||||
socket = FakeSocket([_results("what's the weather")])
|
||||
session = StreamingTranscriber(socket)
|
||||
|
||||
for _ in range(3):
|
||||
session.feed(_frame())
|
||||
text = session.finish()
|
||||
|
||||
assert len(socket.sent) == 4 # three frames plus the close message
|
||||
assert text == "what's the weather"
|
||||
assert socket.closed
|
||||
|
||||
|
||||
def test_only_final_results_are_kept():
|
||||
"""Interim hypotheses change under you; concatenating them would produce
|
||||
"what what's what's the what's the weather"."""
|
||||
socket = FakeSocket([
|
||||
_results("what's", is_final=False),
|
||||
_results("what's the", is_final=False),
|
||||
_results("what's the weather", is_final=True),
|
||||
])
|
||||
session = StreamingTranscriber(socket)
|
||||
assert session.finish() == "what's the weather"
|
||||
|
||||
|
||||
def test_several_final_segments_are_joined():
|
||||
socket = FakeSocket([_results("turn the lights on"), _results("in the kitchen")])
|
||||
session = StreamingTranscriber(socket)
|
||||
assert session.finish() == "turn the lights on in the kitchen"
|
||||
|
||||
|
||||
def test_junk_frames_are_ignored_rather_than_killing_the_reader():
|
||||
"""An exception on the reader thread would silently end transcription for
|
||||
the rest of the utterance."""
|
||||
socket = FakeSocket(["not json at all", '{"type":"Metadata"}',
|
||||
_results("still works")])
|
||||
session = StreamingTranscriber(socket)
|
||||
assert session.finish() == "still works"
|
||||
|
||||
|
||||
def test_an_empty_frame_means_the_socket_closed():
|
||||
"""websocket-client returns "" from recv() on a closed connection, so it
|
||||
ends the read loop rather than being treated as a blank transcript."""
|
||||
socket = FakeSocket([_results("heard this much"), "", _results("never arrives")])
|
||||
session = StreamingTranscriber(socket)
|
||||
assert session.finish() == "heard this much"
|
||||
|
||||
|
||||
def test_a_socket_that_dies_mid_utterance_gives_up_quietly():
|
||||
socket = FakeSocket([], fail_on_send=True)
|
||||
session = StreamingTranscriber(socket)
|
||||
|
||||
session.feed(_frame()) # must not raise — recording carries on
|
||||
assert session.finish() == ""
|
||||
|
||||
|
||||
# ── opening: failure is an ordinary outcome ────────────────────────────────
|
||||
|
||||
def test_open_returns_none_when_it_cannot_connect():
|
||||
"""None means "the one-shot path will do it", not an error."""
|
||||
def refuse():
|
||||
raise OSError("no network")
|
||||
|
||||
assert StreamingTranscriber.open(connect=refuse) is None
|
||||
|
||||
|
||||
def test_open_returns_a_session_when_it_can():
|
||||
session = StreamingTranscriber.open(connect=lambda: FakeSocket([_results("hi")]))
|
||||
assert session is not None
|
||||
assert session.finish() == "hi"
|
||||
|
||||
|
||||
def test_streaming_is_off_without_the_switch_or_server_config(monkeypatch):
|
||||
from bolt_pet.audio import stt_stream
|
||||
|
||||
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", False)
|
||||
monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "test-key")
|
||||
assert stt_stream.available() is False
|
||||
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True)
|
||||
assert stt_stream.available() is True
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "")
|
||||
assert stt_stream.available() is False
|
||||
|
||||
|
||||
# ── connecting to the server relay ──────────────────────────────────────────
|
||||
|
||||
def test_connect_builds_the_server_relay_url(monkeypatch):
|
||||
"""No local Deepgram account any more — the pet connects to its own
|
||||
server's /desk/stt, authenticated with its own desk key."""
|
||||
from bolt_pet.audio import stt_stream
|
||||
|
||||
monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://my-server:5002")
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "my-desk-key")
|
||||
monkeypatch.setattr(stt_stream.config, "SESSION_ID", "pet-test")
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakeWebsocketModule:
|
||||
@staticmethod
|
||||
def create_connection(url, header=None, timeout=None):
|
||||
captured["url"] = url
|
||||
captured["header"] = header
|
||||
return "a-socket"
|
||||
|
||||
monkeypatch.setitem(sys.modules, "websocket", _FakeWebsocketModule())
|
||||
|
||||
result = stt_stream.connect(sample_rate=16000)
|
||||
|
||||
assert result == "a-socket"
|
||||
assert captured["url"] == (
|
||||
"ws://my-server:5002/desk/stt?session_id=pet-test"
|
||||
"&encoding=linear16&sample_rate=16000"
|
||||
)
|
||||
assert captured["header"] == ["X-Desk-Api-Key: my-desk-key"]
|
||||
|
||||
|
||||
def test_open_uses_connect_by_default(monkeypatch):
|
||||
"""StreamingTranscriber.open() with no injected connect() goes through
|
||||
the real server-relay connector."""
|
||||
from bolt_pet.audio import stt_stream
|
||||
|
||||
monkeypatch.setattr(stt_stream.config, "STT_STREAMING", True)
|
||||
monkeypatch.setattr(stt_stream.config, "SERVER_URL", "http://my-server:5002")
|
||||
monkeypatch.setattr(stt_stream.config, "API_KEY", "my-desk-key")
|
||||
monkeypatch.setattr(stt_stream, "_connect", lambda rate: FakeSocket([_results("hi")]))
|
||||
|
||||
session = stt_stream.StreamingTranscriber.open()
|
||||
|
||||
assert session is not None
|
||||
assert session.finish() == "hi"
|
||||
|
||||
|
||||
# ── the capture hook ────────────────────────────────────────────────────────
|
||||
|
||||
class _Stream:
|
||||
"""Loud frames, then quiet ones, so the VAD ends the utterance."""
|
||||
|
||||
def __init__(self, loud=6, quiet=40):
|
||||
self.frames = ([np.full((320, 1), 3000, dtype=np.int16)] * loud
|
||||
+ [np.zeros((320, 1), dtype=np.int16)] * quiet)
|
||||
|
||||
def read(self, n):
|
||||
return (self.frames.pop(0) if self.frames
|
||||
else np.zeros((320, 1), dtype=np.int16)), None
|
||||
|
||||
|
||||
def test_recording_hands_every_speech_frame_to_the_listener():
|
||||
seen = []
|
||||
pcm = mic.record_utterance(_Stream(), on_frame=seen.append,
|
||||
silence_end_sec=0.2, min_utterance_s=0.0)
|
||||
assert pcm is not None
|
||||
assert len(seen) >= 6 # every frame of speech was streamed
|
||||
|
||||
|
||||
def test_a_listener_that_throws_cannot_break_the_recording():
|
||||
"""The fallback is about to need this audio — a dead stream must not cost
|
||||
the recording too."""
|
||||
def explode(frame):
|
||||
raise RuntimeError("stream died")
|
||||
|
||||
pcm = mic.record_utterance(_Stream(), on_frame=explode,
|
||||
silence_end_sec=0.2, min_utterance_s=0.0)
|
||||
assert pcm is not None and len(pcm) > 0
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Graphical sudo prompts: command rewriting and helper resolution.
|
||||
Pure logic — no display, no sudo, no password."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import sudo_askpass
|
||||
|
||||
|
||||
# ── rewriting ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", "sudo -A apt update"),
|
||||
("sudo apt update", "sudo -A apt update"), # spacing preserved
|
||||
("apt update && sudo apt upgrade", "apt update && sudo -A apt upgrade"),
|
||||
("ls; sudo reboot", "ls; sudo -A reboot"),
|
||||
("echo hi | sudo tee /etc/motd", "echo hi | sudo -A tee /etc/motd"),
|
||||
("sudo systemctl restart x\nsudo systemctl status x",
|
||||
"sudo -A systemctl restart x\nsudo -A systemctl status x"),
|
||||
],
|
||||
)
|
||||
def test_bare_sudo_gets_the_askpass_flag(command, expected):
|
||||
assert sudo_askpass.add_askpass_flag(command) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"sudo -n apt update", # explicitly non-interactive
|
||||
"sudo -A apt update", # already asking
|
||||
"sudo -u bob whoami", # the caller was explicit
|
||||
"ls -la", # no sudo at all
|
||||
"echo 'run sudo later'", # inside a quoted string
|
||||
"pseudo --version", # not the word sudo
|
||||
],
|
||||
)
|
||||
def test_commands_that_must_not_be_rewritten(command):
|
||||
assert sudo_askpass.add_askpass_flag(command) == command
|
||||
|
||||
|
||||
def test_blank_input():
|
||||
assert sudo_askpass.add_askpass_flag("") == ""
|
||||
assert sudo_askpass.add_askpass_flag(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", True),
|
||||
("ls && sudo reboot", True),
|
||||
("ls -la", False),
|
||||
("echo 'sudo'", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_which_commands_get_the_longer_timeout(command, expected):
|
||||
assert sudo_askpass.needs_password_prompt(command) is expected
|
||||
|
||||
|
||||
# ── helper resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_configured_helper_wins():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="/opt/my-askpass", is_executable=lambda p: p == "/opt/my-askpass",
|
||||
)
|
||||
assert found == "/opt/my-askpass"
|
||||
|
||||
|
||||
def test_a_configured_helper_that_is_not_executable_is_not_silently_replaced():
|
||||
"""Better to have sudo fail than to quietly prompt with something the
|
||||
user didn't choose."""
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="/opt/typo", is_executable=lambda p: False, which=lambda t: "/usr/bin/zenity",
|
||||
) is None
|
||||
|
||||
|
||||
def test_a_real_askpass_binary_beats_a_generated_wrapper():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: p == "/usr/bin/ksshaskpass",
|
||||
which=lambda tool: "/usr/bin/zenity",
|
||||
)
|
||||
assert found == "/usr/bin/ksshaskpass"
|
||||
|
||||
|
||||
def test_falls_back_to_wrapping_a_dialog_tool(tmp_path):
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False,
|
||||
which=lambda tool: "/usr/bin/zenity" if tool == "zenity" else None,
|
||||
cache_dir=tmp_path,
|
||||
)
|
||||
script = tmp_path / "askpass.sh"
|
||||
assert found == str(script)
|
||||
assert "zenity --password" in script.read_text()
|
||||
assert script.stat().st_mode & 0o777 == 0o700 # nobody else edits the password box
|
||||
|
||||
|
||||
def test_no_helper_available_at_all():
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False, which=lambda tool: None,
|
||||
) is None
|
||||
|
||||
|
||||
def test_the_wrapper_passes_sudos_prompt_through():
|
||||
"""sudo hands the helper its prompt as $1 — it names the account the
|
||||
password is for, which is worth showing in the dialog."""
|
||||
script = sudo_askpass.helper_script("/usr/bin/zenity")
|
||||
assert script.startswith("#!/bin/sh")
|
||||
assert '"$1"' in script
|
||||
|
||||
|
||||
def test_environment_points_sudo_at_the_helper():
|
||||
env = sudo_askpass.environment("/tmp/askpass.sh", base={"PATH": "/usr/bin"})
|
||||
assert env["SUDO_ASKPASS"] == "/tmp/askpass.sh"
|
||||
assert env["PATH"] == "/usr/bin" # the rest of the environment survives
|
||||
@@ -0,0 +1,164 @@
|
||||
"""The relayed-command dispatcher: what runs, what asks first, what's refused."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_console import tools as tools_mod
|
||||
from bolt_console.tools import Tools
|
||||
|
||||
|
||||
class FakeUI:
|
||||
def __init__(self, answers=()):
|
||||
self.answers = list(answers)
|
||||
self.calls = []
|
||||
self.results = []
|
||||
self.questions = []
|
||||
|
||||
def tool_call(self, name, argument):
|
||||
self.calls.append((name, argument))
|
||||
|
||||
def tool_result(self, text, error=False):
|
||||
self.results.append((text, error))
|
||||
|
||||
def diff(self, old, new):
|
||||
self.calls.append(("diff", old, new))
|
||||
|
||||
def confirm(self, question):
|
||||
self.questions.append(question)
|
||||
return self.answers.pop(0) if self.answers else "no"
|
||||
|
||||
|
||||
class FakeShell:
|
||||
def __init__(self, output="[exit 0]\nhello"):
|
||||
self.output = output
|
||||
self.ran = []
|
||||
|
||||
def __call__(self, command):
|
||||
self.ran.append(command)
|
||||
return self.output
|
||||
|
||||
|
||||
def filectl(**payload) -> str:
|
||||
return "filectl " + json.dumps(payload)
|
||||
|
||||
|
||||
def test_shell_command_asks_first_and_runs_on_yes():
|
||||
ui, shell = FakeUI(["yes"]), FakeShell()
|
||||
output = Tools(ui, run_shell=shell).handle("echo hello")
|
||||
assert shell.ran == ["echo hello"]
|
||||
assert output == "[exit 0]\nhello"
|
||||
assert ui.calls == [("Shell", "echo hello")]
|
||||
assert ui.results == [("hello", False)]
|
||||
|
||||
|
||||
def test_declined_command_never_runs_and_tells_the_model_why():
|
||||
ui, shell = FakeUI(["no"]), FakeShell()
|
||||
output = Tools(ui, run_shell=shell).handle("rm -rf build")
|
||||
assert shell.ran == []
|
||||
assert "declined" in output and "did not run" in output
|
||||
|
||||
|
||||
def test_always_switches_the_session_to_auto():
|
||||
ui, shell = FakeUI(["always"]), FakeShell()
|
||||
tools = Tools(ui, run_shell=shell)
|
||||
tools.handle("echo one")
|
||||
tools.handle("echo two")
|
||||
assert tools.mode == "auto"
|
||||
assert len(ui.questions) == 1
|
||||
assert shell.ran == ["echo one", "echo two"]
|
||||
|
||||
|
||||
def test_auto_mode_never_asks():
|
||||
ui, shell = FakeUI(), FakeShell()
|
||||
Tools(ui, mode="auto", run_shell=shell).handle("echo hi")
|
||||
assert ui.questions == []
|
||||
assert shell.ran == ["echo hi"]
|
||||
|
||||
|
||||
def test_failed_command_is_shown_as_an_error_with_its_status():
|
||||
ui = FakeUI(["yes"])
|
||||
Tools(ui, run_shell=FakeShell("[exit 2]\nno such file")).handle("cat nope")
|
||||
assert ui.results == [("[exit 2]\nno such file", True)]
|
||||
|
||||
|
||||
def test_reads_and_lists_never_ask(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("one\ntwo\n", encoding="utf-8")
|
||||
ui = FakeUI()
|
||||
tools = Tools(ui, run_shell=FakeShell())
|
||||
read = tools.handle(filectl(op="read", path=str(tmp_path / "a.txt")))
|
||||
listed = tools.handle(filectl(op="list", path=str(tmp_path)))
|
||||
assert ui.questions == []
|
||||
assert "one" in read and "two" in read
|
||||
assert "a.txt" in listed
|
||||
assert ui.calls[0] == ("Read", str(tmp_path / "a.txt"))
|
||||
assert ui.results[0] == ("Read 2 lines", False)
|
||||
|
||||
|
||||
def test_write_asks_and_declining_leaves_the_disk_alone(tmp_path):
|
||||
target = tmp_path / "new.txt"
|
||||
ui = FakeUI(["no"])
|
||||
output = Tools(ui).handle(filectl(op="write", path=str(target), content="x"))
|
||||
assert not target.exists()
|
||||
assert "declined" in output
|
||||
|
||||
|
||||
def test_edit_shows_the_diff_then_applies_on_yes(tmp_path):
|
||||
target = tmp_path / "f.py"
|
||||
target.write_text("x = 1\n", encoding="utf-8")
|
||||
ui = FakeUI(["yes"])
|
||||
output = Tools(ui).handle(filectl(op="edit", path=str(target), old="x = 1", new="x = 2"))
|
||||
assert target.read_text(encoding="utf-8") == "x = 2\n"
|
||||
assert ("diff", "x = 1", "x = 2") in ui.calls
|
||||
assert "edited" in output
|
||||
|
||||
|
||||
def test_filectl_errors_go_back_to_the_model_not_up_the_stack(tmp_path):
|
||||
ui = FakeUI()
|
||||
output = Tools(ui).handle(filectl(op="read", path=str(tmp_path / "missing.txt")))
|
||||
assert output.startswith("[filectl] no such file")
|
||||
assert ui.results[-1][1] is True
|
||||
|
||||
|
||||
def test_pet_commands_are_explained_rather_than_run():
|
||||
ui, shell = FakeUI(), FakeShell()
|
||||
output = Tools(ui, mode="auto", run_shell=shell).handle("petctl emote wave")
|
||||
assert shell.ran == []
|
||||
assert output == tools_mod.PET_UNAVAILABLE.format(name="petctl")
|
||||
|
||||
|
||||
def test_dialogue_scenes_are_explained_rather_than_run():
|
||||
shell = FakeShell()
|
||||
output = Tools(FakeUI(), mode="auto", run_shell=shell).handle('dialoguectl {"lines": []}')
|
||||
assert shell.ran == []
|
||||
assert "isn't available" in output
|
||||
|
||||
|
||||
def test_a_stray_command_label_is_stripped_and_reported():
|
||||
"""Seen live: the server relayed `command: powershell -Command "..."` and
|
||||
cmd.exe answered "'command:' is not recognized"."""
|
||||
ui, shell = FakeUI(["yes"]), FakeShell()
|
||||
output = Tools(ui, run_shell=shell).handle('command: powershell -Command "Get-PnpDevice"')
|
||||
assert shell.ran == ['powershell -Command "Get-PnpDevice"']
|
||||
assert ui.calls[0] == ("Shell", 'powershell -Command "Get-PnpDevice"')
|
||||
assert 'removed a leading "command:" label' in output
|
||||
|
||||
|
||||
def test_wrapping_backticks_are_stripped():
|
||||
shell = FakeShell()
|
||||
Tools(FakeUI(), mode="auto", run_shell=shell).handle("`git status`")
|
||||
assert shell.ran == ["git status"]
|
||||
|
||||
|
||||
def test_the_label_is_stripped_before_filectl_is_recognised(tmp_path):
|
||||
(tmp_path / "a.txt").write_text("hi", encoding="utf-8")
|
||||
output = Tools(FakeUI()).handle(
|
||||
"command: filectl " + json.dumps({"op": "read", "path": str(tmp_path / "a.txt")}))
|
||||
assert "hi" in output and "command:" in output # read ran; note explains the fix
|
||||
|
||||
|
||||
def test_a_clean_command_gets_no_note():
|
||||
output = Tools(FakeUI(), mode="auto", run_shell=FakeShell()).handle("echo hi")
|
||||
assert "note" not in output
|
||||
@@ -1,175 +0,0 @@
|
||||
"""Streaming-TTS chunk reassembly and the near-miss log. Both are pure —
|
||||
no network, no audio device, no ONNX model."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config as tts_config
|
||||
from bolt_pet.audio import tts
|
||||
from bolt_pet.audio.tts import TtsError, chunks_to_int16, voice_for
|
||||
from bolt_pet.audio.wake_word import NearMissLog
|
||||
|
||||
|
||||
def _pcm(*values):
|
||||
return np.array(values, dtype=np.int16).tobytes()
|
||||
|
||||
|
||||
def test_whole_samples_pass_straight_through():
|
||||
chunks = list(chunks_to_int16([_pcm(1, 2), _pcm(3, 4)]))
|
||||
assert np.concatenate(chunks).tolist() == [1, 2, 3, 4]
|
||||
|
||||
|
||||
def test_a_sample_split_across_two_http_chunks_is_rejoined():
|
||||
# The killer bug this exists to prevent: an odd byte at a chunk boundary
|
||||
# shifts everything after it by one byte and plays as static.
|
||||
raw = _pcm(100, -200, 300, -400)
|
||||
chunks = list(chunks_to_int16([raw[:3], raw[3:]]))
|
||||
assert np.concatenate(chunks).tolist() == [100, -200, 300, -400]
|
||||
|
||||
|
||||
def test_many_odd_boundaries_in_a_row():
|
||||
raw = _pcm(*range(1, 21))
|
||||
pieces = [raw[i:i + 3] for i in range(0, len(raw), 3)] # every boundary odd
|
||||
assert np.concatenate(list(chunks_to_int16(pieces))).tolist() == list(range(1, 21))
|
||||
|
||||
|
||||
def test_empty_chunks_are_skipped():
|
||||
assert list(chunks_to_int16([b"", b""])) == []
|
||||
|
||||
|
||||
def test_a_dangling_byte_at_the_end_is_dropped_not_played():
|
||||
raw = _pcm(7, 8) + b"\x01"
|
||||
assert np.concatenate(list(chunks_to_int16([raw]))).tolist() == [7, 8]
|
||||
|
||||
|
||||
# ── wake-word near misses ───────────────────────────────────────────────────
|
||||
|
||||
def test_scores_just_under_the_threshold_are_recorded():
|
||||
log = NearMissLog(limit=10, margin=0.2)
|
||||
assert log.observe(0.45, threshold=0.5, timestamp=1.0) is True
|
||||
assert log.entries() == [(1.0, 0.45, 0.5)]
|
||||
|
||||
|
||||
def test_detections_and_background_noise_are_not_near_misses():
|
||||
log = NearMissLog(limit=10, margin=0.2)
|
||||
assert log.observe(0.90, threshold=0.5, timestamp=1.0) is False # it fired
|
||||
assert log.observe(0.05, threshold=0.5, timestamp=2.0) is False # just noise
|
||||
assert log.entries() == []
|
||||
|
||||
|
||||
def test_peak_tracks_every_score_not_just_near_misses():
|
||||
log = NearMissLog(limit=10, margin=0.2)
|
||||
log.observe(0.30, threshold=0.5, timestamp=1.0)
|
||||
log.observe(0.95, threshold=0.5, timestamp=2.0)
|
||||
log.observe(0.10, threshold=0.5, timestamp=3.0)
|
||||
assert log.peak == 0.95
|
||||
|
||||
|
||||
def test_the_log_is_bounded():
|
||||
log = NearMissLog(limit=3, margin=0.2)
|
||||
for i in range(10):
|
||||
log.observe(0.45, threshold=0.5, timestamp=float(i))
|
||||
assert len(log.entries()) == 3
|
||||
assert log.entries()[-1][0] == 9.0
|
||||
|
||||
|
||||
def test_clear_resets_peak_and_entries():
|
||||
log = NearMissLog(limit=3, margin=0.2)
|
||||
log.observe(0.45, threshold=0.5, timestamp=1.0)
|
||||
log.clear()
|
||||
assert log.entries() == [] and log.peak == 0.0
|
||||
|
||||
|
||||
# ── voice selection (server speak_as) ───────────────────────────────────────
|
||||
|
||||
def test_the_override_voice_wins_over_the_configured_one(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "DEFAULT")
|
||||
assert voice_for("VOICE1") == "VOICE1"
|
||||
assert voice_for("") == "DEFAULT"
|
||||
assert voice_for(None) == "DEFAULT"
|
||||
|
||||
|
||||
# ── server-hosted synthesis (/desk/tts) ─────────────────────────────────────
|
||||
# No local ElevenLabs account: both the whole-clip and streaming paths post
|
||||
# to this pet's own server, same as server_client.py's other endpoints.
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configured(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "SERVER_URL", "http://test-server:5002")
|
||||
monkeypatch.setattr(tts_config, "API_KEY", "test-key")
|
||||
monkeypatch.setattr(tts_config, "SESSION_ID", "pet-test")
|
||||
monkeypatch.setattr(tts_config, "ELEVENLABS_VOICE_ID", "default-voice")
|
||||
|
||||
|
||||
def _mock_response(content=b"", ok=True):
|
||||
resp = MagicMock()
|
||||
resp.content = content
|
||||
resp.raise_for_status = MagicMock() if ok else MagicMock(side_effect=Exception("boom"))
|
||||
return resp
|
||||
|
||||
|
||||
def test_synthesize_pcm_posts_to_the_servers_tts_endpoint():
|
||||
pcm_bytes = np.array([1, 2, 3], dtype=np.int16).tobytes()
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(pcm_bytes)
|
||||
pcm, rate = tts.synthesize_pcm("hello there")
|
||||
post.assert_called_once()
|
||||
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"] == {
|
||||
"session_id": "pet-test", "text": "hello there", "voice_id": "default-voice",
|
||||
}
|
||||
assert pcm.tolist() == [1, 2, 3]
|
||||
assert rate == tts_config.TTS_SAMPLE_RATE
|
||||
|
||||
|
||||
def test_synthesize_pcm_uses_the_override_voice():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(b"\x01\x00")
|
||||
tts.synthesize_pcm("hi", voice_id="picked-voice")
|
||||
assert post.call_args.kwargs["json"]["voice_id"] == "picked-voice"
|
||||
|
||||
|
||||
def test_synthesize_pcm_raises_when_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "API_KEY", "")
|
||||
with pytest.raises(TtsError, match="BOLT_SERVER_URL"):
|
||||
tts.synthesize_pcm("hi")
|
||||
|
||||
|
||||
def test_synthesize_pcm_raises_on_empty_audio():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(b"")
|
||||
with pytest.raises(TtsError, match="no audio"):
|
||||
tts.synthesize_pcm("hi")
|
||||
|
||||
|
||||
def test_synthesize_pcm_raises_when_the_request_fails():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
post.return_value = _mock_response(b"", ok=False)
|
||||
with pytest.raises(TtsError, match="server tts request failed"):
|
||||
tts.synthesize_pcm("hi")
|
||||
|
||||
|
||||
def test_stream_pcm_posts_to_the_same_endpoint_with_stream_true():
|
||||
with patch.object(tts.requests, "post") as post:
|
||||
response = MagicMock()
|
||||
response.raise_for_status = MagicMock()
|
||||
response.iter_content.return_value = [np.array([4, 5], dtype=np.int16).tobytes()]
|
||||
post.return_value = response
|
||||
chunks = list(tts.stream_pcm("hi"))
|
||||
assert post.call_args.kwargs["stream"] is True
|
||||
assert post.call_args[0][0] == "http://test-server:5002/desk/tts"
|
||||
assert np.concatenate(chunks).tolist() == [4, 5]
|
||||
|
||||
|
||||
def test_stream_pcm_raises_when_not_configured(monkeypatch):
|
||||
monkeypatch.setattr(tts_config, "SERVER_URL", "")
|
||||
with pytest.raises(TtsError, match="BOLT_SERVER_URL"):
|
||||
list(tts.stream_pcm("hi"))
|
||||
@@ -1,237 +0,0 @@
|
||||
"""Auto-updater: version comparison, release parsing, and the apply/rollback
|
||||
dance driven by a fake git (no network, no real repo, nothing checked out)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import updater
|
||||
|
||||
|
||||
# ── version comparison ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag,expected",
|
||||
[
|
||||
("v1.2.3", (1, 2, 3)),
|
||||
("1.2.3", (1, 2, 3)),
|
||||
("V0.1.0", (0, 1, 0)),
|
||||
("1.2", (1, 2)),
|
||||
("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse
|
||||
("", ()),
|
||||
("nightly", ()),
|
||||
],
|
||||
)
|
||||
def test_parse_version(tag, expected):
|
||||
assert updater.parse_version(tag) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,current",
|
||||
[("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")],
|
||||
)
|
||||
def test_is_newer_accepts_newer_versions(candidate, current):
|
||||
assert updater.is_newer(candidate, current)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"candidate,current",
|
||||
[
|
||||
("0.1.0", "0.1.0"),
|
||||
("0.1.0", "0.2.0"),
|
||||
("0.1", "0.1.0"), # zero-padded: equal, not newer
|
||||
("", "0.1.0"),
|
||||
("nightly", "0.1.0"), # unparseable is never newer
|
||||
],
|
||||
)
|
||||
def test_is_newer_rejects_same_or_older(candidate, current):
|
||||
assert not updater.is_newer(candidate, current)
|
||||
|
||||
|
||||
def test_release_parsing_skips_drafts_and_untagged():
|
||||
assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None
|
||||
assert updater.release_from_payload({"name": "no tag"}) is None
|
||||
release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True})
|
||||
assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True)
|
||||
|
||||
|
||||
# ── fake git ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FakeGit:
|
||||
"""Records every git invocation and answers from a canned script.
|
||||
*failures* maps a leading-args tuple to the (code, output) it should
|
||||
return, so a test can make exactly one command fail."""
|
||||
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False,
|
||||
head="abc1234", tag_commit="def5678"):
|
||||
self.calls = []
|
||||
self._ref = ref
|
||||
self._dirty = dirty
|
||||
self._failures = failures or {}
|
||||
self._requirements_changed = requirements_changed
|
||||
self._head = head
|
||||
self._tag_commit = tag_commit # equal to head = "already on this tag"
|
||||
|
||||
def __call__(self, args):
|
||||
self.calls.append(list(args))
|
||||
for prefix, result in self._failures.items():
|
||||
if tuple(args[: len(prefix)]) == prefix:
|
||||
return result
|
||||
head = args[0]
|
||||
if head == "rev-parse" and args[1] == "--git-dir":
|
||||
return 0, ".git"
|
||||
if head == "status":
|
||||
return 0, " M bolt_pet/config.py" if self._dirty else ""
|
||||
if head == "symbolic-ref":
|
||||
return (0, self._ref) if self._ref else (1, "")
|
||||
if head == "rev-parse":
|
||||
return 0, self._tag_commit if args[1].startswith("tags/") else self._head
|
||||
if head == "diff":
|
||||
return 0, "requirements.txt" if self._requirements_changed else ""
|
||||
return 0, ""
|
||||
|
||||
def commands(self):
|
||||
"""Just the verbs, for asserting on the sequence."""
|
||||
return [call[0] for call in self.calls]
|
||||
|
||||
|
||||
def test_apply_update_checks_out_the_tag(tmp_path):
|
||||
git = FakeGit()
|
||||
previous = updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None
|
||||
)
|
||||
assert previous == "main"
|
||||
assert ["fetch", "--tags", "--prune", "origin"] in git.calls
|
||||
assert ["checkout", "--force", "tags/v1.0.0"] in git.calls
|
||||
|
||||
|
||||
def test_a_dirty_working_tree_is_left_completely_alone(tmp_path):
|
||||
git = FakeGit(dirty=True)
|
||||
with pytest.raises(updater.UpdateError, match="local changes"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "fetch" not in git.commands()
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_non_git_install_refuses_before_touching_anything(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")})
|
||||
with pytest.raises(updater.UpdateError, match="not a git clone"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path):
|
||||
git = FakeGit(ref="main")
|
||||
|
||||
def broken(repo):
|
||||
raise updater.UpdateError("the new version failed to import: boom")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="rolled back to main"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken)
|
||||
|
||||
checkouts = [call for call in git.calls if call[0] == "checkout"]
|
||||
assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]]
|
||||
|
||||
|
||||
def test_rollback_targets_the_commit_when_head_is_detached(tmp_path):
|
||||
# No branch to go back to (symbolic-ref fails) — the SHA is the ref.
|
||||
git = FakeGit(ref="")
|
||||
|
||||
with pytest.raises(updater.UpdateError):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")),
|
||||
)
|
||||
assert ["checkout", "--force", "abc1234"] in git.calls
|
||||
|
||||
|
||||
def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
||||
git = FakeGit()
|
||||
|
||||
def exploding(repo):
|
||||
raise ValueError("not even an UpdateError")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="rolled back"):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding
|
||||
)
|
||||
assert ["checkout", "--force", "main"] in git.calls
|
||||
|
||||
|
||||
def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path):
|
||||
"""Cut a release but forget to bump __version__ in the tagged commit and
|
||||
every check would see the same "newer" tag: check out (a no-op), restart,
|
||||
read the old version, repeat — a restart loop every check interval."""
|
||||
git = FakeGit(head="same1234", tag_commit="same1234")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"):
|
||||
updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: None)
|
||||
|
||||
assert "checkout" not in git.commands() # nothing moved, so nothing to restart into
|
||||
|
||||
|
||||
def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")})
|
||||
# The failure key matches by prefix, so make it explicit that a tag we
|
||||
# can't resolve means "not there yet" rather than "already applied".
|
||||
assert updater.already_at_tag(git, "v9.9.9") is False
|
||||
|
||||
|
||||
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
||||
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
||||
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
||||
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
|
||||
assert "checkout" not in git.commands()
|
||||
|
||||
|
||||
def test_a_stranded_checkout_is_logged_loudly(tmp_path):
|
||||
"""Rollback itself failing is the one case a human has to fix by hand."""
|
||||
git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")})
|
||||
logs = []
|
||||
|
||||
with pytest.raises(updater.UpdateError):
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append,
|
||||
verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")),
|
||||
)
|
||||
assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs)
|
||||
|
||||
|
||||
def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch):
|
||||
installs = []
|
||||
monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo))
|
||||
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path,
|
||||
install_deps=True, verify=lambda repo: None,
|
||||
)
|
||||
assert installs == []
|
||||
|
||||
updater.apply_update(
|
||||
"v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path,
|
||||
install_deps=True, verify=lambda repo: None,
|
||||
)
|
||||
assert installs == [tmp_path]
|
||||
|
||||
|
||||
# ── release checking ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_check_for_update_returns_nothing_when_current(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
updater, "fetch_latest_release",
|
||||
lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False),
|
||||
)
|
||||
assert updater.check_for_update(current_version="0.1.0") is None
|
||||
assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0"
|
||||
|
||||
|
||||
def test_no_releases_yet_is_not_an_error(monkeypatch):
|
||||
monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None)
|
||||
assert updater.check_for_update(current_version="0.1.0") is None
|
||||
@@ -1,148 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bolt_pet.audio import wake_word
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Yields a fixed sequence of frames, then silence forever."""
|
||||
|
||||
def __init__(self, frames, frame_len):
|
||||
self._frames = list(frames)
|
||||
self._frame_len = frame_len
|
||||
|
||||
def read(self, frames):
|
||||
if self._frames:
|
||||
frame = self._frames.pop(0)
|
||||
else:
|
||||
frame = np.zeros(self._frame_len, dtype=np.int16)
|
||||
return frame.reshape(-1, 1), False
|
||||
|
||||
|
||||
def _frame(frame_len):
|
||||
return np.zeros(frame_len, dtype=np.int16)
|
||||
|
||||
|
||||
class _FakeModel:
|
||||
"""Reports the given score sequence (one dict per predict() call, then
|
||||
repeats the last entry) and records reset() calls."""
|
||||
|
||||
def __init__(self, score_sequence):
|
||||
self._scores = list(score_sequence)
|
||||
self.reset_calls = 0
|
||||
|
||||
def predict(self, frame):
|
||||
if self._scores:
|
||||
return self._scores.pop(0)
|
||||
return {"thunderbolt": 0.0}
|
||||
|
||||
def reset(self):
|
||||
self.reset_calls += 1
|
||||
|
||||
|
||||
def test_returns_true_and_resets_on_detection(monkeypatch):
|
||||
frame_len = 1280
|
||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) # 1 frame
|
||||
monkeypatch.setattr(wake_word.config, "WAKE_WORD_THRESHOLD", 0.5)
|
||||
|
||||
stream = _FakeStream([_frame(frame_len)] * 3, frame_len)
|
||||
model = _FakeModel([{"thunderbolt": 0.1}, {"thunderbolt": 0.9}])
|
||||
|
||||
detected = wake_word.listen_for_wake_word(stream, model=model)
|
||||
|
||||
assert detected is True
|
||||
assert model.reset_calls == 1
|
||||
|
||||
|
||||
def test_returns_false_when_should_continue_goes_false_first():
|
||||
frame_len = 1280
|
||||
stream = _FakeStream([_frame(frame_len)] * 5, frame_len)
|
||||
model = _FakeModel([{"thunderbolt": 0.0}] * 5)
|
||||
calls = {"n": 0}
|
||||
|
||||
def should_continue():
|
||||
calls["n"] += 1
|
||||
return calls["n"] <= 3
|
||||
|
||||
detected = wake_word.listen_for_wake_word(
|
||||
stream, should_continue=should_continue, model=model,
|
||||
)
|
||||
assert detected is False
|
||||
assert model.reset_calls == 0
|
||||
|
||||
|
||||
def test_custom_threshold_is_respected(monkeypatch):
|
||||
frame_len = 1280
|
||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08)
|
||||
|
||||
stream = _FakeStream([_frame(frame_len)] * 3, frame_len)
|
||||
model = _FakeModel([{"thunderbolt": 0.6}] * 3)
|
||||
calls = {"n": 0}
|
||||
|
||||
def should_continue():
|
||||
calls["n"] += 1
|
||||
return calls["n"] <= 3
|
||||
|
||||
detected = wake_word.listen_for_wake_word(
|
||||
stream, should_continue=should_continue, model=model, threshold=0.7,
|
||||
)
|
||||
assert detected is False
|
||||
|
||||
|
||||
def test_on_tick_fires_once_per_check_interval(monkeypatch):
|
||||
frame_len = 1280
|
||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.08) # 1 frame/check
|
||||
|
||||
frames = [_frame(frame_len) for _ in range(5)]
|
||||
stream = _FakeStream(frames, frame_len)
|
||||
model = _FakeModel([{"thunderbolt": 0.0}] * 5)
|
||||
ticks = {"n": 0}
|
||||
state = {"i": 0}
|
||||
|
||||
def should_continue():
|
||||
state["i"] += 1
|
||||
return state["i"] <= 5
|
||||
|
||||
wake_word.listen_for_wake_word(
|
||||
stream,
|
||||
should_continue=should_continue,
|
||||
model=model,
|
||||
on_tick=lambda: ticks.__setitem__("n", ticks["n"] + 1),
|
||||
)
|
||||
assert ticks["n"] == 5 # one check-interval per frame at this config
|
||||
|
||||
|
||||
def test_on_tick_interval_can_span_multiple_frames(monkeypatch):
|
||||
frame_len = 1280
|
||||
monkeypatch.setattr(wake_word.config, "FRAME_LEN", frame_len)
|
||||
monkeypatch.setattr(wake_word.config, "SAMPLE_RATE", 16000)
|
||||
monkeypatch.setattr(wake_word.config, "WAKE_CHECK_INTERVAL_SECONDS", 0.24) # 3 frames/check
|
||||
|
||||
frames = [_frame(frame_len) for _ in range(6)]
|
||||
stream = _FakeStream(frames, frame_len)
|
||||
model = _FakeModel([{"thunderbolt": 0.0}] * 6)
|
||||
ticks = {"n": 0}
|
||||
state = {"i": 0}
|
||||
|
||||
def should_continue():
|
||||
state["i"] += 1
|
||||
return state["i"] <= 6
|
||||
|
||||
wake_word.listen_for_wake_word(
|
||||
stream,
|
||||
should_continue=should_continue,
|
||||
model=model,
|
||||
on_tick=lambda: ticks.__setitem__("n", ticks["n"] + 1),
|
||||
)
|
||||
assert ticks["n"] == 2 # 6 frames / 3 frames-per-check
|
||||
@@ -1,89 +0,0 @@
|
||||
"""Autonomous wandering. Needs a QApplication, so run with
|
||||
QT_QPA_PLATFORM=offscreen (same as test_controller.py)."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet import config
|
||||
from bolt_pet.state import PetState
|
||||
from bolt_pet.ui.pet_window import PetWindow
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qt_app():
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pet(qt_app):
|
||||
window = PetWindow()
|
||||
window.set_wander_enabled(True)
|
||||
yield window
|
||||
window.close()
|
||||
|
||||
|
||||
def _walk_until_done(pet, max_ticks=2000):
|
||||
for _ in range(max_ticks):
|
||||
pet._wander_tick()
|
||||
if pet._wander_target is None:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_wanders_when_idle_and_reaches_its_target(pet, monkeypatch):
|
||||
# Snapping is tested separately; here it would tug the pet off its target
|
||||
# the moment it arrives near an edge.
|
||||
monkeypatch.setattr(config, "PET_EDGE_SNAP", False)
|
||||
pet.wander_now()
|
||||
pet._wander_tick()
|
||||
target = pet._wander_target
|
||||
if target is None:
|
||||
pytest.skip("no usable screen geometry for a stroll on this host")
|
||||
assert _walk_until_done(pet), "pet never reached its wander target"
|
||||
assert pet.pos() == target
|
||||
|
||||
|
||||
def test_stays_put_while_talking(pet):
|
||||
pet.set_state(PetState.TALKING)
|
||||
start = pet.pos()
|
||||
pet.wander_now()
|
||||
for _ in range(50):
|
||||
pet._wander_tick()
|
||||
assert pet.pos() == start
|
||||
assert pet._wander_target is None
|
||||
|
||||
|
||||
def test_stays_put_while_bubble_is_up(pet):
|
||||
pet.say("hello there", duration_ms=60000)
|
||||
start = pet.pos()
|
||||
pet.wander_now()
|
||||
for _ in range(50):
|
||||
pet._wander_tick()
|
||||
assert pet.pos() == start
|
||||
|
||||
|
||||
def test_disabling_wander_stops_movement(pet):
|
||||
pet.set_wander_enabled(False)
|
||||
start = pet.pos()
|
||||
pet.wander_now()
|
||||
for _ in range(50):
|
||||
pet._wander_tick()
|
||||
assert pet.pos() == start
|
||||
|
||||
|
||||
def test_stroll_stays_on_screen(pet):
|
||||
geo = pet._screen_geometry()
|
||||
if geo is None:
|
||||
pytest.skip("no usable screen geometry on this host")
|
||||
for _ in range(10):
|
||||
pet.wander_now()
|
||||
pet._wander_tick()
|
||||
assert _walk_until_done(pet)
|
||||
assert geo.contains(pet.geometry())
|
||||
@@ -1,84 +0,0 @@
|
||||
"""Remembering where the pet was left — and refusing to when that's a trap."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import window_state
|
||||
|
||||
|
||||
def test_a_saved_position_comes_back(tmp_path):
|
||||
path = tmp_path / "window.json"
|
||||
window_state.save(1200, 640, path)
|
||||
assert window_state.load(path) == (1200, 640)
|
||||
|
||||
|
||||
def test_no_file_yet_is_not_an_error(tmp_path):
|
||||
assert window_state.load(tmp_path / "nope.json") is None
|
||||
|
||||
|
||||
def test_a_corrupt_file_falls_back_to_the_default_corner(tmp_path):
|
||||
"""Whatever is in there, the pet still has to start."""
|
||||
path = tmp_path / "window.json"
|
||||
for junk in ("", "{", "null", "[]", '{"x": "left"}', '{"y": 3}'):
|
||||
path.write_text(junk)
|
||||
assert window_state.load(path) is None
|
||||
|
||||
|
||||
def test_saving_creates_the_cache_directory(tmp_path):
|
||||
path = tmp_path / "deep" / "nested" / "window.json"
|
||||
window_state.save(10, 20, path)
|
||||
assert window_state.load(path) == (10, 20)
|
||||
|
||||
|
||||
def test_an_unwritable_location_is_swallowed(tmp_path):
|
||||
"""A read-only cache dir is a reason to forget the position, not to crash
|
||||
on every drag."""
|
||||
window_state.save(1, 2, tmp_path / "no" / "\0bad" / "window.json")
|
||||
|
||||
|
||||
def test_the_write_is_atomic_and_leaves_no_litter(tmp_path):
|
||||
path = tmp_path / "window.json"
|
||||
window_state.save(5, 5, path)
|
||||
window_state.save(7, 7, path)
|
||||
assert json.loads(path.read_text()) == {"x": 7, "y": 7}
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["window.json"]
|
||||
|
||||
|
||||
# ── validating against the screens that exist *now* ─────────────────────────
|
||||
|
||||
LAPTOP = (0, 0, 1920, 1080)
|
||||
EXTERNAL = (1920, 0, 4480, 1440)
|
||||
|
||||
|
||||
def test_a_position_on_a_connected_screen_is_kept():
|
||||
assert window_state.is_visible_on(1700, 900, 128, [LAPTOP])
|
||||
|
||||
|
||||
def test_a_position_on_an_unplugged_monitor_is_refused():
|
||||
"""The dangerous case: restoring it faithfully puts the pet somewhere you
|
||||
can't see or reach."""
|
||||
assert not window_state.is_visible_on(3000, 700, 128, [LAPTOP])
|
||||
assert window_state.is_visible_on(3000, 700, 128, [LAPTOP, EXTERNAL])
|
||||
|
||||
|
||||
def test_mostly_off_screen_counts_as_gone():
|
||||
"""A few pixels of ear poking onto the desktop is not "reachable"."""
|
||||
assert not window_state.is_visible_on(1910, 500, 128, [LAPTOP])
|
||||
assert window_state.is_visible_on(1830, 500, 128, [LAPTOP])
|
||||
|
||||
|
||||
def test_touching_an_edge_is_not_overlapping():
|
||||
assert not window_state.is_visible_on(1920, 0, 128, [LAPTOP])
|
||||
|
||||
|
||||
def test_negative_coordinates_are_fine_when_a_screen_is_there():
|
||||
"""Monitors left of or above the primary have negative origins."""
|
||||
left_of_primary = (-1920, 0, 0, 1080)
|
||||
assert window_state.is_visible_on(-900, 400, 128, [left_of_primary, LAPTOP])
|
||||
|
||||
|
||||
def test_no_screens_at_all_is_not_visible():
|
||||
assert not window_state.is_visible_on(100, 100, 128, [])
|
||||
Reference in New Issue
Block a user