diff --git a/src/accessiweather/config/config_manager.py b/src/accessiweather/config/config_manager.py index 4fa5920b..17dfe537 100644 --- a/src/accessiweather/config/config_manager.py +++ b/src/accessiweather/config/config_manager.py @@ -278,10 +278,21 @@ def update_settings(self, **kwargs) -> bool: return self._settings.update_settings(**kwargs) def add_location( - self, name: str, latitude: float, longitude: float, country_code: str | None = None + self, + name: str, + latitude: float, + longitude: float, + country_code: str | None = None, + marine_mode: bool = False, ) -> bool: """Add a new location.""" - return self._locations.add_location(name, latitude, longitude, country_code) + return self._locations.add_location( + name, + latitude, + longitude, + country_code, + marine_mode=marine_mode, + ) def remove_location(self, name: str) -> bool: """Remove a location.""" diff --git a/src/accessiweather/config/locations.py b/src/accessiweather/config/locations.py index 764c3e28..9a5dd61a 100644 --- a/src/accessiweather/config/locations.py +++ b/src/accessiweather/config/locations.py @@ -30,6 +30,7 @@ def add_location( latitude: float, longitude: float, country_code: str | None = None, + marine_mode: bool = False, ) -> bool: """Add a new location if it doesn't already exist.""" config = self._manager.get_config() @@ -44,6 +45,7 @@ def add_location( latitude=latitude, longitude=longitude, country_code=country_code, + marine_mode=marine_mode, ) config.locations.append(new_location) diff --git a/src/accessiweather/display/presentation/forecast.py b/src/accessiweather/display/presentation/forecast.py index 8d70b548..6607dd17 100644 --- a/src/accessiweather/display/presentation/forecast.py +++ b/src/accessiweather/display/presentation/forecast.py @@ -6,7 +6,14 @@ from datetime import datetime, tzinfo from ...forecast_confidence import ForecastConfidence -from ...models import AppSettings, Forecast, ForecastPeriod, HourlyForecast, Location +from ...models import ( + AppSettings, + Forecast, + ForecastPeriod, + HourlyForecast, + Location, + MarineForecast, +) from ...utils import TemperatureUnit, calculate_dewpoint from ...utils.unit_utils import format_precipitation, format_wind_speed from ..weather_presenter import ( @@ -105,6 +112,7 @@ def build_forecast( unit_pref: TemperatureUnit, settings: AppSettings | None = None, *, + marine: MarineForecast | None = None, confidence: ForecastConfidence | None = None, ) -> ForecastPresentation: """Create a structured forecast including optional hourly highlights.""" @@ -277,7 +285,32 @@ def build_forecast( hours=hourly_hours, summary_line=hourly_summary_line, ) + + marine_summary = None + marine_highlights: list[str] = [] + marine_section_text = "" + if marine and marine.has_data(): + marine_lines = [f"Marine conditions for {location.name}:"] + if marine.zone_name: + zone_label = marine.zone_name + if marine.zone_id: + zone_label = f"{zone_label} ({marine.zone_id})" + marine_lines.append(f"Marine zone: {zone_label}") + marine_summary = marine.forecast_summary + if marine_summary: + marine_lines.append(f"Summary: {marine_summary}") + marine_highlights = marine.highlights[:4] + if marine_highlights: + marine_lines.append("Wind and wave highlights:") + marine_lines.extend(f" • {highlight}" for highlight in marine_highlights) + for period in marine.periods[:3]: + if period.summary: + marine_lines.append(f"{period.name}: {wrap_text(period.summary, 80)}") + marine_section_text = "\n".join(marine_lines).rstrip() + fallback_sections = [daily_section_text] + if marine_section_text: + fallback_sections.append(marine_section_text) if hourly_section_text: fallback_sections.append(hourly_section_text) fallback_text = "\n\n".join(section for section in fallback_sections if section).rstrip() @@ -291,6 +324,9 @@ def build_forecast( fallback_text=fallback_text, daily_section_text=daily_section_text, hourly_section_text=hourly_section_text, + marine_section_text=marine_section_text, + marine_summary=marine_summary, + marine_highlights=marine_highlights, confidence_label=confidence_label, summary=summary_line, ) diff --git a/src/accessiweather/display/presentation/html_formatters.py b/src/accessiweather/display/presentation/html_formatters.py index 92194abf..60eab97e 100644 --- a/src/accessiweather/display/presentation/html_formatters.py +++ b/src/accessiweather/display/presentation/html_formatters.py @@ -133,6 +133,23 @@ color: #888; font-style: italic; } +.marine-section { + margin-bottom: 16px; + padding: 8px; + background-color: #f3f8ff; + border-radius: 4px; + border: 1px solid #d7e7ff; +} +.marine-section ul { + margin: 6px 0 0 18px; + padding: 0; +} +.marine-section li { + margin-bottom: 4px; +} +.marine-period { + margin-top: 8px; +} """ @@ -259,6 +276,54 @@ def generate_forecast_html(presentation: ForecastPresentation | None) -> str:
{"".join(hourly_items)}
+""" + + # Build marine section if available + marine_html = "" + if presentation.marine_section_text: + marine_summary_html = "" + if presentation.marine_summary: + marine_summary_html = ( + f'

{_escape_html(presentation.marine_summary)}

' + ) + + marine_highlights_html = "" + if presentation.marine_highlights: + marine_items = "".join( + f"
  • {_escape_html(highlight)}
  • " + for highlight in presentation.marine_highlights + ) + marine_highlights_html = f""" +

    Marine Highlights

    +""" + + period_lines = [ + line.strip() + for line in presentation.marine_section_text.splitlines() + if line.strip() + and ":" in line + and not line.startswith( + ( + "Marine conditions", + "Marine zone:", + "Summary:", + "Wind and wave highlights:", + "•", + ) + ) + ] + marine_periods_html = "".join( + f'

    {_escape_html(line)}

    ' for line in period_lines + ) + + marine_html = f""" +
    +

    Marine Forecast

    +{marine_summary_html} +{marine_highlights_html} +{marine_periods_html}
    """ # Build forecast periods @@ -295,6 +360,7 @@ def generate_forecast_html(presentation: ForecastPresentation | None) -> str:

    {title}

    {hourly_html} +{marine_html} {periods_html} {generated_html}
    diff --git a/src/accessiweather/display/weather_presenter.py b/src/accessiweather/display/weather_presenter.py index 15a2bf4d..0448054c 100644 --- a/src/accessiweather/display/weather_presenter.py +++ b/src/accessiweather/display/weather_presenter.py @@ -25,6 +25,7 @@ Forecast, HourlyForecast, Location, + MarineForecast, TrendInsight, WeatherAlerts, WeatherData, @@ -105,6 +106,9 @@ class ForecastPresentation: fallback_text: str = "" daily_section_text: str = "" hourly_section_text: str = "" + marine_section_text: str = "" + marine_summary: str | None = None + marine_highlights: list[str] = field(default_factory=list) confidence_label: str | None = None summary: str | None = None @@ -233,6 +237,7 @@ def present(self, weather_data: WeatherData) -> WeatherPresentation: weather_data.hourly_forecast, weather_data.location, unit_pref, + marine=weather_data.marine, confidence=weather_data.forecast_confidence, ) if weather_data.forecast @@ -308,13 +313,19 @@ def present_forecast( forecast: Forecast | None, location: Location, hourly_forecast: HourlyForecast | None = None, + marine: MarineForecast | None = None, confidence: ForecastConfidence | None = None, ) -> ForecastPresentation | None: if not forecast or not forecast.has_data(): return None unit_pref, _unit_system = self._resolve_unit_preferences(location) return self._build_forecast( - forecast, hourly_forecast, location, unit_pref, confidence=confidence + forecast, + hourly_forecast, + location, + unit_pref, + marine=marine, + confidence=confidence, ) def present_alerts( @@ -365,6 +376,7 @@ def _build_forecast( hourly_forecast: HourlyForecast | None, location: Location, unit_pref: TemperatureUnit, + marine: MarineForecast | None = None, confidence: ForecastConfidence | None = None, ) -> ForecastPresentation: return build_forecast( @@ -373,6 +385,7 @@ def _build_forecast( location, unit_pref, settings=self.settings, + marine=marine, confidence=confidence, ) diff --git a/src/accessiweather/models/__init__.py b/src/accessiweather/models/__init__.py index 12a1b39c..fc575f76 100644 --- a/src/accessiweather/models/__init__.py +++ b/src/accessiweather/models/__init__.py @@ -25,6 +25,8 @@ HourlyForecastPeriod, HourlyUVIndex, Location, + MarineForecast, + MarineForecastPeriod, MinutelyPrecipitationForecast, MinutelyPrecipitationPoint, SourceAttribution, @@ -42,6 +44,8 @@ "HourlyForecast", "HourlyAirQuality", "HourlyUVIndex", + "MarineForecastPeriod", + "MarineForecast", "MinutelyPrecipitationPoint", "MinutelyPrecipitationForecast", "TrendInsight", diff --git a/src/accessiweather/models/config.py b/src/accessiweather/models/config.py index 269e2ff0..3aaa8e14 100644 --- a/src/accessiweather/models/config.py +++ b/src/accessiweather/models/config.py @@ -636,6 +636,7 @@ def to_dict(self) -> dict: "latitude": loc.latitude, "longitude": loc.longitude, **({"country_code": loc.country_code} if loc.country_code else {}), + **({"marine_mode": True} if loc.marine_mode else {}), } for loc in self.locations ], @@ -648,6 +649,7 @@ def to_dict(self) -> dict: if self.current_location.country_code else {} ), + **({"marine_mode": True} if self.current_location.marine_mode else {}), } if self.current_location else None, @@ -666,6 +668,7 @@ def from_dict(cls, data: dict) -> AppConfig: latitude=loc_data["latitude"], longitude=loc_data["longitude"], country_code=loc_data.get("country_code"), + marine_mode=bool(loc_data.get("marine_mode", False)), ) ) @@ -677,6 +680,7 @@ def from_dict(cls, data: dict) -> AppConfig: latitude=loc_data["latitude"], longitude=loc_data["longitude"], country_code=loc_data.get("country_code"), + marine_mode=bool(loc_data.get("marine_mode", False)), ) return cls( diff --git a/src/accessiweather/models/weather.py b/src/accessiweather/models/weather.py index 222b3ffe..318ae2b0 100644 --- a/src/accessiweather/models/weather.py +++ b/src/accessiweather/models/weather.py @@ -130,6 +130,7 @@ class Location: longitude: float timezone: str | None = None country_code: str | None = None + marine_mode: bool = False def __str__(self) -> str: return self.name @@ -532,6 +533,30 @@ def has_taf(self) -> bool: ) +@dataclass +class MarineForecastPeriod: + """Single marine forecast period from an NWS marine zone product.""" + + name: str + summary: str + + +@dataclass +class MarineForecast: + """Marine zone essentials for a coastal location.""" + + zone_id: str | None = None + zone_name: str | None = None + forecast_summary: str | None = None + issued_at: datetime | None = None + periods: list[MarineForecastPeriod] = field(default_factory=list) + highlights: list[str] = field(default_factory=list) + + def has_data(self) -> bool: + """Return True when any marine essentials are available.""" + return bool(self.forecast_summary or self.zone_name or self.periods or self.highlights) + + @dataclass class WeatherData: """Complete weather data for a location.""" @@ -547,6 +572,7 @@ class WeatherData: alerts: WeatherAlerts | None = None environmental: EnvironmentalConditions | None = None aviation: AviationData | None = None + marine: MarineForecast | None = None trend_insights: list[TrendInsight] = field(default_factory=list) stale: bool = False stale_since: datetime | None = None @@ -584,5 +610,6 @@ def has_any_data(self) -> bool: self.alerts and self.alerts.has_alerts(), self.environmental and self.environmental.has_data(), self.aviation and self.aviation.has_taf(), + self.marine and self.marine.has_data(), ] ) diff --git a/src/accessiweather/ui/dialogs/location_dialog.py b/src/accessiweather/ui/dialogs/location_dialog.py index 8ed0e70a..77fac504 100644 --- a/src/accessiweather/ui/dialogs/location_dialog.py +++ b/src/accessiweather/ui/dialogs/location_dialog.py @@ -107,6 +107,15 @@ def _create_ui(self): help_text.SetForegroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_GRAYTEXT)) name_sizer.Add(help_text, 0, wx.TOP, 5) + self.marine_mode_checkbox = wx.CheckBox( + panel, + label="Enable Marine Mode for this location (coastal essentials only)", + ) + self.marine_mode_checkbox.SetToolTip( + "Adds nearby NWS marine zone summary, wind and wave highlights, and marine advisories." + ) + name_sizer.Add(self.marine_mode_checkbox, 0, wx.TOP, 8) + main_sizer.Add(name_sizer, 0, wx.EXPAND | wx.ALL, 10) # Search section @@ -174,6 +183,7 @@ def _setup_accessibility(self): self.name_input.SetName("Location name input") self.search_input.SetName("Search for location") self.results_list.SetName("Search results") + self.marine_mode_checkbox.SetName("Enable Marine Mode for this location") def _on_key(self, event: wx.KeyEvent) -> None: """Handle key events.""" @@ -307,7 +317,11 @@ def _on_save(self, event): # Add location success = self.config_manager.add_location( - name, latitude, longitude, country_code=country_code + name, + latitude, + longitude, + country_code=country_code, + marine_mode=self.marine_mode_checkbox.GetValue(), ) if success: diff --git a/src/accessiweather/ui/main_window.py b/src/accessiweather/ui/main_window.py index 55123e60..91dd725c 100644 --- a/src/accessiweather/ui/main_window.py +++ b/src/accessiweather/ui/main_window.py @@ -1201,9 +1201,11 @@ def _on_weather_data_received(self, weather_data) -> None: # Update forecast if presentation.forecast: - daily_text = ( - presentation.forecast.daily_section_text or "No daily forecast available." - ) + daily_sections = [presentation.forecast.daily_section_text] + if presentation.forecast.marine_section_text: + daily_sections.append(presentation.forecast.marine_section_text) + daily_text = "\n\n".join(section for section in daily_sections if section).rstrip() + daily_text = daily_text or "No daily forecast available." hourly_text = ( presentation.forecast.hourly_section_text or "No hourly forecast available." ) diff --git a/src/accessiweather/weather_client_base.py b/src/accessiweather/weather_client_base.py index da424a05..c23dcab4 100644 --- a/src/accessiweather/weather_client_base.py +++ b/src/accessiweather/weather_client_base.py @@ -1174,6 +1174,9 @@ def _launch_enrichment_tasks( tasks["aviation"] = asyncio.create_task( enrichment.enrich_with_aviation_data(self, weather_data, location) ) + tasks["marine"] = asyncio.create_task( + enrichment.enrich_with_marine_data(self, weather_data, location) + ) return tasks diff --git a/src/accessiweather/weather_client_enrichment.py b/src/accessiweather/weather_client_enrichment.py index f997b117..827c7f17 100644 --- a/src/accessiweather/weather_client_enrichment.py +++ b/src/accessiweather/weather_client_enrichment.py @@ -3,12 +3,22 @@ from __future__ import annotations import logging +import re +from datetime import datetime from typing import TYPE_CHECKING, Any from . import weather_client_nws as nws_client from .api.avwx_client import AvwxApiError, fetch_avwx_taf, is_us_station from .display.presentation.environmental import _get_uv_category -from .models import AviationData, Location, WeatherAlert, WeatherAlerts, WeatherData +from .models import ( + AviationData, + Location, + MarineForecast, + MarineForecastPeriod, + WeatherAlert, + WeatherAlerts, + WeatherData, +) from .utils import decode_taf_text if TYPE_CHECKING: @@ -291,6 +301,132 @@ async def get_aviation_weather( return aviation +_MARINE_HIGHLIGHT_PATTERN = re.compile( + r"([^.;]*\b(?:wind|winds|gust|gusts|wave|waves|seas|swell|swells)\b[^.;]*)", + re.IGNORECASE, +) + + +def _build_marine_highlights(periods: list[dict[str, Any]]) -> list[str]: + """Extract concise wind and wave highlights from marine text periods.""" + highlights: list[str] = [] + seen: set[str] = set() + for period in periods: + for field in ("shortForecast", "detailedForecast"): + text = str(period.get(field) or "").strip() + if not text: + continue + for match in _MARINE_HIGHLIGHT_PATTERN.findall(text): + candidate = " ".join(match.split()).strip(" .") + if not candidate: + continue + normalized = candidate.lower() + if normalized in seen: + continue + seen.add(normalized) + highlights.append(candidate) + if len(highlights) >= 4: + return highlights + return highlights + + +def _parse_marine_issued_at(value: Any) -> datetime | None: + if not value or not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +async def enrich_with_marine_data( + client: WeatherClient, weather_data: WeatherData, location: Location +) -> None: + """Populate marine essentials for coastal locations when marine mode is enabled.""" + if not getattr(location, "marine_mode", False) or not client._is_us_location(location): + return + + try: + http_client = client._get_http_client() + headers = {"User-Agent": client.user_agent} + marine_zone_response = await nws_client._client_get( + http_client, + f"{client.nws_base_url}/zones", + headers=headers, + params={"type": "marine", "point": f"{location.latitude},{location.longitude}"}, + ) + marine_zone_response.raise_for_status() + marine_zone_data = marine_zone_response.json() + features = marine_zone_data.get("features") or [] + if not features: + return + + marine_feature = features[0] + marine_properties = marine_feature.get("properties", {}) + zone_id = marine_properties.get("id") or marine_feature.get("id") + if not zone_id: + return + + marine_forecast_data = await nws_client.get_nws_marine_forecast( + "marine", + zone_id, + client.nws_base_url, + client.user_agent, + client.timeout, + http_client, + ) + if not marine_forecast_data: + return + + forecast_properties = marine_forecast_data.get("properties", {}) + forecast_periods = forecast_properties.get("periods") or [] + periods = [ + MarineForecastPeriod( + name=str(period.get("name") or "Marine period"), + summary=str( + period.get("detailedForecast") or period.get("shortForecast") or "" + ).strip(), + ) + for period in forecast_periods[:3] + if (period.get("detailedForecast") or period.get("shortForecast")) + ] + marine = MarineForecast( + zone_id=zone_id, + zone_name=forecast_properties.get("name") or marine_properties.get("name"), + forecast_summary=( + str( + forecast_periods[0].get("detailedForecast") + or forecast_periods[0].get("shortForecast") + ) + if forecast_periods + else None + ), + issued_at=_parse_marine_issued_at(forecast_properties.get("updateTime")), + periods=periods, + highlights=_build_marine_highlights(forecast_periods[:4]), + ) + if marine.has_data(): + weather_data.marine = marine + + marine_alerts_response = await nws_client._client_get( + http_client, + f"{client.nws_base_url}/alerts/active", + headers=headers, + params={"zone": zone_id, "status": "actual"}, + ) + marine_alerts_response.raise_for_status() + marine_alerts = nws_client.parse_nws_alerts(marine_alerts_response.json()) + if marine_alerts and marine_alerts.alerts: + existing = weather_data.alerts.alerts if weather_data.alerts else [] + merged: dict[str, WeatherAlert] = {alert.get_unique_id(): alert for alert in existing} + for alert in marine_alerts.alerts: + alert.source = "NWS Marine" + merged.setdefault(alert.get_unique_id(), alert) + weather_data.alerts = WeatherAlerts(alerts=list(merged.values())) + except Exception as exc: # noqa: BLE001 + logger.debug("Failed to fetch marine essentials for %s: %s", location.name, exc) + + async def enrich_with_aviation_data( client: WeatherClient, weather_data: WeatherData, location: Location ) -> None: diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index bdd1e666..38e04717 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -83,6 +83,29 @@ def test_add_location(self, manager): assert len(non_nationwide) == 1 assert non_nationwide[0].name == "New York" + def test_add_location_persists_marine_mode_roundtrip(self, manager): + """Marine mode should save and reload with locations.""" + result = manager.add_location( + name="Annapolis", + latitude=38.9784, + longitude=-76.4922, + country_code="US", + marine_mode=True, + ) + assert result is True + + manager.set_current_location("Annapolis") + manager.save_config() + + manager2 = ConfigManager(manager.app, config_dir=manager.config_dir) + loaded = manager2.load_config() + + saved_location = next(loc for loc in loaded.locations if loc.name == "Annapolis") + assert saved_location.marine_mode is True + assert loaded.current_location is not None + assert loaded.current_location.name == "Annapolis" + assert loaded.current_location.marine_mode is True + def test_add_duplicate_location_fails(self, manager): """Test that adding duplicate location fails.""" manager.add_location("Test", 40.0, -74.0) diff --git a/tests/test_coverage_fix_pr449.py b/tests/test_coverage_fix_pr449.py index 913de405..2d135442 100644 --- a/tests/test_coverage_fix_pr449.py +++ b/tests/test_coverage_fix_pr449.py @@ -110,11 +110,11 @@ def test_parse_nws_alerts_extracts_references(): # --------------------------------------------------------------------------- # 4. weather_client_base.py lines 912-924: _launch_enrichment_tasks auto-mode # These lines are inside `if self.data_source == "auto":` and create tasks -# for sunrise_sunset, nws_discussion, vc_alerts, and vc_moon_data. +# for sunrise_sunset, nws_discussion, vc_alerts, vc_moon_data, and marine. # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_launch_enrichment_tasks_auto_mode_creates_smart_tasks(): - """_launch_enrichment_tasks with data_source='auto' creates all four smart enrichment tasks.""" + """_launch_enrichment_tasks with data_source='auto' creates all smart enrichment tasks.""" import asyncio import accessiweather.weather_client_enrichment as enrichment @@ -135,6 +135,7 @@ async def _noop(*args, **kwargs): patch.object(enrichment, "enrich_with_visual_crossing_moon_data", side_effect=_noop), patch.object(enrichment, "populate_environmental_metrics", side_effect=_noop), patch.object(enrichment, "enrich_with_aviation_data", side_effect=_noop), + patch.object(enrichment, "enrich_with_marine_data", side_effect=_noop), ): tasks = client._launch_enrichment_tasks(weather_data, location) # Cancel tasks to prevent ResourceWarning about pending coroutines @@ -148,6 +149,7 @@ async def _noop(*args, **kwargs): assert "vc_moon_data" in tasks assert "environmental" in tasks assert "aviation" in tasks + assert "marine" in tasks @pytest.mark.asyncio @@ -169,6 +171,7 @@ async def _noop(*args, **kwargs): with ( patch.object(enrichment, "populate_environmental_metrics", side_effect=_noop), patch.object(enrichment, "enrich_with_aviation_data", side_effect=_noop), + patch.object(enrichment, "enrich_with_marine_data", side_effect=_noop), ): tasks = client._launch_enrichment_tasks(weather_data, location) for t in tasks.values(): @@ -181,3 +184,4 @@ async def _noop(*args, **kwargs): assert "vc_moon_data" not in tasks assert "environmental" in tasks assert "aviation" in tasks + assert "marine" in tasks diff --git a/tests/test_hourly_forecast_presentation.py b/tests/test_hourly_forecast_presentation.py index 5078a4af..fb3c2190 100644 --- a/tests/test_hourly_forecast_presentation.py +++ b/tests/test_hourly_forecast_presentation.py @@ -15,6 +15,8 @@ HourlyForecast, HourlyForecastPeriod, Location, + MarineForecast, + MarineForecastPeriod, ) from accessiweather.utils import TemperatureUnit @@ -115,3 +117,48 @@ def test_build_forecast_exposes_daily_and_hourly_sections(): assert "Hourly outlook: Clear through mid afternoon." in result.hourly_section_text assert "Next 1 Hours:" in result.hourly_section_text assert result.fallback_text == (f"{result.daily_section_text}\n\n{result.hourly_section_text}") + + +def test_build_forecast_includes_marine_section_in_fallback_text(): + forecast = Forecast( + periods=[ + ForecastPeriod( + name="Today", + temperature=70.0, + temperature_low=54.0, + temperature_unit="F", + short_forecast="Sunny", + ) + ] + ) + marine = MarineForecast( + zone_id="ANZ530", + zone_name="Chesapeake Bay from Pooles Island to Sandy Point", + forecast_summary="South winds 10 to 15 knots with waves 1 to 2 feet.", + highlights=["South winds 10 to 15 knots", "Waves 1 to 2 feet"], + periods=[ + MarineForecastPeriod( + name="Tonight", + summary="South winds 10 to 15 knots with waves 1 to 2 feet.", + ) + ], + ) + + result = build_forecast( + forecast, + None, + Location(name="Annapolis", latitude=38.9784, longitude=-76.4922, marine_mode=True), + TemperatureUnit.FAHRENHEIT, + settings=AppSettings(), + marine=marine, + ) + + assert result.marine_summary == marine.forecast_summary + assert result.marine_highlights == marine.highlights + assert "Marine conditions for Annapolis:" in result.marine_section_text + assert ( + "Marine zone: Chesapeake Bay from Pooles Island to Sandy Point (ANZ530)" + in result.marine_section_text + ) + assert "Wind and wave highlights:" in result.marine_section_text + assert result.fallback_text == (f"{result.daily_section_text}\n\n{result.marine_section_text}") diff --git a/tests/test_html_formatters.py b/tests/test_html_formatters.py index 9029d273..c8b572e7 100644 --- a/tests/test_html_formatters.py +++ b/tests/test_html_formatters.py @@ -231,6 +231,27 @@ def test_with_generated_at(self): assert "Forecast generated: 2026-02-14 02:00 UTC" in html assert "generated-at" in html + def test_with_marine_section(self): + pres = ForecastPresentation( + title="Forecast", + marine_section_text=( + "Marine conditions for Annapolis:\n" + "Marine zone: Chesapeake Bay (ANZ530)\n" + "Summary: South winds 10 to 15 knots with waves 1 to 2 feet.\n" + "Wind and wave highlights:\n" + " • South winds 10 to 15 knots\n" + "Tonight: South winds 10 to 15 knots with waves 1 to 2 feet." + ), + marine_summary="South winds 10 to 15 knots with waves 1 to 2 feet.", + marine_highlights=["South winds 10 to 15 knots", "Waves 1 to 2 feet"], + ) + html = generate_forecast_html(pres) + assert 'aria-label="Marine forecast"' in html + assert "Marine Forecast" in html + assert "Marine Highlights" in html + assert "South winds 10 to 15 knots" in html + assert "Tonight: South winds 10 to 15 knots with waves 1 to 2 feet." in html + def test_no_generated_at(self): pres = ForecastPresentation(title="F") html = generate_forecast_html(pres) diff --git a/tests/test_main_window_forecast_sections.py b/tests/test_main_window_forecast_sections.py index a832136a..ee12fe5c 100644 --- a/tests/test_main_window_forecast_sections.py +++ b/tests/test_main_window_forecast_sections.py @@ -64,3 +64,23 @@ def test_set_forecast_sections_updates_both_controls(): win.daily_forecast_display.SetValue.assert_called_with("Daily text") win.hourly_forecast_display.SetValue.assert_called_with("Hourly text") + + +def test_on_weather_data_received_appends_marine_to_daily_forecast_section(): + win = _make_window() + win.app.presenter.present.return_value.forecast = ForecastPresentation( + title="Forecast", + fallback_text="Combined forecast", + daily_section_text="Daily section", + marine_section_text="Marine section", + hourly_section_text="Hourly section", + ) + + weather_data = MagicMock() + weather_data.alerts = None + weather_data.alert_lifecycle_diff = None + + win._on_weather_data_received(weather_data) + + win.daily_forecast_display.SetValue.assert_called_once_with("Daily section\n\nMarine section") + win.hourly_forecast_display.SetValue.assert_called_once_with("Hourly section") diff --git a/tests/test_marine_enrichment.py b/tests/test_marine_enrichment.py new file mode 100644 index 00000000..6f2b8423 --- /dev/null +++ b/tests/test_marine_enrichment.py @@ -0,0 +1,196 @@ +"""Tests for marine enrichment helpers and marine mode NWS enrichment.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from accessiweather.models import Location, WeatherAlert, WeatherAlerts, WeatherData +from accessiweather.weather_client_enrichment import ( + _build_marine_highlights, + _parse_marine_issued_at, + enrich_with_marine_data, +) + + +class _MockResponse: + def __init__(self, payload: dict): + self._payload = payload + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return self._payload + + +def test_build_marine_highlights_deduplicates_and_limits_results(): + periods = [ + { + "shortForecast": "South winds 10 to 15 knots. Waves 1 to 2 feet.", + "detailedForecast": "South winds 10 to 15 knots. Seas around 2 feet.", + }, + { + "shortForecast": "Gusts up to 20 knots. Swells 3 feet.", + "detailedForecast": "Gusts up to 20 knots. Swells 3 feet.", + }, + {"shortForecast": "North winds 5 knots.", "detailedForecast": ""}, + ] + + highlights = _build_marine_highlights(periods) + + assert highlights == [ + "South winds 10 to 15 knots", + "Waves 1 to 2 feet", + "Seas around 2 feet", + "Gusts up to 20 knots", + ] + + +@pytest.mark.parametrize( + ("value", "expected_none"), + [ + ("2026-03-27T15:00:00Z", False), + ("not-a-date", True), + (None, True), + ], +) +def test_parse_marine_issued_at_handles_valid_and_invalid_values(value, expected_none): + result = _parse_marine_issued_at(value) + + assert (result is None) is expected_none + + +@pytest.mark.asyncio +async def test_enrich_with_marine_data_noops_when_marine_mode_disabled(): + location = Location(name="Inland", latitude=39.0, longitude=-76.0, marine_mode=False) + weather_data = WeatherData(location=location) + client = MagicMock() + client._is_us_location.return_value = True + + await enrich_with_marine_data(client, weather_data, location) + + client._get_http_client.assert_not_called() + assert weather_data.marine is None + + +@pytest.mark.asyncio +async def test_enrich_with_marine_data_populates_forecast_and_merges_alerts(): + location = Location(name="Annapolis", latitude=38.9784, longitude=-76.4922, marine_mode=True) + weather_data = WeatherData( + location=location, + alerts=WeatherAlerts( + alerts=[ + WeatherAlert( + id="existing-alert", + title="Existing Advisory", + description="Existing advisory", + ) + ] + ), + ) + + client = MagicMock() + client._is_us_location.return_value = True + client._get_http_client.return_value = object() + client.user_agent = "AccessiWeather Test" + client.nws_base_url = "https://api.weather.gov" + client.timeout = 30 + + zone_response = _MockResponse( + { + "features": [ + { + "id": "ANZ530", + "properties": { + "id": "ANZ530", + "name": "Chesapeake Bay from Pooles Island to Sandy Point", + }, + } + ] + } + ) + alerts_response = _MockResponse({"features": []}) + marine_forecast = { + "properties": { + "name": "Chesapeake Bay from Pooles Island to Sandy Point", + "updateTime": "2026-03-27T15:00:00Z", + "periods": [ + { + "name": "Tonight", + "shortForecast": "South winds 10 to 15 knots. Waves 1 to 2 feet.", + "detailedForecast": "South winds 10 to 15 knots. Waves 1 to 2 feet.", + }, + { + "name": "Saturday", + "shortForecast": "Gusts up to 20 knots.", + "detailedForecast": "Gusts up to 20 knots. Seas around 2 feet.", + }, + ], + } + } + marine_alert = WeatherAlert( + id="marine-alert", + title="Small Craft Advisory", + description="Hazardous conditions expected.", + source="NWS", + ) + + with ( + patch( + "accessiweather.weather_client_enrichment.nws_client._client_get", + new=AsyncMock(side_effect=[zone_response, alerts_response]), + ), + patch( + "accessiweather.weather_client_enrichment.nws_client.get_nws_marine_forecast", + new=AsyncMock(return_value=marine_forecast), + ), + patch( + "accessiweather.weather_client_enrichment.nws_client.parse_nws_alerts", + return_value=WeatherAlerts(alerts=[marine_alert]), + ), + ): + await enrich_with_marine_data(client, weather_data, location) + + assert weather_data.marine is not None + assert weather_data.marine.zone_id == "ANZ530" + assert weather_data.marine.zone_name == "Chesapeake Bay from Pooles Island to Sandy Point" + assert weather_data.marine.forecast_summary == "South winds 10 to 15 knots. Waves 1 to 2 feet." + assert weather_data.marine.issued_at is not None + assert len(weather_data.marine.periods) == 2 + assert weather_data.marine.highlights == [ + "South winds 10 to 15 knots", + "Waves 1 to 2 feet", + "Gusts up to 20 knots", + "Seas around 2 feet", + ] + assert weather_data.alerts is not None + assert sorted(alert.id for alert in weather_data.alerts.alerts) == [ + "existing-alert", + "marine-alert", + ] + merged_marine_alert = next( + alert for alert in weather_data.alerts.alerts if alert.id == "marine-alert" + ) + assert merged_marine_alert.source == "NWS Marine" + + +@pytest.mark.asyncio +async def test_enrich_with_marine_data_returns_cleanly_when_no_zone_found(): + location = Location(name="Annapolis", latitude=38.9784, longitude=-76.4922, marine_mode=True) + weather_data = WeatherData(location=location) + client = MagicMock() + client._is_us_location.return_value = True + client._get_http_client.return_value = object() + client.user_agent = "AccessiWeather Test" + client.nws_base_url = "https://api.weather.gov" + + with patch( + "accessiweather.weather_client_enrichment.nws_client._client_get", + new=AsyncMock(return_value=_MockResponse({"features": []})), + ): + await enrich_with_marine_data(client, weather_data, location) + + assert weather_data.marine is None + assert weather_data.alerts is None diff --git a/tests/test_models.py b/tests/test_models.py index 55ca186e..46d3dcfe 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,6 +17,8 @@ HourlyForecast, HourlyForecastPeriod, Location, + MarineForecast, + MarineForecastPeriod, MinutelyPrecipitationForecast, MinutelyPrecipitationPoint, WeatherAlert, @@ -359,6 +361,20 @@ def test_has_any_data(self): ) assert with_minutely.has_any_data() is True + with_marine = WeatherData( + location=loc, + marine=MarineForecast( + zone_id="ANZ530", + periods=[ + MarineForecastPeriod( + name="Tonight", + summary="South winds 10 to 15 knots with waves 1 to 2 feet.", + ) + ], + ), + ) + assert with_marine.has_any_data() is True + class TestAppSettings: """Tests for AppSettings model."""