Major changes: - Add StyleConfig dataclass with independent color, width, opacity, duration, pulse_speed, sound, and volume per mode (running/completed) - Replace flat flash_*/sound_*/play_on config with running: and completed: YAML sections - Replace CGEventTap (silently fails in forked daemon) with CGEventSourceSecondsSinceLastEventType polling for reliable input-based pulse dismissal when Cursor is already frontmost - Update overlay, sound, and daemon to pass StyleConfig per call - Rewrite tests for new config shape and dismiss mechanism Made-with: Cursor
389 lines
16 KiB
Python
389 lines
16 KiB
Python
"""Tests for the daemon's message handling logic."""
|
|
import json
|
|
import time
|
|
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
from cursor_flasher.config import Config, StyleConfig
|
|
from cursor_flasher.daemon import FlasherDaemon
|
|
|
|
|
|
class TestFlasherDaemon:
|
|
def _make_daemon(self, **config_overrides) -> FlasherDaemon:
|
|
config = Config(**config_overrides)
|
|
with patch("cursor_flasher.daemon.OverlayManager"):
|
|
daemon = FlasherDaemon(config)
|
|
daemon.overlay.is_pulsing = False
|
|
return daemon
|
|
|
|
def test_preToolUse_queues_pending(self):
|
|
daemon = self._make_daemon()
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
assert daemon._pending is not None
|
|
assert daemon._pending.tool == "Shell"
|
|
daemon.overlay.pulse.assert_not_called()
|
|
|
|
def test_pending_promotes_after_delay(self):
|
|
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen")
|
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
|
screen = ((0, 0), (1920, 1080))
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._check_pending()
|
|
|
|
daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.running)
|
|
|
|
def test_postToolUse_cancels_pending(self):
|
|
"""Auto-approved tools: postToolUse arrives before delay, cancels the pulse."""
|
|
daemon = self._make_daemon(approval_delay=10.0)
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
assert daemon._pending is not None
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
assert daemon._pending is None
|
|
daemon.overlay.pulse.assert_not_called()
|
|
|
|
def test_preToolUse_skips_non_approval_tool(self):
|
|
daemon = self._make_daemon()
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Read"}).encode()
|
|
)
|
|
|
|
assert daemon._pending is None
|
|
|
|
def test_preToolUse_respects_custom_tool_list(self):
|
|
daemon = self._make_daemon(approval_delay=0.0, flash_mode="screen", approval_tools=["Shell", "MCP"])
|
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "MCP"}).encode()
|
|
)
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=((0, 0), (1920, 1080))), \
|
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._check_pending()
|
|
|
|
daemon.overlay.pulse.assert_called_once()
|
|
|
|
def test_stop_flashes_briefly(self):
|
|
daemon = self._make_daemon(flash_mode="screen")
|
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
|
screen = ((0, 0), (1920, 1080))
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
|
|
daemon.overlay.flash.assert_called_once_with([screen], daemon.config.completed)
|
|
|
|
def test_stop_flashes_window_frame_when_window_mode(self):
|
|
daemon = self._make_daemon(flash_mode="window")
|
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
|
|
daemon.overlay.flash.assert_called_once_with(
|
|
[((0, 0), (800, 600))], daemon.config.completed
|
|
)
|
|
|
|
def test_allscreens_mode_uses_all_screens(self):
|
|
daemon = self._make_daemon(flash_mode="allscreens", approval_delay=0.0)
|
|
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
|
|
screens = [((0, 0), (1920, 1080)), ((-1920, 0), (1920, 1080))]
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.all_screen_frames", return_value=screens), \
|
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._check_pending()
|
|
|
|
daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.running)
|
|
|
|
def test_stop_dismisses_active_pulse(self):
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
|
|
daemon.overlay.dismiss.assert_called_once()
|
|
daemon.overlay.flash.assert_not_called()
|
|
|
|
def test_stop_clears_pending(self):
|
|
daemon = self._make_daemon(approval_delay=10.0)
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
assert daemon._pending is not None
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
assert daemon._pending is None
|
|
|
|
def test_postToolUse_dismisses_active_pulse(self):
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "postToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
daemon.overlay.dismiss.assert_called_once()
|
|
|
|
def test_postToolUseFailure_dismisses_pulse(self):
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "postToolUseFailure", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
daemon.overlay.dismiss.assert_called_once()
|
|
|
|
def test_cooldown_prevents_rapid_triggers(self):
|
|
daemon = self._make_daemon(cooldown=5.0)
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
daemon._last_flash = time.monotonic()
|
|
daemon._pending = None
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
assert daemon._pending is None
|
|
|
|
def test_invalid_json_ignored(self):
|
|
daemon = self._make_daemon()
|
|
daemon._handle_message(b"not json")
|
|
daemon.overlay.pulse.assert_not_called()
|
|
daemon.overlay.flash.assert_not_called()
|
|
|
|
def test_no_window_found(self):
|
|
daemon = self._make_daemon(approval_delay=0.0)
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/nope", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=None):
|
|
daemon._check_pending()
|
|
|
|
daemon.overlay.pulse.assert_not_called()
|
|
|
|
def test_focus_transition_dismisses_pulse(self):
|
|
"""Pulse dismisses when user switches TO Cursor from another app."""
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._cursor_was_frontmost = False
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
|
daemon._check_focus()
|
|
|
|
daemon.overlay.dismiss.assert_called_once()
|
|
|
|
def test_focus_no_dismiss_when_cursor_already_frontmost(self):
|
|
"""No dismiss if Cursor was already frontmost (no transition)."""
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._cursor_was_frontmost = True
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
|
daemon._check_focus()
|
|
|
|
daemon.overlay.dismiss.assert_not_called()
|
|
|
|
def test_focus_tracks_state_changes(self):
|
|
"""_cursor_was_frontmost updates each tick."""
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._cursor_was_frontmost = True
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
|
|
daemon._check_focus()
|
|
assert daemon._cursor_was_frontmost is False
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
|
daemon._check_focus()
|
|
daemon.overlay.dismiss.assert_called_once()
|
|
|
|
def test_focus_no_dismiss_when_not_pulsing(self):
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = False
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True):
|
|
daemon._check_focus()
|
|
|
|
daemon.overlay.dismiss.assert_not_called()
|
|
|
|
def test_input_dismiss_when_pulsing_and_cursor_focused(self):
|
|
"""Recent input + Cursor frontmost + past grace period = dismiss."""
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._pulse_started_at = time.monotonic() - 2.0
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.2):
|
|
daemon._check_input_dismiss()
|
|
|
|
daemon.overlay.dismiss.assert_called_once()
|
|
|
|
def test_input_dismiss_skipped_during_grace_period(self):
|
|
"""No dismiss if pulse just started (within grace period)."""
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._pulse_started_at = time.monotonic() - 0.1
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.05):
|
|
daemon._check_input_dismiss()
|
|
|
|
daemon.overlay.dismiss.assert_not_called()
|
|
|
|
def test_input_dismiss_skipped_when_not_pulsing(self):
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = False
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=0.1):
|
|
daemon._check_input_dismiss()
|
|
|
|
daemon.overlay.dismiss.assert_not_called()
|
|
|
|
def test_input_dismiss_skipped_when_cursor_not_frontmost(self):
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._pulse_started_at = time.monotonic() - 2.0
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False):
|
|
daemon._check_input_dismiss()
|
|
|
|
daemon.overlay.dismiss.assert_not_called()
|
|
|
|
def test_input_dismiss_ignores_old_input(self):
|
|
"""Input from before the pulse started should not trigger dismiss."""
|
|
daemon = self._make_daemon()
|
|
daemon.overlay.is_pulsing = True
|
|
daemon._pulse_started_at = time.monotonic() - 2.0
|
|
|
|
with patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=True), \
|
|
patch("cursor_flasher.daemon.CGEventSourceSecondsSinceLastEventType", return_value=5.0):
|
|
daemon._check_input_dismiss()
|
|
|
|
daemon.overlay.dismiss.assert_not_called()
|
|
|
|
def test_running_style_sound_plays_on_approval(self):
|
|
"""Running style with sound configured plays on approval pulse."""
|
|
running = StyleConfig(sound="Glass", volume=0.5)
|
|
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", running=running)
|
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
|
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
|
daemon._check_pending()
|
|
|
|
mock_alert.assert_called_once_with(running)
|
|
|
|
def test_completed_style_sound_plays_on_stop(self):
|
|
"""Completed style with sound configured plays on stop flash."""
|
|
completed = StyleConfig(sound="Ping", volume=0.7)
|
|
daemon = self._make_daemon(flash_mode="window", completed=completed)
|
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
|
|
mock_alert.assert_called_once_with(completed)
|
|
|
|
def test_no_sound_when_style_sound_empty(self):
|
|
"""No sound plays when the style has sound="" (the completed default)."""
|
|
completed = StyleConfig(sound="", volume=0.0)
|
|
daemon = self._make_daemon(flash_mode="window", completed=completed)
|
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.play_alert") as mock_alert:
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
|
|
mock_alert.assert_called_once_with(completed)
|
|
|
|
def test_custom_colors_per_mode(self):
|
|
"""Different colors for running vs completed are passed through."""
|
|
running = StyleConfig(color="#FF0000")
|
|
completed = StyleConfig(color="#00FF00")
|
|
daemon = self._make_daemon(
|
|
approval_delay=0.0, flash_mode="window",
|
|
running=running, completed=completed,
|
|
)
|
|
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
|
|
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
|
|
)
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._check_pending()
|
|
|
|
daemon.overlay.pulse.assert_called_once_with(
|
|
[((0, 0), (800, 600))], running
|
|
)
|
|
|
|
daemon.overlay.pulse.reset_mock()
|
|
daemon._last_flash = 0
|
|
|
|
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
|
|
patch("cursor_flasher.daemon.play_alert"):
|
|
daemon._handle_message(
|
|
json.dumps({"workspace": "/path", "event": "stop"}).encode()
|
|
)
|
|
|
|
daemon.overlay.flash.assert_called_once_with(
|
|
[((0, 0), (800, 600))], completed
|
|
)
|