From e03c84a9e26c1358a36280eae49f2566b17fcf46 Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 12:50:10 +0000 Subject: [PATCH 1/4] feat(alerts): add opt-in immediate alert popups --- .../alert_notification_system.py | 24 ++++ src/accessiweather/app.py | 31 ++++++ src/accessiweather/app_initialization.py | 5 +- src/accessiweather/models/config.py | 6 + src/accessiweather/ui/dialogs/__init__.py | 3 + .../ui/dialogs/alerts_summary_dialog.py | 103 ++++++++++++++++++ .../ui/dialogs/settings_tabs/notifications.py | 11 ++ tests/test_alert_notification_system.py | 87 +++++++++++++++ tests/test_app_immediate_alert_popups.py | 61 +++++++++++ tests/test_models.py | 8 ++ tests/test_settings_dialog_tray_text.py | 26 ++++- 11 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 src/accessiweather/ui/dialogs/alerts_summary_dialog.py create mode 100644 tests/test_app_immediate_alert_popups.py diff --git a/src/accessiweather/alert_notification_system.py b/src/accessiweather/alert_notification_system.py index 49d2f5acc..fc6a8d4ae 100644 --- a/src/accessiweather/alert_notification_system.py +++ b/src/accessiweather/alert_notification_system.py @@ -8,6 +8,7 @@ import asyncio import logging +from collections.abc import Callable from .alert_lifecycle import AlertLifecycleDiff from .alert_manager import AlertManager, AlertSettings @@ -134,11 +135,13 @@ def __init__( alert_manager: AlertManager, notifier: SafeDesktopNotifier | None = None, settings: AppSettings | None = None, + on_alerts_popup: Callable[[list[WeatherAlert]], None] | None = None, ): """Initialize the instance.""" self.alert_manager = alert_manager self.notifier = notifier or SafeDesktopNotifier() self.settings = settings + self.on_alerts_popup = on_alerts_popup logger.info("AlertNotificationSystem initialized") @@ -182,6 +185,8 @@ async def process_and_notify(self, alerts: WeatherAlerts) -> int: reverse=True, ) + self._trigger_immediate_alert_popup_if_enabled(sorted_notifications) + # Send notifications - only play sound for the first (most severe) one notifications_sent = 0 for i, (alert, reason) in enumerate(sorted_notifications): @@ -207,6 +212,25 @@ async def process_and_notify(self, alerts: WeatherAlerts) -> int: logger.error(f"Error processing alert notifications: {e}") return 0 + def _trigger_immediate_alert_popup_if_enabled( + self, + sorted_notifications: list[tuple[WeatherAlert, str]], + ) -> None: + """Open in-app alert popups for the current eligible batch when opted in.""" + if not getattr(self.settings, "immediate_alert_details_popups", False): + return + if not callable(self.on_alerts_popup): + return + + popup_alerts = [alert for alert, _reason in sorted_notifications] + if not popup_alerts: + return + + try: + self.on_alerts_popup(popup_alerts) + except Exception as exc: + logger.error("Failed to trigger immediate alert popup: %s", exc) + async def _send_alert_notification( self, alert: WeatherAlert, reason: str, play_sound: bool = True ) -> bool: diff --git a/src/accessiweather/app.py b/src/accessiweather/app.py index 51289f2bf..f7940a687 100644 --- a/src/accessiweather/app.py +++ b/src/accessiweather/app.py @@ -44,6 +44,20 @@ logger = logging.getLogger(__name__) +def show_alert_dialog(parent, alert) -> None: + """Lazy wrapper for the single-alert details dialog.""" + from .ui.dialogs import show_alert_dialog as _show_alert_dialog + + _show_alert_dialog(parent, alert) + + +def show_alerts_summary_dialog(parent, alerts) -> None: + """Lazy wrapper for the combined multi-alert dialog.""" + from .ui.dialogs import show_alerts_summary_dialog as _show_alerts_summary_dialog + + _show_alerts_summary_dialog(parent, alerts) + + class AccessiWeatherApp(wx.App): """AccessiWeather application using wxPython.""" @@ -251,6 +265,23 @@ def _schedule_startup_activation_request(self) -> None: if self._activation_request is not None: wx.CallAfter(self._handle_notification_activation_request, self._activation_request) + def _queue_immediate_alert_popup(self, alerts) -> None: + """Queue an in-app alert popup onto the UI thread.""" + if not alerts: + return + wx.CallAfter(self._show_immediate_alert_popup, list(alerts)) + + def _show_immediate_alert_popup(self, alerts) -> None: + """Show the opted-in in-app alert popup without restoring the main window.""" + if self.main_window is None or not alerts: + return + + if len(alerts) == 1: + show_alert_dialog(self.main_window, alerts[0]) + return + + show_alerts_summary_dialog(self.main_window, alerts) + def _wire_notifier_activation_callback(self) -> None: """Connect the notifier's in-process activation callback to the UI thread.""" notifier = self._notifier diff --git a/src/accessiweather/app_initialization.py b/src/accessiweather/app_initialization.py index 4d0c06756..d28858138 100644 --- a/src/accessiweather/app_initialization.py +++ b/src/accessiweather/app_initialization.py @@ -105,7 +105,10 @@ def initialize_components(app: AccessiWeatherApp) -> None: runtime_state_manager=app.runtime_state_manager, ) app.alert_notification_system = AlertNotificationSystem( - app.alert_manager, app._notifier, config.settings + app.alert_manager, + app._notifier, + config.settings, + on_alerts_popup=app._queue_immediate_alert_popup, ) # Defer weather history service initialization diff --git a/src/accessiweather/models/config.py b/src/accessiweather/models/config.py index 2736f8f96..766f18c58 100644 --- a/src/accessiweather/models/config.py +++ b/src/accessiweather/models/config.py @@ -26,6 +26,7 @@ "alert_notify_moderate", "alert_notify_minor", "alert_notify_unknown", + "immediate_alert_details_popups", "alert_global_cooldown_minutes", "alert_per_alert_cooldown_minutes", "alert_escalation_cooldown_minutes", @@ -134,6 +135,7 @@ class AppSettings: alert_notify_moderate: bool = True alert_notify_minor: bool = False alert_notify_unknown: bool = False + immediate_alert_details_popups: bool = False alert_global_cooldown_minutes: int = 5 alert_per_alert_cooldown_minutes: int = 60 alert_escalation_cooldown_minutes: int = 15 @@ -433,6 +435,7 @@ def to_dict(self) -> dict: "alert_notify_moderate": self.alert_notify_moderate, "alert_notify_minor": self.alert_notify_minor, "alert_notify_unknown": self.alert_notify_unknown, + "immediate_alert_details_popups": self.immediate_alert_details_popups, "alert_global_cooldown_minutes": self.alert_global_cooldown_minutes, "alert_per_alert_cooldown_minutes": self.alert_per_alert_cooldown_minutes, "alert_escalation_cooldown_minutes": self.alert_escalation_cooldown_minutes, @@ -519,6 +522,9 @@ def from_dict(cls, data: dict) -> AppSettings: alert_notify_moderate=cls._as_bool(data.get("alert_notify_moderate"), True), alert_notify_minor=cls._as_bool(data.get("alert_notify_minor"), False), alert_notify_unknown=cls._as_bool(data.get("alert_notify_unknown"), False), + immediate_alert_details_popups=cls._as_bool( + data.get("immediate_alert_details_popups"), False + ), alert_global_cooldown_minutes=data.get("alert_global_cooldown_minutes", 5), alert_per_alert_cooldown_minutes=data.get("alert_per_alert_cooldown_minutes", 60), alert_escalation_cooldown_minutes=data.get("alert_escalation_cooldown_minutes", 15), diff --git a/src/accessiweather/ui/dialogs/__init__.py b/src/accessiweather/ui/dialogs/__init__.py index 7bb751cae..a71e6f7ae 100644 --- a/src/accessiweather/ui/dialogs/__init__.py +++ b/src/accessiweather/ui/dialogs/__init__.py @@ -2,6 +2,7 @@ from .air_quality_dialog import show_air_quality_dialog from .alert_dialog import show_alert_dialog +from .alerts_summary_dialog import show_alerts_summary_dialog from .aviation_dialog import show_aviation_dialog from .discussion_dialog import show_discussion_dialog from .explanation_dialog import show_explanation_dialog @@ -19,6 +20,7 @@ "show_add_location_dialog", "show_air_quality_dialog", "show_alert_dialog", + "show_alerts_summary_dialog", "show_aviation_dialog", "show_discussion_dialog", "show_explanation_dialog", @@ -36,6 +38,7 @@ _LAZY_IMPORTS = { "show_air_quality_dialog": ".air_quality_dialog", "show_alert_dialog": ".alert_dialog", + "show_alerts_summary_dialog": ".alerts_summary_dialog", "show_aviation_dialog": ".aviation_dialog", "show_discussion_dialog": ".discussion_dialog", "show_explanation_dialog": ".explanation_dialog", diff --git a/src/accessiweather/ui/dialogs/alerts_summary_dialog.py b/src/accessiweather/ui/dialogs/alerts_summary_dialog.py new file mode 100644 index 000000000..7b512a480 --- /dev/null +++ b/src/accessiweather/ui/dialogs/alerts_summary_dialog.py @@ -0,0 +1,103 @@ +"""Combined dialog for multiple newly eligible weather alerts.""" + +from __future__ import annotations + +import logging + +import wx + +logger = logging.getLogger(__name__) + + +def show_alerts_summary_dialog(parent, alerts) -> None: + """Show a combined summary dialog for multiple alerts.""" + try: + dlg = AlertsSummaryDialog(parent, alerts) + dlg.ShowModal() + dlg.Destroy() + except Exception as exc: + logger.error("Failed to show alerts summary dialog: %s", exc) + wx.MessageBox( + f"Failed to open the alerts summary: {exc}", + "Error", + wx.OK | wx.ICON_ERROR, + ) + + +class AlertsSummaryDialog(wx.Dialog): + """Dialog listing multiple newly eligible alerts in one place.""" + + def __init__(self, parent, alerts): + """Initialize the dialog with the current batch of newly eligible alerts.""" + super().__init__( + parent, + title="New Weather Alerts", + size=(720, 480), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self.alerts = list(alerts) + self._create_ui() + self._setup_accessibility() + self.Bind(wx.EVT_CHAR_HOOK, self._on_key) + + def _create_ui(self) -> None: + panel = wx.Panel(self) + main_sizer = wx.BoxSizer(wx.VERTICAL) + + intro = wx.StaticText( + panel, + label="Multiple new alerts arrived in this update. Review the summaries below.", + ) + main_sizer.Add(intro, 0, wx.ALL, 15) + + self.summary_ctrl = wx.TextCtrl( + panel, + value=self._build_summary_text(), + style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2, + ) + main_sizer.Add(self.summary_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 15) + + button_sizer = wx.BoxSizer(wx.HORIZONTAL) + button_sizer.AddStretchSpacer() + + close_btn = wx.Button(panel, wx.ID_CLOSE, "Close") + close_btn.Bind(wx.EVT_BUTTON, self._on_close) + button_sizer.Add(close_btn, 0) + + main_sizer.Add(button_sizer, 0, wx.EXPAND | wx.ALL, 15) + panel.SetSizer(main_sizer) + self.summary_ctrl.SetFocus() + + def _build_summary_text(self) -> str: + sections: list[str] = [] + for index, alert in enumerate(self.alerts, start=1): + header = ( + getattr(alert, "headline", None) or getattr(alert, "event", None) or "Weather Alert" + ) + details = [ + f"{index}. {header}", + f"Severity: {getattr(alert, 'severity', 'Unknown')}", + ] + urgency = getattr(alert, "urgency", None) + if urgency: + details.append(f"Urgency: {urgency}") + certainty = getattr(alert, "certainty", None) + if certainty: + details.append(f"Certainty: {certainty}") + description = getattr(alert, "description", None) + if description: + details.append(f"Details: {description}") + sections.append("\n".join(details)) + return "\n\n".join(sections) + + def _setup_accessibility(self) -> None: + self.summary_ctrl.SetName("New alert summaries") + + def _on_key(self, event) -> None: + if event.GetKeyCode() == wx.WXK_ESCAPE: + self.Close() + return + event.Skip() + + def _on_close(self, _event) -> None: + self.EndModal(wx.ID_CLOSE) diff --git a/src/accessiweather/ui/dialogs/settings_tabs/notifications.py b/src/accessiweather/ui/dialogs/settings_tabs/notifications.py index 46834f3b2..28493681e 100644 --- a/src/accessiweather/ui/dialogs/settings_tabs/notifications.py +++ b/src/accessiweather/ui/dialogs/settings_tabs/notifications.py @@ -43,6 +43,12 @@ def create(self): controls["alert_notif"] = wx.CheckBox(panel, label="Enable alert notifications") sizer.Add(controls["alert_notif"], 0, wx.LEFT | wx.BOTTOM, 5) + controls["immediate_alert_details_popups"] = wx.CheckBox( + panel, + label="Open alert details popups immediately while AccessiWeather is running", + ) + sizer.Add(controls["immediate_alert_details_popups"], 0, wx.LEFT | wx.BOTTOM, 5) + row_area = wx.BoxSizer(wx.HORIZONTAL) row_area.Add( wx.StaticText(panel, label="Alert Area:"), @@ -164,6 +170,9 @@ def load(self, settings): controls["notify_moderate"].SetValue(getattr(settings, "alert_notify_moderate", True)) controls["notify_minor"].SetValue(getattr(settings, "alert_notify_minor", False)) controls["notify_unknown"].SetValue(getattr(settings, "alert_notify_unknown", False)) + controls["immediate_alert_details_popups"].SetValue( + getattr(settings, "immediate_alert_details_popups", False) + ) controls["global_cooldown"].SetValue(getattr(settings, "alert_global_cooldown_minutes", 5)) controls["per_alert_cooldown"].SetValue( @@ -201,6 +210,7 @@ def save(self) -> dict: "alert_notify_moderate": controls["notify_moderate"].GetValue(), "alert_notify_minor": controls["notify_minor"].GetValue(), "alert_notify_unknown": controls["notify_unknown"].GetValue(), + "immediate_alert_details_popups": controls["immediate_alert_details_popups"].GetValue(), "alert_global_cooldown_minutes": controls["global_cooldown"].GetValue(), "alert_per_alert_cooldown_minutes": controls["per_alert_cooldown"].GetValue(), "alert_freshness_window_minutes": controls["freshness_window"].GetValue(), @@ -227,6 +237,7 @@ def setup_accessibility(self): "notify_moderate": "Moderate - Potentially hazardous (e.g., Winter Weather Advisory)", "notify_minor": "Minor - Low impact events (e.g., Frost Advisory, Fog Advisory)", "notify_unknown": "Unknown - Uncategorized alerts", + "immediate_alert_details_popups": "Open alert details popups immediately while AccessiWeather is running", "notify_discussion_update": "Notify when Area Forecast Discussion is updated (NWS US only)", "notify_severe_risk_change": "Notify when severe weather risk level changes (Visual Crossing only)", "notify_minutely_precipitation_start": "Notify when precipitation is expected to start soon (Pirate Weather)", diff --git a/tests/test_alert_notification_system.py b/tests/test_alert_notification_system.py index 732f7fae7..5d61b106f 100644 --- a/tests/test_alert_notification_system.py +++ b/tests/test_alert_notification_system.py @@ -242,6 +242,93 @@ async def test_send_notification_without_sound(self, notification_system, mock_n assert call_kwargs.get("play_sound") is False +class TestImmediateAlertPopupOptIn: + """Tests for immediate in-app alert popup opt-in behavior.""" + + @pytest.fixture + def mock_notifier(self): + notifier = MagicMock() + notifier.send_notification = MagicMock(return_value=True) + notifier.sound_enabled = True + return notifier + + @pytest.fixture + def popup_callback(self): + return MagicMock() + + @pytest.fixture + def alert_manager(self, tmp_path): + return AlertManager(str(tmp_path / "alerts")) + + @pytest.fixture + def popup_enabled_system(self, alert_manager, mock_notifier, popup_callback): + settings = MagicMock() + settings.immediate_alert_details_popups = True + return AlertNotificationSystem( + alert_manager=alert_manager, + notifier=mock_notifier, + settings=settings, + on_alerts_popup=popup_callback, + ) + + @pytest.fixture + def popup_disabled_system(self, alert_manager, mock_notifier, popup_callback): + settings = MagicMock() + settings.immediate_alert_details_popups = False + return AlertNotificationSystem( + alert_manager=alert_manager, + notifier=mock_notifier, + settings=settings, + on_alerts_popup=popup_callback, + ) + + @pytest.fixture + def eligible_alerts(self): + now = datetime.now(UTC) + return WeatherAlerts( + alerts=[ + WeatherAlert( + id="alert-one", + title="Severe Thunderstorm Warning", + description="Severe thunderstorms expected.", + severity="Severe", + urgency="Immediate", + certainty="Observed", + event="Severe Thunderstorm Warning", + expires=now + timedelta(hours=1), + ), + WeatherAlert( + id="alert-two", + title="Tornado Warning", + description="A tornado has been spotted.", + severity="Extreme", + urgency="Immediate", + certainty="Observed", + event="Tornado Warning", + expires=now + timedelta(hours=1), + ), + ] + ) + + @pytest.mark.asyncio + async def test_process_and_notify_triggers_popup_callback_for_newly_eligible_alerts( + self, popup_enabled_system, popup_callback, eligible_alerts + ): + await popup_enabled_system.process_and_notify(eligible_alerts) + + popup_callback.assert_called_once() + popup_alerts = popup_callback.call_args.args[0] + assert [alert.get_unique_id() for alert in popup_alerts] == ["alert-two", "alert-one"] + + @pytest.mark.asyncio + async def test_process_and_notify_skips_popup_callback_when_opt_out( + self, popup_disabled_system, popup_callback, eligible_alerts + ): + await popup_disabled_system.process_and_notify(eligible_alerts) + + popup_callback.assert_not_called() + + class TestNotifyLifecycleChanges: """Tests for AlertNotificationSystem.notify_lifecycle_changes().""" diff --git a/tests/test_app_immediate_alert_popups.py b/tests/test_app_immediate_alert_popups.py new file mode 100644 index 000000000..3e3a221b0 --- /dev/null +++ b/tests/test_app_immediate_alert_popups.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from accessiweather.app import AccessiWeatherApp + + +class _AlertStub: + def __init__(self, unique_id: str) -> None: + self._unique_id = unique_id + + def get_unique_id(self) -> str: + return self._unique_id + + +def test_show_immediate_alert_popup_uses_existing_single_alert_dialog() -> None: + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + app.main_window = MagicMock() + app.tray_icon = SimpleNamespace(show_main_window=MagicMock()) + alert = _AlertStub("alpha") + + with ( + patch("accessiweather.app.show_alert_dialog") as mock_show_alert_dialog, + patch("accessiweather.app.show_alerts_summary_dialog") as mock_show_summary_dialog, + ): + app._show_immediate_alert_popup([alert]) + + mock_show_alert_dialog.assert_called_once_with(app.main_window, alert) + mock_show_summary_dialog.assert_not_called() + + +def test_show_immediate_alert_popup_uses_combined_dialog_for_multiple_alerts() -> None: + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + app.main_window = MagicMock() + app.tray_icon = SimpleNamespace(show_main_window=MagicMock()) + alerts = [_AlertStub("alpha"), _AlertStub("beta")] + + with ( + patch("accessiweather.app.show_alert_dialog") as mock_show_alert_dialog, + patch("accessiweather.app.show_alerts_summary_dialog") as mock_show_summary_dialog, + ): + app._show_immediate_alert_popup(alerts) + + mock_show_alert_dialog.assert_not_called() + mock_show_summary_dialog.assert_called_once_with(app.main_window, alerts) + + +def test_show_immediate_alert_popup_does_not_restore_main_window() -> None: + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + app.main_window = MagicMock() + app.tray_icon = SimpleNamespace(show_main_window=MagicMock()) + alerts = [_AlertStub("alpha"), _AlertStub("beta")] + + with patch("accessiweather.app.show_alerts_summary_dialog"): + app._show_immediate_alert_popup(alerts) + + app.tray_icon.show_main_window.assert_not_called() + app.main_window.Show.assert_not_called() + app.main_window.Iconize.assert_not_called() + app.main_window.Raise.assert_not_called() diff --git a/tests/test_models.py b/tests/test_models.py index 55ca186eb..dece0ebcb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -400,6 +400,14 @@ def test_minutely_notification_settings_round_trip(self): assert restored.notify_minutely_precipitation_start is True assert restored.notify_minutely_precipitation_stop is False + def test_immediate_alert_popup_setting_round_trip(self): + """Immediate alert popup opt-in should serialize and load cleanly.""" + settings = AppSettings(immediate_alert_details_popups=True) + + restored = AppSettings.from_dict(settings.to_dict()) + + assert restored.immediate_alert_details_popups is True + def test_forecast_time_reference_validation(self): """Ensure invalid forecast_time_reference values fall back to location.""" settings = AppSettings() diff --git a/tests/test_settings_dialog_tray_text.py b/tests/test_settings_dialog_tray_text.py index 1f8fadf24..b8676130d 100644 --- a/tests/test_settings_dialog_tray_text.py +++ b/tests/test_settings_dialog_tray_text.py @@ -6,6 +6,7 @@ from accessiweather.ui.dialogs.settings_dialog import SettingsDialogSimple from accessiweather.ui.dialogs.settings_tabs.display import DisplayTab from accessiweather.ui.dialogs.settings_tabs.general import GeneralTab +from accessiweather.ui.dialogs.settings_tabs.notifications import NotificationsTab class _DummyControl: @@ -75,8 +76,9 @@ def _make_dialog_for_settings(settings: SimpleNamespace) -> SettingsDialogSimple # Wire up tab objects so _load_settings/_save_settings delegate correctly general_tab = GeneralTab(dialog) display_tab = DisplayTab(dialog) + notifications_tab = NotificationsTab(dialog) dialog._display_tab = display_tab - dialog._tab_objects = [general_tab, display_tab] + dialog._tab_objects = [general_tab, display_tab, notifications_tab] return dialog @@ -132,6 +134,28 @@ def test_save_settings_persists_tray_text_fields(): assert kwargs["taskbar_icon_text_format"] == "{temp}" +def test_load_settings_populates_immediate_alert_popup_opt_in(): + settings = SimpleNamespace(immediate_alert_details_popups=True) + dialog = _make_dialog_for_settings(settings) + + dialog._load_settings() + + assert dialog._controls["immediate_alert_details_popups"].GetValue() is True + + +def test_save_settings_persists_immediate_alert_popup_opt_in(): + dialog = _make_dialog_for_settings(SimpleNamespace()) + dialog._get_ai_model_preference = lambda: "openrouter/free" + dialog.config_manager.update_settings.return_value = True + dialog._controls["immediate_alert_details_popups"].SetValue(True) + + success = dialog._save_settings() + + assert success is True + kwargs = dialog.config_manager.update_settings.call_args.kwargs + assert kwargs["immediate_alert_details_popups"] is True + + def test_get_selected_temperature_unit_uses_current_choice(): dialog = _make_dialog_for_settings(SimpleNamespace()) dialog._controls["temp_unit"].SetSelection(2) From 978c7da83fa2da7c8605cd78e977d1bcae96b9eb Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 13:04:23 +0000 Subject: [PATCH 2/4] fix(ci): allow optional alert popup callback --- src/accessiweather/app_initialization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accessiweather/app_initialization.py b/src/accessiweather/app_initialization.py index d28858138..4eae2d776 100644 --- a/src/accessiweather/app_initialization.py +++ b/src/accessiweather/app_initialization.py @@ -108,7 +108,7 @@ def initialize_components(app: AccessiWeatherApp) -> None: app.alert_manager, app._notifier, config.settings, - on_alerts_popup=app._queue_immediate_alert_popup, + on_alerts_popup=getattr(app, "_queue_immediate_alert_popup", None), ) # Defer weather history service initialization From f5d99b7d6b425799b972a2c73bc1cc0dd1fa7123 Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 13:07:49 +0000 Subject: [PATCH 3/4] docs(changelog): note immediate alert popups --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47732f267..c6a03f535 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [0.4.5] - 2026-03-26 ### Added +- **Optional in-app alert detail popups** — while AccessiWeather is already running, you can now choose to open newly eligible alert details immediately instead of only getting toast notifications (#578) - **Pirate Weather full integration** — Pirate Weather is now a first-class data source with alerts, hourly and minutely forecasts, and automatic fusion alongside NWS and Open-Meteo (#479) - **AVWX aviation source** — TAF and METAR data from AVWX for international locations (#480) - **All Locations summary view** — see a compact weather overview for all your saved locations at once (#518) From 3a6824a3e4794965972caff1b830bd3e1d867a40 Mon Sep 17 00:00:00 2001 From: Orinks Date: Wed, 8 Apr 2026 13:32:02 +0000 Subject: [PATCH 4/4] test(alerts): cover popup diff paths --- tests/test_app_immediate_alert_popups.py | 89 +++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/tests/test_app_immediate_alert_popups.py b/tests/test_app_immediate_alert_popups.py index 3e3a221b0..88b78f1ec 100644 --- a/tests/test_app_immediate_alert_popups.py +++ b/tests/test_app_immediate_alert_popups.py @@ -1,9 +1,12 @@ from __future__ import annotations -from types import SimpleNamespace +import sys +from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock, patch -from accessiweather.app import AccessiWeatherApp +import pytest + +from accessiweather.app import AccessiWeatherApp, show_alert_dialog, show_alerts_summary_dialog class _AlertStub: @@ -14,6 +17,64 @@ def get_unique_id(self) -> str: return self._unique_id +def _dialogs_module( + *, + show_single: MagicMock | None = None, + show_summary: MagicMock | None = None, +) -> ModuleType: + module = ModuleType("accessiweather.ui.dialogs") + module.show_alert_dialog = show_single or MagicMock() + module.show_alerts_summary_dialog = show_summary or MagicMock() + return module + + +def test_show_alert_dialog_lazy_wrapper_forwards_to_dialog_module() -> None: + parent = object() + alert = _AlertStub("alpha") + mock_show_single = MagicMock() + dialogs_module = _dialogs_module(show_single=mock_show_single) + + with patch.dict(sys.modules, {"accessiweather.ui.dialogs": dialogs_module}): + show_alert_dialog(parent, alert) + + mock_show_single.assert_called_once_with(parent, alert) + + +def test_show_alerts_summary_dialog_lazy_wrapper_forwards_to_dialog_module() -> None: + parent = object() + alerts = [_AlertStub("alpha"), _AlertStub("beta")] + mock_show_summary = MagicMock() + dialogs_module = _dialogs_module(show_summary=mock_show_summary) + + with patch.dict(sys.modules, {"accessiweather.ui.dialogs": dialogs_module}): + show_alerts_summary_dialog(parent, alerts) + + mock_show_summary.assert_called_once_with(parent, alerts) + + +def test_queue_immediate_alert_popup_uses_callafter_with_alert_copy() -> None: + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + alerts = (_AlertStub("alpha"), _AlertStub("beta")) + + with patch("accessiweather.app.wx.CallAfter") as mock_call_after: + app._queue_immediate_alert_popup(alerts) + + mock_call_after.assert_called_once() + callback, queued_alerts = mock_call_after.call_args.args + assert callback == app._show_immediate_alert_popup + assert queued_alerts == list(alerts) + assert isinstance(queued_alerts, list) + + +def test_queue_immediate_alert_popup_skips_empty_alert_list() -> None: + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + + with patch("accessiweather.app.wx.CallAfter") as mock_call_after: + app._queue_immediate_alert_popup([]) + + mock_call_after.assert_not_called() + + def test_show_immediate_alert_popup_uses_existing_single_alert_dialog() -> None: app = AccessiWeatherApp.__new__(AccessiWeatherApp) app.main_window = MagicMock() @@ -59,3 +120,27 @@ def test_show_immediate_alert_popup_does_not_restore_main_window() -> None: app.main_window.Show.assert_not_called() app.main_window.Iconize.assert_not_called() app.main_window.Raise.assert_not_called() + + +@pytest.mark.parametrize( + ("main_window", "alerts"), + [ + (None, [_AlertStub("alpha")]), + (MagicMock(), []), + ], +) +def test_show_immediate_alert_popup_ignores_missing_window_or_empty_alerts( + main_window, + alerts, +) -> None: + app = AccessiWeatherApp.__new__(AccessiWeatherApp) + app.main_window = main_window + + with ( + patch("accessiweather.app.show_alert_dialog") as mock_show_alert_dialog, + patch("accessiweather.app.show_alerts_summary_dialog") as mock_show_summary_dialog, + ): + app._show_immediate_alert_popup(alerts) + + mock_show_alert_dialog.assert_not_called() + mock_show_summary_dialog.assert_not_called()