"""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"] == "Восток"
