Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 06:25:41 -06:00
commit 80bef6f524
63 changed files with 5674 additions and 0 deletions
View File
+65
View File
@@ -0,0 +1,65 @@
"""Barge-in detection, driven by a fake mic stream (no audio hardware)."""
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
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
+195
View File
@@ -0,0 +1,195 @@
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
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: "sunny and 72")
spoken = []
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=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: 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):
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: (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
+243
View File
@@ -0,0 +1,243 @@
"""Controller-level wiring for the newer behaviours: petctl routing,
barge-in follow-up, quiet hours, screen context, notification forwarding and
the live wake threshold.
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
"""
import sys
from pathlib import Path
import numpy as np
import pytest
from PySide6.QtWidgets import QApplication
from bolt_pet import controller as controller_mod
from bolt_pet.notifications import Notification
from bolt_pet.state import PetState
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():
return controller_mod.PetController()
def _capture(signal):
events = []
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
return events
# ── petctl routing ──────────────────────────────────────────────────────────
def test_petctl_commands_never_reach_the_shell(monkeypatch, ctrl):
ran = []
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
actions = _capture(ctrl.action)
output = ctrl._handle_command("petctl move top-left")
assert ran == []
assert actions == [{"action": "move", "anchor": "top-left"}]
assert "top-left" in output
def test_ordinary_commands_still_run_locally(monkeypatch, ctrl):
ran = []
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
lambda cmd: ran.append(cmd) or "[exit 0]\n")
actions = _capture(ctrl.action)
ctrl._handle_command("df -h /")
assert ran == ["df -h /"]
assert actions == []
def test_bad_petctl_syntax_is_reported_back_not_executed(monkeypatch, ctrl):
ran = []
monkeypatch.setattr(controller_mod.server_client, "run_local_command", lambda cmd: ran.append(cmd))
output = ctrl._handle_command("petctl move sideways")
assert ran == []
assert "[pet]" in output
def test_petctl_nap_also_flips_the_controller_state(ctrl):
napping = _capture(ctrl.napping)
ctrl._handle_command("petctl nap on")
assert napping == [True]
assert ctrl._napping is True
# ── barge-in ────────────────────────────────────────────────────────────────
def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
logs = _capture(ctrl.log)
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: False) # interrupted
ctrl._speak("a very long explanation")
assert ctrl._talk_now.is_set() # loop picks the next turn up without a wake word
assert any("Interrupted" in message for message in logs)
def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True)
ctrl._speak("short answer")
assert not ctrl._talk_now.is_set()
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True)
ctrl._speak("**bold** reply")
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
# ── screen context ──────────────────────────────────────────────────────────
def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
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 this error?")
monkeypatch.setattr(controller_mod.screen_context, "context_for",
lambda text: f"{text}\n\n[on screen right now: app.py]")
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: True)
sent = []
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: sent.append(text) or "that's a KeyError")
ctrl._handle_conversation_turn()
assert "[on screen right now: app.py]" in sent[0]
# ...but the *history* keeps what you actually said, not the annotation.
assert ctrl.history.entries()[0].text == "what's this error?"
# ── quiet hours / do-not-disturb ────────────────────────────────────────────
def test_quiet_hours_suppress_the_heartbeat(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.config, "HEARTBEAT_INTERVAL_SECONDS", 0)
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "00:00-23:59")
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
assert ctrl._napping is True
def test_fullscreen_triggers_do_not_disturb(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "")
monkeypatch.setattr(controller_mod.config, "DND_ON_FULLSCREEN", True)
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: True)
napping = _capture(ctrl.napping)
ctrl._refresh_nap_state()
assert napping == [True]
def test_a_manual_nap_overrides_the_schedule(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.config, "QUIET_HOURS", "")
ctrl.set_napping(True)
ctrl._last_nap_check = 0.0
ctrl._refresh_nap_state()
assert ctrl._napping is True # the schedule doesn't wake it back up
ctrl.set_napping(None) # back on schedule
ctrl._last_nap_check = 0.0
ctrl._refresh_nap_state()
assert ctrl._napping is False
def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.mic, "record_utterance",
lambda *a, **k: np.zeros(10, dtype=np.int16))
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "you awake?")
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: "always")
spoken = []
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
ctrl.set_napping(True)
ctrl._handle_conversation_turn()
assert spoken == ["always"]
# ── notification bridge ─────────────────────────────────────────────────────
def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0)
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
sent, spoken = [], []
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: sent.append(text) or "your build is green")
monkeypatch.setattr(controller_mod.tts, "speak",
lambda text, on_error=None, should_stop=None: spoken.append(text) or True)
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
ctrl._drain_notifications()
assert "CI: Build finished" in sent[0]
assert spoken == ["your build is green"]
assert ctrl._state.state == PetState.IDLE
def test_filtered_out_notifications_are_never_queued(ctrl):
ctrl._notification_gate = controller_mod.notifications.NotificationGate("deploy", 0)
ctrl._queue_notification(Notification(app="Chat", summary="lunch?", body=""))
assert ctrl._pending_notifications == []
def test_notifications_are_not_forwarded_while_napping(monkeypatch, ctrl):
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
called = {"n": 0}
monkeypatch.setattr(controller_mod.server_client, "converse",
lambda text, on_command=None: called.__setitem__("n", called["n"] + 1))
ctrl.set_napping(True)
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
ctrl._drain_notifications()
assert called["n"] == 0
# ── wake threshold ──────────────────────────────────────────────────────────
def test_threshold_is_live_and_clamped(ctrl):
ctrl.set_wake_threshold(0.72)
assert ctrl.wake_threshold() == pytest.approx(0.72)
ctrl.set_wake_threshold(5)
assert ctrl.wake_threshold() == pytest.approx(0.99)
ctrl.set_wake_threshold(-1)
assert ctrl.wake_threshold() == pytest.approx(0.01)
def test_near_misses_are_recorded_for_the_tuner(ctrl):
ctrl.set_wake_threshold(0.5)
ctrl._observe_wake_score(0.42, 0.5) # near miss
ctrl._observe_wake_score(0.01, 0.5) # background noise, not interesting
stats = ctrl.wake_stats()
assert len(stats["near_misses"]) == 1
assert stats["peak"] == pytest.approx(0.42)
ctrl.reset_wake_stats()
assert ctrl.wake_stats()["near_misses"] == []
+92
View File
@@ -0,0 +1,92 @@
"""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
+114
View File
@@ -0,0 +1,114 @@
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_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()
+90
View File
@@ -0,0 +1,90 @@
"""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
+71
View File
@@ -0,0 +1,71 @@
"""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
+181
View File
@@ -0,0 +1,181 @@
"""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, PetWindow, emote_transform
@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
+54
View File
@@ -0,0 +1,54 @@
"""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
+56
View File
@@ -0,0 +1,56 @@
"""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") == ""
+86
View File
@@ -0,0 +1,86 @@
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pytest
from bolt_pet 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")
def _mock_response(json_data, ok=True):
resp = MagicMock()
resp.json.return_value = json_data
resp.raise_for_status = MagicMock() if ok else MagicMock(side_effect=Exception("boom"))
return resp
def test_converse_returns_reply_directly():
with patch.object(server_client.requests, "post") as post:
post.return_value = _mock_response({"type": "reply", "text": "hello there"})
result = server_client.converse("hi")
assert result == "hello there"
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["headers"] == {"X-Desk-Api-Key": "test-key"}
def test_converse_relays_a_command_then_returns_reply():
responses = [
_mock_response({"type": "command", "command": "echo hi", "token": "tok1"}),
_mock_response({"type": "reply", "text": "done"}),
]
with patch.object(server_client.requests, "post", side_effect=responses) as post:
on_command = MagicMock(return_value="[exit 0]\nhi")
result = server_client.converse("run echo hi", on_command=on_command)
assert result == "done"
on_command.assert_called_once_with("echo hi")
# second call was to /desk/tool_result with the command's output
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",
}
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"})
with pytest.raises(server_client.ServerError, match="unauthorized"):
server_client.converse("hi")
def test_converse_raises_server_error_when_unreachable():
with patch.object(server_client.requests, "post", side_effect=ConnectionError("no route")):
with pytest.raises(server_client.ServerError):
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"})
result = server_client.check_health()
assert result == {"ok": True, "service": "bolt-desk-api"}
+59
View File
@@ -0,0 +1,59 @@
"""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
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet.speech_text import for_display, for_speech
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"
+73
View File
@@ -0,0 +1,73 @@
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
+82
View File
@@ -0,0 +1,82 @@
"""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
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet.audio.tts import chunks_to_int16
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
+148
View File
@@ -0,0 +1,148 @@
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
+89
View File
@@ -0,0 +1,89 @@
"""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())