...
This commit is contained in:
@@ -81,6 +81,44 @@ def test_petctl_nap_also_flips_the_controller_state(ctrl):
|
||||
|
||||
# ── 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",
|
||||
@@ -99,6 +137,110 @@ def test_uninterrupted_playback_does_not_queue_a_turn(monkeypatch, ctrl):
|
||||
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)
|
||||
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet.speech_text import for_display, for_speech
|
||||
from bolt_pet.speech_text import for_display, for_speech, is_question
|
||||
|
||||
|
||||
def test_bold_markers_are_not_spoken():
|
||||
@@ -57,3 +57,23 @@ def test_blank_and_symbol_only_input():
|
||||
def test_display_keeps_emoji_but_drops_markdown():
|
||||
assert for_display("**Done** ✅") == "Done ✅"
|
||||
assert for_display("* one\n* two") == "• one • two"
|
||||
|
||||
|
||||
def test_is_question_only_fires_on_a_trailing_question():
|
||||
assert is_question("Ready to run a command or start a project?")
|
||||
assert is_question("It's 7:15 AM. Want me to set a timer?")
|
||||
assert not is_question("It's 7:15 AM on July 23, 2026.")
|
||||
assert not is_question("What time is it? It's 7:15 AM.") # asked in passing
|
||||
|
||||
|
||||
def test_is_question_ignores_trailing_decoration():
|
||||
assert is_question("Ready to go? 🚀")
|
||||
assert is_question('Shall I continue?"')
|
||||
assert is_question("Want me to fix it? **")
|
||||
|
||||
|
||||
def test_is_question_ignores_question_marks_that_are_not_spoken():
|
||||
# The '?' here is inside a URL query string, which for_speech strips.
|
||||
assert not is_question("Docs are at https://example.com/x?y=1")
|
||||
assert not is_question("")
|
||||
assert not is_question(None)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Graphical sudo prompts: command rewriting and helper resolution.
|
||||
Pure logic — no display, no sudo, no password."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import sudo_askpass
|
||||
|
||||
|
||||
# ── rewriting ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", "sudo -A apt update"),
|
||||
("sudo apt update", "sudo -A apt update"), # spacing preserved
|
||||
("apt update && sudo apt upgrade", "apt update && sudo -A apt upgrade"),
|
||||
("ls; sudo reboot", "ls; sudo -A reboot"),
|
||||
("echo hi | sudo tee /etc/motd", "echo hi | sudo -A tee /etc/motd"),
|
||||
("sudo systemctl restart x\nsudo systemctl status x",
|
||||
"sudo -A systemctl restart x\nsudo -A systemctl status x"),
|
||||
],
|
||||
)
|
||||
def test_bare_sudo_gets_the_askpass_flag(command, expected):
|
||||
assert sudo_askpass.add_askpass_flag(command) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"sudo -n apt update", # explicitly non-interactive
|
||||
"sudo -A apt update", # already asking
|
||||
"sudo -u bob whoami", # the caller was explicit
|
||||
"ls -la", # no sudo at all
|
||||
"echo 'run sudo later'", # inside a quoted string
|
||||
"pseudo --version", # not the word sudo
|
||||
],
|
||||
)
|
||||
def test_commands_that_must_not_be_rewritten(command):
|
||||
assert sudo_askpass.add_askpass_flag(command) == command
|
||||
|
||||
|
||||
def test_blank_input():
|
||||
assert sudo_askpass.add_askpass_flag("") == ""
|
||||
assert sudo_askpass.add_askpass_flag(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command,expected",
|
||||
[
|
||||
("sudo apt update", True),
|
||||
("ls && sudo reboot", True),
|
||||
("ls -la", False),
|
||||
("echo 'sudo'", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_which_commands_get_the_longer_timeout(command, expected):
|
||||
assert sudo_askpass.needs_password_prompt(command) is expected
|
||||
|
||||
|
||||
# ── helper resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_configured_helper_wins():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="/opt/my-askpass", is_executable=lambda p: p == "/opt/my-askpass",
|
||||
)
|
||||
assert found == "/opt/my-askpass"
|
||||
|
||||
|
||||
def test_a_configured_helper_that_is_not_executable_is_not_silently_replaced():
|
||||
"""Better to have sudo fail than to quietly prompt with something the
|
||||
user didn't choose."""
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="/opt/typo", is_executable=lambda p: False, which=lambda t: "/usr/bin/zenity",
|
||||
) is None
|
||||
|
||||
|
||||
def test_a_real_askpass_binary_beats_a_generated_wrapper():
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: p == "/usr/bin/ksshaskpass",
|
||||
which=lambda tool: "/usr/bin/zenity",
|
||||
)
|
||||
assert found == "/usr/bin/ksshaskpass"
|
||||
|
||||
|
||||
def test_falls_back_to_wrapping_a_dialog_tool(tmp_path):
|
||||
found = sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False,
|
||||
which=lambda tool: "/usr/bin/zenity" if tool == "zenity" else None,
|
||||
cache_dir=tmp_path,
|
||||
)
|
||||
script = tmp_path / "askpass.sh"
|
||||
assert found == str(script)
|
||||
assert "zenity --password" in script.read_text()
|
||||
assert script.stat().st_mode & 0o777 == 0o700 # nobody else edits the password box
|
||||
|
||||
|
||||
def test_no_helper_available_at_all():
|
||||
assert sudo_askpass.find_helper(
|
||||
configured="", is_executable=lambda p: False, which=lambda tool: None,
|
||||
) is None
|
||||
|
||||
|
||||
def test_the_wrapper_passes_sudos_prompt_through():
|
||||
"""sudo hands the helper its prompt as $1 — it names the account the
|
||||
password is for, which is worth showing in the dialog."""
|
||||
script = sudo_askpass.helper_script("/usr/bin/zenity")
|
||||
assert script.startswith("#!/bin/sh")
|
||||
assert '"$1"' in script
|
||||
|
||||
|
||||
def test_environment_points_sudo_at_the_helper():
|
||||
env = sudo_askpass.environment("/tmp/askpass.sh", base={"PATH": "/usr/bin"})
|
||||
assert env["SUDO_ASKPASS"] == "/tmp/askpass.sh"
|
||||
assert env["PATH"] == "/usr/bin" # the rest of the environment survives
|
||||
+25
-2
@@ -67,12 +67,15 @@ class FakeGit:
|
||||
*failures* maps a leading-args tuple to the (code, output) it should
|
||||
return, so a test can make exactly one command fail."""
|
||||
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False):
|
||||
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False,
|
||||
head="abc1234", tag_commit="def5678"):
|
||||
self.calls = []
|
||||
self._ref = ref
|
||||
self._dirty = dirty
|
||||
self._failures = failures or {}
|
||||
self._requirements_changed = requirements_changed
|
||||
self._head = head
|
||||
self._tag_commit = tag_commit # equal to head = "already on this tag"
|
||||
|
||||
def __call__(self, args):
|
||||
self.calls.append(list(args))
|
||||
@@ -87,7 +90,7 @@ class FakeGit:
|
||||
if head == "symbolic-ref":
|
||||
return (0, self._ref) if self._ref else (1, "")
|
||||
if head == "rev-parse":
|
||||
return 0, "abc1234"
|
||||
return 0, self._tag_commit if args[1].startswith("tags/") else self._head
|
||||
if head == "diff":
|
||||
return 0, "requirements.txt" if self._requirements_changed else ""
|
||||
return 0, ""
|
||||
@@ -160,6 +163,26 @@ def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
|
||||
assert ["checkout", "--force", "main"] in git.calls
|
||||
|
||||
|
||||
def test_a_release_tagged_without_bumping_the_version_does_not_loop(tmp_path):
|
||||
"""Cut a release but forget to bump __version__ in the tagged commit and
|
||||
every check would see the same "newer" tag: check out (a no-op), restart,
|
||||
read the old version, repeat — a restart loop every check interval."""
|
||||
git = FakeGit(head="same1234", tag_commit="same1234")
|
||||
|
||||
with pytest.raises(updater.UpdateError, match="bump it in the tagged commit"):
|
||||
updater.apply_update("v0.2.1", run=git, repo=tmp_path, install_deps=False,
|
||||
verify=lambda repo: None)
|
||||
|
||||
assert "checkout" not in git.commands() # nothing moved, so nothing to restart into
|
||||
|
||||
|
||||
def test_an_unknown_tag_is_not_mistaken_for_being_already_on_it(tmp_path):
|
||||
git = FakeGit(failures={("rev-parse", "tags/"): (128, "unknown revision")})
|
||||
# The failure key matches by prefix, so make it explicit that a tag we
|
||||
# can't resolve means "not there yet" rather than "already applied".
|
||||
assert updater.already_at_tag(git, "v9.9.9") is False
|
||||
|
||||
|
||||
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
|
||||
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
|
||||
with pytest.raises(updater.UpdateError, match="git fetch failed"):
|
||||
|
||||
Reference in New Issue
Block a user