feat: агент «Погода» для AMS (Open-Meteo + met.no)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Unit-тесты погодного сервиса. Сеть не используется: HTTP-слой подменяется.
|
||||
|
||||
Запуск: pytest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# weather.py лежит внутри каталога агента и не является пакетом,
|
||||
# поэтому для тестов добавляем его каталог в sys.path.
|
||||
AGENT_DIR = Path(__file__).resolve().parents[1] / "agents" / "custom" / "weather"
|
||||
sys.path.insert(0, str(AGENT_DIR))
|
||||
|
||||
import weather # noqa: E402
|
||||
|
||||
LOCATION = weather.Location("Казань", "Татарстан", "Россия", 55.7887, 49.1221)
|
||||
|
||||
GEOCODE_PAYLOAD = {
|
||||
"results": [
|
||||
{
|
||||
"name": "Казань",
|
||||
"admin1": "Татарстан",
|
||||
"country": "Россия",
|
||||
"latitude": 55.78874,
|
||||
"longitude": 49.12214,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
FORECAST_PAYLOAD = {
|
||||
"properties": {
|
||||
"timeseries": [
|
||||
{
|
||||
"data": {
|
||||
"instant": {
|
||||
"details": {
|
||||
"air_temperature": 22.2,
|
||||
"relative_humidity": 54.0,
|
||||
"wind_speed": 4.1,
|
||||
"wind_from_direction": 103.0,
|
||||
}
|
||||
},
|
||||
"next_1_hours": {
|
||||
"summary": {"symbol_code": "partlycloudy_day"},
|
||||
"details": {"precipitation_amount": 0.0},
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_geocode_cache():
|
||||
"""Кэш геокодера — глобальный, поэтому чистим его перед каждым тестом."""
|
||||
weather.geocode.cache_clear()
|
||||
yield
|
||||
weather.geocode.cache_clear()
|
||||
|
||||
|
||||
# --- geocode ---------------------------------------------------------------
|
||||
|
||||
def test_geocode_empty_city_raises():
|
||||
with pytest.raises(weather.WeatherError) as exc:
|
||||
weather.geocode(" ")
|
||||
assert exc.value.message == "не указан город"
|
||||
|
||||
|
||||
def test_geocode_not_found(monkeypatch):
|
||||
monkeypatch.setattr(weather, "_get", lambda url, params: {"results": []})
|
||||
with pytest.raises(weather.WeatherError) as exc:
|
||||
weather.geocode("Абракадабрания")
|
||||
assert "не найден" in exc.value.message
|
||||
|
||||
|
||||
def test_geocode_builds_location_and_rounds_coordinates(monkeypatch):
|
||||
monkeypatch.setattr(weather, "_get", lambda url, params: GEOCODE_PAYLOAD)
|
||||
location = weather.geocode("Казань")
|
||||
|
||||
assert location.title == "Казань, Татарстан, Россия"
|
||||
# met.no принимает не более 4 знаков после запятой.
|
||||
assert location.latitude == 55.7887
|
||||
assert location.longitude == 49.1221
|
||||
|
||||
|
||||
def test_geocode_is_cached(monkeypatch):
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_get(url, params):
|
||||
calls.append(params["name"])
|
||||
return GEOCODE_PAYLOAD
|
||||
|
||||
monkeypatch.setattr(weather, "_get", fake_get)
|
||||
weather.geocode("Казань")
|
||||
weather.geocode("Казань")
|
||||
|
||||
assert calls == ["Казань"] # второй вызов обслужен из кэша
|
||||
|
||||
|
||||
# --- fetch_weather ---------------------------------------------------------
|
||||
|
||||
def test_fetch_weather_maps_fields(monkeypatch):
|
||||
monkeypatch.setattr(weather, "_get", lambda url, params: FORECAST_PAYLOAD)
|
||||
current = weather.fetch_weather(LOCATION)
|
||||
|
||||
assert current.temperature_c == 22.2
|
||||
assert current.humidity_pct == 54
|
||||
assert current.wind_ms == 4.1
|
||||
assert current.wind_dir_deg == 103
|
||||
assert current.precipitation_mm == 0.0
|
||||
assert current.description == "переменная облачность"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("symbol", "expected"),
|
||||
[("clearsky_night", "ясно"), ("snow", "снег"), ("unknowncode", "нет данных")],
|
||||
)
|
||||
def test_fetch_weather_strips_symbol_suffix(monkeypatch, symbol, expected):
|
||||
payload = {
|
||||
"properties": {
|
||||
"timeseries": [
|
||||
{
|
||||
"data": {
|
||||
"instant": {
|
||||
"details": {
|
||||
"air_temperature": 0.0,
|
||||
"relative_humidity": 90.0,
|
||||
"wind_speed": 1.0,
|
||||
"wind_from_direction": 0.0,
|
||||
}
|
||||
},
|
||||
"next_1_hours": {"summary": {"symbol_code": symbol}, "details": {}},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr(weather, "_get", lambda url, params: payload)
|
||||
assert weather.fetch_weather(LOCATION).description == expected
|
||||
|
||||
|
||||
def test_fetch_weather_bad_payload_raises(monkeypatch):
|
||||
monkeypatch.setattr(weather, "_get", lambda url, params: {})
|
||||
with pytest.raises(weather.WeatherError):
|
||||
weather.fetch_weather(LOCATION)
|
||||
|
||||
|
||||
# --- _get ------------------------------------------------------------------
|
||||
|
||||
def test_get_does_not_retry_4xx(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
class Response:
|
||||
status_code = 404
|
||||
|
||||
class Session:
|
||||
def get(self, *args, **kwargs):
|
||||
calls["n"] += 1
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr(weather, "_session", Session())
|
||||
with pytest.raises(weather.WeatherError):
|
||||
weather._get("https://example.invalid", {})
|
||||
|
||||
assert calls["n"] == 1 # постоянную 4xx не повторяем
|
||||
|
||||
|
||||
# --- format_summary --------------------------------------------------------
|
||||
|
||||
def test_format_summary_contains_key_facts():
|
||||
current = weather.Weather(22.2, 54, 4.1, 103, 0.0, "переменная облачность")
|
||||
text = weather.format_summary(LOCATION, current)
|
||||
|
||||
assert "Казань, Татарстан, Россия" in text
|
||||
assert "переменная облачность" in text
|
||||
assert "22.2 °C" in text
|
||||
Reference in New Issue
Block a user