From 3551cc249f466cc938d7bb6ff7eb9e8d4b5581bd Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Sun, 15 Mar 2026 16:20:52 -0700 Subject: [PATCH] refactor: phase 4 - event-driven architecture foundation - Add EventBus class with pub/sub messaging (thread-safe) - Add emitter Protocol classes (EventEmitter, Startable, Stoppable) - Add event emission to NtfyPoller (NtfyMessageEvent) - Add event emission to MicMonitor (MicLevelEvent) - Update StreamController to publish stream start/end events - Add comprehensive tests for eventbus and emitters modules --- AGENTS.md | 110 ++++++++++++++++++++++ README.md | 33 ++++--- engine/controller.py | 24 ++++- engine/emitters.py | 25 +++++ engine/eventbus.py | 72 +++++++++++++++ engine/mic.py | 30 ++++++ engine/ntfy.py | 29 ++++++ engine/scroll.py | 6 +- tests/test_emitters.py | 69 ++++++++++++++ tests/test_eventbus.py | 202 +++++++++++++++++++++++++++++++++++++++++ tests/test_mic.py | 66 ++++++++++++++ tests/test_ntfy.py | 52 +++++++++++ 12 files changed, 704 insertions(+), 14 deletions(-) create mode 100644 AGENTS.md create mode 100644 engine/emitters.py create mode 100644 engine/eventbus.py create mode 100644 tests/test_emitters.py create mode 100644 tests/test_eventbus.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..21d7e2c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# Agent Development Guide + +## Development Environment + +This project uses: +- **mise** (mise.jdx.dev) - tool version manager and task runner +- **hk** (hk.jdx.dev) - git hook manager +- **uv** - fast Python package installer +- **ruff** - linter and formatter +- **pytest** - test runner + +### Setup + +```bash +# Install dependencies +mise run install + +# Or equivalently: +uv sync +``` + +### Available Commands + +```bash +mise run test # Run tests +mise run test-v # Run tests verbose +mise run test-cov # Run tests with coverage report +mise run lint # Run ruff linter +mise run lint-fix # Run ruff with auto-fix +mise run format # Run ruff formatter +mise run ci # Full CI pipeline (sync + test + coverage) +``` + +## Git Hooks + +**At the start of every agent session**, verify hooks are installed: + +```bash +ls -la .git/hooks/pre-commit +``` + +If hooks are not installed, install them with: + +```bash +hk init --mise +mise run pre-commit +``` + +The project uses hk configured in `hk.pkl`: +- **pre-commit**: runs ruff-format and ruff (with auto-fix) +- **pre-push**: runs ruff check + +## Workflow Rules + +### Before Committing + +1. **Always run the test suite** - never commit code that fails tests: + ```bash + mise run test + ``` + +2. **Always run the linter**: + ```bash + mise run lint + ``` + +3. **Fix any lint errors** before committing (or let the pre-commit hook handle it). + +4. **Review your changes** using `git diff` to understand what will be committed. + +### On Failing Tests + +When tests fail, **determine whether it's an out-of-date test or a correctly failing test**: + +- **Out-of-date test**: The test was written for old behavior that has legitimately changed. Update the test to match the new expected behavior. + +- **Correctly failing test**: The test correctly identifies a broken contract. Fix the implementation, not the test. + +**Never** modify a test to make it pass without understanding why it failed. + +### Code Review + +Before committing significant changes: +- Run `git diff` to review all changes +- Ensure new code follows existing patterns in the codebase +- Check that type hints are added for new functions +- Verify that tests exist for new functionality + +## Testing + +Tests live in `tests/` and follow the pattern `test_*.py`. + +Run all tests: +```bash +mise run test +``` + +Run with coverage: +```bash +mise run test-cov +``` + +The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`. + +## Architecture Notes + +- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies +- **eventbus.py** provides thread-safe event publishing for decoupled communication +- **controller.py** coordinates ntfy/mic monitoring +- The render pipeline: fetch → render → effects → scroll → terminal output diff --git a/README.md b/README.md index 5d0d429..dfc0257 100644 --- a/README.md +++ b/README.md @@ -79,18 +79,27 @@ To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or po ``` engine/ - config.py constants, CLI flags, glyph tables - sources.py FEEDS, POETRY_SOURCES, language/script maps - terminal.py ANSI codes, tw/th, type_out, boot_ln - filter.py HTML stripping, content filter - translate.py Google Translate wrapper + region detection - render.py OTF → half-block pipeline (SSAA, gradient) - effects.py noise, glitch_bar, fade, firehose - fetch.py RSS/Gutenberg fetching + cache load/save - ntfy.py NtfyPoller — standalone, zero internal deps - mic.py MicMonitor — standalone, graceful fallback - scroll.py stream() frame loop + message rendering - app.py main(), font picker TUI, boot sequence, signal handler + __init__.py package marker + app.py main(), font picker TUI, boot sequence, signal handler + config.py constants, CLI flags, glyph tables + sources.py FEEDS, POETRY_SOURCES, language/script maps + terminal.py ANSI codes, tw/th, type_out, boot_ln + filter.py HTML stripping, content filter + translate.py Google Translate wrapper + region detection + render.py OTF → half-block pipeline (SSAA, gradient) + effects.py noise, glitch_bar, fade, firehose + fetch.py RSS/Gutenberg fetching + cache load/save + ntfy.py NtfyPoller — standalone, zero internal deps + mic.py MicMonitor — standalone, graceful fallback + scroll.py stream() frame loop + message rendering + viewport.py terminal dimension tracking (tw/th) + frame.py scroll step calculation, timing + layers.py ticker zone, firehose, message overlay rendering + eventbus.py thread-safe event publishing for decoupled communication + events.py event types and definitions + controller.py coordinates ntfy/mic monitoring and event publishing + emitters.py background emitters for ntfy and mic + types.py type definitions and dataclasses ``` `ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer. diff --git a/engine/controller.py b/engine/controller.py index 98b24e5..e6e2e3d 100644 --- a/engine/controller.py +++ b/engine/controller.py @@ -3,6 +3,8 @@ Stream controller - manages input sources and orchestrates the render stream. """ from engine.config import Config, get_config +from engine.eventbus import EventBus +from engine.events import EventType, StreamEvent from engine.mic import MicMonitor from engine.ntfy import NtfyPoller from engine.scroll import stream @@ -11,8 +13,9 @@ from engine.scroll import stream class StreamController: """Controls the stream lifecycle - initializes sources and runs the stream.""" - def __init__(self, config: Config | None = None): + def __init__(self, config: Config | None = None, event_bus: EventBus | None = None): self.config = config or get_config() + self.event_bus = event_bus self.mic: MicMonitor | None = None self.ntfy: NtfyPoller | None = None @@ -38,8 +41,27 @@ class StreamController: """Run the stream with initialized sources.""" if self.mic is None or self.ntfy is None: self.initialize_sources() + + if self.event_bus: + self.event_bus.publish( + EventType.STREAM_START, + StreamEvent( + event_type=EventType.STREAM_START, + headline_count=len(items), + ), + ) + stream(items, self.ntfy, self.mic) + if self.event_bus: + self.event_bus.publish( + EventType.STREAM_END, + StreamEvent( + event_type=EventType.STREAM_END, + headline_count=len(items), + ), + ) + def cleanup(self) -> None: """Clean up resources.""" if self.mic: diff --git a/engine/emitters.py b/engine/emitters.py new file mode 100644 index 0000000..6d6a5a1 --- /dev/null +++ b/engine/emitters.py @@ -0,0 +1,25 @@ +""" +Event emitter protocols - abstract interfaces for event-producing components. +""" + +from collections.abc import Callable +from typing import Any, Protocol + + +class EventEmitter(Protocol): + """Protocol for components that emit events.""" + + def subscribe(self, callback: Callable[[Any], None]) -> None: ... + def unsubscribe(self, callback: Callable[[Any], None]) -> None: ... + + +class Startable(Protocol): + """Protocol for components that can be started.""" + + def start(self) -> Any: ... + + +class Stoppable(Protocol): + """Protocol for components that can be stopped.""" + + def stop(self) -> None: ... diff --git a/engine/eventbus.py b/engine/eventbus.py new file mode 100644 index 0000000..6ea5628 --- /dev/null +++ b/engine/eventbus.py @@ -0,0 +1,72 @@ +""" +Event bus - pub/sub messaging for decoupled component communication. +""" + +import threading +from collections import defaultdict +from collections.abc import Callable +from typing import Any + +from engine.events import EventType + + +class EventBus: + """Thread-safe event bus for publish-subscribe messaging.""" + + def __init__(self): + self._subscribers: dict[EventType, list[Callable[[Any], None]]] = defaultdict( + list + ) + self._lock = threading.Lock() + + def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None: + """Register a callback for a specific event type.""" + with self._lock: + self._subscribers[event_type].append(callback) + + def unsubscribe( + self, event_type: EventType, callback: Callable[[Any], None] + ) -> None: + """Remove a callback for a specific event type.""" + with self._lock: + if callback in self._subscribers[event_type]: + self._subscribers[event_type].remove(callback) + + def publish(self, event_type: EventType, event: Any = None) -> None: + """Publish an event to all subscribers.""" + with self._lock: + callbacks = list(self._subscribers.get(event_type, [])) + for callback in callbacks: + try: + callback(event) + except Exception: + pass + + def clear(self) -> None: + """Remove all subscribers.""" + with self._lock: + self._subscribers.clear() + + def subscriber_count(self, event_type: EventType | None = None) -> int: + """Get subscriber count for an event type, or total if None.""" + with self._lock: + if event_type is None: + return sum(len(cb) for cb in self._subscribers.values()) + return len(self._subscribers.get(event_type, [])) + + +_event_bus: EventBus | None = None + + +def get_event_bus() -> EventBus: + """Get the global event bus instance.""" + global _event_bus + if _event_bus is None: + _event_bus = EventBus() + return _event_bus + + +def set_event_bus(bus: EventBus) -> None: + """Set the global event bus instance (for testing).""" + global _event_bus + _event_bus = bus diff --git a/engine/mic.py b/engine/mic.py index b8c5175..c72a440 100644 --- a/engine/mic.py +++ b/engine/mic.py @@ -4,6 +4,8 @@ Gracefully degrades if sounddevice/numpy are unavailable. """ import atexit +from collections.abc import Callable +from datetime import datetime try: import numpy as _np @@ -14,6 +16,9 @@ except Exception: _HAS_MIC = False +from engine.events import MicLevelEvent + + class MicMonitor: """Background mic stream that exposes current RMS dB level.""" @@ -21,6 +26,7 @@ class MicMonitor: self.threshold_db = threshold_db self._db = -99.0 self._stream = None + self._subscribers: list[Callable[[MicLevelEvent], None]] = [] @property def available(self): @@ -37,6 +43,23 @@ class MicMonitor: """dB above threshold (clamped to 0).""" return max(0.0, self._db - self.threshold_db) + def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None: + """Register a callback to be called when mic level changes.""" + self._subscribers.append(callback) + + def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None: + """Remove a registered callback.""" + if callback in self._subscribers: + self._subscribers.remove(callback) + + def _emit(self, event: MicLevelEvent) -> None: + """Emit an event to all subscribers.""" + for cb in self._subscribers: + try: + cb(event) + except Exception: + pass + def start(self): """Start background mic stream. Returns True on success, False/None otherwise.""" if not _HAS_MIC: @@ -45,6 +68,13 @@ class MicMonitor: def _cb(indata, frames, t, status): rms = float(_np.sqrt(_np.mean(indata**2))) self._db = 20 * _np.log10(rms) if rms > 0 else -99.0 + if self._subscribers: + event = MicLevelEvent( + db_level=self._db, + excess_above_threshold=max(0.0, self._db - self.threshold_db), + timestamp=datetime.now(), + ) + self._emit(event) try: self._stream = _sd.InputStream( diff --git a/engine/ntfy.py b/engine/ntfy.py index d819ea2..583fc66 100644 --- a/engine/ntfy.py +++ b/engine/ntfy.py @@ -16,8 +16,12 @@ import json import threading import time import urllib.request +from collections.abc import Callable +from datetime import datetime from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from engine.events import NtfyMessageEvent + class NtfyPoller: """SSE stream listener for ntfy.sh topics. Messages arrive in ~1s (network RTT).""" @@ -28,6 +32,24 @@ class NtfyPoller: self.display_secs = display_secs self._message = None # (title, body, monotonic_timestamp) or None self._lock = threading.Lock() + self._subscribers: list[Callable[[NtfyMessageEvent], None]] = [] + + def subscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None: + """Register a callback to be called when a message is received.""" + self._subscribers.append(callback) + + def unsubscribe(self, callback: Callable[[NtfyMessageEvent], None]) -> None: + """Remove a registered callback.""" + if callback in self._subscribers: + self._subscribers.remove(callback) + + def _emit(self, event: NtfyMessageEvent) -> None: + """Emit an event to all subscribers.""" + for cb in self._subscribers: + try: + cb(event) + except Exception: + pass def start(self): """Start background stream thread. Returns True.""" @@ -88,6 +110,13 @@ class NtfyPoller: data.get("message", ""), time.monotonic(), ) + event = NtfyMessageEvent( + title=data.get("title", ""), + body=data.get("message", ""), + message_id=data.get("id"), + timestamp=datetime.now(), + ) + self._emit(event) except Exception: pass time.sleep(self.reconnect_delay) diff --git a/engine/scroll.py b/engine/scroll.py index dcb96f7..41445ad 100644 --- a/engine/scroll.py +++ b/engine/scroll.py @@ -43,11 +43,15 @@ def stream(items, ntfy_poller, mic_monitor): scroll_motion_accum = 0.0 msg_cache = (None, None) - while queued < config.HEADLINE_LIMIT or active: + while True: + if queued >= config.HEADLINE_LIMIT and not active: + break + t0 = time.monotonic() w, h = tw(), th() fh = config.FIREHOSE_H if config.FIREHOSE else 0 ticker_view_h = h - fh + scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h) msg = ntfy_poller.get_active_message() msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache) diff --git a/tests/test_emitters.py b/tests/test_emitters.py new file mode 100644 index 0000000..b28cddb --- /dev/null +++ b/tests/test_emitters.py @@ -0,0 +1,69 @@ +""" +Tests for engine.emitters module. +""" + +from engine.emitters import EventEmitter, Startable, Stoppable + + +class TestEventEmitterProtocol: + """Tests for EventEmitter protocol.""" + + def test_protocol_exists(self): + """EventEmitter protocol is defined.""" + assert EventEmitter is not None + + def test_protocol_has_subscribe_method(self): + """EventEmitter has subscribe method in protocol.""" + assert hasattr(EventEmitter, "subscribe") + + def test_protocol_has_unsubscribe_method(self): + """EventEmitter has unsubscribe method in protocol.""" + assert hasattr(EventEmitter, "unsubscribe") + + +class TestStartableProtocol: + """Tests for Startable protocol.""" + + def test_protocol_exists(self): + """Startable protocol is defined.""" + assert Startable is not None + + def test_protocol_has_start_method(self): + """Startable has start method in protocol.""" + assert hasattr(Startable, "start") + + +class TestStoppableProtocol: + """Tests for Stoppable protocol.""" + + def test_protocol_exists(self): + """Stoppable protocol is defined.""" + assert Stoppable is not None + + def test_protocol_has_stop_method(self): + """Stoppable has stop method in protocol.""" + assert hasattr(Stoppable, "stop") + + +class TestProtocolCompliance: + """Tests that existing classes comply with protocols.""" + + def test_ntfy_poller_complies_with_protocol(self): + """NtfyPoller implements EventEmitter protocol.""" + from engine.ntfy import NtfyPoller + + poller = NtfyPoller("http://example.com/topic") + assert hasattr(poller, "subscribe") + assert hasattr(poller, "unsubscribe") + assert callable(poller.subscribe) + assert callable(poller.unsubscribe) + + def test_mic_monitor_complies_with_protocol(self): + """MicMonitor implements EventEmitter and Startable protocols.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + assert hasattr(monitor, "subscribe") + assert hasattr(monitor, "unsubscribe") + assert hasattr(monitor, "start") + assert hasattr(monitor, "stop") diff --git a/tests/test_eventbus.py b/tests/test_eventbus.py new file mode 100644 index 0000000..7094c7b --- /dev/null +++ b/tests/test_eventbus.py @@ -0,0 +1,202 @@ +""" +Tests for engine.eventbus module. +""" + + +from engine.eventbus import EventBus, get_event_bus, set_event_bus +from engine.events import EventType, NtfyMessageEvent + + +class TestEventBusInit: + """Tests for EventBus initialization.""" + + def test_init_creates_empty_subscribers(self): + """EventBus starts with no subscribers.""" + bus = EventBus() + assert bus.subscriber_count() == 0 + + +class TestEventBusSubscribe: + """Tests for EventBus.subscribe method.""" + + def test_subscribe_adds_callback(self): + """subscribe() adds a callback for an event type.""" + bus = EventBus() + def callback(e): + return None + + bus.subscribe(EventType.NTFY_MESSAGE, callback) + + assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1 + + def test_subscribe_multiple_callbacks_same_event(self): + """Multiple callbacks can be subscribed to the same event type.""" + bus = EventBus() + def cb1(e): + return None + def cb2(e): + return None + + bus.subscribe(EventType.NTFY_MESSAGE, cb1) + bus.subscribe(EventType.NTFY_MESSAGE, cb2) + + assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 2 + + def test_subscribe_different_event_types(self): + """Callbacks can be subscribed to different event types.""" + bus = EventBus() + def cb1(e): + return None + def cb2(e): + return None + + bus.subscribe(EventType.NTFY_MESSAGE, cb1) + bus.subscribe(EventType.MIC_LEVEL, cb2) + + assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1 + assert bus.subscriber_count(EventType.MIC_LEVEL) == 1 + + +class TestEventBusUnsubscribe: + """Tests for EventBus.unsubscribe method.""" + + def test_unsubscribe_removes_callback(self): + """unsubscribe() removes a callback.""" + bus = EventBus() + def callback(e): + return None + + bus.subscribe(EventType.NTFY_MESSAGE, callback) + bus.unsubscribe(EventType.NTFY_MESSAGE, callback) + + assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 0 + + def test_unsubscribe_nonexistent_callback_no_error(self): + """unsubscribe() handles non-existent callback gracefully.""" + bus = EventBus() + def callback(e): + return None + + bus.unsubscribe(EventType.NTFY_MESSAGE, callback) + + +class TestEventBusPublish: + """Tests for EventBus.publish method.""" + + def test_publish_calls_subscriber(self): + """publish() calls the subscriber callback.""" + bus = EventBus() + received = [] + + def callback(event): + received.append(event) + + bus.subscribe(EventType.NTFY_MESSAGE, callback) + event = NtfyMessageEvent(title="Test", body="Body") + bus.publish(EventType.NTFY_MESSAGE, event) + + assert len(received) == 1 + assert received[0].title == "Test" + + def test_publish_multiple_subscribers(self): + """publish() calls all subscribers for an event type.""" + bus = EventBus() + received1 = [] + received2 = [] + + def callback1(event): + received1.append(event) + + def callback2(event): + received2.append(event) + + bus.subscribe(EventType.NTFY_MESSAGE, callback1) + bus.subscribe(EventType.NTFY_MESSAGE, callback2) + event = NtfyMessageEvent(title="Test", body="Body") + bus.publish(EventType.NTFY_MESSAGE, event) + + assert len(received1) == 1 + assert len(received2) == 1 + + def test_publish_different_event_types(self): + """publish() only calls subscribers for the specific event type.""" + bus = EventBus() + ntfy_received = [] + mic_received = [] + + def ntfy_callback(event): + ntfy_received.append(event) + + def mic_callback(event): + mic_received.append(event) + + bus.subscribe(EventType.NTFY_MESSAGE, ntfy_callback) + bus.subscribe(EventType.MIC_LEVEL, mic_callback) + event = NtfyMessageEvent(title="Test", body="Body") + bus.publish(EventType.NTFY_MESSAGE, event) + + assert len(ntfy_received) == 1 + assert len(mic_received) == 0 + + +class TestEventBusClear: + """Tests for EventBus.clear method.""" + + def test_clear_removes_all_subscribers(self): + """clear() removes all subscribers.""" + bus = EventBus() + def cb1(e): + return None + def cb2(e): + return None + + bus.subscribe(EventType.NTFY_MESSAGE, cb1) + bus.subscribe(EventType.MIC_LEVEL, cb2) + bus.clear() + + assert bus.subscriber_count() == 0 + + +class TestEventBusThreadSafety: + """Tests for EventBus thread safety.""" + + def test_concurrent_subscribe_unsubscribe(self): + """subscribe and unsubscribe can be called concurrently.""" + import threading + + bus = EventBus() + callbacks = [lambda e: None for _ in range(10)] + + def subscribe(): + for cb in callbacks: + bus.subscribe(EventType.NTFY_MESSAGE, cb) + + def unsubscribe(): + for cb in callbacks: + bus.unsubscribe(EventType.NTFY_MESSAGE, cb) + + t1 = threading.Thread(target=subscribe) + t2 = threading.Thread(target=unsubscribe) + t1.start() + t2.start() + t1.join() + t2.join() + + +class TestGlobalEventBus: + """Tests for global event bus functions.""" + + def test_get_event_bus_returns_singleton(self): + """get_event_bus() returns the same instance.""" + bus1 = get_event_bus() + bus2 = get_event_bus() + assert bus1 is bus2 + + def test_set_event_bus_replaces_singleton(self): + """set_event_bus() replaces the global event bus.""" + new_bus = EventBus() + set_event_bus(new_bus) + try: + assert get_event_bus() is new_bus + finally: + set_event_bus(None) diff --git a/tests/test_mic.py b/tests/test_mic.py index 3e610b9..a347e5f 100644 --- a/tests/test_mic.py +++ b/tests/test_mic.py @@ -2,8 +2,11 @@ Tests for engine.mic module. """ +from datetime import datetime from unittest.mock import patch +from engine.events import MicLevelEvent + class TestMicMonitorImport: """Tests for module import behavior.""" @@ -81,3 +84,66 @@ class TestMicMonitorStop: monitor = MicMonitor() monitor.stop() assert monitor._stream is None + + +class TestMicMonitorEventEmission: + """Tests for MicMonitor event emission.""" + + def test_subscribe_adds_callback(self): + """subscribe() adds a callback.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + def callback(e): + return None + + monitor.subscribe(callback) + + assert callback in monitor._subscribers + + def test_unsubscribe_removes_callback(self): + """unsubscribe() removes a callback.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + def callback(e): + return None + monitor.subscribe(callback) + + monitor.unsubscribe(callback) + + assert callback not in monitor._subscribers + + def test_emit_calls_subscribers(self): + """_emit() calls all subscribers.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + received = [] + + def callback(event): + received.append(event) + + monitor.subscribe(callback) + event = MicLevelEvent( + db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now() + ) + monitor._emit(event) + + assert len(received) == 1 + assert received[0].db_level == 60.0 + + def test_emit_handles_subscriber_exception(self): + """_emit() handles exceptions in subscribers gracefully.""" + from engine.mic import MicMonitor + + monitor = MicMonitor() + + def bad_callback(event): + raise RuntimeError("test") + + monitor.subscribe(bad_callback) + event = MicLevelEvent( + db_level=60.0, excess_above_threshold=10.0, timestamp=datetime.now() + ) + monitor._emit(event) diff --git a/tests/test_ntfy.py b/tests/test_ntfy.py index 778fc6e..6c3437c 100644 --- a/tests/test_ntfy.py +++ b/tests/test_ntfy.py @@ -5,6 +5,7 @@ Tests for engine.ntfy module. import time from unittest.mock import MagicMock, patch +from engine.events import NtfyMessageEvent from engine.ntfy import NtfyPoller @@ -68,3 +69,54 @@ class TestNtfyPollerDismiss: poller.dismiss() assert poller._message is None + + +class TestNtfyPollerEventEmission: + """Tests for NtfyPoller event emission.""" + + def test_subscribe_adds_callback(self): + """subscribe() adds a callback.""" + poller = NtfyPoller("http://example.com/topic") + def callback(e): + return None + + poller.subscribe(callback) + + assert callback in poller._subscribers + + def test_unsubscribe_removes_callback(self): + """unsubscribe() removes a callback.""" + poller = NtfyPoller("http://example.com/topic") + def callback(e): + return None + poller.subscribe(callback) + + poller.unsubscribe(callback) + + assert callback not in poller._subscribers + + def test_emit_calls_subscribers(self): + """_emit() calls all subscribers.""" + poller = NtfyPoller("http://example.com/topic") + received = [] + + def callback(event): + received.append(event) + + poller.subscribe(callback) + event = NtfyMessageEvent(title="Test", body="Body") + poller._emit(event) + + assert len(received) == 1 + assert received[0].title == "Test" + + def test_emit_handles_subscriber_exception(self): + """_emit() handles exceptions in subscribers gracefully.""" + poller = NtfyPoller("http://example.com/topic") + + def bad_callback(event): + raise RuntimeError("test") + + poller.subscribe(bad_callback) + event = NtfyMessageEvent(title="Test", body="Body") + poller._emit(event)