diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1114e82..28639583 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,14 +79,30 @@ 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 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", "sound_lib @") + 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 == "-r requirements.txt" or stripped.startswith(runtime_stubs): + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2620ef2f..c869f428 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 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 0307c94a..71c83ec9 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", @@ -101,7 +102,19 @@ 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. + +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/installer/accessiweather.spec b/installer/accessiweather.spec index 75d7054a..881b6319 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 834e7d68..0df8cea4 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 d95aa101..b92611b4 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/src/accessiweather/alert_notification_system.py b/src/accessiweather/alert_notification_system.py index b9073ee2..a942e457 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: @@ -198,7 +213,10 @@ 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=self._should_use_specific_alert_sounds(), + ) 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 3170c69c..cb901a10 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_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 b99b67c3..18f2912a 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_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, @@ -110,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), @@ -125,6 +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_sound_packs=specific_alert_sound_packs, notify_discussion_update=settings_cls._as_bool( data.get("notify_discussion_update"), True ), @@ -261,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 6ccaa691..3c2bfb48 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_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 05616a6f..2b2fb4a1 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/alert_sound_mapper.py b/src/accessiweather/notifications/alert_sound_mapper.py index 56650264..fb38eb93 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/notifications/sound_pack_helpers.py b/src/accessiweather/notifications/sound_pack_helpers.py index 64ddeea1..f199c804 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 c848dc98..75517ef6 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 ce49f819..a61a4d4a 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,6 +175,19 @@ def create(self, page_label: str = "Audio"): wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10, ) + controls["specific_alert_sounds_for_pack"] = wx.CheckBox( + panel, label="Use specific alert sounds for this sound pack" + ) + 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_for_pack"], + 0, + wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, + 10, + ) self.dialog._sound_pack_ids = ["default"] pack_names = ["Default"] @@ -152,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) @@ -198,6 +253,9 @@ def load(self, settings): controls = self.dialog._controls controls["sound_enabled"].SetValue(getattr(settings, "sound_enabled", True)) + self.dialog._specific_alert_sound_packs = list( + getattr(settings, "specific_alert_sound_packs", []) + ) current_pack = getattr(settings, "sound_pack", "default") pack_ids = getattr(self.dialog, "_sound_pack_ids", ["default"]) @@ -206,20 +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_sound_packs": sorted(specific_packs), } def setup_accessibility(self): @@ -227,6 +293,7 @@ def setup_accessibility(self): controls = self.dialog._controls names = { "sound_enabled": "Play notification sounds", + "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/conftest.py b/tests/conftest.py index f9340264..b0a3e025 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,22 +10,38 @@ 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. _wx = types.ModuleType("wx") + _wx.__spec__ = ModuleSpec("wx", loader=None, is_package=True) _wx.__package__ = "wx" _wx.__path__ = [] @@ -33,28 +49,187 @@ 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 + + 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 + + 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_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] + 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 _set_test_name(self, name): + self._test_name = name + self.GetName.return_value = name + + def _wx_mock(*args, **kwargs): + return MagicMock() _wx.Frame = _WxStubBase _wx.Panel = _WxStubBase _wx.Dialog = _WxStubBase _wx.App = _WxStubBase + _wx.GetApp = MagicMock(return_value=None) _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 = _WxControlStub + _wx.TextCtrl = _WxControlStub + _wx.Button = _WxControlStub + _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) + _wx.TextDataObject = _WxTextDataObject + _wx.TheClipboard = _WxClipboard() # Common constants _wx.EVT_CLOSE = MagicMock() @@ -71,20 +246,37 @@ def __init__(self, *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 + _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.HSCROLL = 0x8000 + _wx.CB_READONLY = 0x0010 _wx.DEFAULT_FRAME_STYLE = 0 _wx.ICON_INFORMATION = 0 _wx.ICON_WARNING = 0 _wx.ICON_ERROR = 0 _wx.CallAfter = MagicMock() + _wx.CallLater = MagicMock() + _wx.BeginBusyCursor = MagicMock() + _wx.EndBusyCursor = MagicMock() # System colour constants _wx.SYS_COLOUR_GRAYTEXT = 17 @@ -98,26 +290,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,47 +336,39 @@ 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 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 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) diff --git a/tests/gui/test_advanced_text_product_dialog.py b/tests/gui/test_advanced_text_product_dialog.py index efcb977d..a6cf0a36 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), ), ) diff --git a/tests/gui/test_forecast_products_dialog.py b/tests/gui/test_forecast_products_dialog.py index 204599fc..ac6c0410 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 bc63ddeb..6e0ab99e 100644 --- a/tests/gui/test_location_dialog_zone_info.py +++ b/tests/gui/test_location_dialog_zone_info.py @@ -36,15 +36,60 @@ "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", "StaticBoxSizer", "StdDialogButtonSizer", "Size"): if not hasattr(_wx, _attr): setattr(_wx, _attr, MagicMock(name=_attr)) + +class _StaticBoxStub(_wx.Control): + """Class-like StaticBox stub so MagicMock(spec=wx.StaticBox) stays valid.""" + + +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" ) @@ -77,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 = [] @@ -125,9 +174,15 @@ 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") + box._test_label = kwargs.get("label", args[1] if len(args) > 1 else "") box.IsShown.return_value = True def _box_show(visible=True): @@ -158,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")) @@ -184,7 +244,9 @@ def _box_show(visible=True): "StaticBoxSizer", "StdDialogButtonSizer", "BoxSizer", + "ListCtrl", "Panel", + "TextCtrl", "Button", "CheckBox", "Size", @@ -203,6 +265,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 +295,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 +305,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 +320,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 +339,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 +349,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 +365,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 +384,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 +416,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:") diff --git a/tests/test_alert_notification_system.py b/tests/test_alert_notification_system.py index 6c84984f..149a9b4f 100644 --- a/tests/test_alert_notification_system.py +++ b/tests/test_alert_notification_system.py @@ -223,11 +223,40 @@ 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 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(sound_pack="custom", specific_alert_sound_packs=["custom"]) + 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 c8ecb141..e6e366c1 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 99acf22a..3b369aa0 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,6 +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_sound_packs=["default"], ) ) @@ -108,6 +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_for_pack"].GetValue() is True total_events = len(AudioTab._build_default_event_sound_states()) assert ( dialog._controls["event_sounds_summary"].GetLabel() @@ -118,6 +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_for_pack"].SetValue(True) dialog._controls["sound_pack"].SetSelection(0) dialog._event_sound_states["data_updated"] = False dialog._event_sound_states["fetch_error"] = True @@ -127,6 +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_sound_packs"] == ["default"] def test_save_settings_preserves_hidden_legacy_muted_audio_events(): @@ -188,6 +203,45 @@ def test_weather_refresh_sound_is_muted_by_default(): assert settings.muted_sound_events == ["data_updated"] +def test_specific_alert_sound_packs_default_empty_and_round_trip(): + settings = AppSettings.from_dict({}) + + assert settings.specific_alert_sound_packs == [] + payload = settings.to_dict() + 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"] + + +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(): section_titles = [title for title, _description, _events in SOUND_EVENT_SECTIONS] diff --git a/tests/test_sound_player.py b/tests/test_sound_player.py index 1851079d..d3081dcd 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."""