diff --git a/tests/test_commands_pg.py b/tests/test_commands_pg.py index 5894850..f7f8367 100644 --- a/tests/test_commands_pg.py +++ b/tests/test_commands_pg.py @@ -27,7 +27,7 @@ class TestPgCommand: def _make_weather_data(self, **extra): """Создать mock weather data (Яндекс Погода формат). - windspeedKmph — м/с от Яндекса. + wind_speed_mps — скорость ветра в м/с от Яндекса. wind_gust — порывы ветра в м/с. wind_dir — направление ветра. pressure — уже в мм рт. ст. @@ -40,7 +40,7 @@ class TestPgCommand: "FeelsLikeC": 24, "weatherDesc": [{"value": "Ясно"}], "humidity": 45, - "windspeedKmph": 5.0, # м/с + "wind_speed_mps": 5.0, # м/с "wind_gust": 8.0, "wind_dir": "n", "pressure": 735.0, @@ -66,7 +66,7 @@ class TestPgCommand: assert "(ощущается как 24°C)" in args assert "Описание: Ясно" in args assert "Влажность: 45%" in args - assert "Ветер: 5.0 м/с" in args + assert "Ветер: 5.0 (порывы 8.0), северный м/с" in args assert "Давление: 735.0 мм рт. ст." in args @pytest.mark.asyncio @@ -94,7 +94,7 @@ class TestPgCommand: @pytest.mark.asyncio async def test_pg_current_condition_none(self) -> None: - """current_condition — пустой dict — бот должен сообщить об ошибке.""" + """current_condition — пустой dict — бот сообщает об ошибке (empty dict is falsy).""" cog = self._make_cog() ctx = self._make_ctx() weather = {"current_condition": [{}]} @@ -106,23 +106,26 @@ class TestPgCommand: @pytest.mark.asyncio async def test_pg_wind_non_numeric(self) -> None: - """windspeedKmph — не число — wind должен быть '—'.""" + """wind_speed_mps — не число — показываются порывы ветра (gust fallback).""" cog = self._make_cog() ctx = self._make_ctx() - weather = self._make_weather_data(windspeedKmph="abc") + weather = self._make_weather_data(wind_speed_mps="abc") # gust=8.0, dir=n по умолчанию with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] - assert "Ветер: — м/с" in args + # base wind невалиден → показываем порывы + assert "Ветер: порывы 8.0, северный м/с" in args @pytest.mark.asyncio async def test_pg_wind_none(self) -> None: - """windspeedKmph отсутствует — wind должен быть '—'.""" + """wind_speed_mps отсутствует, порывов нет — wind должен быть '— м/с'.""" cog = self._make_cog() ctx = self._make_ctx() - weather = self._make_weather_data(windspeedKmph=None) + weather = self._make_weather_data( + wind_speed_mps=None, wind_gust=None, wind_dir=None + ) with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): await cog.pg.callback(cog, ctx) @@ -132,16 +135,16 @@ class TestPgCommand: @pytest.mark.asyncio async def test_pg_zero_wind(self) -> None: - """windspeedKmph = 0 — wind должен быть 0.0.""" + """wind_speed_mps = 0 — 0.0 + порывы + направление.""" cog = self._make_cog() ctx = self._make_ctx() - weather = self._make_weather_data(windspeedKmph=0) + weather = self._make_weather_data(wind_speed_mps=0) # gust=8.0, dir=n по умолчанию with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] - assert "Ветер: 0.0 м/с" in args + assert "Ветер: 0.0 (порывы 8.0), северный м/с" in args @pytest.mark.asyncio async def test_pg_default_values(self) -> None: @@ -188,7 +191,7 @@ class TestPgCommand: cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data( - windspeedKmph=3.0, + wind_speed_mps=3.0, wind_gust=10.5, wind_dir="n", ) @@ -197,7 +200,7 @@ class TestPgCommand: await cog.pg.callback(cog, ctx) args = ctx.send.call_args[0][0] - assert "Ветер: 3.0 м/с (порывы 10.5 м/с), северный" in args + assert "Ветер: 3.0 (порывы 10.5), северный м/с" in args @pytest.mark.asyncio async def test_pg_with_wind_direction(self) -> None: @@ -205,7 +208,7 @@ class TestPgCommand: cog = self._make_cog() ctx = self._make_ctx() weather = self._make_weather_data( - windspeedKmph=5.0, + wind_speed_mps=5.0, wind_gust=8.0, wind_dir="se", ) @@ -225,7 +228,7 @@ class TestPgCommand: temp_C=17.3, FeelsLikeC=16.8, humidity=87.5, - windspeedKmph=2.1, # м/с + wind_speed_mps=2.1, # м/с wind_gust=5.5, wind_dir="n", pressure=750.1, @@ -236,5 +239,41 @@ class TestPgCommand: args = ctx.send.call_args[0][0] assert "Температура: 17.3°C" in args - assert "Ветер: 2.1 м/с" in args + assert "Ветер: 2.1 (порывы 5.5), северный м/с" in args assert "Давление: 750.1 мм рт. ст." in args + + @pytest.mark.asyncio + async def test_pg_gust_only_no_base_wind(self) -> None: + """При отсутствии base wind порывы ветра всё равно показываются.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data( + wind_speed_mps=None, + wind_gust=10.5, + wind_dir="n", + ) + + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Ветер: порывы 10.5, северный м/с" in args + + @pytest.mark.asyncio + async def test_pg_gust_only_no_direction(self) -> None: + """Порывы ветра без направления и base wind — без дублирования 'м/с'.""" + cog = self._make_cog() + ctx = self._make_ctx() + weather = self._make_weather_data( + wind_speed_mps=None, + wind_gust=10.5, + wind_dir=None, + ) + + with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)): + await cog.pg.callback(cog, ctx) + + args = ctx.send.call_args[0][0] + assert "Ветер: порывы 10.5 м/с" in args + # Убеждаемся, что 'м/с' не дублируется + assert args.count("м/с") == 1 diff --git a/tests/test_fetch_weather.py b/tests/test_fetch_weather.py index 0422ea9..49c4332 100644 --- a/tests/test_fetch_weather.py +++ b/tests/test_fetch_weather.py @@ -229,7 +229,7 @@ class TestFetchWeather: result = await fetch_weather() assert result is not None - assert result["current_condition"][0]["windspeedKmph"] == 0 + assert result["current_condition"][0]["wind_speed_mps"] == 0 assert result["current_condition"][0]["pressure"] == 750.0 @patch("utils.pogoda._session.get") @@ -285,3 +285,17 @@ class TestFetchWeather: call_kwargs = mock_get.call_args[1] assert "X-Yandex-API-Key" in call_kwargs["headers"] assert call_kwargs["headers"]["X-Yandex-API-Key"] == "test-key" + + @patch("utils.pogoda._session.get") + async def test_fetch_weather_json_decode_error(self, mock_get) -> None: + """json.JSONDecodeError (невалидный JSON от API) → graceful None.""" + import json as _json + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_response.json.side_effect = _json.JSONDecodeError("Expecting value", "", 0) + mock_get.return_value = mock_response + + result = await fetch_weather(max_retries=1) + + assert result is None diff --git a/tests/test_pogoda.py b/tests/test_pogoda.py index 28f24be..56c1351 100644 --- a/tests/test_pogoda.py +++ b/tests/test_pogoda.py @@ -20,7 +20,7 @@ class TestFormatWeatherDataForConsole: "FeelsLikeC": 26, "weatherDesc": [{"value": "Ясно"}], "humidity": 45, - "windspeedKmph": 5.0, # м/с от Яндекса + "wind_speed_mps": 5.0, # м/с от Яндекса "wind_gust": 8.0, "wind_dir": "n", "pressure": 735.0, # уже в мм рт. ст. @@ -35,7 +35,7 @@ class TestFormatWeatherDataForConsole: assert "Температура: 25°C (ощущается как 26°C)" in result[0] assert "Описание: Ясно" in result[1] assert "Влажность: 45%" in result[2] - assert "Ветер: 5.0 м/с (порывы 8.0 м/с), северный" in result[3] + assert "Ветер: 5.0 (порывы 8.0), северный м/с" in result[3] assert "Давление: 735.0 мм рт. ст." in result[4] def test_format_empty_data(self) -> None: @@ -63,7 +63,8 @@ class TestFormatWeatherDataForConsole: "FeelsLikeC": None, "weatherDesc": [{"value": "Неизвестно"}], "humidity": None, - "windspeedKmph": None, + "wind_speed_mps": None, + "wind_gust": None, "pressure": None, } ] @@ -87,7 +88,7 @@ class TestFormatWeatherDataForConsole: "FeelsLikeC": -10, "weatherDesc": [{"value": "Снег"}], "humidity": 80, - "windspeedKmph": 5, + "wind_speed_mps": 5, "wind_gust": 10, "wind_dir": "n", "pressure": 720.0, @@ -109,7 +110,7 @@ class TestFormatWeatherDataForConsole: "FeelsLikeC": 16.8, "weatherDesc": [{"value": "Облачно"}], "humidity": 87.5, - "windspeedKmph": 2.1, # м/с + "wind_speed_mps": 2.1, # м/с "pressure": 750.0, } ] @@ -140,7 +141,7 @@ class TestFormatWeatherDataForConsole: "FeelsLikeC": 18, "weatherDesc": [{"value": "Ясно"}], "humidity": 50, - "windspeedKmph": 3.0, + "wind_speed_mps": 3.0, "wind_dir": code, "pressure": 750.0, } @@ -149,6 +150,68 @@ class TestFormatWeatherDataForConsole: result = format_weather_data_for_console(data) assert expected in result[3], f"Направление {code} → {expected} не найдено" + def test_format_gust_only_no_base_wind(self) -> None: + """При отсутствии base wind порывы ветра всё равно показываются.""" + data = { + "current_condition": [ + { + "temp_C": 20, + "FeelsLikeC": 18, + "weatherDesc": [{"value": "Ясно"}], + "humidity": 50, + "wind_speed_mps": None, + "wind_gust": 10.5, + "wind_dir": "n", + "pressure": 750.0, + } + ] + } + + result = format_weather_data_for_console(data) + assert "Ветер: порывы 10.5, северный м/с" in result[3] + + def test_format_gust_only_no_duplication(self) -> None: + """Порывы без направления и base wind — 'м/с' не дублируется.""" + data = { + "current_condition": [ + { + "temp_C": 20, + "FeelsLikeC": 18, + "weatherDesc": [{"value": "Ясно"}], + "humidity": 50, + "wind_speed_mps": None, + "wind_gust": 10.5, + "wind_dir": None, + "pressure": 750.0, + } + ] + } + + result = format_weather_data_for_console(data) + assert "Ветер: порывы 10.5 м/с" in result[3] + assert result[3].count("м/с") == 1 + + def test_format_base_wind_with_gust_no_dir(self) -> None: + """Base wind + порывы без направления — 'м/с' не дублируется.""" + data = { + "current_condition": [ + { + "temp_C": 20, + "FeelsLikeC": 18, + "weatherDesc": [{"value": "Ясно"}], + "humidity": 50, + "wind_speed_mps": 5.0, + "wind_gust": 8.0, + "wind_dir": None, + "pressure": 750.0, + } + ] + } + + result = format_weather_data_for_console(data) + assert "Ветер: 5.0 (порывы 8.0) м/с" in result[3] + assert result[3].count("м/с") == 1 + class TestPressureToMMHG: """Тесты функции pressure_to_mmhg() — конвертация давления из мб в мм рт. ст.""" diff --git a/utils/pogoda.py b/utils/pogoda.py index 1a19a8b..993b1bf 100644 --- a/utils/pogoda.py +++ b/utils/pogoda.py @@ -7,6 +7,7 @@ https://yandex.ru/dev/weather/ """ import asyncio +import json import logging import os from typing import Optional @@ -66,7 +67,7 @@ async def fetch_weather( "FeelsLikeC": fact.get("feels_like"), "weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}], "humidity": fact.get("humidity"), - "windspeedKmph": fact.get("wind_speed"), + "wind_speed_mps": fact.get("wind_speed"), "wind_gust": fact.get("wind_gust"), "wind_dir": fact.get("wind_dir"), "pressure": fact.get("pressure_mm"), @@ -83,7 +84,7 @@ async def fetch_weather( await asyncio.sleep(delay) continue break - except requests.RequestException as e: + except (requests.RequestException, json.JSONDecodeError, ValueError) as e: logger.error("Ошибка Яндекс Погоды: %s", e) break @@ -153,23 +154,34 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]] if humidity is None: humidity = "—" - # Яндекс возвращает ветер в м/с, но для совместимости с унифицированным - # форматом WindspeedKmph содержит м/с — конвертируем обратно в м/с для вывода - wind_mps = current.get("windspeedKmph") - if wind_mps is None: - wind = "—" - else: + # Скорость ветра (м/с) + wind_mps = current.get("wind_speed_mps") + wind_value = None + if wind_mps is not None: try: - # windspeedKmph на самом деле хранит м/с от Яндекса - wind = round(float(wind_mps), 1) + wind_value = round(float(wind_mps), 1) except (ValueError, TypeError): - wind = "—" + pass - # Порывы ветра (м/с) + # Собираем компоненты ветра + wind_parts = [] + + if wind_value is not None: + wind_parts.append(str(wind_value)) + elif wind_value is None: + # Базовая скорость отсутствует — покажем порывы, если есть + wind_gust = current.get("wind_gust") + if wind_gust is not None: + try: + wind_parts.append(f"порывы {round(float(wind_gust), 1)}") + except (ValueError, TypeError): + pass + + # Порывы ветра (м/с) — добавляем к базовой скорости wind_gust = current.get("wind_gust") - if wind_gust is not None: + if wind_gust is not None and wind_value is not None: try: - wind = f"{wind} м/с (порывы {round(float(wind_gust), 1)} м/с)" + wind_parts[-1] = f"{wind_parts[-1]} (порывы {round(float(wind_gust), 1)})" except (ValueError, TypeError): pass @@ -177,8 +189,12 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]] wind_dir = current.get("wind_dir") if wind_dir is not None: wind_dir_ru = _wind_dir_to_russian(wind_dir) - if wind != "—": - wind = f"{wind}, {wind_dir_ru}" + wind_parts.append(wind_dir_ru) + + if wind_parts: + wind = ", ".join(wind_parts) + " м/с" + else: + wind = "— м/с" # Давление — Яндекс уже возвращает в мм рт. ст. pressure_mm = current.get("pressure") @@ -194,7 +210,7 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]] f"Температура: {temp}°C (ощущается как {feels_like}°C)", f"Описание: {description}", f"Влажность: {humidity}%", - f"Ветер: {wind} м/с" if not wind_dir or wind_dir in ("—", None) else f"Ветер: {wind}", + f"Ветер: {wind}", f"Давление: {pressure_mm} мм рт. ст.", ]