Restructure config for per-mode style/sound and fix pulse dismiss
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
This commit is contained in:
@@ -1,56 +1,119 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from cursor_flasher.config import Config, load_config, DEFAULT_CONFIG_PATH
|
||||
from cursor_flasher.config import Config, StyleConfig, load_config
|
||||
|
||||
|
||||
class TestDefaultConfig:
|
||||
def test_has_pulse_settings(self):
|
||||
cfg = Config()
|
||||
assert cfg.pulse_color == "#FF9500"
|
||||
assert cfg.pulse_width == 4
|
||||
assert cfg.pulse_speed == 1.5
|
||||
assert cfg.pulse_opacity_min == 0.3
|
||||
assert cfg.pulse_opacity_max == 1.0
|
||||
def test_running_defaults(self):
|
||||
c = Config()
|
||||
assert c.running.color == "#FF9500"
|
||||
assert c.running.width == 4
|
||||
assert c.running.duration == 1.5
|
||||
assert c.running.opacity == 0.85
|
||||
assert c.running.pulse_speed == 1.5
|
||||
assert c.running.sound == "Glass"
|
||||
assert c.running.volume == 0.5
|
||||
|
||||
def test_has_sound_settings(self):
|
||||
cfg = Config()
|
||||
assert cfg.sound_enabled is True
|
||||
assert cfg.sound_name == "Glass"
|
||||
assert cfg.sound_volume == 0.5
|
||||
def test_completed_defaults(self):
|
||||
c = Config()
|
||||
assert c.completed.color == "#FF9500"
|
||||
assert c.completed.width == 4
|
||||
assert c.completed.sound == ""
|
||||
assert c.completed.volume == 0.0
|
||||
|
||||
def test_has_detection_settings(self):
|
||||
cfg = Config()
|
||||
assert cfg.poll_interval == 0.5
|
||||
assert cfg.cooldown == 3.0
|
||||
def test_has_approval_tools(self):
|
||||
c = Config()
|
||||
assert c.approval_tools == ["Shell", "Write", "Delete"]
|
||||
|
||||
def test_has_timeout_settings(self):
|
||||
cfg = Config()
|
||||
assert cfg.auto_dismiss == 300
|
||||
def test_has_cooldown(self):
|
||||
c = Config()
|
||||
assert c.cooldown == 2.0
|
||||
|
||||
def test_has_flash_mode(self):
|
||||
c = Config()
|
||||
assert c.flash_mode == "screen"
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_loads_from_yaml(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(
|
||||
"pulse:\n"
|
||||
' color: "#00FF00"\n'
|
||||
" width: 8\n"
|
||||
"sound:\n"
|
||||
" enabled: false\n"
|
||||
)
|
||||
cfg = load_config(config_file)
|
||||
assert cfg.pulse_color == "#00FF00"
|
||||
assert cfg.pulse_width == 8
|
||||
assert cfg.sound_enabled is False
|
||||
assert cfg.pulse_speed == 1.5
|
||||
assert cfg.sound_name == "Glass"
|
||||
|
||||
def test_missing_file_returns_defaults(self, tmp_path):
|
||||
cfg = load_config(tmp_path / "nonexistent.yaml")
|
||||
assert cfg.pulse_color == "#FF9500"
|
||||
c = load_config(tmp_path / "nope.yaml")
|
||||
assert c == Config()
|
||||
|
||||
def test_empty_file_returns_defaults(self, tmp_path):
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text("")
|
||||
cfg = load_config(config_file)
|
||||
assert cfg.pulse_color == "#FF9500"
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("")
|
||||
c = load_config(p)
|
||||
assert c == Config()
|
||||
|
||||
def test_loads_running_overrides(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"running:\n color: '#00FF00'\n duration: 2.0\n sound: Ping\n"
|
||||
)
|
||||
c = load_config(p)
|
||||
assert c.running.color == "#00FF00"
|
||||
assert c.running.duration == 2.0
|
||||
assert c.running.sound == "Ping"
|
||||
assert c.running.width == 4
|
||||
|
||||
def test_loads_completed_overrides(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"completed:\n color: '#0000FF'\n sound: Hero\n volume: 0.8\n"
|
||||
)
|
||||
c = load_config(p)
|
||||
assert c.completed.color == "#0000FF"
|
||||
assert c.completed.sound == "Hero"
|
||||
assert c.completed.volume == 0.8
|
||||
|
||||
def test_loads_flash_mode(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("flash:\n mode: allscreens\n")
|
||||
c = load_config(p)
|
||||
assert c.flash_mode == "allscreens"
|
||||
|
||||
def test_loads_general_overrides(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("general:\n cooldown: 5.0\n approval_delay: 3.0\n")
|
||||
c = load_config(p)
|
||||
assert c.cooldown == 5.0
|
||||
assert c.approval_delay == 3.0
|
||||
|
||||
def test_loads_approval_tools(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text("approval_tools:\n - Shell\n - MCP\n")
|
||||
c = load_config(p)
|
||||
assert c.approval_tools == ["Shell", "MCP"]
|
||||
|
||||
def test_full_config(self, tmp_path):
|
||||
p = tmp_path / "config.yaml"
|
||||
p.write_text(
|
||||
"running:\n"
|
||||
" color: '#FF0000'\n"
|
||||
" width: 6\n"
|
||||
" opacity: 0.9\n"
|
||||
" pulse_speed: 2.0\n"
|
||||
" sound: Glass\n"
|
||||
" volume: 0.8\n"
|
||||
"completed:\n"
|
||||
" color: '#00FF00'\n"
|
||||
" sound: ''\n"
|
||||
"flash:\n"
|
||||
" mode: window\n"
|
||||
"general:\n"
|
||||
" approval_delay: 1.0\n"
|
||||
" cooldown: 3.0\n"
|
||||
"approval_tools:\n"
|
||||
" - Shell\n"
|
||||
)
|
||||
c = load_config(p)
|
||||
assert c.running.color == "#FF0000"
|
||||
assert c.running.width == 6
|
||||
assert c.running.opacity == 0.9
|
||||
assert c.running.pulse_speed == 2.0
|
||||
assert c.running.sound == "Glass"
|
||||
assert c.running.volume == 0.8
|
||||
assert c.completed.color == "#00FF00"
|
||||
assert c.completed.sound == ""
|
||||
assert c.flash_mode == "window"
|
||||
assert c.approval_delay == 1.0
|
||||
assert c.cooldown == 3.0
|
||||
assert c.approval_tools == ["Shell"]
|
||||
|
||||
388
tests/test_daemon.py
Normal file
388
tests/test_daemon.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""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
|
||||
)
|
||||
@@ -1,81 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from cursor_flasher.detector import (
|
||||
CursorDetector,
|
||||
PollResult,
|
||||
parse_ui_signals,
|
||||
UISignals,
|
||||
)
|
||||
|
||||
|
||||
class TestParseUISignals:
|
||||
def test_no_elements_means_no_signals(self):
|
||||
signals = parse_ui_signals([])
|
||||
assert signals.agent_working is False
|
||||
assert signals.approval_needed is False
|
||||
|
||||
def test_stop_text_means_agent_working(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Stop"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is True
|
||||
|
||||
def test_cancel_generating_means_agent_working(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Cancel generating"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is True
|
||||
|
||||
def test_accept_text_means_approval_needed(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Accept"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_reject_text_means_approval_needed(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Reject"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_run_this_time_means_approval_needed(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Run this time only (⏎)"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_both_signals(self):
|
||||
elements = [
|
||||
{"role": "AXStaticText", "value": "Stop"},
|
||||
{"role": "AXStaticText", "value": "Accept"},
|
||||
]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is True
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_irrelevant_text_ignored(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Settings"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.agent_working is False
|
||||
assert signals.approval_needed is False
|
||||
|
||||
def test_button_role_also_detected(self):
|
||||
elements = [{"role": "AXButton", "title": "Accept"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
def test_partial_match_on_run_command(self):
|
||||
elements = [{"role": "AXStaticText", "value": "Run command"}]
|
||||
signals = parse_ui_signals(elements)
|
||||
assert signals.approval_needed is True
|
||||
|
||||
|
||||
class TestCursorDetector:
|
||||
def test_returns_none_when_cursor_not_running(self):
|
||||
detector = CursorDetector()
|
||||
with patch.object(detector, "_find_cursor_pid", return_value=None):
|
||||
result = detector.poll()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestPollResult:
|
||||
def test_default_has_empty_active_windows(self):
|
||||
result = PollResult(signals=UISignals())
|
||||
assert result.active_windows == []
|
||||
assert result.signals.agent_working is False
|
||||
assert result.signals.approval_needed is False
|
||||
105
tests/test_hook.py
Normal file
105
tests/test_hook.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Tests for the hook notification script."""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import tempfile
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _short_sock_path():
|
||||
"""Create a short socket path that fits macOS's 104-char limit."""
|
||||
fd, path = tempfile.mkstemp(suffix=".sock", dir="/tmp")
|
||||
os.close(fd)
|
||||
os.unlink(path)
|
||||
return path
|
||||
|
||||
|
||||
def _run_hook_main(stdin_data: str, socket_path: str):
|
||||
"""Run the hook's main() with patched stdin and socket path."""
|
||||
import io
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "hooks"))
|
||||
import notify
|
||||
|
||||
with patch.object(notify, "SOCKET_PATH", socket_path), \
|
||||
patch("sys.stdin", io.StringIO(stdin_data)):
|
||||
notify.main()
|
||||
|
||||
|
||||
class TestHookNotify:
|
||||
def test_sends_message_to_socket(self):
|
||||
sock_path = _short_sock_path()
|
||||
received = []
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
|
||||
def accept():
|
||||
conn, _ = server.accept()
|
||||
data = conn.recv(4096)
|
||||
received.append(json.loads(data))
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=accept)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
hook_input = json.dumps({
|
||||
"workspace_roots": ["/Users/me/project"],
|
||||
"hook_event_name": "preToolUse",
|
||||
"tool_name": "Shell",
|
||||
})
|
||||
_run_hook_main(hook_input, sock_path)
|
||||
t.join(timeout=2)
|
||||
finally:
|
||||
server.close()
|
||||
if os.path.exists(sock_path):
|
||||
os.unlink(sock_path)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0]["workspace"] == "/Users/me/project"
|
||||
assert received[0]["event"] == "preToolUse"
|
||||
assert received[0]["tool"] == "Shell"
|
||||
|
||||
def test_handles_missing_socket_gracefully(self):
|
||||
hook_input = json.dumps({
|
||||
"workspace_roots": ["/Users/me/project"],
|
||||
"hook_event_name": "stop",
|
||||
})
|
||||
_run_hook_main(hook_input, "/tmp/nonexistent.sock")
|
||||
|
||||
def test_handles_empty_workspace_roots(self):
|
||||
sock_path = _short_sock_path()
|
||||
received = []
|
||||
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
|
||||
def accept():
|
||||
conn, _ = server.accept()
|
||||
data = conn.recv(4096)
|
||||
received.append(json.loads(data))
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=accept)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
hook_input = json.dumps({
|
||||
"workspace_roots": [],
|
||||
"hook_event_name": "stop",
|
||||
})
|
||||
_run_hook_main(hook_input, sock_path)
|
||||
t.join(timeout=2)
|
||||
finally:
|
||||
server.close()
|
||||
if os.path.exists(sock_path):
|
||||
os.unlink(sock_path)
|
||||
|
||||
assert received[0]["workspace"] == ""
|
||||
@@ -1,20 +0,0 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from cursor_flasher.sound import play_alert
|
||||
from cursor_flasher.config import Config
|
||||
|
||||
|
||||
class TestPlayAlert:
|
||||
def test_does_nothing_when_disabled(self):
|
||||
config = Config(sound_enabled=False)
|
||||
play_alert(config)
|
||||
|
||||
@patch("cursor_flasher.sound.NSSound")
|
||||
def test_plays_named_sound(self, mock_nssound):
|
||||
mock_sound_obj = MagicMock()
|
||||
mock_nssound.soundNamed_.return_value = mock_sound_obj
|
||||
config = Config(sound_enabled=True, sound_name="Glass", sound_volume=0.7)
|
||||
play_alert(config)
|
||||
mock_nssound.soundNamed_.assert_called_once_with("Glass")
|
||||
mock_sound_obj.setVolume_.assert_called_once_with(0.7)
|
||||
mock_sound_obj.play.assert_called_once()
|
||||
@@ -1,82 +0,0 @@
|
||||
import pytest
|
||||
from cursor_flasher.state import FlasherState, StateMachine
|
||||
|
||||
|
||||
class TestStateMachine:
|
||||
def test_initial_state_is_idle(self):
|
||||
sm = StateMachine()
|
||||
assert sm.state == FlasherState.IDLE
|
||||
|
||||
def test_idle_to_agent_working(self):
|
||||
sm = StateMachine()
|
||||
changed = sm.update(agent_working=True, approval_needed=False)
|
||||
assert sm.state == FlasherState.AGENT_WORKING
|
||||
assert changed is True
|
||||
|
||||
def test_agent_working_to_waiting(self):
|
||||
sm = StateMachine()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
changed = sm.update(agent_working=False, approval_needed=False)
|
||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
||||
assert changed is True
|
||||
|
||||
def test_approval_needed_triggers_waiting(self):
|
||||
sm = StateMachine()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
changed = sm.update(agent_working=False, approval_needed=True)
|
||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
||||
assert changed is True
|
||||
|
||||
def test_idle_does_not_jump_to_waiting(self):
|
||||
sm = StateMachine()
|
||||
changed = sm.update(agent_working=False, approval_needed=False)
|
||||
assert sm.state == FlasherState.IDLE
|
||||
assert changed is False
|
||||
|
||||
def test_waiting_to_user_interacting(self):
|
||||
sm = StateMachine()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
sm.update(agent_working=False, approval_needed=False)
|
||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
||||
changed = sm.dismiss()
|
||||
assert sm.state == FlasherState.IDLE
|
||||
assert changed is True
|
||||
|
||||
def test_waiting_to_agent_working(self):
|
||||
sm = StateMachine()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
sm.update(agent_working=False, approval_needed=False)
|
||||
changed = sm.update(agent_working=True, approval_needed=False)
|
||||
assert sm.state == FlasherState.AGENT_WORKING
|
||||
assert changed is True
|
||||
|
||||
def test_no_change_returns_false(self):
|
||||
sm = StateMachine()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
changed = sm.update(agent_working=True, approval_needed=False)
|
||||
assert changed is False
|
||||
|
||||
def test_cooldown_prevents_immediate_retrigger(self):
|
||||
sm = StateMachine(cooldown=5.0)
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
sm.update(agent_working=False, approval_needed=False)
|
||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
||||
sm.dismiss()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
changed = sm.update(agent_working=False, approval_needed=False)
|
||||
assert sm.cooldown == 5.0
|
||||
|
||||
def test_stale_approval_from_idle_ignored(self):
|
||||
"""Approval buttons in IDLE state (stale chat history) must not trigger flash."""
|
||||
sm = StateMachine()
|
||||
changed = sm.update(agent_working=False, approval_needed=True)
|
||||
assert sm.state == FlasherState.IDLE
|
||||
assert changed is False
|
||||
|
||||
def test_approval_after_working_triggers(self):
|
||||
"""Approval buttons after seeing agent work should trigger flash."""
|
||||
sm = StateMachine()
|
||||
sm.update(agent_working=True, approval_needed=False)
|
||||
changed = sm.update(agent_working=False, approval_needed=True)
|
||||
assert sm.state == FlasherState.WAITING_FOR_USER
|
||||
assert changed is True
|
||||
Reference in New Issue
Block a user