diff --git a/src/accessiweather/alert_notification_system.py b/src/accessiweather/alert_notification_system.py index 710ef16a8..3aac13efd 100644 --- a/src/accessiweather/alert_notification_system.py +++ b/src/accessiweather/alert_notification_system.py @@ -24,6 +24,7 @@ from .display.presentation.formatters import format_display_datetime from .models import AppSettings, WeatherAlert, WeatherAlerts from .notifications.toast_notifier import SafeDesktopNotifier +from .weather_event_pipeline import WeatherEvent, WeatherEventDispatcher logger = logging.getLogger(__name__) @@ -133,11 +134,13 @@ def __init__( alert_manager: AlertManager, notifier: SafeDesktopNotifier | None = None, settings: AppSettings | None = None, + event_dispatcher: WeatherEventDispatcher | None = None, ): """Initialize the instance.""" self.alert_manager = alert_manager self.notifier = notifier or SafeDesktopNotifier() self.settings = settings + self.event_dispatcher = event_dispatcher logger.info("AlertNotificationSystem initialized") @@ -260,6 +263,25 @@ async def _send_alert_notification( play_sound=play_sound, ) + if self.event_dispatcher is not None: + self.event_dispatcher.dispatch_event( + WeatherEvent( + channel="urgent", + location=", ".join(alert.areas[:1]) if alert.areas else "Unknown", + headline=title, + speech_text=message, + change_text=message, + payload={ + "reason": reason, + "severity": alert.severity, + "event": alert.event, + "alert_id": alert.get_unique_id(), + }, + ), + announce=True, + mirror_toast=False, + ) + if success: logger.info(f"[notify] Alert notification sent: {title[:60]!r}") else: diff --git a/src/accessiweather/app.py b/src/accessiweather/app.py index 14766d15a..b34269989 100644 --- a/src/accessiweather/app.py +++ b/src/accessiweather/app.py @@ -28,6 +28,7 @@ from .location_manager import LocationManager from .ui.main_window import MainWindow from .weather_client import WeatherClient + from .weather_event_pipeline import WeatherEvent, WeatherEventChannel, WeatherEventDispatcher # Configure logging if not logging.getLogger().handlers: @@ -132,6 +133,9 @@ def __init__( # Notification system self._notifier = None + # Event pipeline (Phase 1) + self.event_dispatcher: WeatherEventDispatcher | None = None + # System tray icon (initialized after main window) self.tray_icon = None @@ -153,6 +157,26 @@ def notifier(self): def notifier(self, value) -> None: self._notifier = value + def get_latest_event(self, channel: WeatherEventChannel | None = None) -> WeatherEvent | None: + """Return latest event, optionally constrained to a channel.""" + if not self.event_dispatcher: + return None + return self.event_dispatcher.store.latest(channel=channel) + + def get_unread_summary(self) -> dict[str, int]: + """Return unread event counts for all channels.""" + if not self.event_dispatcher: + return { + "urgent": 0, + "now": 0, + "hourly": 0, + "daily": 0, + "discussion": 0, + "system": 0, + "total": 0, + } + return self.event_dispatcher.store.unread_counts() + def OnInit(self) -> bool: """Initialize the application (wxPython entry point).""" logger.info("Starting AccessiWeather application (wxPython)") diff --git a/src/accessiweather/app_initialization.py b/src/accessiweather/app_initialization.py index ecc838084..e25ec7132 100644 --- a/src/accessiweather/app_initialization.py +++ b/src/accessiweather/app_initialization.py @@ -104,12 +104,21 @@ def initialize_components(app: AccessiWeatherApp) -> None: # Lazy import alert components from .alert_manager import AlertManager from .alert_notification_system import AlertNotificationSystem + from .weather_event_pipeline import WeatherEventDispatcher, WeatherEventStore + + app.event_dispatcher = WeatherEventDispatcher( + WeatherEventStore(max_size=300), + notifier=app._notifier, + ) config_dir_str = str(app.paths.config) alert_settings = config.settings.to_alert_settings() app.alert_manager = AlertManager(config_dir_str, alert_settings) app.alert_notification_system = AlertNotificationSystem( - app.alert_manager, app._notifier, config.settings + app.alert_manager, + app._notifier, + config.settings, + event_dispatcher=app.event_dispatcher, ) # Defer weather history service initialization diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index 395f67368..f827d44f7 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -13,6 +13,8 @@ import wx from wx.lib.sized_controls import SizedFrame, SizedPanel +from ..weather_event_pipeline import WeatherEvent, should_emit_current_conditions_event + if TYPE_CHECKING: from ..app import AccessiWeatherApp from ..models.location import Location @@ -970,6 +972,7 @@ async def _pre_warm_other_locations(self, current_location: Location) -> None: def _on_weather_data_received(self, weather_data) -> None: """Handle received weather data (called on main thread).""" try: + previous_weather_data = self.app.current_weather_data self.app.current_weather_data = weather_data # Use presenter to create formatted presentation @@ -988,6 +991,42 @@ def _on_weather_data_received(self, weather_data) -> None: self.current_conditions.SetValue(current_text) + # Emit channelized current-conditions event only on meaningful change. + if self.app.event_dispatcher: + previous_current = ( + previous_weather_data.current_conditions if previous_weather_data else None + ) + should_emit, change_text = should_emit_current_conditions_event( + previous_current, + weather_data.current_conditions, + ) + if should_emit and weather_data.current_conditions is not None: + location = self.app.config_manager.get_current_location() + location_name = location.name if location else "Unknown" + condition = weather_data.current_conditions.condition or "Unknown conditions" + temperature = weather_data.current_conditions.temperature + temp_text = ( + f", {temperature:.0f} degrees" + if isinstance(temperature, (int, float)) + else "" + ) + speech_text = f"{location_name}: {condition}{temp_text}. {change_text}".strip() + self.app.event_dispatcher.dispatch_event( + WeatherEvent( + channel="now", + location=location_name, + headline=f"Current conditions: {condition}", + speech_text=speech_text, + change_text=change_text, + payload={ + "condition": condition, + "temperature": temperature, + }, + ), + announce=True, + mirror_toast=False, + ) + # Update stale/cached data warning if presentation.status_messages: warning_text = " ".join(presentation.status_messages) diff --git a/src/accessiweather/weather_event_pipeline.py b/src/accessiweather/weather_event_pipeline.py new file mode 100644 index 000000000..90b4ff116 --- /dev/null +++ b/src/accessiweather/weather_event_pipeline.py @@ -0,0 +1,168 @@ +"""Channelized weather event pipeline primitives (Phase 1).""" + +from __future__ import annotations + +import json +import uuid +from collections import deque +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any, Literal + +from .models import CurrentConditions +from .screen_reader import ScreenReaderAnnouncer + +WeatherEventChannel = Literal["urgent", "now", "hourly", "daily", "discussion", "system"] + + +@dataclass(slots=True) +class WeatherEvent: + """A lightweight event envelope for visible and non-visual weather flows.""" + + id: str = field(default_factory=lambda: str(uuid.uuid4())) + timestamp: datetime = field(default_factory=lambda: datetime.now(UTC)) + channel: WeatherEventChannel = "system" + location: str = "Unknown" + headline: str = "" + speech_text: str = "" + change_text: str = "" + payload: dict[str, Any] = field(default_factory=dict) + read: bool = False + + +class WeatherEventStore: + """In-memory ring buffer with unread accounting and cursor helpers.""" + + def __init__(self, max_size: int = 300) -> None: + """Create a ring buffer with configurable maximum size.""" + self.max_size = max(1, int(max_size)) + self._events: deque[WeatherEvent] = deque(maxlen=self.max_size) + + def append(self, event: WeatherEvent) -> WeatherEvent: + if len(self._events) == self._events.maxlen and self._events and not self._events[0].read: + # Item will be evicted unread; no per-event index to maintain, so just append. + pass + self._events.append(event) + return event + + def latest(self, channel: WeatherEventChannel | None = None) -> WeatherEvent | None: + for event in reversed(self._events): + if channel is None or event.channel == channel: + return event + return None + + def get_after( + self, cursor: str | None = None, channel: WeatherEventChannel | None = None + ) -> list[WeatherEvent]: + seen_cursor = cursor is None + out: list[WeatherEvent] = [] + for event in self._events: + if not seen_cursor: + if event.id == cursor: + seen_cursor = True + continue + if channel is None or event.channel == channel: + out.append(event) + return out + + def mark_read(self, event_id: str) -> bool: + for event in self._events: + if event.id == event_id: + event.read = True + return True + return False + + def unread_counts(self) -> dict[str, int]: + counts: dict[str, int] = { + "urgent": 0, + "now": 0, + "hourly": 0, + "daily": 0, + "discussion": 0, + "system": 0, + "total": 0, + } + for event in self._events: + if not event.read: + counts[event.channel] += 1 + counts["total"] += 1 + return counts + + +class WeatherEventDispatcher: + """Centralized event writer + announcer + optional toast mirroring.""" + + def __init__(self, store: WeatherEventStore, notifier: Any | None = None) -> None: + """Create dispatcher with a store and optional toast notifier.""" + self.store = store + self.notifier = notifier + self.announcer = ScreenReaderAnnouncer() + + def dispatch_event( + self, + event: WeatherEvent, + *, + announce: bool = True, + mirror_toast: bool = True, + ) -> WeatherEvent: + self.store.append(event) + + if announce and event.speech_text: + self.announcer.announce(event.speech_text) + + if mirror_toast and self.notifier is not None: + message = ( + event.change_text or event.speech_text or self._serialize_payload(event.payload) + ) + self.notifier.send_notification( + title=event.headline or "Weather Update", + message=message, + timeout=10, + play_sound=False, + ) + + return event + + @staticmethod + def _serialize_payload(payload: dict[str, Any]) -> str: + if not payload: + return "" + try: + return json.dumps(payload, ensure_ascii=False) + except Exception: + return str(payload) + + +def should_emit_current_conditions_event( + previous: CurrentConditions | None, + current: CurrentConditions | None, + *, + temp_delta_threshold: float = 2.0, + wind_delta_threshold: float = 5.0, +) -> tuple[bool, str]: + """Return whether a current-conditions event should be emitted and why.""" + if current is None: + return False, "" + if previous is None: + return True, "Initial current conditions" + + prev_temp = previous.temperature + curr_temp = current.temperature + if prev_temp is not None and curr_temp is not None: + delta = abs(curr_temp - prev_temp) + if delta >= temp_delta_threshold: + return True, f"Temperature changed by {delta:.0f}°" + + prev_wind = previous.wind_speed + curr_wind = current.wind_speed + if prev_wind is not None and curr_wind is not None: + wind_delta = abs(curr_wind - prev_wind) + if wind_delta >= wind_delta_threshold: + return True, f"Wind changed by {wind_delta:.0f}" + + prev_cond = (previous.condition or "").strip().lower() + curr_cond = (current.condition or "").strip().lower() + if prev_cond and curr_cond and prev_cond != curr_cond: + return True, f"Conditions changed from {previous.condition} to {current.condition}" + + return False, "" diff --git a/tests/test_app_update_download_flow.py b/tests/test_app_update_download_flow.py new file mode 100644 index 000000000..a417e9fa0 --- /dev/null +++ b/tests/test_app_update_download_flow.py @@ -0,0 +1,73 @@ +"""Coverage test for startup update download/apply flow.""" + +from __future__ import annotations + +import threading +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +from accessiweather.app import AccessiWeatherApp + + +def test_download_and_apply_update_success_path(monkeypatch, tmp_path): + import accessiweather.app as app_module + import accessiweather.config_utils as config_utils + import accessiweather.services.simple_update as simple_update + + progress_dialog = SimpleNamespace( + Update=MagicMock(return_value=(True, False)), Destroy=MagicMock() + ) + fake_wx = SimpleNamespace( + PD_APP_MODAL=1, + PD_AUTO_HIDE=2, + PD_CAN_ABORT=4, + YES_NO=8, + ICON_QUESTION=16, + YES=1, + OK=32, + ICON_ERROR=64, + ProgressDialog=MagicMock(return_value=progress_dialog), + MessageBox=MagicMock(return_value=1), + CallAfter=lambda func, *args, **kwargs: func(*args, **kwargs), + ) + monkeypatch.setattr(app_module, "wx", fake_wx) + + class InlineThread: + def __init__(self, target, daemon): + self._target = target + self.daemon = daemon + + def start(self): + self._target() + + monkeypatch.setattr(threading, "Thread", InlineThread) + + class FakeUpdateService: + def __init__(self, app_name): + self.app_name = app_name + + async def download_update(self, update_info, dest_dir: Path, progress_callback): + progress_callback(1024, 2048) + return dest_dir / "accessiweather-update.zip" + + async def close(self): + return None + + apply_update = MagicMock() + monkeypatch.setattr(simple_update, "UpdateService", FakeUpdateService) + monkeypatch.setattr(simple_update, "apply_update", apply_update) + monkeypatch.setattr(config_utils, "is_portable_mode", MagicMock(return_value=False)) + + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + app.main_window = None + update_info = SimpleNamespace(artifact_name="accessiweather-update.zip") + + app._download_and_apply_update(update_info) + + fake_wx.ProgressDialog.assert_called_once() + progress_dialog.Update.assert_called_once() + assert "Downloading..." in progress_dialog.Update.call_args.args[1] + apply_update.assert_called_once() + assert apply_update.call_args.args[0].name == "accessiweather-update.zip" + assert apply_update.call_args.kwargs == {"portable": False} diff --git a/tests/test_main_window_weather_event_emission.py b/tests/test_main_window_weather_event_emission.py new file mode 100644 index 000000000..22ac9e2f7 --- /dev/null +++ b/tests/test_main_window_weather_event_emission.py @@ -0,0 +1,70 @@ +"""Focused tests for current-conditions event emission in MainWindow.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from accessiweather.models import CurrentConditions + + +@patch("accessiweather.ui.main_window.MainWindow.__init__", lambda self, *a, **kw: None) +def test_on_weather_data_received_emits_now_event_for_meaningful_change(): + from accessiweather.ui.main_window import MainWindow + + previous_data = SimpleNamespace( + current_conditions=CurrentConditions(temperature=70.0, condition="Clear", wind_speed=2.0) + ) + incoming_data = SimpleNamespace( + current_conditions=CurrentConditions(temperature=75.0, condition="Cloudy", wind_speed=3.0), + alerts=None, + alert_lifecycle_diff=None, + ) + presentation = SimpleNamespace( + current_conditions=SimpleNamespace(fallback_text="Cloudy and cooler"), + source_attribution=SimpleNamespace(summary_text="Source: Open-Meteo"), + status_messages=[], + forecast=SimpleNamespace(fallback_text="Tonight: Breezy"), + ) + + event_dispatcher = MagicMock() + app = SimpleNamespace( + current_weather_data=previous_data, + presenter=SimpleNamespace(present=MagicMock(return_value=presentation)), + event_dispatcher=event_dispatcher, + config_manager=SimpleNamespace( + get_current_location=MagicMock(return_value=SimpleNamespace(name="Boston")) + ), + alert_notification_system=None, + update_tray_tooltip=MagicMock(), + run_async=MagicMock(), + is_updating=True, + ) + + win = MainWindow.__new__(MainWindow) + win.app = app + win.current_conditions = SimpleNamespace(SetValue=MagicMock()) + win.forecast_display = SimpleNamespace(SetValue=MagicMock()) + win.stale_warning_label = SimpleNamespace(SetLabel=MagicMock()) + win.refresh_button = SimpleNamespace(Enable=MagicMock()) + win._update_alerts = MagicMock() + win._process_notification_events = MagicMock() + win.set_status = MagicMock() + win._alert_lifecycle_labels = {} + + win._on_weather_data_received(incoming_data) + + assert event_dispatcher.dispatch_event.call_count == 1 + dispatched_event = event_dispatcher.dispatch_event.call_args.args[0] + assert dispatched_event.channel == "now" + assert dispatched_event.location == "Boston" + assert dispatched_event.headline == "Current conditions: Cloudy" + assert dispatched_event.payload == {"condition": "Cloudy", "temperature": 75.0} + assert event_dispatcher.dispatch_event.call_args.kwargs == { + "announce": True, + "mirror_toast": False, + } + app.update_tray_tooltip.assert_called_once_with(incoming_data, "Boston") + win._process_notification_events.assert_called_once_with(incoming_data) + assert app.is_updating is False + win.refresh_button.Enable.assert_called_once() diff --git a/tests/test_weather_event_pipeline.py b/tests/test_weather_event_pipeline.py new file mode 100644 index 000000000..bb517257e --- /dev/null +++ b/tests/test_weather_event_pipeline.py @@ -0,0 +1,155 @@ +from accessiweather.models import CurrentConditions +from accessiweather.weather_event_pipeline import ( + WeatherEvent, + WeatherEventDispatcher, + WeatherEventStore, + should_emit_current_conditions_event, +) + + +class DummyNotifier: + """Capture toast calls for assertions.""" + + def __init__(self): + """Initialize capture storage.""" + self.calls = [] + + def send_notification(self, **kwargs): + self.calls.append(kwargs) + return True + + +class DummyAnnouncer: + """Capture spoken announcements for assertions.""" + + def __init__(self): + """Initialize capture storage.""" + self.messages = [] + + def announce(self, text: str): + self.messages.append(text) + + +def test_store_ring_buffer_and_unread_counts(): + store = WeatherEventStore(max_size=2) + + e1 = WeatherEvent(channel="urgent", headline="A") + e2 = WeatherEvent(channel="now", headline="B") + e3 = WeatherEvent(channel="system", headline="C") + + store.append(e1) + store.append(e2) + store.append(e3) + + assert store.latest().headline == "C" + assert store.latest(channel="now").headline == "B" + + counts = store.unread_counts() + assert counts["system"] == 1 + assert counts["urgent"] == 0 + assert counts["total"] == 2 + + +def test_dispatcher_writes_store_and_announces_without_toast_when_disabled(): + notifier = DummyNotifier() + store = WeatherEventStore() + dispatcher = WeatherEventDispatcher(store, notifier=notifier) + dispatcher.announcer = DummyAnnouncer() + + event = WeatherEvent(channel="now", headline="Now", speech_text="Rain starting") + dispatcher.dispatch_event(event, announce=True, mirror_toast=False) + + assert store.latest() == event + assert dispatcher.announcer.messages == ["Rain starting"] + assert notifier.calls == [] + + +def test_dispatcher_mirrors_toast_when_enabled(): + notifier = DummyNotifier() + store = WeatherEventStore() + dispatcher = WeatherEventDispatcher(store, notifier=notifier) + dispatcher.announcer = DummyAnnouncer() + + event = WeatherEvent(channel="urgent", headline="Alert", speech_text="Storm warning") + dispatcher.dispatch_event(event, announce=False, mirror_toast=True) + + assert len(notifier.calls) == 1 + assert notifier.calls[0]["title"] == "Alert" + + +def test_should_emit_current_conditions_event_threshold_gating(): + prev = CurrentConditions(temperature=70.0, wind_speed=4.0, condition="Clear") + + curr_small = CurrentConditions(temperature=71.0, wind_speed=6.0, condition="Clear") + emit_small, _ = should_emit_current_conditions_event(prev, curr_small) + assert emit_small is False + + curr_temp_jump = CurrentConditions(temperature=73.0, wind_speed=6.0, condition="Clear") + emit_temp, reason_temp = should_emit_current_conditions_event(prev, curr_temp_jump) + assert emit_temp is True + assert "Temperature changed" in reason_temp + + curr_condition_change = CurrentConditions(temperature=70.5, wind_speed=5.0, condition="Rain") + emit_cond, reason_cond = should_emit_current_conditions_event(prev, curr_condition_change) + assert emit_cond is True + assert "Conditions changed" in reason_cond + + +def test_store_get_after_mark_read_and_latest_none(): + store = WeatherEventStore() + e1 = WeatherEvent(id="e1", channel="now", headline="One") + e2 = WeatherEvent(id="e2", channel="hourly", headline="Two") + store.append(e1) + store.append(e2) + + assert store.latest(channel="urgent") is None + assert [event.id for event in store.get_after()] == ["e1", "e2"] + assert [event.id for event in store.get_after(cursor="e1")] == ["e2"] + assert [event.id for event in store.get_after(cursor="missing")] == [] + assert [event.id for event in store.get_after(channel="hourly")] == ["e2"] + + assert store.mark_read("e1") is True + assert e1.read is True + assert store.mark_read("missing") is False + + +def test_dispatcher_toast_message_falls_back_to_serialized_payload(): + notifier = DummyNotifier() + store = WeatherEventStore() + dispatcher = WeatherEventDispatcher(store, notifier=notifier) + dispatcher.announcer = DummyAnnouncer() + + event = WeatherEvent(channel="system", payload={"source": "nws", "ok": True}) + dispatcher.dispatch_event(event, announce=False, mirror_toast=True) + + assert len(notifier.calls) == 1 + assert notifier.calls[0]["title"] == "Weather Update" + assert '"source": "nws"' in notifier.calls[0]["message"] + + +def test_serialize_payload_handles_empty_and_unserializable_payload(): + class BadPayload: + def __repr__(self) -> str: + return "BAD_PAYLOAD" + + assert WeatherEventDispatcher._serialize_payload({}) == "" + assert ( + WeatherEventDispatcher._serialize_payload({"bad": BadPayload()}) == "{'bad': BAD_PAYLOAD}" + ) + + +def test_should_emit_current_conditions_event_none_and_initial_and_wind(): + emit_none, reason_none = should_emit_current_conditions_event(None, None) + assert emit_none is False + assert reason_none == "" + + current = CurrentConditions(temperature=70.0, wind_speed=3.0, condition="Clear") + emit_initial, reason_initial = should_emit_current_conditions_event(None, current) + assert emit_initial is True + assert reason_initial == "Initial current conditions" + + previous = CurrentConditions(temperature=70.0, wind_speed=2.0, condition="Clear") + windy = CurrentConditions(temperature=70.1, wind_speed=9.0, condition="Clear") + emit_wind, reason_wind = should_emit_current_conditions_event(previous, windy) + assert emit_wind is True + assert "Wind changed by 7" in reason_wind