"""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"] == []