|
| 1 | +"""Tests for accessiweather.display.presentation.minutely_timeline module.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from datetime import UTC, datetime, timedelta |
| 6 | + |
| 7 | +from accessiweather.display.presentation.minutely_timeline import ( |
| 8 | + build_minutely_timeline, |
| 9 | + generate_minutely_summary, |
| 10 | +) |
| 11 | +from accessiweather.models import ( |
| 12 | + MinutelyPrecipitationForecast, |
| 13 | + MinutelyPrecipitationPoint, |
| 14 | +) |
| 15 | + |
| 16 | + |
| 17 | +def _make_point( |
| 18 | + offset_minutes: int = 0, |
| 19 | + intensity: float | None = None, |
| 20 | + probability: float | None = None, |
| 21 | + precip_type: str | None = None, |
| 22 | +) -> MinutelyPrecipitationPoint: |
| 23 | + return MinutelyPrecipitationPoint( |
| 24 | + time=datetime(2025, 6, 1, 12, 0, tzinfo=UTC) + timedelta(minutes=offset_minutes), |
| 25 | + precipitation_intensity=intensity, |
| 26 | + precipitation_probability=probability, |
| 27 | + precipitation_type=precip_type, |
| 28 | + ) |
| 29 | + |
| 30 | + |
| 31 | +def _make_forecast( |
| 32 | + points: list[MinutelyPrecipitationPoint], |
| 33 | + summary: str | None = None, |
| 34 | +) -> MinutelyPrecipitationForecast: |
| 35 | + return MinutelyPrecipitationForecast(summary=summary, points=points) |
| 36 | + |
| 37 | + |
| 38 | +# ── generate_minutely_summary ── |
| 39 | + |
| 40 | + |
| 41 | +class TestGenerateMinutelySummary: |
| 42 | + def test_none_forecast(self): |
| 43 | + assert generate_minutely_summary(None) is None |
| 44 | + |
| 45 | + def test_empty_points(self): |
| 46 | + forecast = _make_forecast([]) |
| 47 | + assert generate_minutely_summary(forecast) is None |
| 48 | + |
| 49 | + def test_all_dry(self): |
| 50 | + points = [_make_point(i, intensity=0.0) for i in range(60)] |
| 51 | + result = generate_minutely_summary(_make_forecast(points)) |
| 52 | + assert result == "No precipitation expected" |
| 53 | + |
| 54 | + def test_all_wet_light_rain(self): |
| 55 | + points = [_make_point(i, intensity=0.05, precip_type="rain") for i in range(60)] |
| 56 | + result = generate_minutely_summary(_make_forecast(points)) |
| 57 | + assert result is not None |
| 58 | + assert "rain" in result.lower() |
| 59 | + assert "next hour" in result.lower() |
| 60 | + |
| 61 | + def test_all_wet_heavy_snow(self): |
| 62 | + points = [_make_point(i, intensity=2.0, precip_type="snow") for i in range(60)] |
| 63 | + result = generate_minutely_summary(_make_forecast(points)) |
| 64 | + assert result is not None |
| 65 | + assert "snow" in result.lower() |
| 66 | + assert "heavy" in result.lower() |
| 67 | + |
| 68 | + def test_dry_to_wet_transition(self): |
| 69 | + points = [_make_point(i, intensity=0.0) for i in range(12)] |
| 70 | + points += [_make_point(i, intensity=0.5, precip_type="rain") for i in range(12, 60)] |
| 71 | + result = generate_minutely_summary(_make_forecast(points)) |
| 72 | + assert result is not None |
| 73 | + assert "starting" in result.lower() |
| 74 | + assert "12" in result |
| 75 | + |
| 76 | + def test_wet_to_dry_transition(self): |
| 77 | + points = [_make_point(i, intensity=0.5, precip_type="rain") for i in range(20)] |
| 78 | + points += [_make_point(i, intensity=0.0) for i in range(20, 60)] |
| 79 | + result = generate_minutely_summary(_make_forecast(points)) |
| 80 | + assert result is not None |
| 81 | + assert "stopping" in result.lower() |
| 82 | + assert "20" in result |
| 83 | + |
| 84 | + def test_uses_precipitation_type_label(self): |
| 85 | + points = [_make_point(i, intensity=0.0) for i in range(5)] |
| 86 | + points += [_make_point(i, intensity=0.3, precip_type="snow") for i in range(5, 60)] |
| 87 | + result = generate_minutely_summary(_make_forecast(points)) |
| 88 | + assert result is not None |
| 89 | + assert "Snow" in result |
| 90 | + |
| 91 | + def test_all_wet_no_type(self): |
| 92 | + """When no precipitation_type is set, falls back to 'precipitation'.""" |
| 93 | + points = [_make_point(i, intensity=0.05) for i in range(60)] |
| 94 | + result = generate_minutely_summary(_make_forecast(points)) |
| 95 | + assert result is not None |
| 96 | + assert "precipitation" in result.lower() |
| 97 | + |
| 98 | + |
| 99 | +# ── build_minutely_timeline ── |
| 100 | + |
| 101 | + |
| 102 | +class TestBuildMinutelyTimeline: |
| 103 | + def test_none_forecast(self): |
| 104 | + assert build_minutely_timeline(None) is None |
| 105 | + |
| 106 | + def test_empty_points(self): |
| 107 | + forecast = _make_forecast([]) |
| 108 | + assert build_minutely_timeline(forecast) is None |
| 109 | + |
| 110 | + def test_samples_at_5_min_intervals(self): |
| 111 | + # 61 points (0..60), all dry |
| 112 | + points = [_make_point(i, intensity=0.0) for i in range(61)] |
| 113 | + result = build_minutely_timeline(_make_forecast(points)) |
| 114 | + assert result is not None |
| 115 | + parts = [p.strip() for p in result.split(",")] |
| 116 | + assert parts[0].startswith("Now:") |
| 117 | + assert parts[1].startswith("+5m:") |
| 118 | + assert len(parts) == 13 # 0, 5, 10, ..., 60 |
| 119 | + |
| 120 | + def test_intensity_classification(self): |
| 121 | + points = [_make_point(0, intensity=0.0)] # None |
| 122 | + points += [_make_point(i, intensity=0.0) for i in range(1, 5)] |
| 123 | + points += [_make_point(5, intensity=0.05)] # Light |
| 124 | + points += [_make_point(i, intensity=0.0) for i in range(6, 10)] |
| 125 | + points += [_make_point(10, intensity=0.5)] # Moderate |
| 126 | + points += [_make_point(i, intensity=0.0) for i in range(11, 15)] |
| 127 | + points += [_make_point(15, intensity=2.0)] # Heavy |
| 128 | + result = build_minutely_timeline(_make_forecast(points)) |
| 129 | + assert result is not None |
| 130 | + assert "Now: None" in result |
| 131 | + assert "+5m: Light" in result |
| 132 | + assert "+10m: Moderate" in result |
| 133 | + assert "+15m: Heavy" in result |
| 134 | + |
| 135 | + def test_fewer_than_5_points(self): |
| 136 | + points = [_make_point(0, intensity=0.0), _make_point(1, intensity=0.05)] |
| 137 | + result = build_minutely_timeline(_make_forecast(points)) |
| 138 | + assert result is not None |
| 139 | + # Only index 0 is sampled (next would be index 5 which doesn't exist) |
| 140 | + assert result == "Now: None" |
| 141 | + |
| 142 | + def test_screen_reader_friendly(self): |
| 143 | + """Output uses commas, no pipes or box-drawing chars.""" |
| 144 | + points = [_make_point(i, intensity=0.0) for i in range(61)] |
| 145 | + result = build_minutely_timeline(_make_forecast(points)) |
| 146 | + assert result is not None |
| 147 | + assert "|" not in result |
| 148 | + assert "─" not in result |
| 149 | + assert "," in result |
| 150 | + |
| 151 | + def test_max_60_minutes(self): |
| 152 | + # 120 points - should only go up to index 60 |
| 153 | + points = [_make_point(i, intensity=0.0) for i in range(120)] |
| 154 | + result = build_minutely_timeline(_make_forecast(points)) |
| 155 | + parts = [p.strip() for p in result.split(",")] |
| 156 | + last_part = parts[-1] |
| 157 | + assert last_part.startswith("+60m:") |
| 158 | + |
| 159 | + |
| 160 | +# ── Integration with build_current_conditions ── |
| 161 | + |
| 162 | + |
| 163 | +class TestCurrentConditionsIntegration: |
| 164 | + """Test that build_current_conditions uses generated summary when PW summary is None.""" |
| 165 | + |
| 166 | + def test_generated_summary_when_pw_summary_none(self): |
| 167 | + from accessiweather.display.presentation.current_conditions import ( |
| 168 | + build_current_conditions, |
| 169 | + ) |
| 170 | + from accessiweather.models import CurrentConditions, Location |
| 171 | + from accessiweather.utils import TemperatureUnit |
| 172 | + |
| 173 | + current = CurrentConditions( |
| 174 | + temperature=72.0, |
| 175 | + condition="Clear", |
| 176 | + ) |
| 177 | + location = Location(name="Test City", latitude=40.0, longitude=-74.0) |
| 178 | + |
| 179 | + # Minutely forecast with points but no summary |
| 180 | + points = [_make_point(i, intensity=0.0) for i in range(60)] |
| 181 | + minutely = _make_forecast(points, summary=None) |
| 182 | + |
| 183 | + result = build_current_conditions( |
| 184 | + current, |
| 185 | + location, |
| 186 | + TemperatureUnit.FAHRENHEIT, |
| 187 | + minutely_precipitation=minutely, |
| 188 | + ) |
| 189 | + |
| 190 | + metric_labels = [m.label for m in result.metrics] |
| 191 | + assert "Precipitation outlook" in metric_labels |
| 192 | + assert "Next hour precipitation" in metric_labels |
| 193 | + |
| 194 | + # Find the summary metric |
| 195 | + outlook = next(m for m in result.metrics if m.label == "Precipitation outlook") |
| 196 | + assert outlook.value == "No precipitation expected" |
| 197 | + |
| 198 | + def test_pw_summary_preferred_over_generated(self): |
| 199 | + from accessiweather.display.presentation.current_conditions import ( |
| 200 | + build_current_conditions, |
| 201 | + ) |
| 202 | + from accessiweather.models import CurrentConditions, Location |
| 203 | + from accessiweather.utils import TemperatureUnit |
| 204 | + |
| 205 | + current = CurrentConditions( |
| 206 | + temperature=72.0, |
| 207 | + condition="Clear", |
| 208 | + ) |
| 209 | + location = Location(name="Test City", latitude=40.0, longitude=-74.0) |
| 210 | + |
| 211 | + points = [_make_point(i, intensity=0.0) for i in range(60)] |
| 212 | + minutely = _make_forecast(points, summary="PW provided summary") |
| 213 | + |
| 214 | + result = build_current_conditions( |
| 215 | + current, |
| 216 | + location, |
| 217 | + TemperatureUnit.FAHRENHEIT, |
| 218 | + minutely_precipitation=minutely, |
| 219 | + ) |
| 220 | + |
| 221 | + outlook = next(m for m in result.metrics if m.label == "Precipitation outlook") |
| 222 | + assert outlook.value == "PW provided summary" |
0 commit comments