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