From 4b5959a768e04bd8694e3dc9f51474bfa00e065b Mon Sep 17 00:00:00 2001 From: Orinks Date: Mon, 16 Mar 2026 16:51:09 +0000 Subject: [PATCH 1/2] refactor(runtime): migrate alert and notification state (#467) --- src/accessiweather/alert_manager.py | 67 ++++----- src/accessiweather/app_initialization.py | 8 +- .../notification_event_manager.py | 78 ++++++++-- src/accessiweather/runtime_state.py | 139 ++++++++++++++++-- .../ui/main_window_notification_events.py | 7 +- tests/test_alert_manager.py | 35 +++++ tests/test_notification_event_manager.py | 74 +++++++++- tests/test_runtime_state_manager.py | 82 +++++++++++ tests/test_split_notification_timers.py | 6 +- 9 files changed, 419 insertions(+), 77 deletions(-) diff --git a/src/accessiweather/alert_manager.py b/src/accessiweather/alert_manager.py index d5cb03c08..5f8e0027a 100644 --- a/src/accessiweather/alert_manager.py +++ b/src/accessiweather/alert_manager.py @@ -6,9 +6,7 @@ alert identification and user controls. """ -import json import logging -import os import time from collections import deque from datetime import UTC, datetime, timedelta @@ -28,6 +26,7 @@ SEVERITY_PRIORITY_MAP, ) from .models import WeatherAlert, WeatherAlerts +from .runtime_state import RuntimeStateManager logger = logging.getLogger(__name__) @@ -251,12 +250,19 @@ def should_notify_category(self, event: str) -> bool: class AlertManager: """Manages alert state tracking, change detection, and notifications.""" - def __init__(self, config_dir: str, settings: AlertSettings | None = None): + def __init__( + self, + config_dir: str, + settings: AlertSettings | None = None, + runtime_state_manager: RuntimeStateManager | None = None, + ): """Initialize the instance.""" self.config_dir = Path(config_dir) self.config_dir.mkdir(parents=True, exist_ok=True) - self.state_file = self.config_dir / "alert_state.json" + self.runtime_state_manager = runtime_state_manager or RuntimeStateManager(self.config_dir) + self.state_file = self.runtime_state_manager.state_file + self.legacy_state_file = self.runtime_state_manager.legacy_alert_state_file self.settings = settings or AlertSettings() # In-memory state tracking @@ -276,7 +282,7 @@ def __init__(self, config_dir: str, settings: AlertSettings | None = None): # Defer state loading until first use for faster startup self._state_loaded = False - logger.info(f"AlertManager initialized with state file: {self.state_file}") + logger.info("AlertManager initialized with runtime state file: %s", self.state_file) def _ensure_state_loaded(self): """Ensure state is loaded before first use (lazy loading).""" @@ -288,24 +294,18 @@ def _ensure_state_loaded(self): def _load_state(self): """Load alert state from persistent storage.""" try: - if self.state_file.exists(): - with open(self.state_file) as f: - data = json.load(f) - - # Load alert states - for state_data in data.get("alert_states", []): - state = AlertState.from_dict(state_data) - self.alert_states[state.alert_id] = state - - # Load global state - if data.get("last_global_notification"): - self.last_global_notification = datetime.fromisoformat( - data["last_global_notification"] - ) - - logger.info(f"Loaded {len(self.alert_states)} alert states from storage") - else: - logger.info("No existing alert state file found, starting fresh") + data = self.runtime_state_manager.load_section("alerts") + + for state_data in data.get("alert_states", []): + state = AlertState.from_dict(state_data) + self.alert_states[state.alert_id] = state + + if data.get("last_global_notification"): + self.last_global_notification = datetime.fromisoformat( + data["last_global_notification"] + ) + + logger.info("Loaded %d alert states from runtime storage", len(self.alert_states)) except Exception as e: logger.error(f"Failed to load alert state: {e}") @@ -324,24 +324,11 @@ def _save_state(self): if self.last_global_notification else None ), - "saved_at": datetime.now(UTC).isoformat(), } - - # Write atomically - temp_file = self.state_file.with_suffix(".tmp") - with open(temp_file, "w") as f: - json.dump(data, f, indent=2) - - temp_file.replace(self.state_file) - - # Set secure permissions on POSIX systems - try: - if os.name != "nt": - os.chmod(self.state_file, 0o600) - except Exception: - logger.debug("Could not set strict permissions on alert state file", exc_info=True) - - logger.debug(f"Saved {len(self.alert_states)} alert states to storage") + if self.runtime_state_manager.save_section("alerts", data): + logger.debug("Saved %d alert states to runtime storage", len(self.alert_states)) + else: + logger.error("Failed to save alert runtime state") except Exception as e: logger.error(f"Failed to save alert state: {e}") diff --git a/src/accessiweather/app_initialization.py b/src/accessiweather/app_initialization.py index 1251a2093..6fb5921e8 100644 --- a/src/accessiweather/app_initialization.py +++ b/src/accessiweather/app_initialization.py @@ -109,9 +109,13 @@ def initialize_components(app: AccessiWeatherApp) -> None: from .alert_manager import AlertManager from .alert_notification_system import AlertNotificationSystem - config_dir_str = str(app.paths.config) + config_dir_str = str(app.config_manager.config_dir) alert_settings = config.settings.to_alert_settings() - app.alert_manager = AlertManager(config_dir_str, alert_settings) + app.alert_manager = AlertManager( + config_dir_str, + alert_settings, + runtime_state_manager=app.runtime_state_manager, + ) app.alert_notification_system = AlertNotificationSystem( app.alert_manager, app._notifier, config.settings ) diff --git a/src/accessiweather/notifications/notification_event_manager.py b/src/accessiweather/notifications/notification_event_manager.py index 15b1c7997..a2ae103e9 100644 --- a/src/accessiweather/notifications/notification_event_manager.py +++ b/src/accessiweather/notifications/notification_event_manager.py @@ -19,6 +19,8 @@ from pathlib import Path from typing import TYPE_CHECKING +from ..runtime_state import RuntimeStateManager + if TYPE_CHECKING: from ..models import AppSettings, CurrentConditions, WeatherData @@ -217,45 +219,93 @@ class NotificationEventManager: Both notifications are opt-in (disabled by default). """ - def __init__(self, state_file: Path | None = None): + def __init__( + self, + state_file: Path | None = None, + runtime_state_manager: RuntimeStateManager | None = None, + ): """ Initialize the notification event manager. Args: state_file: Optional path to persist notification state + runtime_state_manager: Optional unified runtime-state manager """ self.state_file = state_file + self.runtime_state_manager = runtime_state_manager self.state = NotificationState() self._load_state() logger.info("NotificationEventManager initialized") def _load_state(self) -> None: """Load state from file if available.""" - if not self.state_file or not self.state_file.exists(): - return - try: - with open(self.state_file, encoding="utf-8") as f: - data = json.load(f) + if self.runtime_state_manager is not None: + data = self._runtime_section_to_legacy_shape( + self.runtime_state_manager.load_section("notification_events") + ) + elif self.state_file and self.state_file.exists(): + with open(self.state_file, encoding="utf-8") as f: + data = json.load(f) + else: + return self.state = NotificationState.from_dict(data) - logger.debug("Loaded notification state from %s", self.state_file) + logger.debug( + "Loaded notification state from %s", + self.runtime_state_manager.state_file if self.runtime_state_manager else self.state_file, + ) except Exception as e: logger.warning("Failed to load notification state: %s", e) def _save_state(self) -> None: """Save state to file if configured.""" - if not self.state_file: - return - try: - self.state_file.parent.mkdir(parents=True, exist_ok=True) - with open(self.state_file, "w", encoding="utf-8") as f: - json.dump(self.state.to_dict(), f, indent=2) - logger.debug("Saved notification state to %s", self.state_file) + if self.runtime_state_manager is not None: + self.runtime_state_manager.save_section( + "notification_events", + self._legacy_shape_to_runtime_section(self.state.to_dict()), + ) + logger.debug( + "Saved notification state to %s", self.runtime_state_manager.state_file + ) + elif self.state_file: + self.state_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.state_file, "w", encoding="utf-8") as f: + json.dump(self.state.to_dict(), f, indent=2) + logger.debug("Saved notification state to %s", self.state_file) except Exception as e: logger.warning("Failed to save notification state: %s", e) + @staticmethod + def _runtime_section_to_legacy_shape(section: dict) -> dict: + """Convert unified runtime state to the legacy notification-state shape.""" + discussion = section.get("discussion", {}) + severe_risk = section.get("severe_risk", {}) + return { + "last_discussion_issuance_time": discussion.get("last_issuance_time"), + "last_discussion_text": discussion.get("last_text"), + "last_severe_risk": severe_risk.get("last_value"), + "last_check_time": discussion.get("last_check_time") + or severe_risk.get("last_check_time"), + } + + @staticmethod + def _legacy_shape_to_runtime_section(data: dict) -> dict: + """Convert legacy notification-state payloads to the unified section shape.""" + last_check_time = data.get("last_check_time") + return { + "discussion": { + "last_issuance_time": data.get("last_discussion_issuance_time"), + "last_text": data.get("last_discussion_text"), + "last_check_time": last_check_time, + }, + "severe_risk": { + "last_value": data.get("last_severe_risk"), + "last_check_time": last_check_time, + }, + } + def check_for_events( self, weather_data: WeatherData, diff --git a/src/accessiweather/runtime_state.py b/src/accessiweather/runtime_state.py index e36e5b991..b2669daf4 100644 --- a/src/accessiweather/runtime_state.py +++ b/src/accessiweather/runtime_state.py @@ -36,6 +36,11 @@ }, } +_SECTION_DEFAULTS: dict[str, dict[str, Any]] = { + "alerts": _DEFAULT_RUNTIME_STATE["alerts"], + "notification_events": _DEFAULT_RUNTIME_STATE["notification_events"], +} + def _merge_nested(defaults: dict[str, Any], loaded: dict[str, Any]) -> dict[str, Any]: """Overlay loaded state onto the default schema without dropping new keys.""" @@ -63,21 +68,53 @@ def __init__(self, config_root: Path | str): def load_state(self) -> dict[str, Any]: """Load runtime state, falling back to the default schema on error.""" - if not self.state_file.exists(): + loaded = self._load_raw_state() + if loaded is None: return deepcopy(_DEFAULT_RUNTIME_STATE) - try: - with open(self.state_file, encoding="utf-8") as handle: - loaded = json.load(handle) - except Exception as exc: - logger.warning("Failed to load runtime state from %s: %s", self.state_file, exc) - return deepcopy(_DEFAULT_RUNTIME_STATE) + return _merge_nested(_DEFAULT_RUNTIME_STATE, loaded) - if not isinstance(loaded, dict): - logger.warning("Runtime state file did not contain a JSON object: %s", self.state_file) - return deepcopy(_DEFAULT_RUNTIME_STATE) + def load_section(self, section: str) -> dict[str, Any]: + """Load a runtime-state section, hydrating from legacy state if needed.""" + if section not in _SECTION_DEFAULTS: + raise KeyError(f"Unknown runtime-state section: {section}") - return _merge_nested(_DEFAULT_RUNTIME_STATE, loaded) + raw_state = self._load_raw_state() + if isinstance(raw_state, dict) and section in raw_state and isinstance(raw_state[section], dict): + return deepcopy(self.load_state()[section]) + + legacy_section = self._load_legacy_section(section) + if legacy_section is None: + return deepcopy(_SECTION_DEFAULTS[section]) + + self.save_section(section, legacy_section, migrated_from=self._legacy_name_for_section(section)) + return legacy_section + + def save_section( + self, + section: str, + section_state: dict[str, Any], + *, + migrated_from: str | None = None, + ) -> bool: + """Persist a single runtime-state section while preserving other sections.""" + if section not in _SECTION_DEFAULTS: + raise KeyError(f"Unknown runtime-state section: {section}") + + state = self.load_state() + state[section] = _merge_nested(_SECTION_DEFAULTS[section], section_state) + + if migrated_from: + migrated = list(state["meta"].get("migrated_from", [])) + if migrated_from not in migrated: + migrated.append(migrated_from) + state["meta"]["migrated_from"] = migrated + if state["meta"].get("migrated_at") is None: + from datetime import UTC, datetime + + state["meta"]["migrated_at"] = datetime.now(UTC).isoformat() + + return self.save_state(state) def save_state(self, state: dict[str, Any]) -> bool: """Save runtime state atomically.""" @@ -102,3 +139,83 @@ def save_state(self, state: dict[str, Any]) -> bool: except Exception: logger.debug("Failed to remove runtime-state temp file", exc_info=True) return False + + def _load_raw_state(self) -> dict[str, Any] | None: + """Return the raw runtime-state payload when available and valid.""" + if not self.state_file.exists(): + return None + + try: + with open(self.state_file, encoding="utf-8") as handle: + loaded = json.load(handle) + except Exception as exc: + logger.warning("Failed to load runtime state from %s: %s", self.state_file, exc) + return None + + if not isinstance(loaded, dict): + logger.warning("Runtime state file did not contain a JSON object: %s", self.state_file) + return None + + return loaded + + def _load_legacy_section(self, section: str) -> dict[str, Any] | None: + """Load and normalize a legacy section payload for migration.""" + if section == "alerts": + return self._load_legacy_alerts_section() + if section == "notification_events": + return self._load_legacy_notification_events_section() + raise KeyError(f"Unknown runtime-state section: {section}") + + def _load_legacy_alerts_section(self) -> dict[str, Any] | None: + data = self._load_legacy_json(self.legacy_alert_state_file) + if data is None: + return None + return _merge_nested( + _SECTION_DEFAULTS["alerts"], + { + "alert_states": data.get("alert_states", []), + "last_global_notification": data.get("last_global_notification"), + }, + ) + + def _load_legacy_notification_events_section(self) -> dict[str, Any] | None: + data = self._load_legacy_json(self.legacy_notification_event_state_file) + if data is None: + return None + last_check_time = data.get("last_check_time") + return _merge_nested( + _SECTION_DEFAULTS["notification_events"], + { + "discussion": { + "last_issuance_time": data.get("last_discussion_issuance_time"), + "last_text": data.get("last_discussion_text"), + "last_check_time": last_check_time, + }, + "severe_risk": { + "last_value": data.get("last_severe_risk"), + "last_check_time": last_check_time, + }, + }, + ) + + def _load_legacy_json(self, path: Path) -> dict[str, Any] | None: + """Load a legacy JSON payload without raising on corruption.""" + if not path.exists(): + return None + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except Exception as exc: + logger.warning("Failed to load legacy runtime state from %s: %s", path, exc) + return None + if not isinstance(data, dict): + logger.warning("Legacy runtime state file did not contain a JSON object: %s", path) + return None + return data + + def _legacy_name_for_section(self, section: str) -> str: + if section == "alerts": + return self.legacy_alert_state_file.name + if section == "notification_events": + return self.legacy_notification_event_state_file.name + raise KeyError(f"Unknown runtime-state section: {section}") diff --git a/src/accessiweather/ui/main_window_notification_events.py b/src/accessiweather/ui/main_window_notification_events.py index e738fc20d..101fbdb4f 100644 --- a/src/accessiweather/ui/main_window_notification_events.py +++ b/src/accessiweather/ui/main_window_notification_events.py @@ -14,6 +14,7 @@ from ..notifications.notification_event_manager import NotificationEventManager from ..notifications.toast_notifier import SafeDesktopNotifier +from ..runtime_state import RuntimeStateManager if TYPE_CHECKING: from .main_window import MainWindow @@ -52,8 +53,10 @@ def get_notification_event_manager(window: MainWindow): not hasattr(window, "_notification_event_manager") or window._notification_event_manager is None ): - state_file = window.app.paths.config / "notification_event_state.json" - window._notification_event_manager = NotificationEventManager(state_file=state_file) + config_root = window.app.config_manager.config_dir + window._notification_event_manager = NotificationEventManager( + runtime_state_manager=RuntimeStateManager(config_root) + ) return window._notification_event_manager diff --git a/tests/test_alert_manager.py b/tests/test_alert_manager.py index 99b7b4535..6c91f674f 100644 --- a/tests/test_alert_manager.py +++ b/tests/test_alert_manager.py @@ -13,6 +13,7 @@ from accessiweather.alert_manager import AlertManager, AlertSettings, AlertState from accessiweather.models import WeatherAlert, WeatherAlerts +from accessiweather.runtime_state import RuntimeStateManager class TestAlertState: @@ -250,6 +251,40 @@ def test_existing_alert_state_suppresses_duplicate_first_run_notification( assert notifications == [] assert manager.alert_states[sample_alert.get_unique_id()].notification_count == 1 + def test_runtime_state_path_is_canonical_under_config_root(self, config_dir): + """Alert state should now write through the unified runtime store.""" + manager = AlertManager(str(config_dir)) + + assert manager.runtime_state_manager.state_file == config_dir / "state" / "runtime_state.json" + + def test_unified_runtime_state_prevents_duplicate_notifications_on_restart( + self, config_dir, sample_alert + ): + """Existing unified alert state should suppress duplicate notifications.""" + runtime_state = RuntimeStateManager(config_dir) + runtime_state.save_section( + "alerts", + { + "alert_states": [ + AlertState( + alert_id=sample_alert.get_unique_id(), + content_hash=sample_alert.get_content_hash(), + first_seen=datetime.now(UTC) - timedelta(hours=2), + last_notified=datetime.now(UTC) - timedelta(minutes=30), + notification_count=1, + severity_priority=sample_alert.get_severity_priority(), + ).to_dict() + ], + "last_global_notification": None, + }, + ) + + manager = AlertManager(str(config_dir)) + notifications = manager.process_alerts(WeatherAlerts(alerts=[sample_alert])) + + assert notifications == [] + assert manager.alert_states[sample_alert.get_unique_id()].notification_count == 1 + def test_get_statistics(self, manager, sample_alert): """Test getting alert statistics.""" alerts = WeatherAlerts(alerts=[sample_alert]) diff --git a/tests/test_notification_event_manager.py b/tests/test_notification_event_manager.py index e26b1b8f3..6f3084aed 100644 --- a/tests/test_notification_event_manager.py +++ b/tests/test_notification_event_manager.py @@ -14,6 +14,7 @@ NotificationState, get_risk_category, ) +from accessiweather.runtime_state import RuntimeStateManager class TestNotificationState: @@ -64,6 +65,11 @@ def manager_with_file(self, tmp_path): state_file = tmp_path / "notification_state.json" return NotificationEventManager(state_file=state_file) + @pytest.fixture + def runtime_manager(self, tmp_path): + """Create a manager backed by the unified runtime state store.""" + return NotificationEventManager(runtime_state_manager=RuntimeStateManager(tmp_path / "config")) + @pytest.fixture def settings_with_discussion(self): """Create settings with discussion notifications enabled.""" @@ -101,6 +107,13 @@ def test_initialization(self, manager): assert manager.state.last_discussion_issuance_time is None assert manager.state.last_severe_risk is None + def test_runtime_state_path_is_canonical_under_config_root(self, runtime_manager, tmp_path): + assert runtime_manager.runtime_state_manager is not None + assert ( + runtime_manager.runtime_state_manager.state_file + == tmp_path / "config" / "state" / "runtime_state.json" + ) + def test_first_discussion_no_notification(self, manager, settings_with_discussion): """Test that first discussion doesn't trigger notification.""" weather_data = MagicMock(spec=WeatherData) @@ -318,15 +331,64 @@ def test_loaded_discussion_state_preserves_first_run_no_spam(self, tmp_path): same_events = manager2.check_for_events(weather_data, settings, "Test Location") assert same_events == [] - assert manager2.state.last_discussion_text == "Same issuance discussion text" - weather_data.discussion = "Updated discussion text" - weather_data.discussion_issuance_time = issuance_time + timedelta(hours=1) + def test_unified_runtime_state_preserves_first_run_no_spam(self, tmp_path): + """Persisted unified discussion state should suppress same-issuance notifications.""" + runtime_state = RuntimeStateManager(tmp_path / "config") + issuance_time = datetime(2026, 1, 20, 14, 35, 0, tzinfo=timezone.utc) + runtime_state.save_section( + "notification_events", + { + "discussion": { + "last_issuance_time": issuance_time.isoformat(), + "last_text": "Old discussion text", + "last_check_time": None, + }, + "severe_risk": { + "last_value": None, + "last_check_time": None, + }, + }, + ) + + manager = NotificationEventManager(runtime_state_manager=runtime_state) + settings = AppSettings(notify_discussion_update=True, notify_severe_risk_change=False) + weather_data = MagicMock(spec=WeatherData) + weather_data.current = None + weather_data.discussion = "Same issuance discussion text" + weather_data.discussion_issuance_time = issuance_time + + same_events = manager.check_for_events(weather_data, settings, "Test Location") + + assert same_events == [] + + def test_legacy_severe_risk_numeric_value_migrates_without_category_change_notification( + self, tmp_path + ): + state_file = tmp_path / "notification_event_state.json" + state_file.write_text( + '{"last_discussion_issuance_time": null, "last_discussion_text": null, ' + '"last_severe_risk": 25, "last_check_time": null}', + encoding="utf-8", + ) + runtime_state = RuntimeStateManager(tmp_path / "config") + manager = NotificationEventManager( + state_file=state_file, + runtime_state_manager=runtime_state, + ) + settings = AppSettings(notify_discussion_update=False, notify_severe_risk_change=True) + current = MagicMock(spec=CurrentConditions) + current.severe_weather_risk = 35 + weather_data = MagicMock(spec=WeatherData) + weather_data.discussion = None + weather_data.discussion_issuance_time = None + weather_data.current = current - updated_events = manager2.check_for_events(weather_data, settings, "Test Location") + events = manager.check_for_events(weather_data, settings, "Test Location") + reloaded = NotificationEventManager(runtime_state_manager=runtime_state) - assert len(updated_events) == 1 - assert updated_events[0].event_type == "discussion_update" + assert events == [] + assert reloaded.state.last_severe_risk == 35 def test_loaded_severe_risk_state_tracks_numeric_value_within_category(self, tmp_path): """Persisted severe-risk state should keep the latest value before a threshold crossing.""" diff --git a/tests/test_runtime_state_manager.py b/tests/test_runtime_state_manager.py index a1576ffe7..1936d0937 100644 --- a/tests/test_runtime_state_manager.py +++ b/tests/test_runtime_state_manager.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + from accessiweather.runtime_state import RuntimeStateManager @@ -53,3 +55,83 @@ def test_corrupt_file_recovers_default_schema(tmp_path): assert state["schema_version"] == 1 assert state["alerts"]["alert_states"] == [] + + +def test_load_section_prefers_unified_data_when_available(tmp_path): + manager = RuntimeStateManager(tmp_path / "config") + manager.state_dir.mkdir(parents=True, exist_ok=True) + manager.state_file.write_text( + json.dumps( + { + "alerts": { + "alert_states": [{"alert_id": "from-unified"}], + "last_global_notification": "2026-03-16T15:00:00+00:00", + } + } + ), + encoding="utf-8", + ) + manager.legacy_alert_state_file.parent.mkdir(parents=True, exist_ok=True) + manager.legacy_alert_state_file.write_text( + json.dumps( + { + "alert_states": [{"alert_id": "from-legacy"}], + "last_global_notification": "2026-03-16T14:00:00+00:00", + } + ), + encoding="utf-8", + ) + + section = manager.load_section("alerts") + + assert section["alert_states"] == [{"alert_id": "from-unified"}] + assert section["last_global_notification"] == "2026-03-16T15:00:00+00:00" + + +def test_load_section_falls_back_to_valid_legacy_data_per_section(tmp_path): + manager = RuntimeStateManager(tmp_path / "config") + manager.legacy_notification_event_state_file.parent.mkdir(parents=True, exist_ok=True) + manager.legacy_notification_event_state_file.write_text( + json.dumps( + { + "last_discussion_issuance_time": "2026-03-16T14:30:00+00:00", + "last_discussion_text": "Discussion text", + "last_severe_risk": 35, + "last_check_time": "2026-03-16T14:31:00+00:00", + } + ), + encoding="utf-8", + ) + + notification_section = manager.load_section("notification_events") + alert_section = manager.load_section("alerts") + + assert notification_section["discussion"]["last_issuance_time"] == "2026-03-16T14:30:00+00:00" + assert notification_section["discussion"]["last_text"] == "Discussion text" + assert notification_section["discussion"]["last_check_time"] == "2026-03-16T14:31:00+00:00" + assert notification_section["severe_risk"]["last_value"] == 35 + assert notification_section["severe_risk"]["last_check_time"] == "2026-03-16T14:31:00+00:00" + assert alert_section["alert_states"] == [] + + +def test_load_section_ignores_corrupt_legacy_file_without_breaking_other_section(tmp_path): + manager = RuntimeStateManager(tmp_path / "config") + manager.legacy_alert_state_file.parent.mkdir(parents=True, exist_ok=True) + manager.legacy_alert_state_file.write_text("{bad json", encoding="utf-8") + manager.legacy_notification_event_state_file.write_text( + json.dumps( + { + "last_discussion_issuance_time": "2026-03-16T14:30:00+00:00", + "last_discussion_text": "Discussion text", + "last_severe_risk": 42, + } + ), + encoding="utf-8", + ) + + alerts = manager.load_section("alerts") + notification_events = manager.load_section("notification_events") + + assert alerts["alert_states"] == [] + assert notification_events["discussion"]["last_issuance_time"] == "2026-03-16T14:30:00+00:00" + assert notification_events["severe_risk"]["last_value"] == 42 diff --git a/tests/test_split_notification_timers.py b/tests/test_split_notification_timers.py index 5f667ad17..c7374e664 100644 --- a/tests/test_split_notification_timers.py +++ b/tests/test_split_notification_timers.py @@ -349,6 +349,7 @@ def _make_window(self): win.app = MagicMock() win.app.paths.config = Path("/tmp/config") + win.app.config_manager.config_dir = Path("/tmp/runtime-config") win._notification_event_manager = None win._fallback_notifier = None return win @@ -363,8 +364,9 @@ def test_get_notification_event_manager_caches_instance(self): second = win._get_notification_event_manager() assert first is second - manager_cls.assert_called_once_with( - state_file=win.app.paths.config / "notification_event_state.json" + _, kwargs = manager_cls.call_args + assert kwargs["runtime_state_manager"].state_file == ( + win.app.config_manager.config_dir / "state" / "runtime_state.json" ) def test_process_notification_events_skips_when_both_disabled(self): From 9aa133fadfa5d10390dc7bc90f5b00a1e516e410 Mon Sep 17 00:00:00 2001 From: Orinks Date: Mon, 16 Mar 2026 20:33:09 +0000 Subject: [PATCH 2/2] style(runtime): format phase 2 migration files --- .../notifications/notification_event_manager.py | 4 +++- src/accessiweather/runtime_state.py | 10 ++++++++-- tests/test_alert_manager.py | 4 +++- tests/test_notification_event_manager.py | 4 +++- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/accessiweather/notifications/notification_event_manager.py b/src/accessiweather/notifications/notification_event_manager.py index a2ae103e9..24af70128 100644 --- a/src/accessiweather/notifications/notification_event_manager.py +++ b/src/accessiweather/notifications/notification_event_manager.py @@ -253,7 +253,9 @@ def _load_state(self) -> None: self.state = NotificationState.from_dict(data) logger.debug( "Loaded notification state from %s", - self.runtime_state_manager.state_file if self.runtime_state_manager else self.state_file, + self.runtime_state_manager.state_file + if self.runtime_state_manager + else self.state_file, ) except Exception as e: logger.warning("Failed to load notification state: %s", e) diff --git a/src/accessiweather/runtime_state.py b/src/accessiweather/runtime_state.py index b2669daf4..55d81b1e7 100644 --- a/src/accessiweather/runtime_state.py +++ b/src/accessiweather/runtime_state.py @@ -80,14 +80,20 @@ def load_section(self, section: str) -> dict[str, Any]: raise KeyError(f"Unknown runtime-state section: {section}") raw_state = self._load_raw_state() - if isinstance(raw_state, dict) and section in raw_state and isinstance(raw_state[section], dict): + if ( + isinstance(raw_state, dict) + and section in raw_state + and isinstance(raw_state[section], dict) + ): return deepcopy(self.load_state()[section]) legacy_section = self._load_legacy_section(section) if legacy_section is None: return deepcopy(_SECTION_DEFAULTS[section]) - self.save_section(section, legacy_section, migrated_from=self._legacy_name_for_section(section)) + self.save_section( + section, legacy_section, migrated_from=self._legacy_name_for_section(section) + ) return legacy_section def save_section( diff --git a/tests/test_alert_manager.py b/tests/test_alert_manager.py index 6c91f674f..f5a1c1500 100644 --- a/tests/test_alert_manager.py +++ b/tests/test_alert_manager.py @@ -255,7 +255,9 @@ def test_runtime_state_path_is_canonical_under_config_root(self, config_dir): """Alert state should now write through the unified runtime store.""" manager = AlertManager(str(config_dir)) - assert manager.runtime_state_manager.state_file == config_dir / "state" / "runtime_state.json" + assert ( + manager.runtime_state_manager.state_file == config_dir / "state" / "runtime_state.json" + ) def test_unified_runtime_state_prevents_duplicate_notifications_on_restart( self, config_dir, sample_alert diff --git a/tests/test_notification_event_manager.py b/tests/test_notification_event_manager.py index 6f3084aed..44935574f 100644 --- a/tests/test_notification_event_manager.py +++ b/tests/test_notification_event_manager.py @@ -68,7 +68,9 @@ def manager_with_file(self, tmp_path): @pytest.fixture def runtime_manager(self, tmp_path): """Create a manager backed by the unified runtime state store.""" - return NotificationEventManager(runtime_state_manager=RuntimeStateManager(tmp_path / "config")) + return NotificationEventManager( + runtime_state_manager=RuntimeStateManager(tmp_path / "config") + ) @pytest.fixture def settings_with_discussion(self):