Files
meshcore-bot/tests/unit/test_rain_live_smoke.py
rlwilliamson-dev ffa5595fff feat(rain): nowcast enhancements (amounts, capitals, snow/ice, !snow, probability, changeover) + tests
Builds on the merged rain/snow nowcast (#193): ten enhancements plus
end-to-end, proactive, and live-smoke test coverage. All new behavior is
config-gated or additive, so existing deployments are unaffected by default.

Enhancements
- Precip amount estimate "(est 0.2 in)" on the command and the proactive push;
  snow shown as real depth (Open-Meteo snowfall, cm); freezing rain tagged "in ice".
- Bare country / US state resolves to its capital with a heads-up
  (self-contained modules/region_capitals.py; no pycountry/us dependency).
- join_location() dedupes "Spain, Spain" / city-states.
- !snow alias + neutral !nowcast; each looks for its own precip family across
  the window, else falls back with a "No snow, but rain ..." cross-type line.
- Keyword-aware help (help rain / help snow).
- Precip probability shown "(..., 70%)"; the proactive incoming alert is gated
  to >= [Weather_Service] rain_nowcast_min_probability (default 50).
- Rain<->snow changeover line when the window holds both families.
- Borderline temperature tag (30-38F).
- Short-lived series cache shared by the command and the proactive poll.

Tests
- test_rain_command_e2e.py: drives RainCommand.execute() end to end and asserts
  the exact rendered reply across dry/incoming/raining, snow depth, cross-type
  mismatch, changeover, ice, temp tag, region capitals, config toggles, DM
  budget, and keyword-aware help.
- test_rain_proactive_e2e.py: drives Weather_Service._check_rain_nowcast() for
  the probability gate, snow depth, ending notice, and once-per-episode dedup.
- test_rain_live_smoke.py: opt-in (RAIN_LIVE_SMOKE=1) live Open-Meteo check for
  upstream schema drift; skipped in CI.
- Shared scaffolding in tests/unit/_rain_harness.py.

New config: [Rain_Command] show_amount/amount_unit/show_probability/show_temp/
cache_seconds/zip_city_lookup; [Weather_Service] rain_nowcast_min_probability/
rain_nowcast_cache_seconds/rain_nowcast_show_amount/rain_nowcast_amount_unit.
ruff + mypy clean.
2026-06-06 16:18:52 -05:00

80 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""Live smoke test against the real Open-Meteo API.
Opt-in (network): SKIPPED unless RAIN_LIVE_SMOKE is set, so CI and the normal
offline suite never depend on it. The mocked suites can't catch upstream schema
drift (Open-Meteo renaming/dropping a field, or no longer serving 15-min
probability/temperature); this one can, and it exercises the real fetch end to
end through the command. It does NOT geocode (Nominatim) — coordinates are fixed
and only the label is stubbed.
RAIN_LIVE_SMOKE=1 .venv/bin/python -m pytest tests/unit/test_rain_live_smoke.py -o addopts="" -s -q
"""
import os
import pytest
import requests
from modules.commands.rain_command import fetch_precip_series
from tests.unit._rain_harness import build_cmd, render
pytestmark = pytest.mark.skipif(
os.environ.get("RAIN_LIVE_SMOKE") in (None, "", "0"),
reason="live network test; set RAIN_LIVE_SMOKE=1 to run",
)
# (label, lat, lon) — a geographic spread so at least one usually has weather.
PLACES = [
("Nashville, TN", 36.1627, -86.7816),
("London, UK", 51.5072, -0.1276),
("Seattle, WA", 47.6062, -122.3321),
]
# Keys the analysis + formatting rely on; prob/temp are the newer dependencies.
REQUIRED_KEYS = {"times", "precip", "snow", "prob", "temp", "codes", "now", "step"}
def test_live_fetch_schema_is_intact():
"""The real API still returns every field we consume, at aligned lengths,
with 15-min probability + temperature populated (the features added last)."""
session = requests.Session()
try:
for label, lat, lon in PLACES:
series = fetch_precip_series(session, lat, lon, timeout=15)
assert series is not None, f"{label}: no series returned"
missing = REQUIRED_KEYS - series.keys()
assert not missing, f"{label}: missing keys {missing}"
n = len(series["times"])
assert n > 0, f"{label}: empty time series"
assert series["step"] in (15, 60)
for k in ("precip", "codes"):
assert len(series[k]) == n, f"{label}: {k} length {len(series[k])} != times {n}"
prob_ok = sum(p is not None for p in series["prob"])
temp_ok = sum(t is not None for t in series["temp"])
assert prob_ok > 0, f"{label}: probability series all None — upstream drift?"
assert temp_ok > 0, f"{label}: temperature series all None — upstream drift?"
print(
f"\n{label:14s} step={series['step']}min n={n} "
f"prob_nonnull={prob_ok} temp_nonnull={temp_ok} now={series['now']}"
)
finally:
session.close()
def test_live_command_renders_for_real_places():
"""Drive execute() with a real fetch for each place and print the real reply.
Asserts only invariants (non-empty, fits budget, no stray 'None')."""
print("\n--- live !rain / !snow ---")
for label, lat, lon in PLACES:
for word in ("rain", "snow"):
# series=None -> real Open-Meteo fetch; location is fixed (no geocoding).
cmd, cap = build_cmd(series=None, coords=(lat, lon), label=label)
resp = render(cmd, cap, f"!{word}")
assert resp, f"{label}: empty !{word} reply"
assert "None" not in resp, f"{label}: stray None in !{word} reply: {resp!r}"
print(f"!{word:4s} {label:14s} -> {resp}")