Files
meshcore-bot/tests/unit/test_rain_proactive_e2e.py
T
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

173 lines
6.6 KiB
Python

#!/usr/bin/env python3
"""End-to-end tests for the proactive rain/snow push (Weather_Service).
Drives WeatherService._check_rain_nowcast() with synthetic Open-Meteo series and
asserts on the heads-up that gets pushed — in particular the probability gate
(low-confidence "incoming" alerts are suppressed and left un-announced so they
can still fire later), snow depth, the rain-ending notice, and once-per-episode
dedup.
Sends are captured at bot.command_manager.send_channel_message; with a single
rain_channel configured the proactive push results in exactly one send, which
the assertions check.
"""
import asyncio
import configparser
from unittest.mock import Mock
from modules.service_plugins.weather_service import WeatherService
from tests.unit._rain_harness import make_series
# 15-min bucket presets (snowfall in cm, prob in %, temp in C).
_INCOMING_HI = dict(precip=[0, 0, 0.5, 0.5, 0.5], codes=[0, 0, 61, 61, 61], prob=[10, 10, 80, 80, 80])
_INCOMING_LO = dict(precip=[0, 0, 0.5, 0.5, 0.5], codes=[0, 0, 61, 61, 61], prob=[10, 10, 30, 30, 30])
_SNOW_INCOMING = dict(
precip=[0, 0, 0.3, 0.3, 0.3], codes=[0, 0, 71, 71, 71],
snow=[0, 0, 2.0, 2.0, 2.0], prob=[10, 10, 80, 80, 80],
)
_ENDING = dict(precip=[0.5, 0.5, 0, 0, 0], codes=[61, 61, 0, 0, 0], prob=[80] * 9,
current_precip=0.5, current_code=61)
def build_service(series, monkeypatch, *, overrides=None):
"""A WeatherService whose Open-Meteo fetch returns `series`, with the send
captured. Returns (service, sends) where sends is a list of (channel, text)."""
cfg = configparser.ConfigParser()
cfg.add_section("Weather")
cfg.add_section("Weather_Service")
cfg.set("Weather_Service", "my_position_lat", "36.16")
cfg.set("Weather_Service", "my_position_lon", "-86.78")
cfg.set("Weather_Service", "rain_nowcast_enabled", "true")
cfg.set("Weather_Service", "rain_channel", "weather") # single -> one send in both versions
for key, val in (overrides or {}).items():
cfg.set("Weather_Service", key, val)
bot = Mock()
bot.logger = Mock()
bot.config = cfg
bot.db_manager = Mock()
sends: list[tuple[str, str]] = []
async def _send_channel_message(channel, text, **kwargs):
sends.append((channel, text))
return True
bot.command_manager.send_channel_message = _send_channel_message
service = WeatherService(bot)
service.api_session = Mock()
# Pin the location label so _format_rain_nowcast doesn't reverse-geocode.
service._cached_rain_location = "Nashville, TN"
# get_mesh_flood_scope lazily imports heavy deps; stub it.
service.get_mesh_flood_scope = Mock(return_value=None)
monkeypatch.setattr(
"modules.service_plugins.weather_service.fetch_precip_series",
lambda *a, **k: series,
)
return service, sends
# --- probability gate -------------------------------------------------------
def test_incoming_above_threshold_pushes(monkeypatch):
service, sends = build_service(make_series(**_INCOMING_HI), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
channel, text = sends[0]
assert channel == "weather"
assert text.startswith("🌧️ Heads up — Rain starting in ~30min")
assert "est" in text and "80%" in text
assert text.endswith("near Nashville, TN")
assert service._rain_start_announced is True
assert service._last_rain_start_time is not None
assert len(text.encode("utf-8")) <= 145
def test_incoming_below_threshold_is_gated_and_left_unannounced(monkeypatch):
service, sends = build_service(make_series(**_INCOMING_LO), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert sends == [] # 30% < default 50% -> suppressed
# Critically: flag stays False so a later, higher-confidence poll can fire.
assert service._rain_start_announced is False
assert service._last_rain_start_time is None
def test_gate_respects_configured_min_probability(monkeypatch):
# Same 30% series, but lower the bar to 20% -> it should push.
service, sends = build_service(
make_series(**_INCOMING_LO), monkeypatch,
overrides={"rain_nowcast_min_probability": "20"},
)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
assert "30%" in sends[0][1]
# --- snow -------------------------------------------------------------------
def test_snow_incoming_pushes_depth(monkeypatch):
service, sends = build_service(make_series(**_SNOW_INCOMING), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
text = sends[0][1]
assert text.startswith("🌨️ Heads up — Snow starting in ~30min")
assert "in snow" in text
assert text.endswith("near Nashville, TN")
# --- ending -----------------------------------------------------------------
def test_rain_ending_pushes_when_enabled(monkeypatch):
service, sends = build_service(make_series(**_ENDING), monkeypatch)
asyncio.run(service._check_rain_nowcast())
assert len(sends) == 1
text = sends[0][1]
assert text.startswith("🌧️ Heads up — Rain ending in ~")
assert service._rain_end_announced is True
def test_rain_ending_suppressed_when_disabled(monkeypatch):
service, sends = build_service(
make_series(**_ENDING), monkeypatch,
overrides={"rain_nowcast_announce_ending": "false"},
)
asyncio.run(service._check_rain_nowcast())
assert sends == []
# --- dedup ------------------------------------------------------------------
def test_fires_once_per_episode(monkeypatch):
service, sends = build_service(make_series(**_INCOMING_HI), monkeypatch)
asyncio.run(service._check_rain_nowcast())
asyncio.run(service._check_rain_nowcast()) # same episode, already announced
assert len(sends) == 1
# --- gallery (visual): run with -s to eyeball the pushes --------------------
def test_gallery_prints_proactive_pushes(monkeypatch):
rows = []
for label, series, ov in [
("rain incoming (80%)", make_series(**_INCOMING_HI), None),
("rain incoming (30%)", make_series(**_INCOMING_LO), None),
("snow incoming (80%)", make_series(**_SNOW_INCOMING), None),
("rain ending", make_series(**_ENDING), None),
]:
service, sends = build_service(series, monkeypatch, overrides=ov)
asyncio.run(service._check_rain_nowcast())
out = sends[0][1] if sends else "(gated — no push)"
rows.append(f"{label:22s} -> {out}")
print("\n--- proactive rain/snow push gallery ---")
for r in rows:
print(r)
assert any("Heads up" in r for r in rows)
assert any("no push" in r for r in rows)