386 lines
15 KiB
Python
386 lines
15 KiB
Python
"""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_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch, ctrl):
|
|
"""_speak resets the detector after playback so the pet's own voice
|
|
doesn't linger in the wake model's window. Reading the stats after that
|
|
reset reports 0.000 at frame 0 for every interruption, which is worse
|
|
than no instrumentation — it looks like hard evidence and isn't."""
|
|
from bolt_pet.audio import barge_in as barge_in_mod
|
|
|
|
class Detector(barge_in_mod.WakeWordBargeIn):
|
|
def __init__(self):
|
|
self._frames, self._peak, self._last, self._last_threshold = 0, 0.0, 0.0, 0.5
|
|
self._frame_len = 1280
|
|
self.reset_calls = 0
|
|
|
|
def reset(self):
|
|
self.reset_calls += 1
|
|
self._frames, self._peak, self._last = 0, 0.0, 0.0
|
|
|
|
detector = Detector()
|
|
ctrl._barge_in = detector
|
|
logs = _capture(ctrl.log)
|
|
|
|
def interrupted_playback(text, on_error=None, should_stop=None):
|
|
# What really happens: frames get scored during playback, then one
|
|
# clears the threshold and playback aborts.
|
|
detector._frames, detector._peak, detector._last = 7, 0.81, 0.81
|
|
return False
|
|
|
|
monkeypatch.setattr(controller_mod.tts, "speak", interrupted_playback)
|
|
|
|
ctrl._speak("a very long explanation")
|
|
|
|
interrupted = next(m for m in logs if "Interrupted" in m)
|
|
assert "0.810" in interrupted and "frame 7" in interrupted
|
|
# Once before playback (clear the window) and once after (drop the pet's
|
|
# own voice) — the point is that the *read* happens between them.
|
|
assert detector.reset_calls == 2
|
|
|
|
|
|
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()
|
|
|
|
|
|
# ── follow-up listening ─────────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def spoke(monkeypatch):
|
|
"""Playback that always completes, so only the follow-up rule decides
|
|
whether another turn is queued."""
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None: True)
|
|
|
|
|
|
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
|
logs = _capture(ctrl.log)
|
|
|
|
ctrl._speak("You're still in ~/Documents/bolt-pet. Ready to run a command?")
|
|
|
|
assert ctrl._talk_now.is_set() # no wake word needed for the answer
|
|
assert ctrl._pending_follow_up # and the next turn knows it's an answer
|
|
assert any("listening for your answer" in message for message in logs)
|
|
|
|
|
|
def test_a_statement_does_not_keep_listening(spoke, ctrl):
|
|
ctrl._speak("It's 7:15 AM on July 23, 2026.")
|
|
assert not ctrl._talk_now.is_set()
|
|
assert not ctrl._pending_follow_up
|
|
|
|
|
|
def test_a_question_in_passing_does_not_count(spoke, ctrl):
|
|
"""Only a reply that *ends* on a question is waiting for an answer."""
|
|
ctrl._speak("What time is it? It's 7:15 AM.")
|
|
assert not ctrl._talk_now.is_set()
|
|
|
|
|
|
def test_follow_ups_stop_at_the_cap(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 2)
|
|
logs = _capture(ctrl.log)
|
|
|
|
for _ in range(2):
|
|
ctrl._speak("Want me to keep going?")
|
|
ctrl._talk_now.clear()
|
|
assert ctrl._follow_ups == 2
|
|
|
|
ctrl._speak("Want me to keep going?")
|
|
|
|
assert not ctrl._talk_now.is_set() # chain broken until you re-trigger it
|
|
assert any("Follow-up limit reached" in message for message in logs)
|
|
|
|
|
|
def test_the_cap_is_not_announced_on_ordinary_replies(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_MAX_TURNS", 1)
|
|
ctrl._follow_ups = 1
|
|
logs = _capture(ctrl.log)
|
|
|
|
ctrl._speak("Done — the file is saved.")
|
|
|
|
assert not any("Follow-up limit" in message for message in logs)
|
|
|
|
|
|
def test_starting_a_turn_yourself_resets_the_chain(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **kw: None) # you said nothing
|
|
ctrl._follow_ups = 3
|
|
|
|
ctrl._handle_conversation_turn()
|
|
|
|
assert ctrl._follow_ups == 0
|
|
|
|
|
|
def test_an_answered_question_gets_a_longer_grace_period(spoke, monkeypatch, ctrl):
|
|
"""You were just asked something — you get longer to think than when you
|
|
deliberately said the wake word."""
|
|
grace = []
|
|
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
|
lambda *a, **kw: grace.append(kw.get("grace_s")) or None)
|
|
|
|
ctrl._handle_conversation_turn() # you started this one
|
|
ctrl._pending_follow_up = True
|
|
ctrl._handle_conversation_turn() # this one answers a question
|
|
|
|
assert grace == [None, controller_mod.config.FOLLOW_UP_GRACE_SECONDS]
|
|
|
|
|
|
def test_muting_stops_follow_ups(spoke, ctrl):
|
|
ctrl._muted = True
|
|
ctrl._speak("Shall I continue?")
|
|
assert not ctrl._talk_now.is_set() # mute means don't listen, question or not
|
|
|
|
|
|
def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.config, "FOLLOW_UP_LISTEN", False)
|
|
ctrl._speak("Shall I continue?")
|
|
assert not ctrl._talk_now.is_set()
|
|
|
|
|
|
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
|
monkeypatch.setattr(controller_mod.tts, "speak",
|
|
lambda text, on_error=None, should_stop=None: False)
|
|
ctrl._follow_ups = 3
|
|
|
|
ctrl._speak("a very long explanation")
|
|
|
|
assert ctrl._follow_ups == 0 # you're clearly engaged
|
|
assert 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"] == []
|