Files
meshcore-bot/tests/characterization/test_weather_execute_dispatch.py
agessaman c905024102 test(weather): let the dispatch golden's wx WXSIM fake take location_name
wx passes a named WXSIM source as location_name=, which the fake did not accept, so every named-source wx case recorded the error reply instead of a successful send or multiday split. The regenerated golden passes against both the code before e76383f2 and after it.
2026-10-03 17:48:30 -07:00

158 lines
7.1 KiB
Python

"""Characterization: how wx and gwx ``execute`` dispatch a request.
Drives both commands through a matrix of inputs and environments with every
provider mocked at its call boundary, and records the ordered calls each
request makes (lookups, fetches, sends, pacing, log lines) plus the return
value. The environments cover the no-location fallback chain (custom MQTT and
WXSIM defaults, the sender's position, default_city, the bot's position),
named custom sources, provider failures, the two-part alert reply, and wx's
alert-only geocoding.
"""
import asyncio
import configparser
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from tests.characterization.golden_util import assert_golden
INPUTS = [
"", "hourly", "HOURLY", "tomorrow", "5d", "3", "alerts", "alerts alerts",
"98101", "98101 alerts", "seattle, wa", "seattle wa 3", "paris 7d", "paris 30d", "paris 1",
"47.6,-122.3", "47.6,-122.3 alerts", "95.0,10.0 alerts", "x alerts", "a b alerts",
"home", "home 3", "home alerts", "home hourly", "cabin", "cabin tomorrow", "cabin alerts",
"both", "both 5d",
]
# Failure environments only change what happens after dispatch, so they run a
# smaller set: no location, an option alone, each location type, alerts.
CORE_INPUTS = ["", "hourly", "5d", "alerts", "98101", "98101 alerts", "seattle, wa", "47.6,-122.3 alerts", "x alerts"]
MULTI = ("multi_message", "WEATHER", "ALERTS")
FAILURE_ENVS = {"multi_first_refused", "weather_raises", "geocode_fails", "city_pair", "send_refused"}
ENVS = {
"plain": {},
"default_city": {"default_city": "Seattle", "default_state": "WA", "default_country": "US"},
"default_city_only": {"default_city": "Tacoma"},
"companion_named": {"companion": (47.6062, -122.3321), "display": "Seattle, WA"},
"companion_unnamed": {"companion": (47.6062, -122.3321)},
"bot_location": {"use_bot": True, "bot": (48.75, -122.48), "display": "Bellingham"},
"bot_location_unnamed": {"use_bot": True, "bot": (48.75, -122.48)},
"bot_location_missing": {"use_bot": True},
"mqtt": {"mqtt": {None: "wx/default", "home": "wx/home", "both": "wx/both"}},
"mqtt_raises": {"mqtt": {None: "wx/default", "home": "wx/home"}, "mqtt_raises": True},
"wxsim": {"wxsim": {None: "https://w.test/default", "cabin": "https://w.test/cabin", "both": "https://w.test/both"}},
"wxsim_raises": {"wxsim": {None: "https://w.test/default", "cabin": "https://w.test/cabin"}, "wxsim_raises": True},
"both_sources": {"mqtt": {"both": "wx/both"}, "wxsim": {"both": "https://w.test/both"}},
"multi": {"weather": MULTI},
"multi_first_refused": {"weather": MULTI, "send_results": [False]},
"weather_raises": {"weather_raises": True},
"geocode_fails": {"geocode_fails": True},
"city_pair": {"city_pair": True},
"send_refused": {"send_results": [False] * 4},
}
def _build(cls_name, env):
from modules.commands.alternatives.wx_international import GlobalWxCommand
from modules.commands.wx_command import WxCommand
cls = {"wx": WxCommand, "gwx": GlobalWxCommand}[cls_name]
config = configparser.ConfigParser()
config.read_dict({
"Weather": {
"weather_provider": "noaa",
"default_city": env.get("default_city", ""),
"default_state": env.get("default_state", ""),
"default_country": env.get("default_country", ""),
},
"Wx_Command": {"use_bot_location_when_no_location": str(env.get("use_bot", False)).lower()},
"Gwx_Command": {},
"Bot": {"bot_tx_rate_limit_seconds": "1.5"},
})
bot = Mock()
bot.config = config
bot.translator.translate = Mock(side_effect=lambda key, **kw: f"<{key}{sorted(kw.items()) or ''}>")
cmd = cls(bot)
rec = Mock()
cmd.logger = rec.logger
mqtt = env.get("mqtt", {})
wxsim = env.get("wxsim", {})
def attach(name, mock):
rec.attach_mock(mock, name)
setattr(cmd, name, mock)
attach("_get_custom_mqtt_weather_topic", Mock(side_effect=lambda loc=None: mqtt.get(loc)))
attach("_get_custom_wxsim_source", Mock(side_effect=lambda loc=None: wxsim.get(loc)))
if env.get("mqtt_raises"):
attach("_mqtt_weather_line", Mock(side_effect=RuntimeError("mqtt down")))
else:
attach("_mqtt_weather_line", Mock(side_effect=lambda topic, ft, loc: f"MQTT[{topic},{ft},{loc}]"))
wxsim_effect = RuntimeError("wxsim down") if env.get("wxsim_raises") else (
# wx passes a named source as location_name=, gwx positionally.
lambda src, ft="default", nd=7, msg=None, loc=None, location_name=None: (
f"WXSIM[{src},{ft},{nd},{loc},{location_name}]"
)
)
if cls_name == "wx":
attach("_get_wxsim_weather_async", AsyncMock(side_effect=wxsim_effect))
else:
attach("_get_wxsim_weather", Mock(side_effect=wxsim_effect))
attach("_get_companion_location", Mock(return_value=env.get("companion")))
attach("_get_bot_location", Mock(return_value=env.get("bot")))
attach("_coordinates_to_location_string_async", AsyncMock(return_value=env.get("display")))
if env.get("geocode_fails"):
zip_result, city_result = (None, None), (None, None, None)
elif env.get("city_pair"):
zip_result, city_result = (47.61, -122.33), (47.6, -122.3)
else:
zip_result, city_result = (47.61, -122.33), (47.6, -122.3, {"city": "Seattle"})
attach("_zipcode_to_lat_lon_async", AsyncMock(return_value=zip_result))
attach("_city_to_lat_lon_async", AsyncMock(return_value=city_result))
attach("_send_full_alert_list", AsyncMock(return_value=True))
if env.get("weather_raises"):
attach("get_weather_for_location", AsyncMock(side_effect=ValueError("api down")))
else:
attach("get_weather_for_location", AsyncMock(return_value=env.get("weather", "WEATHER")))
results = list(env.get("send_results", []))
attach("send_response", AsyncMock(side_effect=lambda *a, **k: results.pop(0) if results else True))
attach("_pace_reply", AsyncMock())
attach("_send_multiday_forecast", AsyncMock(return_value=True))
attach("record_execution", Mock())
return cmd, rec
def _message(keyword, text):
content = f"{keyword} {text}".strip() if text else keyword
return SimpleNamespace(content=content, sender_id="u1", sender_pubkey="pk", channel="general", is_dm=False)
def _trace(cls_name, env, text):
cmd, rec = _build(cls_name, env)
message = _message(cmd.keywords[0], text)
result = asyncio.run(cmd.execute(message))
calls = []
for c in rec.mock_calls:
name, args, kwargs = c
args = ["<msg>" if a is message else a for a in args]
kwargs = {k: ("<msg>" if v is message else v) for k, v in kwargs.items()}
calls.append(f"{name}{tuple(args)!r}{kwargs or ''}")
return {"result": result, "calls": calls}
@pytest.mark.parametrize("cls_name", ["wx", "gwx"])
def test_execute_dispatch(cls_name):
data = {
f"{env_name} | {text!r}": _trace(cls_name, env, text)
for env_name, env in ENVS.items()
for text in (CORE_INPUTS if env_name in FAILURE_ENVS else INPUTS)
}
assert_golden(f"weather_execute_dispatch_{cls_name}", data)