Files
meshcore-bot/tests/unit/test_rain_command_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

253 lines
9.6 KiB
Python

#!/usr/bin/env python3
"""End-to-end render tests for the rain/snow command (mocked weather).
Unlike test_rain_nowcast.py (which unit-tests the pure helpers), this drives the
real RainCommand.execute() coroutine and asserts on the *exact string a user
gets back* on the mesh. Scaffolding (real config, real i18n translator, a
capturing send_response, synthetic Open-Meteo series) lives in _rain_harness.py.
Only the two network seams are stubbed: the Open-Meteo fetch (synthetic series)
and location resolution (fixed). Everything in between — mode parsing,
region-capital defaulting, the rain/snow family two-pass, the mismatch
("No snow, but rain ...") and changeover lines, amount/probability/temperature
suffixes, the region heads-up, and the byte-budget truncation — runs for real.
This is the regression gate for "do the commands still come back right".
.venv/bin/python -m pytest tests/unit/test_rain_command_e2e.py -o addopts="" -q
# see every rendered reply:
.venv/bin/python -m pytest tests/unit/test_rain_command_e2e.py -o addopts="" -s -q -k gallery
"""
from modules.models import MeshMessage
from modules.region_capitals import REGION_DEFAULT_NOTE
from tests.unit._rain_harness import (
DEFAULT_LABEL,
assert_render,
build_cmd,
make_series,
render,
)
# Reusable code/precip presets (15-min buckets).
_RAIN_INCOMING = dict(precip=[0, 0, 0.5, 0.5, 0.5], codes=[0, 0, 61, 61, 61], prob=[10, 20, 80, 80, 70])
_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, 20, 80, 80, 70],
)
_RAINING_NOW = dict(precip=[0.5] * 9, codes=[61] * 9, prob=[90] * 9, current_precip=0.5, current_code=61)
# --- dry / clear ------------------------------------------------------------
def test_rain_dry_clear():
cmd, cap = build_cmd(make_series())
assert render(cmd, cap, "!rain") == f"☀️ No rain expected in next 2h for {DEFAULT_LABEL}"
def test_snow_dry_clear_says_snow():
cmd, cap = build_cmd(make_series())
assert render(cmd, cap, "!snow") == f"☀️ No snow expected in next 2h for {DEFAULT_LABEL}"
def test_nowcast_dry_clear_defaults_to_rain_word():
cmd, cap = build_cmd(make_series())
assert render(cmd, cap, "!nowcast") == f"☀️ No rain expected in next 2h for {DEFAULT_LABEL}"
# --- incoming precipitation -------------------------------------------------
def test_rain_incoming_has_amount_and_probability():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!rain")
assert_render(
resp,
r"🌧️ Rain starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in, 80%\)",
)
def test_nowcast_incoming_is_plain_rain_no_mismatch_wording():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!nowcast")
assert "No " not in resp
assert resp.startswith("🌧️ Rain starting in ~30min for Nashville, TN")
def test_snow_incoming_shows_depth():
cmd, cap = build_cmd(make_series(**_SNOW_INCOMING))
resp = render(cmd, cap, "!snow")
assert_render(
resp,
r"🌨️ Snow starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in snow, 80%\)",
)
def test_raining_now_continuing():
cmd, cap = build_cmd(make_series(**_RAINING_NOW))
resp = render(cmd, cap, "!rain")
assert_render(resp, r"🌧️ Rain steady for 2h\+ in Nashville, TN \(est [\d.]+ in, 90%\)")
# --- cross-type mismatch ----------------------------------------------------
def test_snow_command_when_raining_says_no_snow_but_rain():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!snow")
assert_render(
resp,
r"🌧️ No snow, but rain starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in, 80%\)",
)
def test_rain_command_when_snowing_says_no_rain_but_snow():
cmd, cap = build_cmd(make_series(**_SNOW_INCOMING))
resp = render(cmd, cap, "!rain")
assert_render(
resp,
r"🌨️ No rain, but snow starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in snow, 80%\)",
)
# --- changeover (both families in the window) -------------------------------
def test_rain_now_then_snow_changeover():
series = make_series(
precip=[0.5] * 9,
codes=[61, 61, 61, 61, 71, 71, 71, 71, 71],
snow=[0, 0, 0, 0, 2.0, 2.0, 2.0, 2.0, 2.0],
prob=[90] * 9,
current_precip=0.5,
current_code=61,
)
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert_render(resp, r"🌧️→🌨️ Rain now → snow in ~\d+min for Nashville, TN")
# --- freezing rain tagged as ice --------------------------------------------
def test_freezing_rain_estimate_tagged_in_ice():
series = make_series(precip=[0, 0, 0.4, 0.4, 0.4], codes=[0, 0, 66, 66, 66], prob=[10, 20, 70, 70, 60])
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert_render(
resp,
r"🧊 Freezing rain starting in ~30min for Nashville, TN \(~\d+min\) \(est [\d.]+ in ice, 70%\)",
)
# --- borderline temperature tag ---------------------------------------------
def test_borderline_temp_tag_appended_near_freezing():
# ~1.7 C == ~35 F at the start bucket -> tag shown.
series = make_series(**{**_RAIN_INCOMING, "temp": [10, 10, 1.7, 1.7, 1.7]})
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert resp.endswith("35°F"), resp
assert_render(resp, r".+ \(est [\d.]+ in, 80%\) 35°F")
def test_no_temp_tag_when_mild():
# 10 C == 50 F -> outside the 30-38 F band -> no tag.
series = make_series(**{**_RAIN_INCOMING, "temp": [10] * 9})
cmd, cap = build_cmd(series)
resp = render(cmd, cap, "!rain")
assert "°F" not in resp
assert resp.endswith("%)"), resp
def test_show_temp_disabled_via_config():
series = make_series(**{**_RAIN_INCOMING, "temp": [1.7] * 9})
cmd, cap = build_cmd(series, rain_overrides={"show_temp": "false"})
resp = render(cmd, cap, "!rain")
assert "°F" not in resp
# --- region-capital defaulting + heads-up -----------------------------------
def test_bare_country_defaults_to_capital_with_note():
cmd, cap = build_cmd(make_series())
resp = render(cmd, cap, "!rain france")
assert "Paris, France" in resp
assert resp.endswith(REGION_DEFAULT_NOTE), resp
def test_spain_does_not_double_and_gets_capital():
cmd, cap = build_cmd(make_series())
resp = render(cmd, cap, "!rain spain")
assert "Madrid, Spain" in resp
assert "Spain, Spain" not in resp
assert resp.endswith(REGION_DEFAULT_NOTE), resp
def test_us_state_defaults_to_capital_with_note():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!rain texas")
assert "Austin, TX" in resp
assert resp.endswith(REGION_DEFAULT_NOTE), resp
# --- config toggles ---------------------------------------------------------
def test_show_amount_disabled_hides_estimate():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING), rain_overrides={"show_amount": "false"})
resp = render(cmd, cap, "!rain")
assert "est" not in resp
assert_render(resp, r"🌧️ Rain starting in ~30min for Nashville, TN \(~\d+min\) \(80%\)")
def test_amount_unit_mm():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING), rain_overrides={"amount_unit": "mm"})
resp = render(cmd, cap, "!rain")
assert "mm" in resp
assert " in," not in resp
# --- DM path ----------------------------------------------------------------
def test_dm_renders_and_fits_dm_budget():
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
resp = render(cmd, cap, "!rain", is_dm=True)
assert resp.startswith("🌧️ Rain starting in ~30min for Nashville, TN")
# --- keyword-aware help -----------------------------------------------------
def test_help_rain_vs_snow_keyword_aware():
cmd, _ = build_cmd(make_series())
rain_help = cmd.get_help_text(MeshMessage(content="help rain", channel="general", sender_id="U1"))
snow_help = cmd.get_help_text(MeshMessage(content="help snow", channel="general", sender_id="U1"))
bare_help = cmd.get_help_text(MeshMessage(content="help", channel="general", sender_id="U1"))
assert "rain" in rain_help.lower() and "amount" in rain_help.lower()
assert "snow" in snow_help.lower() and "depth" in snow_help.lower()
assert rain_help != snow_help
assert bare_help == rain_help # defaults to rain when unspecified
# --- gallery (visual): print one of each, run with -s to eyeball ------------
def test_gallery_prints_representative_replies():
cases = [
("!rain (dry)", make_series()),
("!rain (incoming)", make_series(**_RAIN_INCOMING)),
("!rain (raining now)", make_series(**_RAINING_NOW)),
("!snow (incoming)", make_series(**_SNOW_INCOMING)),
("!rain (freezing)", make_series(precip=[0, 0, 0.4, 0.4, 0.4], codes=[0, 0, 66, 66, 66], prob=[0, 0, 70, 70, 60])),
]
lines = []
for label, series in cases:
cmd, cap = build_cmd(series)
word = label.split()[0].lstrip("!")
resp = render(cmd, cap, f"!{word}")
lines.append(f"{label:24s} -> {resp} [{len(resp.encode('utf-8'))}B]")
# snow-but-raining mismatch + region capital, for completeness
cmd, cap = build_cmd(make_series(**_RAIN_INCOMING))
lines.append(f"{'!snow (but raining)':24s} -> {render(cmd, cap, '!snow')}")
cmd, cap = build_cmd(make_series())
lines.append(f"{'!rain france (capital)':24s} -> {render(cmd, cap, '!rain france')}")
print("\n--- rain/snow command gallery ---")
for ln in lines:
print(ln)
assert all("None" not in ln for ln in lines) # no stray Nones leaked into output