mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-06-28 02:11:38 +00:00
ffa5595fff
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.
253 lines
9.6 KiB
Python
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
|