mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-07-20 18:41:06 +00:00
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.
135 lines
4.7 KiB
Python
135 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared scaffolding for the rain/snow command tests (not a test module).
|
|
|
|
The leading underscore keeps pytest from collecting this file. It builds a
|
|
RainCommand wired to a real ConfigParser + real i18n Translator, captures the
|
|
reply at bot.command_manager.send_response, and (optionally) stubs the two
|
|
network seams so renders are deterministic. Imported by test_rain_command_e2e
|
|
(mocked weather) and test_rain_live_smoke (real Open-Meteo).
|
|
"""
|
|
|
|
import asyncio
|
|
import configparser
|
|
import re
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
from modules.commands.rain_command import RainCommand
|
|
from modules.i18n import Translator
|
|
from modules.models import MeshMessage
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
TRANSLATIONS = str(REPO_ROOT / "translations")
|
|
NOW = "2026-06-03T14:00"
|
|
DEFAULT_LABEL = "Nashville, TN"
|
|
DEFAULT_COORDS = (36.1627, -86.7816)
|
|
|
|
|
|
def make_series(
|
|
*, n=9, step=15, now=NOW,
|
|
precip=None, codes=None, snow=None, prob=None, temp=None,
|
|
current_precip=0.0, current_code=0,
|
|
):
|
|
"""Build the dict shape fetch_precip_series() returns. Lists shorter than n
|
|
are zero/None-padded; temp defaults to a mild 18 C (no borderline tag)."""
|
|
base = datetime.fromisoformat(now)
|
|
times = [(base + timedelta(minutes=step * i)).isoformat(timespec="minutes") for i in range(n)]
|
|
|
|
def pad(seq, fill):
|
|
seq = list(seq if seq is not None else [])
|
|
return (seq + [fill] * (n - len(seq)))[:n]
|
|
|
|
return {
|
|
"times": times,
|
|
"precip": pad(precip, 0.0),
|
|
"codes": pad(codes, 0),
|
|
"snow": pad(snow, 0.0),
|
|
"prob": pad(prob, 0),
|
|
"temp": pad(temp, 18.0),
|
|
"now": now,
|
|
"current_precip": current_precip,
|
|
"current_code": current_code,
|
|
"step": step,
|
|
}
|
|
|
|
|
|
def make_bot(*, bot_name="WeatherBot-V3", rain_overrides=None):
|
|
"""A minimal bot with a real config + real translator and a capturing
|
|
command_manager.send_response. Returns (bot, captured_responses_list).
|
|
|
|
A SimpleNamespace (not a Mock) is deliberate: get_max_message_length checks
|
|
hasattr(bot, 'meshcore'), and a Mock would answer True to everything.
|
|
"""
|
|
cfg = configparser.ConfigParser()
|
|
cfg.add_section("Bot")
|
|
cfg.set("Bot", "bot_name", bot_name)
|
|
cfg.add_section("Channels")
|
|
cfg.set("Channels", "monitor_channels", "general")
|
|
cfg.set("Channels", "respond_to_dms", "true")
|
|
cfg.add_section("Rain_Command")
|
|
cfg.set("Rain_Command", "enabled", "true")
|
|
for key, val in (rain_overrides or {}).items():
|
|
cfg.set("Rain_Command", key, val)
|
|
|
|
captured: list[str] = []
|
|
|
|
async def _send_response(message, content, **kwargs):
|
|
captured.append(content)
|
|
return True
|
|
|
|
async def _send_response_chunked(message, chunks, **kwargs):
|
|
captured.extend(chunks)
|
|
return True
|
|
|
|
command_manager = SimpleNamespace(
|
|
send_response=_send_response,
|
|
send_response_chunked=_send_response_chunked,
|
|
monitor_channels=["general"],
|
|
)
|
|
bot = SimpleNamespace(
|
|
logger=Mock(),
|
|
config=cfg,
|
|
translator=Translator(language="en", translation_path=TRANSLATIONS),
|
|
command_manager=command_manager,
|
|
)
|
|
return bot, captured
|
|
|
|
|
|
def build_cmd(
|
|
series=None, *, coords=DEFAULT_COORDS, label=DEFAULT_LABEL,
|
|
rain_overrides=None, bot_name="WeatherBot-V3",
|
|
):
|
|
"""RainCommand wired to a fixed resolved location. When `series` is given,
|
|
the Open-Meteo fetch is stubbed to it; when None, the real fetch runs
|
|
(used by the live smoke). Geocoding is always bypassed for determinism."""
|
|
bot, captured = make_bot(bot_name=bot_name, rain_overrides=rain_overrides)
|
|
cmd = RainCommand(bot)
|
|
if series is not None:
|
|
cmd._fetch_series = lambda lat, lon: series
|
|
cmd._resolve_location = lambda message, location: (coords[0], coords[1], location or label, None)
|
|
return cmd, captured
|
|
|
|
|
|
def render(cmd, captured, content, *, is_dm=False):
|
|
"""Run execute() once and return the single captured reply, asserting it
|
|
fits the real channel/DM byte budget."""
|
|
msg = MeshMessage(
|
|
content=content,
|
|
channel=None if is_dm else "general",
|
|
is_dm=is_dm,
|
|
sender_id="U1",
|
|
)
|
|
ok = asyncio.run(cmd.execute(msg))
|
|
assert ok is True
|
|
assert len(captured) == 1, f"expected one reply, got {captured!r}"
|
|
resp = captured[-1]
|
|
budget = cmd.get_max_message_length(msg)
|
|
assert len(resp.encode("utf-8")) <= budget, f"{len(resp.encode('utf-8'))}B > {budget}B: {resp!r}"
|
|
return resp
|
|
|
|
|
|
def assert_render(resp, pattern):
|
|
assert re.fullmatch(pattern, resp), f"\nGOT: {resp!r}\nEXPECTED: /{pattern}/"
|