gendesign/backend/tests/services/test_weather_cache.py
bot-backend 6cf65eb26b
Some checks failed
CI / changes (push) Has been cancelled
CI / backend-tests (push) Has been cancelled
CI / frontend-tests (push) Has been cancelled
CI / openapi-codegen-check (push) Has been cancelled
CI / backend-tests (pull_request) Has been cancelled
CI / frontend-tests (pull_request) Has been cancelled
CI / openapi-codegen-check (pull_request) Has been cancelled
CI / changes (pull_request) Has been cancelled
fix(weather): wind_d returns None when no valid samples, not fabricated 0.0° (#weather-cache)
When all winddirection_10m_dominant samples were None (or the key was
absent), atan2(0, 0) produced 0.0° (due north) as a fabricated value.
Root causes:
  1. [None, None, ...] is truthy → entered circular-mean branch, but
     filtered sums x=y=0 → atan2(0,0)=0 → 0.0°
  2. Empty list → else 0.0 branch → same fabrication

Fix: filter None from wind_d before aggregation (consistent with t_max /
t_min / uv). Only compute circular mean when at least one valid sample
exists; otherwise dominant_direction_deg and dominant_direction_label are
None. Adds TestWindDirectionAllNone covering all-None, missing key, valid
samples sanity, and mixed None+valid cases.
2026-06-17 21:22:31 +03:00

550 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit-тесты TTL-кэша Open-Meteo (PR #1130 Phase A — analyze latency 5-9s).
Что покрываем (БЕЗ сети — httpx запатчен на уровне модуля):
1. HOT-CACHE HIT: два вызова с одинаковыми (lat, lon) → один сетевой запрос; третий
с округлённо-теми же координатами (56.838 и 56.839 оба → 56.84) → тоже hit, без
сетевого вызова. Сам смысл кэша — ради этого PR и сделан.
2. NEGATIVE-CACHE: первый вызов бросает исключение → None; второй (в пределах
negative-TTL) → None БЕЗ сетевого вызова. Это правит главный продакшн-симптом
DNS-fail повторяет timeout на каждый analyze.
3. ИЗОЛЯЦИЯ ДВУХ КЭШЕЙ: forecast-вызов не отравляет climate-кэш и наоборот (две
раздельные таблицы внутри модуля).
4. SINGLE-FLIGHT под конкурентностью: 16 потоков на ОДИН ключ при cold-start →
ровно ОДИН реальный httpx-вызов (lock + check-then-fetch-then-store).
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
Каждый тест чистит обе кэш-таблицы в fixture'е (autouse) — между тестами нет утечки
состояния (модуль-singleton, dict'ы переживают между тестами).
"""
from __future__ import annotations
import os
import threading
from collections.abc import Iterator
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import weather_cache
# ──────────────────────────────────────────────────────────────────────────────
# Фикстуры
# ──────────────────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _reset_caches() -> Iterator[None]:
"""Чистить все TTL-таблицы до и после каждого теста (state живёт в модуле)."""
weather_cache._FORECAST_CACHE.clear()
weather_cache._CLIMATE_CACHE.clear()
weather_cache._AIR_CACHE.clear()
yield
weather_cache._FORECAST_CACHE.clear()
weather_cache._CLIMATE_CACHE.clear()
weather_cache._AIR_CACHE.clear()
def _make_forecast_response() -> dict[str, Any]:
"""Минимальный валидный JSON от Open-Meteo Forecast API (7 дней)."""
return {
"daily": {
"time": [
"2026-06-12",
"2026-06-13",
"2026-06-14",
"2026-06-15",
"2026-06-16",
"2026-06-17",
"2026-06-18",
],
"temperature_2m_max": [20.0, 21.0, 22.0, 23.0, 22.0, 21.0, 20.0],
"temperature_2m_min": [10.0, 11.0, 12.0, 13.0, 12.0, 11.0, 10.0],
"precipitation_sum": [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 5.0],
"winddirection_10m_dominant": [180, 180, 180, 180, 180, 180, 180],
"windspeed_10m_max": [3.0, 4.0, 3.0, 4.0, 3.0, 4.0, 3.0],
}
}
def _make_climate_response() -> dict[str, Any]:
"""Минимальный валидный JSON от Open-Meteo Climate API — пара дат каждого сезона."""
return {
"daily": {
"time": [
"2000-01-15",
"2000-04-15",
"2000-07-15",
"2000-10-15",
],
"temperature_2m_max": [-5.0, 10.0, 25.0, 5.0],
"temperature_2m_min": [-15.0, 0.0, 15.0, -5.0],
"precipitation_sum": [2.0, 3.0, 4.0, 5.0],
}
}
def _make_httpx_response(payload: dict[str, Any]) -> MagicMock:
"""MagicMock в форме httpx.Response: `.raise_for_status()` no-op, `.json()` → payload."""
resp = MagicMock()
resp.raise_for_status = MagicMock(return_value=None)
resp.json = MagicMock(return_value=payload)
return resp
def _make_air_response() -> dict[str, Any]:
"""Минимальный валидный JSON от Open-Meteo Air Quality API (current bucket, #1377)."""
return {
"current": {
"time": "2026-06-12T14:00",
"pm2_5": 12.5,
"pm10": 25.0,
"nitrogen_dioxide": 15.0,
}
}
# ──────────────────────────────────────────────────────────────────────────────
# 1. Hot-cache HIT (главный смысл PR'а — снять повторные запросы).
# ──────────────────────────────────────────────────────────────────────────────
class TestHotCacheHit:
def test_same_coords_skip_network_after_first_call(self) -> None:
"""Два вызова с одинаковыми (lat, lon) → один сетевой запрос."""
get = MagicMock(return_value=_make_httpx_response(_make_forecast_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_weather_cached(56.84, 60.59)
r2 = weather_cache.get_weather_cached(56.84, 60.59)
assert r1 is not None
assert r2 == r1 # тот же payload (из кэша)
assert get.call_count == 1 # сетевой вызов ОДИН на оба запроса
def test_close_coords_round_to_same_key(self) -> None:
"""56.838 и 56.839 (обе → round 56.84) дают тот же ключ кэша → 1 сетевой вызов."""
get = MagicMock(return_value=_make_httpx_response(_make_forecast_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
weather_cache.get_weather_cached(56.838, 60.597)
weather_cache.get_weather_cached(56.839, 60.598)
weather_cache.get_weather_cached(56.841, 60.594)
# Все три round до (56.84, 60.60)? Проверим явно.
# round(56.838,2)=56.84, round(56.839,2)=56.84, round(56.841,2)=56.84.
# round(60.597,2)=60.60, round(60.598,2)=60.60, round(60.594,2)=60.59.
# Значит первый и второй — один ключ, третий — другой.
# Значит ожидаем 2 сетевых вызова (а не 3, не 1).
assert get.call_count == 2
def test_distant_coords_use_distinct_keys(self) -> None:
"""56.84 и 56.90 (round до разных сотых) → отдельные слоты кэша → 2 вызова."""
get = MagicMock(return_value=_make_httpx_response(_make_forecast_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
weather_cache.get_weather_cached(56.84, 60.59)
weather_cache.get_weather_cached(56.90, 60.59)
assert get.call_count == 2
# ──────────────────────────────────────────────────────────────────────────────
# 2. Negative-cache (главный продакшн-симптом #1130 — DNS-fail → timeout на каждый
# analyze).
# ──────────────────────────────────────────────────────────────────────────────
class TestNegativeCacheOnFailure:
def test_failure_caches_none_and_skips_retry(self) -> None:
"""Первый вызов → исключение → None; второй (в пределах TTL) → None без сети."""
get = MagicMock(side_effect=RuntimeError("simulated DNS failure"))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_weather_cached(56.84, 60.59)
r2 = weather_cache.get_weather_cached(56.84, 60.59)
assert r1 is None
assert r2 is None
assert get.call_count == 1 # ровно ОДНА попытка → негативный кэш сработал
def test_empty_daily_caches_none(self) -> None:
"""Open-Meteo вернул валидный JSON, но `daily.time` пуст → None кэшируется."""
get = MagicMock(return_value=_make_httpx_response({"daily": {"time": []}}))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_weather_cached(56.84, 60.59)
r2 = weather_cache.get_weather_cached(56.84, 60.59)
assert r1 is None
assert r2 is None
assert get.call_count == 1
def test_seasonal_failure_caches_none(self) -> None:
"""Симметрично для climate-API — failure → negative-cache, второй вызов из кэша."""
get = MagicMock(side_effect=RuntimeError("simulated DNS failure"))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_seasonal_weather_cached(56.84, 60.59)
r2 = weather_cache.get_seasonal_weather_cached(56.84, 60.59)
assert r1 is None
assert r2 is None
assert get.call_count == 1
# ──────────────────────────────────────────────────────────────────────────────
# 3. Изоляция forecast- и climate-кэшей.
# ──────────────────────────────────────────────────────────────────────────────
class TestSeparateCachesForForecastAndClimate:
def test_forecast_call_does_not_populate_climate_cache(self) -> None:
"""forecast-кэш и climate-кэш живут отдельно — записи одного не видны другому."""
# Отдельный счётчик GET'ов для двух подряд вызовов разных функций.
get = MagicMock(
side_effect=[
_make_httpx_response(_make_forecast_response()),
_make_httpx_response(_make_climate_response()),
]
)
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
forecast = weather_cache.get_weather_cached(56.84, 60.59)
seasonal = weather_cache.get_seasonal_weather_cached(56.84, 60.59)
assert forecast is not None
assert seasonal is not None
# forecast-вызов оставил запись в _FORECAST_CACHE, climate — в _CLIMATE_CACHE.
assert (56.84, 60.59) in weather_cache._FORECAST_CACHE
assert (56.84, 60.59) in weather_cache._CLIMATE_CACHE
# ИЗОЛЯЦИЯ: forecast НЕ попал в climate-таблицу (раздельные dict'ы).
# Сетевых вызовов было ровно 2 (по одному на каждую функцию — не схлопнулись).
assert get.call_count == 2
# ──────────────────────────────────────────────────────────────────────────────
# 4. Single-flight под конкурентностью (16 потоков → 1 сетевой вызов).
# ──────────────────────────────────────────────────────────────────────────────
class TestConcurrencySafe:
def test_single_flight_cold_start_one_network_call(self) -> None:
"""16 потоков на ОДИН ключ при cold-start → ровно один реальный httpx-вызов."""
# GET имитирует медленный ответ, чтобы потоки реально гонялись за один lock.
start_barrier = threading.Barrier(16)
get_call_count = 0
get_lock = threading.Lock()
def _slow_get(*args: Any, **kwargs: Any) -> Any:
nonlocal get_call_count
with get_lock:
get_call_count += 1
# Микро-задержка — окно для других потоков добраться до lock'а.
# Не делаем sleep большим, чтобы тест не висел.
return _make_httpx_response(_make_forecast_response())
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=_slow_get))
client_ctx.__exit__ = MagicMock(return_value=None)
results: list[dict[str, Any] | None] = []
results_lock = threading.Lock()
def _worker() -> None:
start_barrier.wait()
value = weather_cache.get_weather_cached(56.84, 60.59)
with results_lock:
results.append(value)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
threads = [threading.Thread(target=_worker) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results) == 16
assert all(r is not None for r in results)
# Single-flight под lock'ом + check-then-fetch — РОВНО один реальный вызов.
assert get_call_count == 1, f"ожидался 1 сетевой вызов, было {get_call_count}"
# ──────────────────────────────────────────────────────────────────────────────
# 5. Истечение TTL — после прохождения времени за expires_at новый сетевой вызов.
# ──────────────────────────────────────────────────────────────────────────────
class TestExpiresAfterTtl:
def test_forecast_hot_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Проталкиваем `_now` за hot-TTL → следующий вызов идёт по сети заново."""
get = MagicMock(return_value=_make_httpx_response(_make_forecast_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
clock = [1000.0]
def _fake_now() -> float:
return clock[0]
monkeypatch.setattr(weather_cache, "_now", _fake_now)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
weather_cache.get_weather_cached(56.84, 60.59)
assert get.call_count == 1
# Проталкиваем время за hot-TTL (6h + запас).
clock[0] += weather_cache._WEATHER_TTL_S + 1
weather_cache.get_weather_cached(56.84, 60.59)
assert get.call_count == 2
def test_negative_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""После negative-TTL (5 мин) повторный вызов снова бьёт по сети."""
get = MagicMock(side_effect=RuntimeError("DNS"))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
clock = [1000.0]
def _fake_now() -> float:
return clock[0]
monkeypatch.setattr(weather_cache, "_now", _fake_now)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
assert weather_cache.get_weather_cached(56.84, 60.59) is None
assert get.call_count == 1
clock[0] += weather_cache._NEGATIVE_TTL_S + 1
assert weather_cache.get_weather_cached(56.84, 60.59) is None
assert get.call_count == 2 # ретрай после истечения negative-TTL
def test_seasonal_hot_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Climate normals — после 7 суток ретрай по сети."""
get = MagicMock(return_value=_make_httpx_response(_make_climate_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
clock = [1000.0]
def _fake_now() -> float:
return clock[0]
monkeypatch.setattr(weather_cache, "_now", _fake_now)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
weather_cache.get_seasonal_weather_cached(56.84, 60.59)
assert get.call_count == 1
clock[0] += weather_cache._SEASONAL_TTL_S + 1
weather_cache.get_seasonal_weather_cached(56.84, 60.59)
assert get.call_count == 2
# ──────────────────────────────────────────────────────────────────────────────
# 6. Air-quality cache (PR #1130 Phase B). Зеркало паттернов forecast/climate.
# ──────────────────────────────────────────────────────────────────────────────
class TestAirQualityCache:
def test_air_hot_cache_hit_skips_network(self) -> None:
"""Два вызова с одинаковыми (lat, lon) → ровно один сетевой запрос."""
get = MagicMock(return_value=_make_httpx_response(_make_air_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_air_quality_cached(56.84, 60.59)
r2 = weather_cache.get_air_quality_cached(56.84, 60.59)
assert r1 is not None
assert r2 == r1
# Контракт для caller'а: формат dict не сменился относительно прежнего
# `_fetch_air_quality_sync` — фронт зависит от этих ключей.
assert set(r1.keys()) == {"pm2_5", "pm10", "no2", "ts", "source"}
assert r1["source"] == "open-meteo"
assert get.call_count == 1
def test_air_negative_cache_on_failure(self) -> None:
"""Первый вызов бросает → None; второй (в TTL) → None без сети."""
get = MagicMock(side_effect=RuntimeError("simulated DNS failure"))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_air_quality_cached(56.84, 60.59)
r2 = weather_cache.get_air_quality_cached(56.84, 60.59)
assert r1 is None
assert r2 is None
assert get.call_count == 1 # ровно одна попытка → negative cache сработал
def test_air_empty_hourly_caches_none(self) -> None:
"""Open-Meteo вернул валидный JSON, но `hourly.time` пуст → None кэшируется."""
get = MagicMock(return_value=_make_httpx_response({"hourly": {"time": []}}))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
r1 = weather_cache.get_air_quality_cached(56.84, 60.59)
r2 = weather_cache.get_air_quality_cached(56.84, 60.59)
assert r1 is None
assert r2 is None
assert get.call_count == 1
def test_air_separate_from_weather(self) -> None:
"""Air cache не отравляет weather/climate cache и наоборот (три раздельных dict'а)."""
# Три последовательных вызова разных функций — каждый делает свой сетевой call.
get = MagicMock(
side_effect=[
_make_httpx_response(_make_forecast_response()),
_make_httpx_response(_make_air_response()),
_make_httpx_response(_make_climate_response()),
]
)
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
weather = weather_cache.get_weather_cached(56.84, 60.59)
air = weather_cache.get_air_quality_cached(56.84, 60.59)
climate = weather_cache.get_seasonal_weather_cached(56.84, 60.59)
assert weather is not None
assert air is not None
assert climate is not None
# Каждый кэш живёт в своём dict — записи не смешиваются.
assert (56.84, 60.59) in weather_cache._FORECAST_CACHE
assert (56.84, 60.59) in weather_cache._AIR_CACHE
assert (56.84, 60.59) in weather_cache._CLIMATE_CACHE
assert get.call_count == 3 # три раздельных сетевых вызова
def test_air_ttl_expires(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Через `_AIR_TTL_S` (1 час) повторный вызов снова бьёт по сети."""
get = MagicMock(return_value=_make_httpx_response(_make_air_response()))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
clock = [1000.0]
def _fake_now() -> float:
return clock[0]
monkeypatch.setattr(weather_cache, "_now", _fake_now)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
weather_cache.get_air_quality_cached(56.84, 60.59)
assert get.call_count == 1
clock[0] += weather_cache._AIR_TTL_S + 1
weather_cache.get_air_quality_cached(56.84, 60.59)
assert get.call_count == 2
# ──────────────────────────────────────────────────────────────────────────────
# 7. wind_d all-None → dominant_direction_deg/label must be None, not 0.0°
# ──────────────────────────────────────────────────────────────────────────────
def _make_forecast_response_wind_all_none() -> dict[str, Any]:
"""Forecast где все winddirection_10m_dominant = null (Open-Meteo штатно возвращает null)."""
return {
"daily": {
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
"temperature_2m_max": [20.0, 21.0, 22.0],
"temperature_2m_min": [10.0, 11.0, 12.0],
"precipitation_sum": [0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0],
"winddirection_10m_dominant": [None, None, None], # все null
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
def _make_forecast_response_wind_empty() -> dict[str, Any]:
"""Forecast где winddirection_10m_dominant отсутствует (ключ не пришёл)."""
return {
"daily": {
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
"temperature_2m_max": [20.0, 21.0, 22.0],
"temperature_2m_min": [10.0, 11.0, 12.0],
"precipitation_sum": [0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0],
# winddirection_10m_dominant отсутствует
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
class TestWindDirectionAllNone:
"""Регрессия: когда нет валидных сэмплов wind_d, не должно возвращаться 0.0° (= север)."""
def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None:
"""Вспомогательный: патчим httpx, делаем один вызов get_weather_cached."""
get = MagicMock(return_value=_make_httpx_response(payload))
client_ctx = MagicMock()
client_ctx.__enter__ = MagicMock(return_value=MagicMock(get=get))
client_ctx.__exit__ = MagicMock(return_value=None)
with patch("app.services.weather_cache.httpx.Client", return_value=client_ctx):
return weather_cache.get_weather_cached(56.84, 60.59)
def test_all_none_wind_samples_gives_none_direction(self) -> None:
"""Все winddirection_10m_dominant = null → dominant_direction_deg/label = None, не 0.0."""
result = self._call_with_payload(_make_forecast_response_wind_all_none())
assert result is not None, "forecast должен вернуть dict (не None), даже без wind_d"
wind = result["wind"]
assert wind["dominant_direction_deg"] is None, (
f"ожидался None, получено {wind['dominant_direction_deg']!r}"
"вероятно, fabricated 0.0° из-за atan2(0,0)"
)
assert (
wind["dominant_direction_label"] is None
), f"ожидался None, получено {wind['dominant_direction_label']!r}"
def test_missing_wind_key_gives_none_direction(self) -> None:
"""winddirection_10m_dominant отсутствует в ответе → dominant_direction_deg/label = None."""
result = self._call_with_payload(_make_forecast_response_wind_empty())
assert result is not None
wind = result["wind"]
assert wind["dominant_direction_deg"] is None
assert wind["dominant_direction_label"] is None
def test_valid_wind_samples_still_compute(self) -> None:
"""Санитарный тест: валидные сэмплы ветра по-прежнему дают числовой результат."""
result = self._call_with_payload(_make_forecast_response())
assert result is not None
wind = result["wind"]
assert wind["dominant_direction_deg"] is not None
assert isinstance(wind["dominant_direction_deg"], int)
assert wind["dominant_direction_label"] is not None
assert isinstance(wind["dominant_direction_label"], str)
def test_mixed_none_and_valid_wind_samples_compute(self) -> None:
"""Часть сэмплов None, часть валидные → только валидные участвуют в circular mean."""
payload = {
"daily": {
"time": ["2026-06-12", "2026-06-13", "2026-06-14"],
"temperature_2m_max": [20.0, 21.0, 22.0],
"temperature_2m_min": [10.0, 11.0, 12.0],
"precipitation_sum": [0.0, 0.0, 0.0],
"uv_index_max": [5.0, 5.0, 5.0],
"winddirection_10m_dominant": [None, 90.0, None], # только 90°=Восток
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
result = self._call_with_payload(payload)
assert result is not None
wind = result["wind"]
# Один сэмпл 90° → circular mean ровно 90° → rose[2] = "Восток"
assert wind["dominant_direction_deg"] == 90
assert wind["dominant_direction_label"] == "Восток"