"""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