fix(weather): исправлены баги в погодном функционале

- JSONDecodeError теперь ловится в fetch_weather (был краш при невалидном JSON от API)
- Порывы ветра больше не теряются при отсутствии base wind (gust fallback)
- Устранено дублирование 'м/с' при порывах + отсутствии направления ветра
- Переименован windspeedKmph → wind_speed_mps (ключ хранил м/с, а не км/ч)
- Обновлены тесты, добавлены 6 новых (gust-only, no-duplication, JSONDecodeError)
This commit is contained in:
deadzilla 2026-07-20 23:56:07 +05:00
parent 517bb4b9fa
commit b8b1801f23
4 changed files with 173 additions and 41 deletions

View File

@ -27,7 +27,7 @@ class TestPgCommand:
def _make_weather_data(self, **extra): def _make_weather_data(self, **extra):
"""Создать mock weather data (Яндекс Погода формат). """Создать mock weather data (Яндекс Погода формат).
windspeedKmph м/с от Яндекса. wind_speed_mps скорость ветра в м/с от Яндекса.
wind_gust порывы ветра в м/с. wind_gust порывы ветра в м/с.
wind_dir направление ветра. wind_dir направление ветра.
pressure уже в мм рт. ст. pressure уже в мм рт. ст.
@ -40,7 +40,7 @@ class TestPgCommand:
"FeelsLikeC": 24, "FeelsLikeC": 24,
"weatherDesc": [{"value": "Ясно"}], "weatherDesc": [{"value": "Ясно"}],
"humidity": 45, "humidity": 45,
"windspeedKmph": 5.0, # м/с "wind_speed_mps": 5.0, # м/с
"wind_gust": 8.0, "wind_gust": 8.0,
"wind_dir": "n", "wind_dir": "n",
"pressure": 735.0, "pressure": 735.0,
@ -66,7 +66,7 @@ class TestPgCommand:
assert "(ощущается как 24°C)" in args assert "(ощущается как 24°C)" in args
assert "Описание: Ясно" in args assert "Описание: Ясно" in args
assert "Влажность: 45%" in args assert "Влажность: 45%" in args
assert "Ветер: 5.0 м/с" in args assert "Ветер: 5.0 (порывы 8.0), северный м/с" in args
assert "Давление: 735.0 мм рт. ст." in args assert "Давление: 735.0 мм рт. ст." in args
@pytest.mark.asyncio @pytest.mark.asyncio
@ -94,7 +94,7 @@ class TestPgCommand:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_current_condition_none(self) -> None: async def test_pg_current_condition_none(self) -> None:
"""current_condition — пустой dict — бот должен сообщить об ошибке.""" """current_condition — пустой dict — бот сообщает об ошибке (empty dict is falsy)."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = {"current_condition": [{}]} weather = {"current_condition": [{}]}
@ -106,23 +106,26 @@ class TestPgCommand:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_wind_non_numeric(self) -> None: async def test_pg_wind_non_numeric(self) -> None:
"""windspeedKmph — не число — wind должен быть ''.""" """wind_speed_mps — не число — показываются порывы ветра (gust fallback)."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() 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)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: — м/с" in args # base wind невалиден → показываем порывы
assert "Ветер: порывы 8.0, северный м/с" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_wind_none(self) -> None: async def test_pg_wind_none(self) -> None:
"""windspeedKmph отсутствует — wind должен быть ''.""" """wind_speed_mps отсутствует, порывов нет — wind должен быть ' м/с'."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() 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)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
@ -132,16 +135,16 @@ class TestPgCommand:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_zero_wind(self) -> None: async def test_pg_zero_wind(self) -> None:
"""windspeedKmph = 0 — wind должен быть 0.0.""" """wind_speed_mps = 0 — 0.0 + порывы + направление."""
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() 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)): with patch("commands.pg.fetch_weather", new=AsyncMock(return_value=weather)):
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: 0.0 м/с" in args assert "Ветер: 0.0 (порывы 8.0), северный м/с" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_default_values(self) -> None: async def test_pg_default_values(self) -> None:
@ -188,7 +191,7 @@ class TestPgCommand:
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data( weather = self._make_weather_data(
windspeedKmph=3.0, wind_speed_mps=3.0,
wind_gust=10.5, wind_gust=10.5,
wind_dir="n", wind_dir="n",
) )
@ -197,7 +200,7 @@ class TestPgCommand:
await cog.pg.callback(cog, ctx) await cog.pg.callback(cog, ctx)
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Ветер: 3.0 м/с (порывы 10.5 м/с), северный" in args assert "Ветер: 3.0 (порывы 10.5), северный м/с" in args
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pg_with_wind_direction(self) -> None: async def test_pg_with_wind_direction(self) -> None:
@ -205,7 +208,7 @@ class TestPgCommand:
cog = self._make_cog() cog = self._make_cog()
ctx = self._make_ctx() ctx = self._make_ctx()
weather = self._make_weather_data( weather = self._make_weather_data(
windspeedKmph=5.0, wind_speed_mps=5.0,
wind_gust=8.0, wind_gust=8.0,
wind_dir="se", wind_dir="se",
) )
@ -225,7 +228,7 @@ class TestPgCommand:
temp_C=17.3, temp_C=17.3,
FeelsLikeC=16.8, FeelsLikeC=16.8,
humidity=87.5, humidity=87.5,
windspeedKmph=2.1, # м/с wind_speed_mps=2.1, # м/с
wind_gust=5.5, wind_gust=5.5,
wind_dir="n", wind_dir="n",
pressure=750.1, pressure=750.1,
@ -236,5 +239,41 @@ class TestPgCommand:
args = ctx.send.call_args[0][0] args = ctx.send.call_args[0][0]
assert "Температура: 17.3°C" in args assert "Температура: 17.3°C" in args
assert "Ветер: 2.1 м/с" in args assert "Ветер: 2.1 (порывы 5.5), северный м/с" in args
assert "Давление: 750.1 мм рт. ст." 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

View File

@ -229,7 +229,7 @@ class TestFetchWeather:
result = await fetch_weather() result = await fetch_weather()
assert result is not None 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 assert result["current_condition"][0]["pressure"] == 750.0
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
@ -285,3 +285,17 @@ class TestFetchWeather:
call_kwargs = mock_get.call_args[1] call_kwargs = mock_get.call_args[1]
assert "X-Yandex-API-Key" in call_kwargs["headers"] assert "X-Yandex-API-Key" in call_kwargs["headers"]
assert call_kwargs["headers"]["X-Yandex-API-Key"] == "test-key" 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

View File

@ -20,7 +20,7 @@ class TestFormatWeatherDataForConsole:
"FeelsLikeC": 26, "FeelsLikeC": 26,
"weatherDesc": [{"value": "Ясно"}], "weatherDesc": [{"value": "Ясно"}],
"humidity": 45, "humidity": 45,
"windspeedKmph": 5.0, # м/с от Яндекса "wind_speed_mps": 5.0, # м/с от Яндекса
"wind_gust": 8.0, "wind_gust": 8.0,
"wind_dir": "n", "wind_dir": "n",
"pressure": 735.0, # уже в мм рт. ст. "pressure": 735.0, # уже в мм рт. ст.
@ -35,7 +35,7 @@ class TestFormatWeatherDataForConsole:
assert "Температура: 25°C (ощущается как 26°C)" in result[0] assert "Температура: 25°C (ощущается как 26°C)" in result[0]
assert "Описание: Ясно" in result[1] assert "Описание: Ясно" in result[1]
assert "Влажность: 45%" in result[2] 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] assert "Давление: 735.0 мм рт. ст." in result[4]
def test_format_empty_data(self) -> None: def test_format_empty_data(self) -> None:
@ -63,7 +63,8 @@ class TestFormatWeatherDataForConsole:
"FeelsLikeC": None, "FeelsLikeC": None,
"weatherDesc": [{"value": "Неизвестно"}], "weatherDesc": [{"value": "Неизвестно"}],
"humidity": None, "humidity": None,
"windspeedKmph": None, "wind_speed_mps": None,
"wind_gust": None,
"pressure": None, "pressure": None,
} }
] ]
@ -87,7 +88,7 @@ class TestFormatWeatherDataForConsole:
"FeelsLikeC": -10, "FeelsLikeC": -10,
"weatherDesc": [{"value": "Снег"}], "weatherDesc": [{"value": "Снег"}],
"humidity": 80, "humidity": 80,
"windspeedKmph": 5, "wind_speed_mps": 5,
"wind_gust": 10, "wind_gust": 10,
"wind_dir": "n", "wind_dir": "n",
"pressure": 720.0, "pressure": 720.0,
@ -109,7 +110,7 @@ class TestFormatWeatherDataForConsole:
"FeelsLikeC": 16.8, "FeelsLikeC": 16.8,
"weatherDesc": [{"value": "Облачно"}], "weatherDesc": [{"value": "Облачно"}],
"humidity": 87.5, "humidity": 87.5,
"windspeedKmph": 2.1, # м/с "wind_speed_mps": 2.1, # м/с
"pressure": 750.0, "pressure": 750.0,
} }
] ]
@ -140,7 +141,7 @@ class TestFormatWeatherDataForConsole:
"FeelsLikeC": 18, "FeelsLikeC": 18,
"weatherDesc": [{"value": "Ясно"}], "weatherDesc": [{"value": "Ясно"}],
"humidity": 50, "humidity": 50,
"windspeedKmph": 3.0, "wind_speed_mps": 3.0,
"wind_dir": code, "wind_dir": code,
"pressure": 750.0, "pressure": 750.0,
} }
@ -149,6 +150,68 @@ class TestFormatWeatherDataForConsole:
result = format_weather_data_for_console(data) result = format_weather_data_for_console(data)
assert expected in result[3], f"Направление {code}{expected} не найдено" 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: class TestPressureToMMHG:
"""Тесты функции pressure_to_mmhg() — конвертация давления из мб в мм рт. ст.""" """Тесты функции pressure_to_mmhg() — конвертация давления из мб в мм рт. ст."""

View File

@ -7,6 +7,7 @@ https://yandex.ru/dev/weather/
""" """
import asyncio import asyncio
import json
import logging import logging
import os import os
from typing import Optional from typing import Optional
@ -66,7 +67,7 @@ async def fetch_weather(
"FeelsLikeC": fact.get("feels_like"), "FeelsLikeC": fact.get("feels_like"),
"weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}], "weatherDesc": [{"value": yandex_condition_to_russian(fact.get("condition"))}],
"humidity": fact.get("humidity"), "humidity": fact.get("humidity"),
"windspeedKmph": fact.get("wind_speed"), "wind_speed_mps": fact.get("wind_speed"),
"wind_gust": fact.get("wind_gust"), "wind_gust": fact.get("wind_gust"),
"wind_dir": fact.get("wind_dir"), "wind_dir": fact.get("wind_dir"),
"pressure": fact.get("pressure_mm"), "pressure": fact.get("pressure_mm"),
@ -83,7 +84,7 @@ async def fetch_weather(
await asyncio.sleep(delay) await asyncio.sleep(delay)
continue continue
break break
except requests.RequestException as e: except (requests.RequestException, json.JSONDecodeError, ValueError) as e:
logger.error("Ошибка Яндекс Погоды: %s", e) logger.error("Ошибка Яндекс Погоды: %s", e)
break break
@ -153,23 +154,34 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
if humidity is None: if humidity is None:
humidity = "" humidity = ""
# Яндекс возвращает ветер в м/с, но для совместимости с унифицированным # Скорость ветра (м/с)
# форматом WindspeedKmph содержит м/с — конвертируем обратно в м/с для вывода wind_mps = current.get("wind_speed_mps")
wind_mps = current.get("windspeedKmph") wind_value = None
if wind_mps is None: if wind_mps is not None:
wind = ""
else:
try: try:
# windspeedKmph на самом деле хранит м/с от Яндекса wind_value = round(float(wind_mps), 1)
wind = round(float(wind_mps), 1)
except (ValueError, TypeError): 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") wind_gust = current.get("wind_gust")
if wind_gust is not None: if wind_gust is not None:
try: try:
wind = f"{wind} м/с (порывы {round(float(wind_gust), 1)} м/с)" 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 and wind_value is not None:
try:
wind_parts[-1] = f"{wind_parts[-1]} (порывы {round(float(wind_gust), 1)})"
except (ValueError, TypeError): except (ValueError, TypeError):
pass pass
@ -177,8 +189,12 @@ def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]
wind_dir = current.get("wind_dir") wind_dir = current.get("wind_dir")
if wind_dir is not None: if wind_dir is not None:
wind_dir_ru = _wind_dir_to_russian(wind_dir) wind_dir_ru = _wind_dir_to_russian(wind_dir)
if wind != "": wind_parts.append(wind_dir_ru)
wind = f"{wind}, {wind_dir_ru}"
if wind_parts:
wind = ", ".join(wind_parts) + " м/с"
else:
wind = "— м/с"
# Давление — Яндекс уже возвращает в мм рт. ст. # Давление — Яндекс уже возвращает в мм рт. ст.
pressure_mm = current.get("pressure") 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"Температура: {temp}°C (ощущается как {feels_like}°C)",
f"Описание: {description}", f"Описание: {description}",
f"Влажность: {humidity}%", f"Влажность: {humidity}%",
f"Ветер: {wind} м/с" if not wind_dir or wind_dir in ("", None) else f"Ветер: {wind}", f"Ветер: {wind}",
f"Давление: {pressure_mm} мм рт. ст.", f"Давление: {pressure_mm} мм рт. ст.",
] ]