Streaming replies and STT, amplitude lip-sync, one place for speaking
Latency: replies are spoken sentence-by-sentence off the desk API's NDJSON endpoint, so the wait is time-to-first-sentence rather than the whole model call, and Deepgram's live websocket transcribes while you're still talking instead of uploading the WAV afterwards. Both fall back invisibly — a stream that fails before anything was said drops to converse(), and a socket that never opens just means the old one-shot path. Speaking lived in four near-copies in the controller (a reply, a holding line, a streamed sentence, a dialogue scene) that had already drifted: one didn't arm barge-in, another skipped the follow-up rule. It's now speech.Speaker plus an Utterance describing the policy differences, with collaborators injected so the whole of it tests without Qt or audio. The mouth follows the audio rather than a timer: tts.level_of reduces each PCM frame to a 0..1 loudness on a sqrt curve (speech sits well below peak, and a linear map leaves the mouth barely open during normal talking) and that indexes the talking frames, which the sprite script now draws as an openness ramp. Offline pyttsx3 has no waveform, so stale levels hand control back to the timed loop instead of freezing the mouth mid-syllable. Also: the pet starts where you left it (ignoring positions on monitors that are no longer connected, since restoring those faithfully is how it ends up somewhere unreachable), and `python -m bolt_pet --doctor` is a preflight that says what to do about each problem rather than only what's wrong. tests/test_pipeline_smoke.py breaks the pure-logic rule on purpose. Every unit test passed all week while notifications sat unspoken for minutes, the pet said things twice and [laughing] got read aloud — each an interaction between two individually-correct units. It drives whole turns against a real HTTP server on a loopback port, faking only the mic and the speakers. It found a NameError in the paint path that would have fired on every repaint while talking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,14 @@ def no_screen_probes(monkeypatch):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_streaming(monkeypatch):
|
||||
"""Most tests drive the plain request/response path; leaving streaming on
|
||||
would have them attempt a real HTTP call, fail, and fall back — passing,
|
||||
slowly, for the wrong reason. The streaming path has its own tests."""
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctrl():
|
||||
return controller_mod.PetController()
|
||||
@@ -160,7 +168,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
|
||||
ctrl._barge_in = detector
|
||||
logs = _capture(ctrl.log)
|
||||
|
||||
def interrupted_playback(text, on_error=None, should_stop=None, voice_id=None):
|
||||
def interrupted_playback(text, on_error=None, should_stop=None, voice_id=None, on_level=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
|
||||
@@ -180,7 +188,7 @@ def test_the_interrupt_log_reports_what_fired_not_the_reset_counters(monkeypatch
|
||||
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, voice_id=None: False) # interrupted
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False) # interrupted
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
|
||||
@@ -190,7 +198,7 @@ def test_interrupted_playback_queues_an_immediate_next_turn(monkeypatch, ctrl):
|
||||
|
||||
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, voice_id=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
ctrl._speak("short answer")
|
||||
assert not ctrl._talk_now.is_set()
|
||||
|
||||
@@ -202,7 +210,7 @@ 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, voice_id=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
|
||||
|
||||
def test_a_reply_ending_in_a_question_keeps_listening(spoke, ctrl):
|
||||
@@ -290,7 +298,7 @@ def test_follow_up_can_be_turned_off(spoke, monkeypatch, ctrl):
|
||||
|
||||
def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: False)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False)
|
||||
ctrl._follow_ups = 3
|
||||
|
||||
ctrl._speak("a very long explanation")
|
||||
@@ -301,7 +309,7 @@ def test_being_interrupted_restarts_the_chain(monkeypatch, ctrl):
|
||||
|
||||
def test_speech_is_recorded_in_the_history(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
ctrl._speak("**bold** reply")
|
||||
assert ctrl.history.last().text == "**bold** reply" # raw, for copy/paste
|
||||
|
||||
@@ -315,10 +323,10 @@ def test_the_active_window_rides_along_with_the_utterance(monkeypatch, ctrl):
|
||||
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, voice_id=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply("that's a KeyError"))
|
||||
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("that's a KeyError"))
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
@@ -371,10 +379,10 @@ def test_napping_still_answers_when_spoken_to(monkeypatch, ctrl):
|
||||
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: Reply("always"))
|
||||
lambda text, on_command=None, on_say=None: Reply("always"))
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: spoken.append(text) or True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True)
|
||||
ctrl.set_napping(True)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
@@ -389,9 +397,9 @@ def test_notifications_are_forwarded_and_spoken(monkeypatch, ctrl):
|
||||
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 Reply("your build is green"))
|
||||
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("your build is green"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: spoken.append(text) or True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: spoken.append(text) or True)
|
||||
|
||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
ctrl._drain_notifications()
|
||||
@@ -411,7 +419,7 @@ 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))
|
||||
lambda text, on_command=None, on_say=None: called.__setitem__("n", called["n"] + 1))
|
||||
ctrl.set_napping(True)
|
||||
|
||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
@@ -507,9 +515,9 @@ def test_check_deliveries_runs_after_a_conversation_turn(monkeypatch, ctrl, tmp_
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "send me that file")
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: Reply("it's on the way"))
|
||||
lambda text, on_command=None, on_say=None: Reply("it's on the way"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None: True)
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
monkeypatch.setattr(controller_mod.config, "DELIVERED_FILES_DIR", tmp_path)
|
||||
monkeypatch.setattr(controller_mod.server_client, "list_outbox_files",
|
||||
lambda: [{"id": "abc", "name": "notes.txt", "size": 2}])
|
||||
@@ -531,10 +539,10 @@ def _voice_turn(monkeypatch, ctrl, reply, said="talk like a pirate"):
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: said)
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: reply)
|
||||
lambda text, on_command=None, on_say=None: reply)
|
||||
monkeypatch.setattr(
|
||||
controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
voices.append(voice_id) or True,
|
||||
)
|
||||
ctrl._handle_conversation_turn()
|
||||
@@ -625,7 +633,7 @@ def test_dialoguectl_never_reaches_the_shell(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
||||
lambda pcm, rate, should_stop=None: True)
|
||||
lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "[cheerfully] hi"}))
|
||||
|
||||
@@ -638,7 +646,7 @@ def test_a_scene_shows_in_the_bubble_with_the_delivery_tags_stripped(monkeypatch
|
||||
monkeypatch.setattr(controller_mod.config, "DIALOGUE_VOICES", "narrator:9BWtsMINqrJLrRacOk9x")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None: True)
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._handle_command(_dialogue_command(
|
||||
@@ -656,7 +664,7 @@ def test_a_mid_turn_scene_returns_to_thinking_not_idle(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "ELEVENLABS_VOICE_ID", "aaorr6ZHIL88gEexu7dC")
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None: True)
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
states = _capture(ctrl.state_changed)
|
||||
@@ -676,7 +684,7 @@ def test_the_scene_uses_a_voice_the_server_picked_with_speak_as(monkeypatch, ctr
|
||||
return np.zeros(4, dtype=np.int16), 24000
|
||||
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue", capture)
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None: True)
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm", lambda pcm, rate, should_stop=None, on_level=None: True)
|
||||
ctrl._apply_voice(controller_mod.server_client.Reply("ok", "PICKEDvoice123456789", "Terence"))
|
||||
|
||||
ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
@@ -718,7 +726,7 @@ def test_talking_over_a_scene_is_reported_up_the_relay(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "synthesize_dialogue",
|
||||
lambda inputs, model_id=None, stability=None: (np.zeros(4, dtype=np.int16), 24000))
|
||||
monkeypatch.setattr(controller_mod.tts, "play_pcm",
|
||||
lambda pcm, rate, should_stop=None: False) # barge-in
|
||||
lambda pcm, rate, should_stop=None, on_level=None: False) # barge-in
|
||||
|
||||
output = ctrl._handle_command(_dialogue_command({"voice": "self", "text": "hi"}))
|
||||
|
||||
@@ -788,9 +796,9 @@ def test_coming_back_up_reports_to_the_server_and_speaks_the_reply(monkeypatch,
|
||||
path=state, now=1000.0)
|
||||
sent, spoken = [], []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: sent.append(text) or Reply("Good, it's up."))
|
||||
lambda text, on_command=None, on_say=None: sent.append(text) or Reply("Good, it's up."))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None:
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
ctrl._report_self_restart()
|
||||
@@ -805,8 +813,230 @@ def test_an_ordinary_start_reports_nothing(monkeypatch, ctrl, tmp_path):
|
||||
monkeypatch.setattr(controller_mod.self_restart, "DEFAULT_STATE_PATH", tmp_path / "none.json")
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None: called.append(text))
|
||||
lambda text, on_command=None, on_say=None: called.append(text))
|
||||
|
||||
ctrl._report_self_restart()
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
# ── holding line: "give me a sec" while a tool runs ────────────────────────
|
||||
# The server used to discard whatever the model wrote alongside a tool call,
|
||||
# so the whole round trip was silence — and the model, with no evidence its
|
||||
# sentence landed, said it again in the final reply.
|
||||
|
||||
def test_a_holding_line_is_spoken_before_the_tool_runs(monkeypatch, ctrl):
|
||||
order = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
order.append(("spoke", text)) or True)
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command",
|
||||
lambda cmd: order.append(("ran", cmd)) or "[exit 0]")
|
||||
|
||||
ctrl._speak_holding("Give me a sec.")
|
||||
ctrl._handle_command("df -h /")
|
||||
|
||||
assert order == [("spoke", "Give me a sec."), ("ran", "df -h /")]
|
||||
|
||||
|
||||
def test_the_holding_line_shows_in_the_bubble_but_not_the_transcript(monkeypatch, ctrl):
|
||||
"""It's filler. The transcript should keep the actual answer."""
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
said = _capture(ctrl.said)
|
||||
|
||||
ctrl._speak_holding("I'll check on that — one moment.")
|
||||
|
||||
assert said == ["I'll check on that — one moment."]
|
||||
assert ctrl.history.entries() == []
|
||||
|
||||
|
||||
def test_a_holding_line_returns_to_waiting_not_idle(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
states = _capture(ctrl.state_changed)
|
||||
|
||||
ctrl._speak_holding("one sec")
|
||||
|
||||
assert states == ["talking", "thinking"]
|
||||
|
||||
|
||||
def test_the_relay_speaks_whatever_the_server_attaches(monkeypatch, ctrl):
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
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 in that folder?")
|
||||
|
||||
def fake_converse(text, on_command=None, on_say=None):
|
||||
on_say("Let me check that for you.") # what the server now sends
|
||||
on_command("filectl {\"op\": \"list\", \"path\": \"/tmp\"}")
|
||||
return Reply("It's got three files in it.")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse", fake_converse)
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["Let me check that for you.", "It's got three files in it."]
|
||||
|
||||
|
||||
# ── streamed replies ───────────────────────────────────────────────────────
|
||||
# Without streaming the pet waits out the entire model call before saying a
|
||||
# word. With it, the wait is time-to-first-sentence.
|
||||
|
||||
def test_each_sentence_is_spoken_as_it_arrives(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
def fake_stream(text, on_say, on_command=None, timeout=180.0):
|
||||
on_say("The disk is fine.")
|
||||
on_say("About sixty percent used.")
|
||||
return controller_mod.server_client.Reply(
|
||||
"The disk is fine. About sixty percent used.", spoken=True)
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse_stream", fake_stream)
|
||||
monkeypatch.setattr(controller_mod.mic, "record_utterance",
|
||||
lambda *a, **k: np.zeros(10, dtype=np.int16))
|
||||
monkeypatch.setattr(controller_mod.stt, "transcribe", lambda pcm: "how's the disk?")
|
||||
|
||||
ctrl._handle_conversation_turn()
|
||||
|
||||
assert spoken == ["The disk is fine.", "About sixty percent used."]
|
||||
# ...and the empty final reply is not spoken as an extra blank utterance.
|
||||
assert ctrl.history.entries()[-1].text == "About sixty percent used."
|
||||
|
||||
|
||||
def test_a_stream_that_fails_before_speaking_falls_back_silently(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
spoken = []
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None:
|
||||
spoken.append(text) or True)
|
||||
|
||||
def broken_stream(text, on_say, on_command=None, timeout=180.0):
|
||||
raise controller_mod.server_client.ServerError("no streaming endpoint")
|
||||
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse_stream", broken_stream)
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: Reply("Fell back fine."))
|
||||
|
||||
assert ctrl._ask_server("hello").text == "Fell back fine."
|
||||
assert spoken == [] # nothing was said twice
|
||||
|
||||
|
||||
def test_streaming_can_be_switched_off(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", False)
|
||||
called = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse_stream",
|
||||
lambda *a, **k: called.append(1))
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: Reply("plain path"))
|
||||
|
||||
assert ctrl._ask_server("hi").text == "plain path"
|
||||
assert called == []
|
||||
|
||||
|
||||
def test_talking_over_a_streamed_reply_still_interrupts(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "STREAMING_REPLIES", True)
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: False)
|
||||
|
||||
ctrl._speak_stream_chunk("A long explanation you cut short.")
|
||||
|
||||
assert ctrl._talk_now.is_set()
|
||||
|
||||
|
||||
# ── notification latency (reported 2026-08-02: 5-10 minutes) ───────────────
|
||||
# Draining was wired to the heartbeat's 60s interval, and a heartbeat that
|
||||
# landed mid-conversation stamped its clock before noticing — so it burned the
|
||||
# slot and waited another full interval. Several of those in a row is minutes.
|
||||
|
||||
def test_a_notification_goes_out_on_the_next_tick_not_the_next_heartbeat(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "NOTIFICATION_MIN_INTERVAL_SECONDS", 0)
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None:
|
||||
sent.append(text) or Reply("noted"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None)
|
||||
# A heartbeat has just run, so the next one is a full interval away.
|
||||
ctrl._last_heartbeat = controller_mod.time.monotonic()
|
||||
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert sent and "Harry" in sent[0]
|
||||
|
||||
|
||||
def test_a_heartbeat_skipped_mid_conversation_does_not_burn_its_slot(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.server_client, "report_status", lambda: None)
|
||||
ctrl._last_heartbeat = 0.0
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
ctrl._state.transition(PetState.THINKING)
|
||||
|
||||
ctrl._maybe_heartbeat() # due, but the pet is busy
|
||||
assert ctrl._last_heartbeat == 0.0, "the clock must not advance on a skipped tick"
|
||||
|
||||
ctrl._state.transition(PetState.IDLE)
|
||||
ctrl._maybe_heartbeat() # free now — runs immediately
|
||||
assert ctrl._last_heartbeat > 0.0
|
||||
|
||||
|
||||
def test_notifications_wait_while_the_pet_is_mid_turn(monkeypatch, ctrl):
|
||||
"""Speaking over the answer they're already getting would be worse than
|
||||
waiting a couple of seconds."""
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None: sent.append(text))
|
||||
ctrl._state.transition(PetState.LISTENING)
|
||||
|
||||
ctrl._queue_notification(Notification(app="CI", summary="Build finished", body=""))
|
||||
ctrl._maybe_heartbeat()
|
||||
|
||||
assert sent == []
|
||||
assert len(ctrl._pending_notifications) == 1 # kept, not dropped
|
||||
|
||||
|
||||
def test_a_second_message_inside_the_rate_limit_is_no_longer_lost(monkeypatch, ctrl):
|
||||
"""The gate used to drop it. With NOTIFICATION_MIN_INTERVAL_SECONDS=60,
|
||||
two texts a minute apart meant you heard about one of them."""
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 60)
|
||||
sent = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "converse",
|
||||
lambda text, on_command=None, on_say=None:
|
||||
sent.append(text) or Reply("ok"))
|
||||
monkeypatch.setattr(controller_mod.tts, "speak",
|
||||
lambda text, on_error=None, should_stop=None, voice_id=None, on_level=None: True)
|
||||
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="you there?"))
|
||||
ctrl._queue_notification(Notification(app="Signal", summary="Harry", body="it's urgent"))
|
||||
ctrl._drain_notifications()
|
||||
|
||||
assert len(sent) == 1 # one round trip...
|
||||
assert "you there?" in sent[0] and "it's urgent" in sent[0] # ...both messages
|
||||
assert "2 desktop notifications" in sent[0]
|
||||
|
||||
|
||||
def test_the_filter_still_applies(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_a_notification_storm_cannot_become_an_unbounded_backlog(ctrl):
|
||||
ctrl._notification_gate = controller_mod.notifications.NotificationGate("", 0)
|
||||
for index in range(40):
|
||||
ctrl._queue_notification(Notification(app="Spam", summary=f"#{index}", body=""))
|
||||
|
||||
assert len(ctrl._pending_notifications) == controller_mod._MAX_PENDING_NOTIFICATIONS
|
||||
assert "#39" in ctrl._pending_notifications[-1].as_text() # newest kept
|
||||
|
||||
Reference in New Issue
Block a user