gendesign/backend/tests/services/test_weather_cache.py
bot-backend 9ae7af5753
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m11s
CI / openapi-codegen-check (pull_request) Successful in 2m3s
CI / backend-tests (pull_request) Successful in 17m10s
fix(ptica): «0 мм осадков» перестаёт означать «данных нет» (#2464)
weather_cache отдавал 0, когда open-meteo не прислал ряд осадков:

    "precipitation_total_mm": round(sum(precip), 1) if precip else 0
    "total_precip_mm":        round(sum(precip), 0) if precip else 0   (сезонные)

Ноль здесь — утверждение о погоде («сухо»), а пустой ряд значит, что мы не знаем.
Разница видна пользователю: ptica-adapt заворачивал значение в real(...)
БЕЗУСЛОВНО, то есть подавал его как измеренную величину.

Непоследовательность внутри одного литерала: шесть соседних агрегатов при пустых
данных дают None (min_c / max_c / avg_max_c / avg_min_c / uv_index_max /
max_speed_m_s). В сезонном блоке ещё нагляднее — avg_precip_per_day_mm и
total_precip_mm считаются из ОДНОГО списка соседними строками, первый даёт None,
второй давал 0.

В репозитории уже был решён ровно этот класс: раздел 7 тестов weather_cache —
«wind_d all-None → dominant_direction_deg = None, а не 0.0° (= север)». Новые
тесты зеркалят его структуру, включая заголовок раздела.

Фронт: оба поля стали nullable, и TypeScript сам нашёл всех потребителей —
четыре ошибки в двух файлах плюс сезонный блок. Везде «нет данных» вместо числа.
В ptica-adapt применён образец соседней строки avg_min_c: есть значение → real(),
нет → notReal().

Тесты: 2 красных на origin/main —
«AssertionError: ожидался None, получено 0 — ноль здесь читается как
«осадков не будет»». Контроль отдельный: настоящая сухая неделя (precip=[0,0,0])
обязана давать ИЗМЕРЕННЫЙ 0.0, а не None — без него правку можно было бы «сделать»
так, что осадки всегда None, и оба теста стали бы зелёными по неверной причине.

pytest tests/services: 3089 passed, 14 skipped, rc=0 (перепрогнано после хуков)
tsc --noEmit rc=0 · vitest 36 файлов / 279 тестов rc=0
2026-08-20 11:10:29 +05:00

680 lines
39 KiB
Python
Raw 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. ШТОРМ НА COLD-START: 16 потоков на ОДИН ключ → сеть зовётся не больше раза на
поток, все получают одно и то же значение, и шторм заканчивается сложившимся
кэшем. Не «ровно один вызов»: single-flight'а тут нет и он снят сознательно
(#1370, см. сам тест).
5. ИСТЕЧЕНИЕ TTL: подменяем `weather_cache._now`, проталкиваем время за expires_at
→ следующий вызов идёт по сети заново (а не из устаревшего кэша).
Каждый тест чистит обе кэш-таблицы в fixture'е (autouse) — между тестами нет утечки
состояния (модуль-singleton, dict'ы переживают между тестами).
"""
from __future__ import annotations
import os
import threading
import time
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_cold_start_storm_bounded_and_cache_converges(self) -> None:
"""16 потоков на ОДИН ключ при cold-start: сеть зовут не больше раза на поток,
все получают одно и то же значение, и после шторма кэш отвечает без сети.
ЗДЕСЬ СТОЯЛО `get_call_count == 1` («single-flight под lock'ом»), и это
было требование, которого код НЕ выполняет и выполнять не собирается:
сетевой вызов вынесен ЗА lock сознательно (#1370 — иначе все analyze
сериализуются на время httpx-вызова даже для разных координат), а рядом с
ним написано, что cold-start на один ключ «может породить несколько
параллельных запросов… приемлемо». Тест зеленел не потому, что защита
работает, а потому что при GIL первый поток обычно успевал сложить
результат раньше остальных.
Замер 2026-08-07, 200 штормов подряд: при дефолтном
`sys.getswitchinterval()` 199 раз вышел 1 вызов и один раз 2 — те самые
~0.5%, которыми гейт красил ЧУЖИЕ PR-ы (#2781: «ожидался 1 сетевой вызов,
было 2» в диффе про парсер КРТ). При `setswitchinterval(1e-6)`, когда
потоки реально чередуются, больше одного вызова дали 197 штормов из 200,
и в 173 из них вызовов было все 16. То есть утверждение ложно почти
всегда, когда гонка вообще случается, — чинить надо было тест.
Менять КОД (per-key lock ради настоящего single-flight) сознательно НЕ
стали: поведение объявлено приемлемым в #1370 с обоснованием, лишние
запросы бывают только на cold-start одного ключа и они идемпотентны.
Понадобится — это отдельная задача с отдельным обоснованием, а не
побочный эффект правки теста.
`time.sleep` в ответе делает гонку НЕслучайной: все 16 успевают пройти
промах кэша до первой записи. Так тест мерит худший случай той самой
уступки, а не везение планировщика.
"""
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
time.sleep(0.05) # окно, в котором остальные потоки видят промах
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()
storm_calls = get_call_count
# Шторм закончился — кэш обязан отвечать сам. Патч ещё активен, так что
# поход в сеть был бы виден счётчиком, а не отказом коннекта.
after_storm = weather_cache.get_weather_cached(56.84, 60.59)
assert len(results) == 16
assert results[0] is not None
assert all(r == results[0] for r in results), "потоки увидели РАЗНЫЕ значения"
# Потолок — число участников: в сеть идут только промахнувшиеся, по разу
# каждый. Больше — значит кто-то фетчит повторно (retry-петля, потерянная
# запись в кэш); меньше единицы невозможно, кэш был пуст.
assert 1 <= storm_calls <= 16, f"сетевых вызовов {storm_calls} при 16 участниках"
# Ключ ОДИН на всех (last-write wins), и цена шторма платится один раз:
# следующий вызов идёт из кэша. Это и есть то, что #1370 обещает взамен
# снятого single-flight — без этого уступка превращается в дыру.
assert list(weather_cache._FORECAST_CACHE) == [weather_cache._round_key(56.84, 60.59)]
assert after_storm == results[0]
assert get_call_count == storm_calls, (
f"после шторма кэш обязан отвечать без сети, а вызовов стало "
f"{get_call_count} против {storm_calls}"
)
# ──────────────────────────────────────────────────────────────────────────────
# 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"] == "Восток"
# ──────────────────────────────────────────────────────────────────────────────
# 8. осадков нет в ответе → precipitation_* = None, а не 0 (#2464)
#
# Тот же класс, что раздел 7 выше (wind_d all-None → None, не 0.0°): «ноль» здесь
# был бы утверждением о погоде («сухо»), тогда как пустой ряд значит, что мы просто
# не знаем. Все шесть соседних агрегатов того же словаря при пустых данных дают None
# — осадки были единственным исключением, и именно они рисуются на фронте как
# измеренная величина (ptica-adapt заворачивал их в `real(...)` безусловно).
# ──────────────────────────────────────────────────────────────────────────────
def _make_forecast_response_precip_all_none() -> dict[str, Any]:
"""Forecast, где все precipitation_sum = null (Open-Meteo так делает штатно)."""
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": [None, None, None],
"uv_index_max": [5.0, 5.0, 5.0],
"winddirection_10m_dominant": [180, 180, 180],
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
def _make_forecast_response_precip_missing() -> dict[str, Any]:
"""Forecast без ключа precipitation_sum вовсе."""
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],
"uv_index_max": [5.0, 5.0, 5.0],
"winddirection_10m_dominant": [180, 180, 180],
"windspeed_10m_max": [3.0, 4.0, 3.0],
}
}
class TestPrecipitationUnknownIsNotZero:
"""Регрессия: без ряда осадков не должно возвращаться 0 (= «сухо»)."""
def _call_with_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None:
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_precip_gives_none_not_zero(self) -> None:
result = self._call_with_payload(_make_forecast_response_precip_all_none())
assert result is not None, "ответ должен строиться и без ряда осадков"
assert result["precipitation_total_mm"] is None, (
f"ожидался None, получено {result['precipitation_total_mm']!r}"
"ноль здесь читается как «осадков не будет»"
)
assert result["precipitation_days"] is None
def test_missing_precip_key_gives_none(self) -> None:
result = self._call_with_payload(_make_forecast_response_precip_missing())
assert result is not None
assert result["precipitation_total_mm"] is None
assert result["precipitation_days"] is None
def test_real_dry_week_is_still_a_measured_zero(self) -> None:
"""КОНТРОЛЬ: настоящая сухая неделя — это измеренный 0.0, а не None.
Без этой проверки правку можно было бы «сделать» так, что осадки всегда
None, и оба теста выше стали бы зелёными по неверной причине.
"""
payload = _make_forecast_response_precip_all_none()
payload["daily"]["precipitation_sum"] = [0.0, 0.0, 0.0]
result = self._call_with_payload(payload)
assert result is not None
assert result["precipitation_total_mm"] == 0.0
assert result["precipitation_days"] == 0