Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == ""
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user