From 87694b63779aa145ebc0932de50e794c05a17df4 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 07:16:44 -0400 Subject: [PATCH 01/15] feat(sounds): add specific alert sound toggle Keep severity-based alert sounds as the default while letting users opt into exact alert and hazard/type sound keys from custom packs. Constraint: default alert sound behavior must remain severity-first Rejected: fully reverting the simplification | it would bring back the long visible alert catalog for everyone Confidence: high Scope-risk: moderate Directive: keep specific alert keys opt-in and preserve severity fallback order Tested: pytest tests/test_settings_dialog_audio_events.py tests/test_alert_sound_mapper.py tests/test_alert_notification_system.py tests/test_toasted_windows_notifier.py::TestToastedWindowsNotifierSend::test_send_uses_sound_candidates_when_provided tests/test_sound_player.py::TestGetSoundEntry::test_get_sound_entry_for_candidates_falls_back_within_pack tests/test_sound_player.py::TestUserLevelMute tests/test_soundpack_event_catalog.py -q; ruff check targeted files; ruff format --check targeted files; pyright; git diff --check Not-tested: manual audio playback through the desktop UI Co-authored-by: OmX --- CHANGELOG.md | 1 + docs/SOUND_PACK_SYSTEM.md | 9 +- .../alert_notification_system.py | 7 +- src/accessiweather/models/config_constants.py | 1 + .../models/config_serialization.py | 4 + src/accessiweather/models/config_settings.py | 1 + .../notifications/alert_sound_mapper.py | 119 ++++++++++++++++-- .../ui/dialogs/settings_tabs/audio.py | 17 +++ tests/test_alert_notification_system.py | 25 ++++ tests/test_alert_sound_mapper.py | 44 +++++++ tests/test_settings_dialog_audio_events.py | 15 +++ 11 files changed, 231 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2620ef2f3..ff6b48660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added +- Settings > Audio now has an optional specific-alert sound mode, so custom sound packs can use different sounds for alerts like tornado watch and tornado warning while the default stays severity-based. - First-run setup can now import existing settings and encrypted API keys from the wizard, or exit with Escape at any wizard step when you want to configure everything yourself. - You can now search for a US street address when adding or editing a location, so AccessiWeather can save coordinates for that specific address instead of the nearest city or ZIP result. - Saved locations now stay sorted alphabetically in the location list (#667). diff --git a/docs/SOUND_PACK_SYSTEM.md b/docs/SOUND_PACK_SYSTEM.md index 0307c94a9..f443bb792 100644 --- a/docs/SOUND_PACK_SYSTEM.md +++ b/docs/SOUND_PACK_SYSTEM.md @@ -101,7 +101,14 @@ AccessiWeather maps alert notifications by severity first, then falls back to Older sound packs can keep specific keys such as `tornado_warning`, `warning`, or `watch`; AccessiWeather still tolerates those mappings for compatibility, -but new packs should use the compact severity keys above. +but new packs should use the compact severity keys above by default. + +Users who want different sounds for specific alerts can turn on **Use specific +alert sounds when available** in Settings > Audio. With that option enabled, +AccessiWeather tries specific alert keys before severity keys. For example, a +Tornado Warning can use `tornado_warning`, while a Tornado Watch can use +`tornado_watch`; if those sounds are missing, playback still falls back to +severity, then `alert`, then `notify`. ## Built-in Sound Packs diff --git a/src/accessiweather/alert_notification_system.py b/src/accessiweather/alert_notification_system.py index b9073ee25..04a95075e 100644 --- a/src/accessiweather/alert_notification_system.py +++ b/src/accessiweather/alert_notification_system.py @@ -198,7 +198,12 @@ async def _send_alert_notification( try: from .notifications.alert_sound_mapper import get_candidate_sound_events - sound_candidates = get_candidate_sound_events(alert) + sound_candidates = get_candidate_sound_events( + alert, + include_specific_events=getattr( + self.settings, "specific_alert_sounds_enabled", False + ), + ) logger.debug(f"[notify] Sound candidates for alert: {sound_candidates}") except Exception as e: logger.debug(f"[notify] Sound mapper unavailable: {e}") diff --git a/src/accessiweather/models/config_constants.py b/src/accessiweather/models/config_constants.py index 3170c69c5..47456dbef 100644 --- a/src/accessiweather/models/config_constants.py +++ b/src/accessiweather/models/config_constants.py @@ -32,6 +32,7 @@ "sound_enabled", "sound_pack", "muted_sound_events", + "specific_alert_sounds_enabled", # Event notifications "notify_discussion_update", "notify_hwo_update", diff --git a/src/accessiweather/models/config_serialization.py b/src/accessiweather/models/config_serialization.py index b99b67c3e..30d1d6186 100644 --- a/src/accessiweather/models/config_serialization.py +++ b/src/accessiweather/models/config_serialization.py @@ -31,6 +31,7 @@ def to_dict(self) -> dict: "sound_enabled": settings.sound_enabled, "sound_pack": settings.sound_pack, "muted_sound_events": settings.muted_sound_events, + "specific_alert_sounds_enabled": settings.specific_alert_sounds_enabled, "notify_discussion_update": settings.notify_discussion_update, "notify_hwo_update": settings.notify_hwo_update, "notify_sps_issued": settings.notify_sps_issued, @@ -125,6 +126,9 @@ def from_dict(cls, data: dict) -> AppSettings: sound_enabled=settings_cls._as_bool(data.get("sound_enabled"), True), sound_pack=data.get("sound_pack", "default"), muted_sound_events=data.get("muted_sound_events", list(DEFAULT_MUTED_SOUND_EVENTS)), + specific_alert_sounds_enabled=settings_cls._as_bool( + data.get("specific_alert_sounds_enabled"), False + ), notify_discussion_update=settings_cls._as_bool( data.get("notify_discussion_update"), True ), diff --git a/src/accessiweather/models/config_settings.py b/src/accessiweather/models/config_settings.py index 6ccaa691e..8fe770cad 100644 --- a/src/accessiweather/models/config_settings.py +++ b/src/accessiweather/models/config_settings.py @@ -27,6 +27,7 @@ class AppSettings(AppSettingsValidationMixin, AppSettingsSerializationMixin): sound_enabled: bool = True sound_pack: str = "default" muted_sound_events: list[str] = field(default_factory=lambda: list(DEFAULT_MUTED_SOUND_EVENTS)) + specific_alert_sounds_enabled: bool = False # Event-based notifications notify_discussion_update: bool = True notify_hwo_update: bool = True diff --git a/src/accessiweather/notifications/alert_sound_mapper.py b/src/accessiweather/notifications/alert_sound_mapper.py index 566502645..fb38eb93f 100644 --- a/src/accessiweather/notifications/alert_sound_mapper.py +++ b/src/accessiweather/notifications/alert_sound_mapper.py @@ -1,15 +1,25 @@ """ Alert-to-sound mapping utilities. -This module maps weather alerts to a compact set of sound event keys. Alert -sounds are intentionally severity-first so providers do not need exact event -text to agree before a useful sound can play. +This module maps weather alerts to sound event keys. The default path is +severity-first so providers do not need exact event text to agree before a +useful sound can play. Users can opt into specific alert sounds, which tries +normalized alert-event keys before the severity fallback. """ from __future__ import annotations +import re + from ..models import WeatherAlert +KNOWN_ALERT_TYPE_KEYS = [ + "warning", + "watch", + "advisory", + "statement", +] + KNOWN_SEVERITY_KEYS = [ "extreme", "severe", @@ -21,6 +31,23 @@ GENERIC_FALLBACKS = ["alert", "notify"] +def _contains_token(text: str | None, token: str) -> bool: + if not text: + return False + return re.search(rf"\b{re.escape(token)}\b", text, flags=re.IGNORECASE) is not None + + +def _extract_alert_type(alert: WeatherAlert) -> str | None: + for key in KNOWN_ALERT_TYPE_KEYS: + if ( + _contains_token(alert.event, key) + or _contains_token(alert.headline, key) + or _contains_token(alert.title, key) + ): + return key + return None + + def _normalize_severity(sev: str | None) -> str | None: if not sev: return None @@ -37,27 +64,99 @@ def _normalize_severity(sev: str | None) -> str | None: return alias if alias in KNOWN_SEVERITY_KEYS else None -def get_candidate_sound_events(alert: WeatherAlert) -> list[str]: +HAZARD_KEYWORDS = { + "flood": ["flood"], + "tornado": ["tornado"], + "heat": ["heat", "excessive heat"], + "wind": ["wind", "high wind"], + "winter": ["winter", "winter storm"], + "snow": ["snow", "heavy snow"], + "ice": ["ice", "freezing rain", "freezing drizzle"], + "thunderstorm": ["thunderstorm", "severe thunderstorm"], + "hurricane": ["hurricane"], + "fire": ["fire", "red flag"], + "fog": ["fog", "dense fog"], + "dust": ["dust", "blowing dust"], + "air_quality": ["air quality", "smoke"], +} + + +def _extract_hazard(alert: WeatherAlert) -> str | None: + text = " ".join( + [ + getattr(alert, "event", "") or "", + getattr(alert, "headline", "") or "", + getattr(alert, "title", "") or "", + getattr(alert, "description", "") or "", + ] + ).lower() + for hazard_key, phrases in HAZARD_KEYWORDS.items(): + for phrase in phrases: + if phrase in text: + return hazard_key + return None + + +def _normalize_event_to_key(text: str | None) -> str | None: + """ + Normalize an alert event/title/headline to a pack key. + + Example: "Excessive Heat Watch" -> "excessive_heat_watch" + """ + if not text: + return None + s = text.strip().lower() + s = re.sub(r"[^a-z0-9]+", "_", s) + s = re.sub(r"_+", "_", s) + s = s.strip("_") + return s or None + + +def _add_unique(candidates: list[str], key: str | None) -> None: + if key and key not in candidates: + candidates.append(key) + + +def _add_specific_candidates(alert: WeatherAlert, candidates: list[str], sev: str | None) -> None: + normalized_event = _normalize_event_to_key(getattr(alert, "event", None)) + _add_unique(candidates, normalized_event) + + atype = _extract_alert_type(alert) + hazard = _extract_hazard(alert) + + if hazard and atype: + _add_unique(candidates, f"{hazard}_{atype}") + if hazard and sev: + _add_unique(candidates, f"{hazard}_{sev}") + _add_unique(candidates, hazard) + _add_unique(candidates, atype) + + +def get_candidate_sound_events( + alert: WeatherAlert, *, include_specific_events: bool = False +) -> list[str]: """ Return an ordered list of candidate sound event keys for an alert. Order of preference: + - Optional specific alert keys when include_specific_events is true - Severity level (extreme/severe/moderate/minor), including provider aliases - Generic fallbacks (alert, notify) """ candidates: list[str] = [] sev = _normalize_severity(getattr(alert, "severity", None)) - if sev and sev not in candidates: - candidates.append(sev) + if include_specific_events: + _add_specific_candidates(alert, candidates, sev) + + _add_unique(candidates, sev) for fb in GENERIC_FALLBACKS: - if fb not in candidates: - candidates.append(fb) + _add_unique(candidates, fb) return candidates -def choose_sound_event(alert: WeatherAlert) -> str: +def choose_sound_event(alert: WeatherAlert, *, include_specific_events: bool = False) -> str: """Return the preferred sound event key for this alert (first candidate).""" - return get_candidate_sound_events(alert)[0] + return get_candidate_sound_events(alert, include_specific_events=include_specific_events)[0] diff --git a/src/accessiweather/ui/dialogs/settings_tabs/audio.py b/src/accessiweather/ui/dialogs/settings_tabs/audio.py index ce49f819f..7ec0c2d91 100644 --- a/src/accessiweather/ui/dialogs/settings_tabs/audio.py +++ b/src/accessiweather/ui/dialogs/settings_tabs/audio.py @@ -134,6 +134,18 @@ def create(self, page_label: str = "Audio"): wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10, ) + controls["specific_alert_sounds_enabled"] = wx.CheckBox( + panel, label="Use specific alert sounds when available" + ) + controls["specific_alert_sounds_enabled"].SetToolTip( + "Try custom sound pack keys like tornado_warning before severity sounds." + ) + playback_section.Add( + controls["specific_alert_sounds_enabled"], + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, + 10, + ) self.dialog._sound_pack_ids = ["default"] pack_names = ["Default"] @@ -198,6 +210,9 @@ def load(self, settings): controls = self.dialog._controls controls["sound_enabled"].SetValue(getattr(settings, "sound_enabled", True)) + controls["specific_alert_sounds_enabled"].SetValue( + getattr(settings, "specific_alert_sounds_enabled", False) + ) current_pack = getattr(settings, "sound_pack", "default") pack_ids = getattr(self.dialog, "_sound_pack_ids", ["default"]) @@ -220,6 +235,7 @@ def save(self) -> dict: "sound_enabled": controls["sound_enabled"].GetValue(), "sound_pack": sound_pack, "muted_sound_events": self._get_muted_sound_events(), + "specific_alert_sounds_enabled": controls["specific_alert_sounds_enabled"].GetValue(), } def setup_accessibility(self): @@ -227,6 +243,7 @@ def setup_accessibility(self): controls = self.dialog._controls names = { "sound_enabled": "Play notification sounds", + "specific_alert_sounds_enabled": "Use specific alert sounds when available", "sound_pack": "Sound pack", "event_sounds_summary": "Event sound summary", "configure_event_sounds": "Choose event sounds", diff --git a/tests/test_alert_notification_system.py b/tests/test_alert_notification_system.py index 6c84984f0..3d07b5f26 100644 --- a/tests/test_alert_notification_system.py +++ b/tests/test_alert_notification_system.py @@ -228,6 +228,31 @@ async def test_send_notification_with_sound(self, notification_system, mock_noti mock_notifier.send_notification.assert_called_once() call_kwargs = mock_notifier.send_notification.call_args.kwargs assert call_kwargs.get("play_sound") is True + assert call_kwargs["sound_candidates"] == ["moderate", "alert", "notify"] + + @pytest.mark.asyncio + async def test_send_notification_can_use_specific_alert_sounds( + self, alert_manager, mock_notifier + ): + """Specific alert sound preference should try exact event keys before severity.""" + settings = AppSettings(specific_alert_sounds_enabled=True) + notification_system = AlertNotificationSystem( + alert_manager=alert_manager, + notifier=mock_notifier, + settings=settings, + ) + alert = WeatherAlert( + id="test-specific", + title="Tornado Warning", + description="A tornado has been spotted.", + severity="Extreme", + event="Tornado Warning", + ) + + await notification_system._send_alert_notification(alert, "new_alert", play_sound=True) + + call_kwargs = mock_notifier.send_notification.call_args.kwargs + assert call_kwargs["sound_candidates"][:2] == ["tornado_warning", "tornado_extreme"] @pytest.mark.asyncio async def test_send_notification_without_sound(self, notification_system, mock_notifier): diff --git a/tests/test_alert_sound_mapper.py b/tests/test_alert_sound_mapper.py index c8ecb141b..e6e366c1c 100644 --- a/tests/test_alert_sound_mapper.py +++ b/tests/test_alert_sound_mapper.py @@ -78,6 +78,45 @@ def test_alert_text_does_not_generate_specific_candidates(self): assert "tornado" not in candidates assert "warning" not in candidates + def test_specific_alert_sounds_try_exact_event_before_severity(self): + candidates = get_candidate_sound_events( + _alert("Extreme", event="Tornado Warning"), include_specific_events=True + ) + + assert candidates == [ + "tornado_warning", + "tornado_extreme", + "tornado", + "warning", + "extreme", + "alert", + "notify", + ] + + def test_specific_alert_sounds_distinguish_watch_from_warning(self): + watch_candidates = get_candidate_sound_events( + _alert("Moderate", event="Tornado Watch"), include_specific_events=True + ) + warning_candidates = get_candidate_sound_events( + _alert("Extreme", event="Tornado Warning"), include_specific_events=True + ) + + assert watch_candidates[0] == "tornado_watch" + assert warning_candidates[0] == "tornado_warning" + + def test_specific_alert_sounds_include_hazard_type_fallback(self): + candidates = get_candidate_sound_events( + _alert("Severe", event="Severe Thunderstorm Warning"), + include_specific_events=True, + ) + + assert candidates[:3] == [ + "severe_thunderstorm_warning", + "thunderstorm_warning", + "thunderstorm_severe", + ] + assert candidates[-3:] == ["severe", "alert", "notify"] + def test_generic_fallbacks_always_present_at_end(self): candidates = get_candidate_sound_events(_alert("Moderate")) @@ -97,6 +136,11 @@ def test_choose_returns_first_candidate(self): def test_choose_with_unknown_severity_returns_alert(self): assert choose_sound_event(_alert("Unknown")) == "alert" + def test_choose_with_specific_alert_sounds_returns_exact_event(self): + assert ( + choose_sound_event(_alert("Extreme"), include_specific_events=True) == "tornado_warning" + ) + class TestEdgeCases: """Tests for edge cases and boundary conditions.""" diff --git a/tests/test_settings_dialog_audio_events.py b/tests/test_settings_dialog_audio_events.py index 99acf22af..3a3d20c26 100644 --- a/tests/test_settings_dialog_audio_events.py +++ b/tests/test_settings_dialog_audio_events.py @@ -101,6 +101,7 @@ def test_load_settings_updates_event_sound_state_and_summary(): sound_enabled=True, sound_pack="default", muted_sound_events=["data_updated"], + specific_alert_sounds_enabled=True, ) ) @@ -108,6 +109,7 @@ def test_load_settings_updates_event_sound_state_and_summary(): assert dialog._event_sound_states["data_updated"] is False assert dialog._event_sound_states["fetch_error"] is True + assert dialog._controls["specific_alert_sounds_enabled"].GetValue() is True total_events = len(AudioTab._build_default_event_sound_states()) assert ( dialog._controls["event_sounds_summary"].GetLabel() @@ -118,6 +120,7 @@ def test_load_settings_updates_event_sound_state_and_summary(): def test_save_settings_collects_unchecked_audio_events(): dialog = _make_dialog(SimpleNamespace()) dialog._controls["sound_enabled"].SetValue(True) + dialog._controls["specific_alert_sounds_enabled"].SetValue(True) dialog._controls["sound_pack"].SetSelection(0) dialog._event_sound_states["data_updated"] = False dialog._event_sound_states["fetch_error"] = True @@ -127,6 +130,7 @@ def test_save_settings_collects_unchecked_audio_events(): assert success is True kwargs = dialog.config_manager.update_settings.call_args.kwargs assert kwargs["muted_sound_events"] == ["data_updated"] + assert kwargs["specific_alert_sounds_enabled"] is True def test_save_settings_preserves_hidden_legacy_muted_audio_events(): @@ -188,6 +192,17 @@ def test_weather_refresh_sound_is_muted_by_default(): assert settings.muted_sound_events == ["data_updated"] +def test_specific_alert_sounds_setting_defaults_off_and_round_trips(): + settings = AppSettings.from_dict({}) + + assert settings.specific_alert_sounds_enabled is False + payload = settings.to_dict() + assert payload["specific_alert_sounds_enabled"] is False + + restored = AppSettings.from_dict({"specific_alert_sounds_enabled": "true"}) + assert restored.specific_alert_sounds_enabled is True + + def test_visible_audio_events_are_core_lifecycle_and_severity_only(): section_titles = [title for title, _description, _events in SOUND_EVENT_SECTIONS] From b1df778888b5759089fbcfaa0615ed55502f8d0d Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 07:30:00 -0400 Subject: [PATCH 02/15] feat(sounds): preserve old alert sound mappings per pack Use sound pack metadata and legacy mappings to decide when specific alert keys should be tried, while keeping severity-only packs simple by default. Constraint: old sound packs should work without users understanding alert-key internals Rejected: global specific-alert toggle | it made users manage pack internals manually Confidence: high Scope-risk: moderate Directive: keep specific alert sounds decided per selected pack and keep severity-only packs simple Tested: uv run pytest tests/test_settings_dialog_audio_events.py tests/test_alert_sound_mapper.py tests/test_alert_notification_system.py tests/test_toasted_windows_notifier.py::TestToastedWindowsNotifierSend::test_send_uses_sound_candidates_when_provided tests/test_sound_player.py::TestGetSoundEntry::test_get_sound_entry_for_candidates_falls_back_within_pack tests/test_sound_player.py::TestUserLevelMute tests/test_sound_player.py::TestSoundPackSpecificAlertMode tests/test_soundpack_event_catalog.py -q; uv run ruff check targeted files; uv run ruff format --check targeted files; uv run pyright; git --no-pager diff --check Not-tested: manual audio playback through the desktop UI Co-authored-by: OmX --- CHANGELOG.md | 2 +- docs/SOUND_PACK_SYSTEM.md | 18 ++-- .../alert_notification_system.py | 19 +++- src/accessiweather/models/config_constants.py | 2 +- .../models/config_serialization.py | 14 ++- src/accessiweather/models/config_settings.py | 2 +- .../models/config_validation.py | 13 +++ .../notifications/sound_pack_helpers.py | 36 ++++++++ .../notifications/sound_player.py | 13 ++- .../ui/dialogs/settings_tabs/audio.py | 74 +++++++++++++--- tests/test_alert_notification_system.py | 8 +- tests/test_settings_dialog_audio_events.py | 57 ++++++++++-- tests/test_sound_player.py | 86 +++++++++++++++++++ 13 files changed, 304 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6b48660..c869f4284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Added -- Settings > Audio now has an optional specific-alert sound mode, so custom sound packs can use different sounds for alerts like tornado watch and tornado warning while the default stays severity-based. +- Settings > Audio can now enable specific-alert sounds per sound pack, so packs with sounds like `tornado_watch` and `tornado_warning` keep working while severity-only packs stay simple. - First-run setup can now import existing settings and encrypted API keys from the wizard, or exit with Escape at any wizard step when you want to configure everything yourself. - You can now search for a US street address when adding or editing a location, so AccessiWeather can save coordinates for that specific address instead of the nearest city or ZIP result. - Saved locations now stay sorted alphabetically in the location list (#667). diff --git a/docs/SOUND_PACK_SYSTEM.md b/docs/SOUND_PACK_SYSTEM.md index f443bb792..71c83ec97 100644 --- a/docs/SOUND_PACK_SYSTEM.md +++ b/docs/SOUND_PACK_SYSTEM.md @@ -27,6 +27,7 @@ Each sound pack is a directory containing: "author": "Author Name", "description": "Pack description", "version": "1.0.0", + "specific_alert_sounds": false, "sounds": { "alert": "alert_sound.wav", "notify": "notification_sound.wav", @@ -103,12 +104,17 @@ Older sound packs can keep specific keys such as `tornado_warning`, `warning`, or `watch`; AccessiWeather still tolerates those mappings for compatibility, but new packs should use the compact severity keys above by default. -Users who want different sounds for specific alerts can turn on **Use specific -alert sounds when available** in Settings > Audio. With that option enabled, -AccessiWeather tries specific alert keys before severity keys. For example, a -Tornado Warning can use `tornado_warning`, while a Tornado Watch can use -`tornado_watch`; if those sounds are missing, playback still falls back to -severity, then `alert`, then `notify`. +If a pack already contains old specific alert keys, AccessiWeather +automatically tries those keys for that pack before the severity keys. Pack +authors can also set `"specific_alert_sounds": true` in `pack.json` to opt in +explicitly. + +Users who want different sounds for specific alerts in a severity-only pack can +turn on **Use specific alert sounds for this sound pack** in Settings > Audio. +That checkbox applies only to the selected pack. For example, a Tornado Warning +can use `tornado_warning`, while a Tornado Watch can use `tornado_watch`; if +those sounds are missing, playback still falls back to severity, then `alert`, +then `notify`. ## Built-in Sound Packs diff --git a/src/accessiweather/alert_notification_system.py b/src/accessiweather/alert_notification_system.py index 04a95075e..a942e457c 100644 --- a/src/accessiweather/alert_notification_system.py +++ b/src/accessiweather/alert_notification_system.py @@ -159,6 +159,21 @@ def _trigger_immediate_alert_popup_if_enabled( except Exception as exc: logger.error("Failed to trigger immediate alert popup: %s", exc) + def _should_use_specific_alert_sounds(self) -> bool: + """Return whether current sound-pack settings should try specific alert keys.""" + pack = getattr(self.settings, "sound_pack", "default") + specific_packs = set(getattr(self.settings, "specific_alert_sound_packs", []) or []) + if pack in specific_packs: + return True + + try: + from .notifications.sound_player import sound_pack_uses_specific_alert_sounds + + return sound_pack_uses_specific_alert_sounds(pack) + except Exception as e: + logger.debug(f"[notify] Could not inspect sound pack for alert sound mode: {e}") + return False + async def _send_alert_notification( self, alert: WeatherAlert, reason: str, play_sound: bool = True ) -> bool: @@ -200,9 +215,7 @@ async def _send_alert_notification( sound_candidates = get_candidate_sound_events( alert, - include_specific_events=getattr( - self.settings, "specific_alert_sounds_enabled", False - ), + include_specific_events=self._should_use_specific_alert_sounds(), ) logger.debug(f"[notify] Sound candidates for alert: {sound_candidates}") except Exception as e: diff --git a/src/accessiweather/models/config_constants.py b/src/accessiweather/models/config_constants.py index 47456dbef..cb901a103 100644 --- a/src/accessiweather/models/config_constants.py +++ b/src/accessiweather/models/config_constants.py @@ -32,7 +32,7 @@ "sound_enabled", "sound_pack", "muted_sound_events", - "specific_alert_sounds_enabled", + "specific_alert_sound_packs", # Event notifications "notify_discussion_update", "notify_hwo_update", diff --git a/src/accessiweather/models/config_serialization.py b/src/accessiweather/models/config_serialization.py index 30d1d6186..18f2912ad 100644 --- a/src/accessiweather/models/config_serialization.py +++ b/src/accessiweather/models/config_serialization.py @@ -31,7 +31,7 @@ def to_dict(self) -> dict: "sound_enabled": settings.sound_enabled, "sound_pack": settings.sound_pack, "muted_sound_events": settings.muted_sound_events, - "specific_alert_sounds_enabled": settings.specific_alert_sounds_enabled, + "specific_alert_sound_packs": settings.specific_alert_sound_packs, "notify_discussion_update": settings.notify_discussion_update, "notify_hwo_update": settings.notify_hwo_update, "notify_sps_issued": settings.notify_sps_issued, @@ -111,6 +111,13 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> AppSettings: """Create from dictionary.""" settings_cls = cast("type[AppSettings]", cls) + specific_alert_sound_packs = data.get("specific_alert_sound_packs") + if specific_alert_sound_packs is None: + specific_alert_sound_packs = [] + if settings_cls._as_bool(data.get("specific_alert_sounds_enabled"), False): + sound_pack = str(data.get("sound_pack", "default")).strip() or "default" + specific_alert_sound_packs = [sound_pack] + settings = settings_cls( temperature_unit=data.get("temperature_unit", "both"), update_interval_minutes=data.get("update_interval_minutes", 10), @@ -126,9 +133,7 @@ def from_dict(cls, data: dict) -> AppSettings: sound_enabled=settings_cls._as_bool(data.get("sound_enabled"), True), sound_pack=data.get("sound_pack", "default"), muted_sound_events=data.get("muted_sound_events", list(DEFAULT_MUTED_SOUND_EVENTS)), - specific_alert_sounds_enabled=settings_cls._as_bool( - data.get("specific_alert_sounds_enabled"), False - ), + specific_alert_sound_packs=specific_alert_sound_packs, notify_discussion_update=settings_cls._as_bool( data.get("notify_discussion_update"), True ), @@ -265,6 +270,7 @@ def from_dict(cls, data: dict) -> AppSettings: settings.validate_on_access("auto_sources_us") settings.validate_on_access("auto_sources_international") settings.validate_on_access("parallel_fetch_timeout") + settings.validate_on_access("specific_alert_sound_packs") if settings.data_source not in {"auto", "nws", "openmeteo", "pirateweather"}: settings.data_source = "auto" return settings diff --git a/src/accessiweather/models/config_settings.py b/src/accessiweather/models/config_settings.py index 8fe770cad..3c2bfb48a 100644 --- a/src/accessiweather/models/config_settings.py +++ b/src/accessiweather/models/config_settings.py @@ -27,7 +27,7 @@ class AppSettings(AppSettingsValidationMixin, AppSettingsSerializationMixin): sound_enabled: bool = True sound_pack: str = "default" muted_sound_events: list[str] = field(default_factory=lambda: list(DEFAULT_MUTED_SOUND_EVENTS)) - specific_alert_sounds_enabled: bool = False + specific_alert_sound_packs: list[str] = field(default_factory=list) # Event-based notifications notify_discussion_update: bool = True notify_hwo_update: bool = True diff --git a/src/accessiweather/models/config_validation.py b/src/accessiweather/models/config_validation.py index 05616a6f9..2b2fb4a1d 100644 --- a/src/accessiweather/models/config_validation.py +++ b/src/accessiweather/models/config_validation.py @@ -100,6 +100,19 @@ def validate_on_access(self, setting_name: str) -> bool: normalized = normalize_known_muted_sound_events(value) setattr(settings, setting_name, normalized) + elif setting_name == "specific_alert_sound_packs": + if not isinstance(value, list): + setattr(settings, setting_name, []) + else: + normalized = [] + seen = set() + for item in value: + pack = str(item).strip() + if pack and pack not in seen: + seen.add(pack) + normalized.append(pack) + setattr(settings, setting_name, normalized) + elif setting_name == "taskbar_icon_text_format": # Ensure format string is valid if not isinstance(value, str) or not value.strip(): diff --git a/src/accessiweather/notifications/sound_pack_helpers.py b/src/accessiweather/notifications/sound_pack_helpers.py index 64ddeea18..f199c8046 100644 --- a/src/accessiweather/notifications/sound_pack_helpers.py +++ b/src/accessiweather/notifications/sound_pack_helpers.py @@ -122,6 +122,42 @@ def get_sound_pack_sounds( return {} +def sound_pack_prefers_specific_alert_sounds( + pack_dir: str, + *, + soundpacks_dir: Path, + specific_alert_keys: set[str] | frozenset[str], + logger: logging.Logger, +) -> bool: + """ + Return whether a pack should use specific alert sound candidates. + + Packs can opt in explicitly with ``"specific_alert_sounds": true`` in + pack.json. Older packs are also treated as specific-alert packs when their + sounds map contains legacy alert keys such as ``tornado_warning`` or + ``watch``. + """ + pack_json = soundpacks_dir / pack_dir / "pack.json" + if not pack_json.exists(): + return False + + try: + with open(pack_json, encoding="utf-8") as f: + pack_data: dict[str, Any] = json.load(f) + + explicit = pack_data.get("specific_alert_sounds") + if isinstance(explicit, bool): + return explicit + + sounds = pack_data.get("sounds", {}) + if not isinstance(sounds, dict): + return False + return bool(set(sounds) & set(specific_alert_keys)) + except Exception as e: + logger.error(f"Failed to inspect sound pack {pack_dir}: {e}") + return False + + def get_sound_entry_for_candidates( candidates: list[str], pack_dir: str, diff --git a/src/accessiweather/notifications/sound_player.py b/src/accessiweather/notifications/sound_player.py index c848dc982..75517ef68 100644 --- a/src/accessiweather/notifications/sound_player.py +++ b/src/accessiweather/notifications/sound_player.py @@ -5,7 +5,7 @@ from typing import Any from ..runtime_env import is_compiled_runtime -from ..sound_events import normalize_muted_sound_events +from ..sound_events import LEGACY_SOUND_EVENT_KEYS, normalize_muted_sound_events from ..soundpack_paths import get_soundpacks_dir from .sound_pack_helpers import ( get_available_sound_packs as _get_available_sound_packs, @@ -13,6 +13,7 @@ get_sound_entry_for_candidates as _get_pack_sound_entry_for_candidates, get_sound_pack_sounds as _get_pack_sounds, parse_sound_entry, + sound_pack_prefers_specific_alert_sounds as _pack_prefers_specific_alert_sounds, validate_sound_pack as _validate_sound_pack, ) @@ -374,6 +375,16 @@ def get_sound_pack_sounds(pack_dir: str) -> dict[str, str]: return _get_pack_sounds(pack_dir, soundpacks_dir=SOUNDPACKS_DIR, logger=logger) +def sound_pack_uses_specific_alert_sounds(pack_dir: str) -> bool: + """Return whether a sound pack should use specific alert sound candidates.""" + return _pack_prefers_specific_alert_sounds( + pack_dir, + soundpacks_dir=SOUNDPACKS_DIR, + specific_alert_keys=LEGACY_SOUND_EVENT_KEYS, + logger=logger, + ) + + def get_sound_entry_for_candidates( candidates: list[str], pack_dir: str ) -> tuple[Path | None, float]: diff --git a/src/accessiweather/ui/dialogs/settings_tabs/audio.py b/src/accessiweather/ui/dialogs/settings_tabs/audio.py index 7ec0c2d91..a61a4d4a8 100644 --- a/src/accessiweather/ui/dialogs/settings_tabs/audio.py +++ b/src/accessiweather/ui/dialogs/settings_tabs/audio.py @@ -88,6 +88,47 @@ def _refresh_event_sound_summary(self) -> None: if summary_control is not None: summary_control.SetLabel(self._get_event_sound_summary_text()) + def _get_selected_sound_pack(self) -> str: + """Return the currently selected sound pack ID.""" + controls = self.dialog._controls + pack_ids = getattr(self.dialog, "_sound_pack_ids", ["default"]) + pack_idx = controls["sound_pack"].GetSelection() + return pack_ids[pack_idx] if 0 <= pack_idx < len(pack_ids) else "default" + + @staticmethod + def _pack_uses_specific_alert_sounds_by_default(sound_pack: str) -> bool: + """Return whether a pack advertises or contains specific alert mappings.""" + try: + from ....notifications.sound_player import sound_pack_uses_specific_alert_sounds + except ImportError: + from accessiweather.notifications.sound_player import ( + sound_pack_uses_specific_alert_sounds, + ) + + try: + return sound_pack_uses_specific_alert_sounds(sound_pack) + except Exception as e: + logger.warning(f"Failed to inspect sound pack {sound_pack}: {e}") + return False + + def _refresh_specific_alert_sounds_control(self) -> None: + """Refresh selected-pack specific-alert sound state.""" + control = self.dialog._controls.get("specific_alert_sounds_for_pack") + if control is None: + return + + sound_pack = self._get_selected_sound_pack() + pack_overrides = set(getattr(self.dialog, "_specific_alert_sound_packs", [])) + auto_enabled = self._pack_uses_specific_alert_sounds_by_default(sound_pack) + control.SetValue(auto_enabled or sound_pack in pack_overrides) + control.Enable(not auto_enabled) + + def _on_sound_pack_changed(self, event) -> None: + """Refresh dependent controls after the selected sound pack changes.""" + self._refresh_specific_alert_sounds_control() + if event is not None and hasattr(event, "Skip"): + event.Skip() + def set_event_sound_states(self, muted_sound_events: list[str] | tuple[str, ...]) -> None: """Apply muted event settings to the in-memory audio state.""" try: @@ -134,14 +175,15 @@ def create(self, page_label: str = "Audio"): wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10, ) - controls["specific_alert_sounds_enabled"] = wx.CheckBox( - panel, label="Use specific alert sounds when available" + controls["specific_alert_sounds_for_pack"] = wx.CheckBox( + panel, label="Use specific alert sounds for this sound pack" ) - controls["specific_alert_sounds_enabled"].SetToolTip( - "Try custom sound pack keys like tornado_warning before severity sounds." + controls["specific_alert_sounds_for_pack"].SetToolTip( + "Try sound pack keys like tornado_warning before severity sounds. " + "This is automatic for packs that already contain those mappings." ) playback_section.Add( - controls["specific_alert_sounds_enabled"], + controls["specific_alert_sounds_for_pack"], 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10, @@ -164,6 +206,7 @@ def create(self, page_label: str = "Audio"): "Sound pack:", lambda parent: wx.Choice(parent, choices=pack_names), ) + controls["sound_pack"].Bind(wx.EVT_CHOICE, self._on_sound_pack_changed) action_row = wx.BoxSizer(wx.HORIZONTAL) test_btn = wx.Button(panel, label="Play sample sound") test_btn.Bind(wx.EVT_BUTTON, self.dialog._on_test_sound) @@ -210,8 +253,8 @@ def load(self, settings): controls = self.dialog._controls controls["sound_enabled"].SetValue(getattr(settings, "sound_enabled", True)) - controls["specific_alert_sounds_enabled"].SetValue( - getattr(settings, "specific_alert_sounds_enabled", False) + self.dialog._specific_alert_sound_packs = list( + getattr(settings, "specific_alert_sound_packs", []) ) current_pack = getattr(settings, "sound_pack", "default") @@ -221,21 +264,28 @@ def load(self, settings): controls["sound_pack"].SetSelection(pack_idx) except (ValueError, AttributeError): controls["sound_pack"].SetSelection(0) + self._refresh_specific_alert_sounds_control() self.set_event_sound_states(getattr(settings, "muted_sound_events", [])) def save(self) -> dict: """Return Audio tab settings as a dict.""" controls = self.dialog._controls - pack_ids = getattr(self.dialog, "_sound_pack_ids", ["default"]) - pack_idx = controls["sound_pack"].GetSelection() - sound_pack = pack_ids[pack_idx] if pack_idx < len(pack_ids) else "default" + sound_pack = self._get_selected_sound_pack() + specific_packs = set(getattr(self.dialog, "_specific_alert_sound_packs", [])) + if self._pack_uses_specific_alert_sounds_by_default(sound_pack): + specific_packs.discard(sound_pack) + else: + if controls["specific_alert_sounds_for_pack"].GetValue(): + specific_packs.add(sound_pack) + else: + specific_packs.discard(sound_pack) return { "sound_enabled": controls["sound_enabled"].GetValue(), "sound_pack": sound_pack, "muted_sound_events": self._get_muted_sound_events(), - "specific_alert_sounds_enabled": controls["specific_alert_sounds_enabled"].GetValue(), + "specific_alert_sound_packs": sorted(specific_packs), } def setup_accessibility(self): @@ -243,7 +293,7 @@ def setup_accessibility(self): controls = self.dialog._controls names = { "sound_enabled": "Play notification sounds", - "specific_alert_sounds_enabled": "Use specific alert sounds when available", + "specific_alert_sounds_for_pack": "Use specific alert sounds for this sound pack", "sound_pack": "Sound pack", "event_sounds_summary": "Event sound summary", "configure_event_sounds": "Choose event sounds", diff --git a/tests/test_alert_notification_system.py b/tests/test_alert_notification_system.py index 3d07b5f26..149a9b4f7 100644 --- a/tests/test_alert_notification_system.py +++ b/tests/test_alert_notification_system.py @@ -223,7 +223,11 @@ async def test_send_notification_with_sound(self, notification_system, mock_noti event="Test Warning", ) - await notification_system._send_alert_notification(alert, "new_alert", play_sound=True) + with patch( + "accessiweather.notifications.sound_player.sound_pack_uses_specific_alert_sounds", + return_value=False, + ): + await notification_system._send_alert_notification(alert, "new_alert", play_sound=True) mock_notifier.send_notification.assert_called_once() call_kwargs = mock_notifier.send_notification.call_args.kwargs @@ -235,7 +239,7 @@ async def test_send_notification_can_use_specific_alert_sounds( self, alert_manager, mock_notifier ): """Specific alert sound preference should try exact event keys before severity.""" - settings = AppSettings(specific_alert_sounds_enabled=True) + settings = AppSettings(sound_pack="custom", specific_alert_sound_packs=["custom"]) notification_system = AlertNotificationSystem( alert_manager=alert_manager, notifier=mock_notifier, diff --git a/tests/test_settings_dialog_audio_events.py b/tests/test_settings_dialog_audio_events.py index 3a3d20c26..3b369aa05 100644 --- a/tests/test_settings_dialog_audio_events.py +++ b/tests/test_settings_dialog_audio_events.py @@ -43,6 +43,12 @@ def GetLabel(self) -> str: def SetName(self, _value: str) -> None: self._name = _value + def Enable(self, value: bool) -> None: + self._enabled = value + + def IsEnabled(self) -> bool: + return self.__dict__.get("_enabled", True) + def GetParent(self): return self._parent @@ -73,12 +79,16 @@ def FitInside(self) -> None: def _make_dialog(settings: SimpleNamespace) -> SettingsDialogSimple: dialog = SettingsDialogSimple.__new__(SettingsDialogSimple) dialog._controls = _Controls() + dialog._controls["sound_enabled"] = _DummyControl() + dialog._controls["sound_pack"] = _DummyControl() + dialog._controls["specific_alert_sounds_for_pack"] = _DummyControl() dialog._controls["event_sounds_summary"] = _DummyControl() dialog._controls["configure_event_sounds"] = _DummyControl() dialog._sound_pack_ids = ["default"] dialog._selected_specific_model = None dialog._event_sound_states = AudioTab._build_default_event_sound_states() dialog._hidden_muted_sound_events = [] + dialog._specific_alert_sound_packs = [] dialog._source_settings_states = SettingsDialogSimple._build_default_source_settings_states() dialog._pw_config_sizer = _DummySizer() dialog._auto_sources_sizer = _DummySizer() @@ -89,6 +99,7 @@ def _make_dialog(settings: SimpleNamespace) -> SettingsDialogSimple: # Wire up tab objects so _load_settings/_save_settings delegate correctly audio_tab = AudioTab(dialog) + audio_tab._pack_uses_specific_alert_sounds_by_default = lambda _pack: False dialog._audio_tab = audio_tab dialog._tab_objects = [audio_tab] @@ -101,7 +112,7 @@ def test_load_settings_updates_event_sound_state_and_summary(): sound_enabled=True, sound_pack="default", muted_sound_events=["data_updated"], - specific_alert_sounds_enabled=True, + specific_alert_sound_packs=["default"], ) ) @@ -109,7 +120,7 @@ def test_load_settings_updates_event_sound_state_and_summary(): assert dialog._event_sound_states["data_updated"] is False assert dialog._event_sound_states["fetch_error"] is True - assert dialog._controls["specific_alert_sounds_enabled"].GetValue() is True + assert dialog._controls["specific_alert_sounds_for_pack"].GetValue() is True total_events = len(AudioTab._build_default_event_sound_states()) assert ( dialog._controls["event_sounds_summary"].GetLabel() @@ -120,7 +131,7 @@ def test_load_settings_updates_event_sound_state_and_summary(): def test_save_settings_collects_unchecked_audio_events(): dialog = _make_dialog(SimpleNamespace()) dialog._controls["sound_enabled"].SetValue(True) - dialog._controls["specific_alert_sounds_enabled"].SetValue(True) + dialog._controls["specific_alert_sounds_for_pack"].SetValue(True) dialog._controls["sound_pack"].SetSelection(0) dialog._event_sound_states["data_updated"] = False dialog._event_sound_states["fetch_error"] = True @@ -130,7 +141,7 @@ def test_save_settings_collects_unchecked_audio_events(): assert success is True kwargs = dialog.config_manager.update_settings.call_args.kwargs assert kwargs["muted_sound_events"] == ["data_updated"] - assert kwargs["specific_alert_sounds_enabled"] is True + assert kwargs["specific_alert_sound_packs"] == ["default"] def test_save_settings_preserves_hidden_legacy_muted_audio_events(): @@ -192,15 +203,43 @@ def test_weather_refresh_sound_is_muted_by_default(): assert settings.muted_sound_events == ["data_updated"] -def test_specific_alert_sounds_setting_defaults_off_and_round_trips(): +def test_specific_alert_sound_packs_default_empty_and_round_trip(): settings = AppSettings.from_dict({}) - assert settings.specific_alert_sounds_enabled is False + assert settings.specific_alert_sound_packs == [] payload = settings.to_dict() - assert payload["specific_alert_sounds_enabled"] is False + assert payload["specific_alert_sound_packs"] == [] + + restored = AppSettings.from_dict( + {"specific_alert_sound_packs": ["custom", "", "custom", " another "]} + ) + assert restored.specific_alert_sound_packs == ["custom", "another"] + + migrated = AppSettings.from_dict( + {"sound_pack": "first_pr_pack", "specific_alert_sounds_enabled": True} + ) + assert migrated.specific_alert_sound_packs == ["first_pr_pack"] + - restored = AppSettings.from_dict({"specific_alert_sounds_enabled": "true"}) - assert restored.specific_alert_sounds_enabled is True +def test_specific_alert_sounds_are_automatic_for_packs_with_specific_mappings(): + dialog = _make_dialog( + SimpleNamespace( + sound_enabled=True, + sound_pack="default", + muted_sound_events=[], + specific_alert_sound_packs=["default"], + ) + ) + dialog._audio_tab._pack_uses_specific_alert_sounds_by_default = lambda _pack: True + + dialog._load_settings() + success = dialog._save_settings() + + assert success is True + assert dialog._controls["specific_alert_sounds_for_pack"].GetValue() is True + assert dialog._controls["specific_alert_sounds_for_pack"].IsEnabled() is False + kwargs = dialog.config_manager.update_settings.call_args.kwargs + assert kwargs["specific_alert_sound_packs"] == [] def test_visible_audio_events_are_core_lifecycle_and_severity_only(): diff --git a/tests/test_sound_player.py b/tests/test_sound_player.py index 1851079d5..d3081dcdc 100644 --- a/tests/test_sound_player.py +++ b/tests/test_sound_player.py @@ -717,6 +717,92 @@ def test_get_sounds_normalizes_inline_format(self): sp.SOUNDPACKS_DIR = original_dir +class TestSoundPackSpecificAlertMode: + """Test sound pack detection for specific alert sound mode.""" + + def test_legacy_alert_keys_enable_specific_alert_sounds(self): + from accessiweather.notifications.sound_player import ( + sound_pack_uses_specific_alert_sounds, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + import accessiweather.notifications.sound_player as sp + + original_dir = sp.SOUNDPACKS_DIR + sp.SOUNDPACKS_DIR = Path(tmpdir) + + try: + pack_path = Path(tmpdir) / "old_pack" + pack_path.mkdir() + pack_data = { + "name": "Old Pack", + "sounds": { + "alert": "alert.wav", + "tornado_warning": "tornado.wav", + }, + } + (pack_path / "pack.json").write_text(json.dumps(pack_data)) + + assert sound_pack_uses_specific_alert_sounds("old_pack") is True + finally: + sp.SOUNDPACKS_DIR = original_dir + + def test_severity_only_pack_does_not_enable_specific_alert_sounds(self): + from accessiweather.notifications.sound_player import ( + sound_pack_uses_specific_alert_sounds, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + import accessiweather.notifications.sound_player as sp + + original_dir = sp.SOUNDPACKS_DIR + sp.SOUNDPACKS_DIR = Path(tmpdir) + + try: + pack_path = Path(tmpdir) / "new_pack" + pack_path.mkdir() + pack_data = { + "name": "New Pack", + "sounds": { + "alert": "alert.wav", + "severe": "severe.wav", + }, + } + (pack_path / "pack.json").write_text(json.dumps(pack_data)) + + assert sound_pack_uses_specific_alert_sounds("new_pack") is False + finally: + sp.SOUNDPACKS_DIR = original_dir + + def test_pack_manifest_can_explicitly_enable_specific_alert_sounds(self): + from accessiweather.notifications.sound_player import ( + sound_pack_uses_specific_alert_sounds, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + import accessiweather.notifications.sound_player as sp + + original_dir = sp.SOUNDPACKS_DIR + sp.SOUNDPACKS_DIR = Path(tmpdir) + + try: + pack_path = Path(tmpdir) / "declared_pack" + pack_path.mkdir() + pack_data = { + "name": "Declared Pack", + "specific_alert_sounds": True, + "sounds": { + "alert": "alert.wav", + "severe": "severe.wav", + }, + } + (pack_path / "pack.json").write_text(json.dumps(pack_data)) + + assert sound_pack_uses_specific_alert_sounds("declared_pack") is True + finally: + sp.SOUNDPACKS_DIR = original_dir + + class TestPlaySoundFileWithVolume: """Test _play_sound_file with volume parameter.""" From 6e75715bcdcb96dc48cb9c289a7925c4cfd037e8 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 07:44:37 -0400 Subject: [PATCH 03/15] ci: avoid wxPython wheel dependency for unit tests Let PR validation use the existing wx test stub instead of requiring extras.wxpython.org during dependency installation. Constraint: extras.wxpython.org is timing out in GitHub Actions and local probes Rejected: repeated CI reruns | they fail before tests while resolving wxPython wheels Confidence: medium Scope-risk: narrow Directive: keep real wxPython installation in packaging workflows where artifact runtime coverage matters Tested: uv run pytest tests/test_changelog_tools.py tests/test_installer_version_metadata.py tests/test_nuitka_build.py -q; git --no-pager diff --check Not-tested: GitHub Actions after this workflow patch until pushed Co-authored-by: OmX --- .github/workflows/ci.yml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1114e822..73c956063 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,14 +79,28 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - # Use prebuilt wx wheels only — never build from source. - # extras.wxpython.org provides Linux wheels for supported Python versions. - pip install \ - -f https://extras.wxpython.org/wxPython4/extras/linux/gtk3/ubuntu-24.04 \ - --only-binary wxPython \ - wxPython - pip install -r requirements-dev.txt - pip install -e . + # Unit tests use the in-repo wx stub when wxPython is unavailable. + # Keep CI validation independent from extras.wxpython.org wheel availability; + # packaging workflows still install real wxPython for build artifacts. + python - <<'PY' + from pathlib import Path + + filters = { + "requirements.txt": Path("/tmp/requirements-ci.txt"), + "requirements-dev.txt": Path("/tmp/requirements-dev-ci.txt"), + } + for source, target in filters.items(): + kept = [] + for line in Path(source).read_text(encoding="utf-8").splitlines(): + stripped = line.strip().lower() + if stripped.startswith("wxpython") or stripped == "-r requirements.txt": + continue + kept.append(line) + target.write_text("\n".join(kept) + "\n", encoding="utf-8") + PY + pip install -r /tmp/requirements-ci.txt + pip install -r /tmp/requirements-dev-ci.txt + pip install --no-deps -e . - name: Check CHANGELOG entry if: matrix.primary From f7e96eb6cafdde37d2ffff196b05c4c31ad23447 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 07:57:47 -0400 Subject: [PATCH 04/15] ci: skip stubbed runtime deps in validation Avoid installing GUI/audio runtime packages during unit-test CI when tests already provide stubs for those imports. Constraint: PR validation was stuck in dependency installation after filtering only wxPython Rejected: installing gui_builder and sound_lib in unit CI | they are runtime packages covered by test stubs here Confidence: medium Scope-risk: narrow Directive: keep artifact workflows responsible for real GUI/audio runtime dependency checks Tested: uv run pytest tests/test_changelog_tools.py tests/test_installer_version_metadata.py tests/test_nuitka_build.py -q; git --no-pager diff --check Not-tested: GitHub Actions after push Co-authored-by: OmX --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c956063..9e543a3ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,12 +79,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - # Unit tests use the in-repo wx stub when wxPython is unavailable. + # Unit tests use in-repo stubs for wx, gui_builder, and sound_lib when + # those GUI/audio runtime packages are unavailable. # Keep CI validation independent from extras.wxpython.org wheel availability; # packaging workflows still install real wxPython for build artifacts. python - <<'PY' from pathlib import Path + runtime_stubs = ("wxpython", "gui_builder @", "sound_lib @") filters = { "requirements.txt": Path("/tmp/requirements-ci.txt"), "requirements-dev.txt": Path("/tmp/requirements-dev-ci.txt"), @@ -93,7 +95,7 @@ jobs: kept = [] for line in Path(source).read_text(encoding="utf-8").splitlines(): stripped = line.strip().lower() - if stripped.startswith("wxpython") or stripped == "-r requirements.txt": + if stripped == "-r requirements.txt" or stripped.startswith(runtime_stubs): continue kept.append(line) target.write_text("\n".join(kept) + "\n", encoding="utf-8") From c33dc9ead95dfe13196f65f01a2535081bf5cd2c Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 08:00:39 -0400 Subject: [PATCH 05/15] chore(deps): remove unused gui_builder dependency Drop gui_builder from runtime dependency declarations and packaging/test scaffolding because the project no longer imports it. Constraint: PR CI dependency installation was blocked by unnecessary GUI runtime dependency resolution Rejected: keeping gui_builder only for historical packaging | no code imports it and the hidden import is stale Confidence: high Scope-risk: narrow Directive: re-add gui_builder only with a concrete import/use site Tested: rg -n gui_builder . returned no matches; pre-commit run check-yaml --files .github/workflows/ci.yml; .venv\\Scripts\\ruff.exe check tests/conftest.py; .venv\\Scripts\\ruff.exe format --check tests/conftest.py; uv run pytest tests/test_changelog_tools.py tests/test_installer_version_metadata.py tests/test_nuitka_build.py -q; git --no-pager diff --check Not-tested: GitHub Actions after push Co-authored-by: OmX --- .github/workflows/ci.yml | 4 ++-- installer/accessiweather.spec | 1 - pyproject.toml | 1 - requirements.txt | 1 - tests/conftest.py | 8 -------- 5 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e543a3ed..286395831 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,14 +79,14 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - # Unit tests use in-repo stubs for wx, gui_builder, and sound_lib when + # Unit tests use in-repo stubs for wx and sound_lib when # those GUI/audio runtime packages are unavailable. # Keep CI validation independent from extras.wxpython.org wheel availability; # packaging workflows still install real wxPython for build artifacts. python - <<'PY' from pathlib import Path - runtime_stubs = ("wxpython", "gui_builder @", "sound_lib @") + runtime_stubs = ("wxpython", "sound_lib @") filters = { "requirements.txt": Path("/tmp/requirements-ci.txt"), "requirements-dev.txt": Path("/tmp/requirements-dev-ci.txt"), diff --git a/installer/accessiweather.spec b/installer/accessiweather.spec index 75d7054af..881b63190 100644 --- a/installer/accessiweather.spec +++ b/installer/accessiweather.spec @@ -95,7 +95,6 @@ hiddenimports = [ "wx.lib.agw.aui", "wx.lib.mixins", "wx.lib.mixins.inspection", - "gui_builder", "httpx", "httpx._transports", "httpx._transports.default", diff --git a/pyproject.toml b/pyproject.toml index 834e7d68d..0df8cea4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ license = {text = "MIT"} requires-python = ">=3.11" dependencies = [ "wxPython>=4.2.5", - "gui_builder @ git+https://github.com/accessibleapps/gui_builder.git", "sound_lib @ git+https://github.com/samtupy/sound_lib_macos_fixes.git", "platform-utils>=1.6.0", # sound_lib dep; >=1.6 needed for Python 3.12+ (no imp module) "desktop-notifier", diff --git a/requirements.txt b/requirements.txt index d95aa101e..b92611b40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ # wxPython UI framework wxPython>=4.2.0 -gui_builder @ git+https://github.com/accessibleapps/gui_builder.git # Audio sound_lib @ git+https://github.com/accessibleapps/sound_lib.git diff --git a/tests/conftest.py b/tests/conftest.py index f9340264f..7e5564895 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,14 +157,6 @@ def __init__(self, *args, **kwargs): sys.modules["sound_lib.output"] = _sl_output sys.modules["sound_lib.stream"] = _sl_stream -# Provide stub gui_builder when not installed -if "gui_builder" not in sys.modules: - try: - import gui_builder # noqa: F401 - except ImportError: - from unittest.mock import MagicMock - - sys.modules["gui_builder"] = MagicMock() from datetime import UTC, datetime, timedelta from pathlib import Path from typing import TYPE_CHECKING From aad05a423e86df9f0370edaecbca2c2b92e69cc1 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 08:10:31 -0400 Subject: [PATCH 06/15] test(ci): harden headless runtime stubs Keep validation independent of wxPython and sound_lib wheels while preserving dialog and package-staging assertions. Constraint: Linux CI skips wxPython and sound_lib runtime deps to avoid source builds. Rejected: Reinstalling wxPython or sound_lib in validation | would reintroduce flaky and slow wheel dependency. Confidence: high Scope-risk: narrow Directive: Keep stubs import-spec-compatible when tests use find_spec or patch sys.modules. Tested: forced CI-style stub pytest cluster; normal targeted pytest cluster; ruff check; ruff format --check; git diff --check. Not-tested: full GitHub Actions matrix after push pending. --- tests/conftest.py | 62 ++++++++++++++----- .../gui/test_advanced_text_product_dialog.py | 37 ++++++++++- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7e5564895..ac4388d06 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ import os import sys import tempfile +from importlib.machinery import ModuleSpec # --------------------------------------------------------------------------- # Provide stub wx module when wxPython is not installed (headless servers). @@ -26,6 +27,7 @@ # Build a minimal wx stub module with real base classes so that # subclassing (e.g. class MainWindow(wx.Frame)) and patch.object work. _wx = types.ModuleType("wx") + _wx.__spec__ = ModuleSpec("wx", loader=None, is_package=True) _wx.__package__ = "wx" _wx.__path__ = [] @@ -35,6 +37,9 @@ class _WxStubBase: def __init__(self, *args, **kwargs): pass + def _wx_mock(*args, **kwargs): + return MagicMock() + _wx.Frame = _WxStubBase _wx.Panel = _WxStubBase _wx.Dialog = _WxStubBase @@ -42,19 +47,23 @@ def __init__(self, *args, **kwargs): _wx.Window = _WxStubBase _wx.Control = _WxStubBase _wx.TaskBarIcon = _WxStubBase - _wx.Menu = MagicMock - _wx.MenuBar = MagicMock - _wx.BoxSizer = MagicMock - _wx.StaticText = MagicMock - _wx.TextCtrl = MagicMock - _wx.Button = MagicMock - _wx.Choice = MagicMock - _wx.CheckBox = MagicMock - _wx.Timer = MagicMock - _wx.Icon = MagicMock - _wx.Bitmap = MagicMock - _wx.Image = MagicMock - _wx.ListBox = MagicMock + _wx.Menu = _wx_mock + _wx.MenuBar = _wx_mock + _wx.BoxSizer = _wx_mock + _wx.StaticText = _wx_mock + _wx.TextCtrl = _wx_mock + _wx.Button = _wx_mock + _wx.Choice = _wx_mock + _wx.ComboBox = _wx_mock + _wx.CheckBox = _wx_mock + _wx.MessageDialog = _wx_mock + _wx.MessageBox = MagicMock() + _wx.Timer = _wx_mock + _wx.Icon = _wx_mock + _wx.Bitmap = _wx_mock + _wx.Image = _wx_mock + _wx.ListBox = _wx_mock + _wx.Size = lambda width, height: (width, height) # Common constants _wx.EVT_CLOSE = MagicMock() @@ -80,11 +89,17 @@ def __init__(self, *args, **kwargs): _wx.VERTICAL = 0x0008 _wx.EXPAND = 0x2000 _wx.ALL = 0x0F + _wx.LEFT = 0x0010 + _wx.RIGHT = 0x0020 + _wx.TOP = 0x0040 + _wx.BOTTOM = 0x0080 _wx.DEFAULT_FRAME_STYLE = 0 _wx.ICON_INFORMATION = 0 _wx.ICON_WARNING = 0 _wx.ICON_ERROR = 0 _wx.CallAfter = MagicMock() + _wx.BeginBusyCursor = MagicMock() + _wx.EndBusyCursor = MagicMock() # System colour constants _wx.SYS_COLOUR_GRAYTEXT = 17 @@ -98,26 +113,32 @@ def __init__(self, *args, **kwargs): # wx sub-modules _wx_lib = types.ModuleType("wx.lib") + _wx_lib.__spec__ = ModuleSpec("wx.lib", loader=None, is_package=True) _wx_lib.__package__ = "wx.lib" _wx_lib.__path__ = [] _wx_lib_sized = types.ModuleType("wx.lib.sized_controls") + _wx_lib_sized.__spec__ = ModuleSpec("wx.lib.sized_controls", loader=None) _wx_lib_sized.SizedFrame = _WxStubBase _wx_lib_sized.SizedPanel = _WxStubBase _wx_lib_sized.SizedDialog = _WxStubBase _wx_lib_newevent = types.ModuleType("wx.lib.newevent") + _wx_lib_newevent.__spec__ = ModuleSpec("wx.lib.newevent", loader=None) _wx_lib_newevent.NewEvent = lambda: (MagicMock, MagicMock()) _wx_lib_newevent.NewCommandEvent = lambda: (MagicMock, MagicMock()) _wx_lib_scrolledpanel = types.ModuleType("wx.lib.scrolledpanel") + _wx_lib_scrolledpanel.__spec__ = ModuleSpec("wx.lib.scrolledpanel", loader=None) _wx_lib_scrolledpanel.ScrolledPanel = _WxStubBase _wx_adv = types.ModuleType("wx.adv") + _wx_adv.__spec__ = ModuleSpec("wx.adv", loader=None) _wx_adv.TaskBarIcon = _WxStubBase _wx_html2 = types.ModuleType("wx.html2") - _wx_html2.WebView = MagicMock + _wx_html2.__spec__ = ModuleSpec("wx.html2", loader=None) + _wx_html2.WebView = _wx_mock # Wire sub-modules as attributes so `wx.adv`, `wx.lib` etc. resolve _wx.lib = _wx_lib @@ -138,20 +159,29 @@ def __init__(self, *args, **kwargs): import sound_lib # noqa: F401 except ImportError: import types + from pathlib import Path from unittest.mock import MagicMock + _sl_package_dir = Path(tempfile.mkdtemp(prefix="accessiweather-sound-lib-stub-")) + _sl_lib_dir = _sl_package_dir / "lib" + _sl_lib_dir.mkdir() + (_sl_lib_dir / "libbass.so").write_bytes(b"stub") + _sl = types.ModuleType("sound_lib") + _sl.__spec__ = ModuleSpec("sound_lib", loader=None, is_package=True) + _sl.__spec__.submodule_search_locations = [str(_sl_package_dir)] _sl.__package__ = "sound_lib" - _sl.__path__ = [] + _sl.__path__ = [str(_sl_package_dir)] _sl_output = types.ModuleType("sound_lib.output") + _sl_output.__spec__ = ModuleSpec("sound_lib.output", loader=None) _sl_output.Output = MagicMock _sl_stream = types.ModuleType("sound_lib.stream") + _sl_stream.__spec__ = ModuleSpec("sound_lib.stream", loader=None) _sl_stream.FileStream = MagicMock() _sl.output = _sl_output - _sl.stream = _sl_stream sys.modules["sound_lib"] = _sl sys.modules["sound_lib.output"] = _sl_output diff --git a/tests/gui/test_advanced_text_product_dialog.py b/tests/gui/test_advanced_text_product_dialog.py index efcb977d3..a6cf0a362 100644 --- a/tests/gui/test_advanced_text_product_dialog.py +++ b/tests/gui/test_advanced_text_product_dialog.py @@ -35,6 +35,41 @@ def _neutralize_wx(): def _noop(self, *a, **kw): return None + def _control_stub(*a, _name: str, **kw): + control = MagicMock(name=kw.get("label", _name)) + choices = list(kw.get("choices", [])) + selection = {"index": 0 if choices else -1} + + def _set_items(items): + choices[:] = list(items) + selection["index"] = 0 if choices else -1 + control.GetCount.return_value = len(choices) + control.GetStringSelection.return_value = _selected_string() + + def _set_selection(index): + selection["index"] = index + control.GetStringSelection.return_value = _selected_string() + + def _get_string(index): + return choices[index] + + def _selected_string(): + index = selection["index"] + if 0 <= index < len(choices): + return choices[index] + return "" + + if _name == "CheckBox": + control.GetValue.return_value = False + else: + control.GetValue.return_value = kw.get("value", "") + control.GetStringSelection.return_value = _selected_string() + control.GetCount.return_value = len(choices) + control.GetString.side_effect = _get_string + control.SetItems.side_effect = _set_items + control.SetSelection.side_effect = _set_selection + return control + _wx.Dialog.__init__ = _noop for name in ("StaticText", "TextCtrl", "Button", "Choice", "ComboBox", "CheckBox"): saved[name] = getattr(_wx, name) @@ -43,7 +78,7 @@ def _noop(self, *a, **kw): name, MagicMock( name=name, - side_effect=lambda *a, _name=name, **kw: MagicMock(name=kw.get("label", _name)), + side_effect=lambda *a, _name=name, **kw: _control_stub(*a, _name=_name, **kw), ), ) From 6aabaf39c5b6bdd295c3919bf35cc7b311962116 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 08:17:23 -0400 Subject: [PATCH 07/15] test(ci): refresh GUI test expectations Keep headless validation aligned with current dialog wiring after the latest dev merge. Constraint: CI skips GUI/audio runtime dependencies and exercises the local wx/sound_lib stubs. Rejected: Reintroducing GUI runtime deps in validation | that would restore slow or unavailable wheel installs instead of fixing the test seam. Confidence: high Scope-risk: narrow Directive: Keep these GUI tests focused on dialog contracts, not exact historical fixture coordinates. Tested: CI-style stubbed pytest for location and forecast product dialog tests; ruff check; ruff format --check; git diff --check. Not-tested: Full GitHub validation after push. --- tests/gui/test_forecast_products_dialog.py | 15 +++--- tests/gui/test_location_dialog_zone_info.py | 58 +++++++++++++++------ 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/tests/gui/test_forecast_products_dialog.py b/tests/gui/test_forecast_products_dialog.py index 204599fc3..ac6c04109 100644 --- a/tests/gui/test_forecast_products_dialog.py +++ b/tests/gui/test_forecast_products_dialog.py @@ -206,13 +206,16 @@ def __init__(self, **kwargs): created.append({"product_type": self.product_type, "instance": self, **kwargs}) # The dialog imports ForecastProductPanel directly from the module; - # patch the symbol it bound at import time. - from accessiweather.ui.dialogs import forecast_products_dialog + # patch the symbols each dialog module bound at import time. + from accessiweather.ui.dialogs import forecast_products_dialog, national_products_dialog - original_sym = forecast_products_dialog.ForecastProductPanel + original_forecast_sym = forecast_products_dialog.ForecastProductPanel + original_national_sym = national_products_dialog.ForecastProductPanel forecast_products_dialog.ForecastProductPanel = _FakePanel # type: ignore[assignment] + national_products_dialog.ForecastProductPanel = _FakePanel # type: ignore[assignment] yield created - forecast_products_dialog.ForecastProductPanel = original_sym # type: ignore[assignment] + forecast_products_dialog.ForecastProductPanel = original_forecast_sym # type: ignore[assignment] + national_products_dialog.ForecastProductPanel = original_national_sym # type: ignore[assignment] @pytest.fixture @@ -349,8 +352,8 @@ async def _fake_spc_outlook(latitude, longitude, **kwargs): result = asyncio.run(dlg._make_loader(spc_tab)()) assert result.product_type == "SPC_OUTLOOK" - assert result.latitude == 35.7796 - assert result.longitude == -78.6382 + assert result.latitude == sample_us_location.latitude + assert result.longitude == sample_us_location.longitude assert result.kwargs == {"day": 1, "current": True, "max_items": 3, "timeout": 10.0} service.get_iem_afos.assert_not_called() diff --git a/tests/gui/test_location_dialog_zone_info.py b/tests/gui/test_location_dialog_zone_info.py index bc63ddeb7..e19281865 100644 --- a/tests/gui/test_location_dialog_zone_info.py +++ b/tests/gui/test_location_dialog_zone_info.py @@ -36,12 +36,21 @@ "RIGHT": 0x0020, "TOP": 0x0040, "BOTTOM": 0x0080, + "RESIZE_BORDER": 0x0040, + "TE_PROCESS_ENTER": 0x0400, + "LC_REPORT": 0x4000, + "LC_SINGLE_SEL": 0x2000, + "BORDER_SUNKEN": 0x0800, }.items(): if not hasattr(_wx, _attr): setattr(_wx, _attr, _val) +for _attr in ("EVT_TEXT_ENTER", "EVT_LIST_ITEM_SELECTED"): + if not hasattr(_wx, _attr): + setattr(_wx, _attr, MagicMock(name=_attr)) + # StaticBox / StaticBoxSizer / StdDialogButtonSizer / Size are not in the root stub. -for _attr in ("StaticBox", "StaticBoxSizer", "StdDialogButtonSizer", "Size"): +for _attr in ("ListCtrl", "StaticBox", "StaticBoxSizer", "StdDialogButtonSizer", "Size"): if not hasattr(_wx, _attr): setattr(_wx, _attr, MagicMock(name=_attr)) @@ -128,6 +137,7 @@ def _make_static_text(*args, **kwargs): # Track StaticBox creations — record Show() calls for visibility assertions. def _make_static_box(*args, **kwargs): box = MagicMock(name="StaticBox") + box._test_label = kwargs.get("label", args[1] if len(args) > 1 else "") box.IsShown.return_value = True def _box_show(visible=True): @@ -203,6 +213,19 @@ def _find_text_with_prefix(texts, prefix: str): return None +def _find_static_box_with_label(boxes, label: str): + """Return the first StaticBox mock whose label matches.""" + for box in boxes: + if box._test_label == label: + return box + return None + + +def _open_edit_dialog(location: Location): + """Open the edit dialog with the app argument required by current UI wiring.""" + return EditLocationDialog(parent=MagicMock(), app=MagicMock(), location=location) + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -220,7 +243,7 @@ def test_happy_path_us_populated(self, recorder): cwa_office="RAH", ) - EditLocationDialog(parent=MagicMock(), location=loc) + _open_edit_dialog(loc) zone_row = _find_text_with_prefix(recorder.static_texts, "Forecast Zone:") office_row = _find_text_with_prefix(recorder.static_texts, "NWS Office:") @@ -230,8 +253,9 @@ def test_happy_path_us_populated(self, recorder): assert office_row._test_label == "NWS Office: RAH" # StaticBox exists and was NOT hidden. - assert len(recorder.static_boxes) == 1 - assert recorder.static_boxes[0].IsShown() is True + zone_box = _find_static_box_with_label(recorder.static_boxes, "NWS Zone Information") + assert zone_box is not None + assert zone_box.IsShown() is True def test_non_us_location_hides_staticbox(self, recorder): """Non-US location hides the entire NWS Zone Information StaticBox.""" @@ -244,10 +268,10 @@ def test_non_us_location_hides_staticbox(self, recorder): cwa_office=None, ) - EditLocationDialog(parent=MagicMock(), location=loc) + _open_edit_dialog(loc) - assert len(recorder.static_boxes) == 1 - box = recorder.static_boxes[0] + box = _find_static_box_with_label(recorder.static_boxes, "NWS Zone Information") + assert box is not None # Show(False) was called on the box. box.Show.assert_called_once_with(False) assert box.IsShown() is False @@ -263,7 +287,7 @@ def test_us_both_null_shows_not_yet_resolved(self, recorder): cwa_office=None, ) - EditLocationDialog(parent=MagicMock(), location=loc) + _open_edit_dialog(loc) zone_row = _find_text_with_prefix(recorder.static_texts, "Forecast Zone:") office_row = _find_text_with_prefix(recorder.static_texts, "NWS Office:") @@ -273,8 +297,10 @@ def test_us_both_null_shows_not_yet_resolved(self, recorder): assert "Not yet resolved" in office_row._test_label # StaticBox is still shown (US location). - assert recorder.static_boxes[0].IsShown() is True - recorder.static_boxes[0].Show.assert_not_called() + zone_box = _find_static_box_with_label(recorder.static_boxes, "NWS Zone Information") + assert zone_box is not None + assert zone_box.IsShown() is True + zone_box.Show.assert_not_called() def test_us_partial_mixed_values(self, recorder): """US location with one field populated and the other null.""" @@ -287,7 +313,7 @@ def test_us_partial_mixed_values(self, recorder): cwa_office=None, ) - EditLocationDialog(parent=MagicMock(), location=loc) + _open_edit_dialog(loc) zone_row = _find_text_with_prefix(recorder.static_texts, "Forecast Zone:") office_row = _find_text_with_prefix(recorder.static_texts, "NWS Office:") @@ -306,15 +332,15 @@ def test_sizing_uses_fit_and_min_size(self, recorder): cwa_office="RAH", ) - EditLocationDialog(parent=MagicMock(), location=loc) + _open_edit_dialog(loc) # Fit() was called at least once assert recorder.fit_calls >= 1 - # SetMinSize was called (with a wx.Size built from 420, -1). + # SetMinSize was called (with a wx.Size built from 640, -1). assert len(recorder.min_size_calls) >= 1 - # wx.Size was constructed with (420, -1) during dialog init. + # wx.Size was constructed with (640, -1) during dialog init. size_ctor_calls = [(c.args, c.kwargs) for c in getattr(_wx.Size, "call_args_list", [])] - assert ((420, -1), {}) in size_ctor_calls + assert ((640, -1), {}) in size_ctor_calls # And the fixed size=(420, 200) is no longer passed to Dialog.__init__. assert "size" not in recorder.dialog_kwargs or recorder.dialog_kwargs.get("size") != ( @@ -338,7 +364,7 @@ def test_accessibility_labels_via_getlabel(self, recorder): cwa_office="RAH", ) - EditLocationDialog(parent=MagicMock(), location=loc) + _open_edit_dialog(loc) zone_row = _find_text_with_prefix(recorder.static_texts, "Forecast Zone:") office_row = _find_text_with_prefix(recorder.static_texts, "NWS Office:") From 65b5c2e7db2c861d81194a70337e9d8eb7a1b34f Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 08:47:43 -0400 Subject: [PATCH 08/15] test(ci): keep StaticBox stub specable Keep the headless wx test surface class-like so Linux CI can spec StaticBox while recorder fixtures still capture dialog widgets. Constraint: CI runs without real wxPython and relies on the local wx stub. Rejected: Removing the affected assertions | would hide the accessibility and visibility regression coverage. Confidence: high. Scope-risk: narrow. Tested: uv run ruff format tests/gui/test_location_dialog_zone_info.py; uv run ruff check tests/gui/test_location_dialog_zone_info.py; uv run python -m py_compile tests/gui/test_location_dialog_zone_info.py; uv run pytest tests/gui/test_location_dialog_zone_info.py -q. Not-tested: Linux CI stub path locally because this Windows checkout has real wxPython and skips this module. --- tests/gui/test_location_dialog_zone_info.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/gui/test_location_dialog_zone_info.py b/tests/gui/test_location_dialog_zone_info.py index e19281865..4d58f052c 100644 --- a/tests/gui/test_location_dialog_zone_info.py +++ b/tests/gui/test_location_dialog_zone_info.py @@ -50,10 +50,18 @@ setattr(_wx, _attr, MagicMock(name=_attr)) # StaticBox / StaticBoxSizer / StdDialogButtonSizer / Size are not in the root stub. -for _attr in ("ListCtrl", "StaticBox", "StaticBoxSizer", "StdDialogButtonSizer", "Size"): +for _attr in ("ListCtrl", "StaticBoxSizer", "StdDialogButtonSizer", "Size"): if not hasattr(_wx, _attr): setattr(_wx, _attr, MagicMock(name=_attr)) + +if not hasattr(_wx, "StaticBox") or isinstance(_wx.StaticBox, MagicMock): + + class _StaticBoxStub(_wx.Control): + """Class-like StaticBox stub so MagicMock(spec=wx.StaticBox) stays valid.""" + + _wx.StaticBox = _StaticBoxStub + _USING_STUB = ( not hasattr(sys.modules.get("wx", None), "App") or _wx.Dialog.__name__ == "_WxStubBase" ) From fafe46da2de6b08a37516ea4a7b9ff4137c1f32b Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 08:52:55 -0400 Subject: [PATCH 09/15] test(ci): isolate location dialog wx stubs Keep the location dialog GUI tests independent from wx globals that may have been mocked by earlier tests in a parallel CI worker. Constraint: Ubuntu CI runs the full wx-stub suite in parallel, so this fixture must own the controls it exercises. Rejected: Relying on module-import defaults alone | full-suite workers can mutate wx.ListCtrl and related controls before these tests run. Confidence: medium Scope-risk: narrow Directive: Keep this test fixture self-contained when adding wx controls to EditLocationDialog coverage. Tested: uv run ruff check tests/gui/test_location_dialog_zone_info.py; uv run python -m py_compile tests/gui/test_location_dialog_zone_info.py; uv run pytest tests/gui/test_location_dialog_zone_info.py -q Not-tested: Linux wx-stub path locally; Windows environment skips this module when real wxPython is loaded. --- tests/gui/test_location_dialog_zone_info.py | 52 +++++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/tests/gui/test_location_dialog_zone_info.py b/tests/gui/test_location_dialog_zone_info.py index 4d58f052c..6e0ab99e1 100644 --- a/tests/gui/test_location_dialog_zone_info.py +++ b/tests/gui/test_location_dialog_zone_info.py @@ -55,12 +55,40 @@ setattr(_wx, _attr, MagicMock(name=_attr)) -if not hasattr(_wx, "StaticBox") or isinstance(_wx.StaticBox, MagicMock): +class _StaticBoxStub(_wx.Control): + """Class-like StaticBox stub so MagicMock(spec=wx.StaticBox) stays valid.""" - class _StaticBoxStub(_wx.Control): - """Class-like StaticBox stub so MagicMock(spec=wx.StaticBox) stays valid.""" - _wx.StaticBox = _StaticBoxStub +def _ensure_static_box_stub(): + if not hasattr(_wx, "StaticBox") or isinstance(_wx.StaticBox, MagicMock): + _wx.StaticBox = _StaticBoxStub + + +_ensure_static_box_stub() + + +class _ListCtrlStub(_wx.Control): + """Class-like ListCtrl stub so full-suite wx mock leaks cannot affect this module.""" + + +def _ensure_list_ctrl_stub(): + if not hasattr(_wx, "ListCtrl") or isinstance(_wx.ListCtrl, MagicMock): + _wx.ListCtrl = _ListCtrlStub + + +_ensure_list_ctrl_stub() + + +class _TextCtrlStub(_wx.Control): + """Class-like TextCtrl stub for stable dialog construction in the wx stub.""" + + +def _ensure_text_ctrl_stub(): + if not hasattr(_wx, "TextCtrl") or isinstance(_wx.TextCtrl, MagicMock): + _wx.TextCtrl = _TextCtrlStub + + +_ensure_text_ctrl_stub() _USING_STUB = ( not hasattr(sys.modules.get("wx", None), "App") or _wx.Dialog.__name__ == "_WxStubBase" @@ -94,6 +122,10 @@ def __init__(self): @pytest.fixture def recorder(): """Patch wx classes to capture widgets and dialog init kwargs.""" + _ensure_static_box_stub() + _ensure_list_ctrl_stub() + _ensure_text_ctrl_stub() + rec = _DialogRecorder() saved: dict = {} active_patches: list = [] @@ -142,6 +174,11 @@ def _make_static_text(*args, **kwargs): saved["StaticText"] = _wx.StaticText _wx.StaticText = _make_static_text + saved["TextCtrl"] = _wx.TextCtrl + _wx.TextCtrl = MagicMock( + name="TextCtrl", side_effect=lambda *a, **kw: MagicMock(name="TextCtrlInst") + ) + # Track StaticBox creations — record Show() calls for visibility assertions. def _make_static_box(*args, **kwargs): box = MagicMock(name="StaticBox") @@ -176,6 +213,11 @@ def _box_show(visible=True): name="BoxSizer", side_effect=lambda *a, **kw: MagicMock(name="BoxSizerInst") ) + saved["ListCtrl"] = _wx.ListCtrl + _wx.ListCtrl = MagicMock( + name="ListCtrl", side_effect=lambda *a, **kw: MagicMock(name="ListCtrlInst") + ) + saved["Panel"] = _wx.Panel _wx.Panel = MagicMock(name="Panel", side_effect=lambda *a, **kw: MagicMock(name="PanelInst")) @@ -202,7 +244,9 @@ def _box_show(visible=True): "StaticBoxSizer", "StdDialogButtonSizer", "BoxSizer", + "ListCtrl", "Panel", + "TextCtrl", "Button", "CheckBox", "Size", From b9465ff303c166a07b2bdd91ac38467ead00df70 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 08:56:48 -0400 Subject: [PATCH 10/15] test(ci): stub wx GetApp for alert dialogs Provide wx.GetApp in the shared headless wx stub so alert dialog tests can initialize an app consistently when wxPython is unavailable. Constraint: Ubuntu validation may run these wx tests against tests/conftest.py's fallback module rather than real wxPython. Rejected: Patching each alert dialog test fixture | the missing API belongs to the shared wx stub. Confidence: high Scope-risk: narrow Directive: Add common wx application APIs to tests/conftest.py when multiple GUI tests depend on them. Tested: uv run ruff check tests/conftest.py tests/test_alert_dialog_copy_integration.py tests/test_alert_dialog_dispatch.py tests/gui/test_location_dialog_zone_info.py; uv run pytest tests/test_alert_dialog_copy_integration.py tests/test_alert_dialog_dispatch.py tests/gui/test_location_dialog_zone_info.py -q Not-tested: Linux wx-stub path locally. --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index ac4388d06..9ae099319 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,7 @@ def _wx_mock(*args, **kwargs): _wx.Panel = _WxStubBase _wx.Dialog = _WxStubBase _wx.App = _WxStubBase + _wx.GetApp = MagicMock(return_value=None) _wx.Window = _WxStubBase _wx.Control = _WxStubBase _wx.TaskBarIcon = _WxStubBase From 4b8bb478b888189d607e047bdac3e0cdfe1c6262 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 09:01:46 -0400 Subject: [PATCH 11/15] test(ci): add wx stub window lifecycle methods Keep headless Linux wx fallback objects close enough to wx.Frame and wx.Dialog for alert-dialog fixtures to hide, show, bind, focus, and destroy them without requiring wxPython to import successfully.\n\nConstraint: Ubuntu CI exercises alert dialog tests through the shared no-wx fallback module.\nRejected: Patch only the alert dialog fixtures | the shared fallback is the failing boundary and other wx imports rely on it.\nConfidence: medium\nScope-risk: narrow\nDirective: Keep wx fallback methods minimal and test-oriented; do not turn the shared stub into an application simulator.\nTested: uv run ruff check tests\\conftest.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py\nTested: uv run pytest tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py -q\nNot-tested: Linux no-wx import path locally; delegated to GitHub Actions on PR #681. --- tests/conftest.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9ae099319..9926d2b43 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,88 @@ class _WxStubBase: """Patchable base class standing in for wx widgets.""" def __init__(self, *args, **kwargs): - pass + self._test_parent = args[0] if args else kwargs.get("parent") + self._test_label = kwargs.get("label", "") + self._test_name = "" + self._test_value = kwargs.get("value", "") + self._test_shown = True + + def Show(self, show=True): + self._test_shown = bool(show) + return True + + def Hide(self): + self._test_shown = False + return True + + def IsShown(self): + return self._test_shown + + def Destroy(self): + return None + + def Close(self): + return None + + def ShowModal(self): + return _wx.ID_OK + + def EndModal(self, *args, **kwargs): + return None + + def Bind(self, *args, **kwargs): + return None + + def Layout(self): + return None + + def Fit(self): + return None + + def SetSizer(self, *args, **kwargs): + return None + + def SetFocus(self): + return None + + def SetFont(self, *args, **kwargs): + return None + + def GetFont(self): + return self + + def Bold(self): + return self + + def SetName(self, name): + self._test_name = name + + def GetName(self): + return self._test_name + + def SetValue(self, value): + self._test_value = value + + def GetValue(self): + return self._test_value + + def SetLabel(self, label): + self._test_label = label + + def GetLabel(self): + return self._test_label + + def SetToolTip(self, *args, **kwargs): + return None + + def SetSize(self, *args, **kwargs): + return None + + def SetMinSize(self, *args, **kwargs): + return None + + def GetParent(self): + return self._test_parent def _wx_mock(*args, **kwargs): return MagicMock() From e43858202a3376a8e9bd672ca22c8aa91ea19946 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 09:05:42 -0400 Subject: [PATCH 12/15] test(ci): stub alert dialog wx constants Complete the shared no-wx fallback surface used by alert dialog CI tests so dialog styles, text controls, button IDs, and clipboard operations exist on headless Ubuntu.\n\nConstraint: GitHub Actions Linux runners import the fallback wx module for serial alert dialog tests.\nRejected: Skip or weaken alert dialog assertions | the tests are valid and the fallback module was incomplete.\nConfidence: medium\nScope-risk: narrow\nDirective: Keep clipboard behavior deterministic and limited to text payload tests.\nTested: uv run ruff check tests\\conftest.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py\nTested: uv run pytest tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py -q\nNot-tested: Linux no-wx import path locally; delegated to GitHub Actions on PR #681. --- tests/conftest.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9926d2b43..f931b2786 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,6 +118,36 @@ def SetMinSize(self, *args, **kwargs): def GetParent(self): return self._test_parent + class _WxTextDataObject: + """Tiny clipboard text carrier matching the wx API used by tests.""" + + def __init__(self, text=""): + self._text = text + + def SetText(self, text): + self._text = text + + def GetText(self): + return self._text + + class _WxClipboard: + def __init__(self): + self._text = "" + + def Open(self): + return True + + def Close(self): + return None + + def SetData(self, data): + self._text = data.GetText() + return True + + def GetData(self, data): + data.SetText(self._text) + return True + def _wx_mock(*args, **kwargs): return MagicMock() @@ -146,6 +176,8 @@ def _wx_mock(*args, **kwargs): _wx.Image = _wx_mock _wx.ListBox = _wx_mock _wx.Size = lambda width, height: (width, height) + _wx.TextDataObject = _WxTextDataObject + _wx.TheClipboard = _WxClipboard() # Common constants _wx.EVT_CLOSE = MagicMock() @@ -165,16 +197,23 @@ def _wx_mock(*args, **kwargs): _wx.ID_ANY = -1 _wx.ID_OK = 5100 _wx.ID_CANCEL = 5101 + _wx.ID_COPY = 5102 + _wx.ID_CLOSE = 5103 _wx.OK = 0x0004 _wx.CANCEL = 0x0010 _wx.HORIZONTAL = 0x0004 _wx.VERTICAL = 0x0008 + _wx.DEFAULT_DIALOG_STYLE = 0 _wx.EXPAND = 0x2000 _wx.ALL = 0x0F _wx.LEFT = 0x0010 _wx.RIGHT = 0x0020 _wx.TOP = 0x0040 _wx.BOTTOM = 0x0080 + _wx.RESIZE_BORDER = 0x4000 + _wx.TE_MULTILINE = 0x0020 + _wx.TE_READONLY = 0x0010 + _wx.TE_RICH2 = 0x8000 _wx.DEFAULT_FRAME_STYLE = 0 _wx.ICON_INFORMATION = 0 _wx.ICON_WARNING = 0 From 6c5cb0cccf63b9b5f160443671dc280cc14aa7cf Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 09:10:15 -0400 Subject: [PATCH 13/15] test(ci): make alert dialog wx stubs stateful Preserve fallback wx control ids, labels, and values so headless alert dialog tests can assert real dialog state instead of MagicMock return values. Constraint: Ubuntu CI exercises serial alert dialog tests through the shared no-wx fallback module. Rejected: Relax alert dialog assertions | the tests cover valid UI state and only the fallback controls were too generic. Confidence: medium Scope-risk: narrow Directive: Keep control stubs limited to constructor state and simple accessors needed by tests. Tested: uv run ruff check tests\\conftest.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py Tested: uv run pytest tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py -q Not-tested: Linux no-wx import path locally; delegated to GitHub Actions on PR #681. --- tests/conftest.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f931b2786..20397b5ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -148,6 +148,19 @@ def GetData(self, data): data.SetText(self._text) return True + class _WxControlStub(_WxStubBase): + """Stateful fallback for controls whose tests inspect ids, labels, or values.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._test_id = args[1] if len(args) > 1 else kwargs.get("id", _wx.ID_ANY) + if len(args) > 2: + self._test_label = args[2] + self._test_value = kwargs.get("value", self._test_value) + + def GetId(self): + return self._test_id + def _wx_mock(*args, **kwargs): return MagicMock() @@ -162,9 +175,9 @@ def _wx_mock(*args, **kwargs): _wx.Menu = _wx_mock _wx.MenuBar = _wx_mock _wx.BoxSizer = _wx_mock - _wx.StaticText = _wx_mock - _wx.TextCtrl = _wx_mock - _wx.Button = _wx_mock + _wx.StaticText = _WxControlStub + _wx.TextCtrl = _WxControlStub + _wx.Button = _WxControlStub _wx.Choice = _wx_mock _wx.ComboBox = _wx_mock _wx.CheckBox = _wx_mock @@ -219,6 +232,7 @@ def _wx_mock(*args, **kwargs): _wx.ICON_WARNING = 0 _wx.ICON_ERROR = 0 _wx.CallAfter = MagicMock() + _wx.CallLater = MagicMock() _wx.BeginBusyCursor = MagicMock() _wx.EndBusyCursor = MagicMock() From 9f74f2f22bf661fcd3b39d1923f3e8d557d1d5f1 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 09:14:16 -0400 Subject: [PATCH 14/15] test(ci): harden wx control fallback state Keep fallback wx controls stateful even when tests monkeypatch shared wx base constructors. Constraint: Ubuntu CI exercises the no-wx fallback while GUI tests patch base widget initialization. Rejected: Special-case advanced text product tests | the fallback controls should keep their own minimal state consistently. Confidence: medium Scope-risk: narrow Directive: Do not make fallback controls depend on patched base constructor side effects. Tested: uv run ruff check tests\\conftest.py tests\\gui\\test_advanced_text_product_dialog.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py Tested: uv run pytest tests\\gui\\test_advanced_text_product_dialog.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py -q Not-tested: Linux no-wx import path locally; delegated to GitHub Actions on PR #681. --- tests/conftest.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 20397b5ad..57691ed78 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,6 +153,13 @@ class _WxControlStub(_WxStubBase): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._test_parent = getattr( + self, "_test_parent", args[0] if args else kwargs.get("parent") + ) + self._test_label = getattr(self, "_test_label", kwargs.get("label", "")) + self._test_name = getattr(self, "_test_name", "") + self._test_value = getattr(self, "_test_value", kwargs.get("value", "")) + self._test_shown = getattr(self, "_test_shown", True) self._test_id = args[1] if len(args) > 1 else kwargs.get("id", _wx.ID_ANY) if len(args) > 2: self._test_label = args[2] From 35070d5a82df6c5526a186b397181b605a91ca27 Mon Sep 17 00:00:00 2001 From: Orinks Date: Tue, 19 May 2026 09:19:42 -0400 Subject: [PATCH 15/15] test(ci): verify gui tests against wx fallback Exercise the no-wx fallback locally and keep fallback controls both stateful and mock-compatible for GUI dialog tests. Constraint: Ubuntu CI runs without wxPython while this Windows workspace has wxPython installed, hiding fallback-only failures unless forced. Rejected: Continue patching one missing shim behavior per CI run | a forced fallback gate gives local coverage for the same surface. Confidence: high Scope-risk: narrow Directive: Use ACCESSIWEATHER_FORCE_WX_STUB=1 when changing wx fallback behavior from a wxPython-equipped machine. Tested: uv run ruff check tests\\conftest.py tests\\gui\\test_advanced_text_product_dialog.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py Tested: ACCESSIWEATHER_FORCE_WX_STUB=1 uv run pytest tests\\gui\\test_advanced_text_product_dialog.py tests\\test_alert_dialog_copy_integration.py tests\\test_alert_dialog_dispatch.py tests\\gui\\test_location_dialog_zone_info.py -q Not-tested: Full Ubuntu matrix locally; delegated to GitHub Actions on PR #681. --- tests/conftest.py | 50 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 57691ed78..b0a3e025c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,19 +10,33 @@ import os import sys import tempfile +from datetime import UTC, datetime, timedelta from importlib.machinery import ModuleSpec +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from hypothesis import settings as hypothesis_settings # --------------------------------------------------------------------------- # Provide stub wx module when wxPython is not installed (headless servers). # This allows tests that mock wx to import accessiweather.ui submodules # without requiring a full wxPython build. # --------------------------------------------------------------------------- +_force_wx_stub = os.environ.get("ACCESSIWEATHER_FORCE_WX_STUB") == "1" +if _force_wx_stub: + for _module_name in list(sys.modules): + if _module_name == "wx" or _module_name.startswith("wx."): + del sys.modules[_module_name] + if "wx" not in sys.modules: try: + if _force_wx_stub: + raise ImportError import wx # noqa: F401 except ImportError: import types - from unittest.mock import MagicMock # Build a minimal wx stub module with real base classes so that # subclassing (e.g. class MainWindow(wx.Frame)) and patch.object work. @@ -164,9 +178,27 @@ def __init__(self, *args, **kwargs): if len(args) > 2: self._test_label = args[2] self._test_value = kwargs.get("value", self._test_value) + self.GetId = MagicMock(return_value=self._test_id) + self.SetValue = MagicMock(side_effect=self._set_test_value) + self.GetValue = MagicMock(return_value=self._test_value) + self.SetLabel = MagicMock(side_effect=self._set_test_label) + self.GetLabel = MagicMock(return_value=self._test_label) + self.SetName = MagicMock(side_effect=self._set_test_name) + self.GetName = MagicMock(return_value=self._test_name) + self.SetToolTip = MagicMock(return_value=None) + self.SetFocus = MagicMock(return_value=None) + + def _set_test_value(self, value): + self._test_value = value + self.GetValue.return_value = value + + def _set_test_label(self, label): + self._test_label = label + self.GetLabel.return_value = label - def GetId(self): - return self._test_id + def _set_test_name(self, name): + self._test_name = name + self.GetName.return_value = name def _wx_mock(*args, **kwargs): return MagicMock() @@ -214,6 +246,7 @@ def _wx_mock(*args, **kwargs): _wx.WXK_RETURN = 13 _wx.WXK_NUMPAD_ENTER = 370 _wx.WXK_SPACE = 32 + _wx.WXK_ESCAPE = 27 _wx.ID_ANY = -1 _wx.ID_OK = 5100 _wx.ID_CANCEL = 5101 @@ -234,6 +267,8 @@ def _wx_mock(*args, **kwargs): _wx.TE_MULTILINE = 0x0020 _wx.TE_READONLY = 0x0010 _wx.TE_RICH2 = 0x8000 + _wx.HSCROLL = 0x8000 + _wx.CB_READONLY = 0x0010 _wx.DEFAULT_FRAME_STYLE = 0 _wx.ICON_INFORMATION = 0 _wx.ICON_WARNING = 0 @@ -329,20 +364,11 @@ def _wx_mock(*args, **kwargs): sys.modules["sound_lib.output"] = _sl_output sys.modules["sound_lib.stream"] = _sl_stream -from datetime import UTC, datetime, timedelta -from pathlib import Path -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - # Set test environment variables before any imports os.environ["ACCESSIWEATHER_TEST_MODE"] = "1" os.environ["PYTEST_CURRENT_TEST"] = "true" # Configure hypothesis for fast CI runs -from hypothesis import settings as hypothesis_settings - hypothesis_settings.register_profile("ci", max_examples=25, deadline=None) hypothesis_settings.register_profile("dev", max_examples=50, deadline=None) hypothesis_settings.register_profile("thorough", max_examples=200, deadline=None)