From 71bcd667943e62c88345ca6b1318f794c5391a85 Mon Sep 17 00:00:00 2001 From: deadzilla Date: Mon, 1 Jun 2026 16:32:02 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D1=8E=20Sprint=201=20=E2=80=94=20=D0=B2=D1=81=D0=B5=203?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20=D0=B2=D1=8B=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Задачи Sprint 1 (Critical Fixes): - [1.1] Fallback в пустом embed (!morning) — добавлена проверка has_real_data и fallback сообщение при отключении внешних API - [1.2] Обработка ошибок токена/сети в bot.run() — raise_exception=True + логирование LoginFailure, HTTPException, Exception - [1.3] Рефакторинг парсинга погоды — вынесено в format_weather_data_for_console(), убран дублирующий код из 2 файлов Изменения: • utils/morning_runner.py — добавлена проверка has_real_data после формирования description_lines • bot.py — обработчики исключений для запуска бота с детализированным логированием и пользовательскими сообщениями • utils/pogoda.py — новая функция format_weather_data_for_console() для центрального форматирования погодных данных • console_commands/pogoda.py — замена 15 строк дублирующейся логики на вызов единой функции (24→8 строк) • console_commands/morning.py — аналогичные изменения для команды morning (19→13 строк с погодой) • tests/test_morning_runner.py — +2 теста для fallback сценариев empty embed и only weather data • tests/test_bot.py — новый файл с 2 тестами на проверку кода обработки ошибок • tests/test_pogoda.py — +6 тестов для format_weather_data_for_console() Статистика тестирования: • Общее количество тестов: 200 (было 190) • Новые тесты: 10 • Все тесты проходят успешно Примечания: - Сообщения без восклицательных знаков согласно preferencem --- bot.py | 14 ++++- console_commands/morning.py | 25 ++------- console_commands/pogoda.py | 25 ++------- tests/test_bot.py | 54 ++++++++++++++++++ tests/test_morning_runner.py | 64 ++++++++++++++++++++- tests/test_pogoda.py | 105 ++++++++++++++++++++++++++++++++++- utils/morning_runner.py | 11 ++++ utils/pogoda.py | 32 +++++++++++ 8 files changed, 286 insertions(+), 44 deletions(-) create mode 100644 tests/test_bot.py diff --git a/bot.py b/bot.py index f0cb54d..95958af 100644 --- a/bot.py +++ b/bot.py @@ -131,7 +131,19 @@ if __name__ == "__main__": thread.start() try: - bot.run(token) + bot.run(token, raise_exception=True) + except discord.LoginFailure as e: + logger.critical(f"Ошибка авторизации бота: {e}", exc_info=True) + print(f"❌ Токен неверный или бот отключён. Код ошибки: {e}") + sys.exit(1) + except discord.HTTPException as e: + logger.critical(f"HTTP ошибка при подключении к Discord: {e}", exc_info=True) + print(f"❌ Сбой соединения с Discord API. Проверьте доступность сервиса.") + sys.exit(1) + except Exception as e: + logger.critical(f"Непредвиденная ошибка при запуске бота: {e}", exc_info=True) + print(f"❌ Критическая ошибка при запуске. Код ошибки: {type(e).__name__}") + sys.exit(1) except KeyboardInterrupt: print("\nОстановка бота...") stop_event.set() diff --git a/console_commands/morning.py b/console_commands/morning.py index 9a66b1c..d29a7d8 100644 --- a/console_commands/morning.py +++ b/console_commands/morning.py @@ -1,6 +1,6 @@ import asyncio -from utils.pogoda import fetch_weather, pressure_to_mmhg, translate_weather +from utils.pogoda import fetch_weather, format_weather_data_for_console from utils.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS from utils.cat import fetch_cat @@ -27,26 +27,11 @@ async def morning(stop_event, bot): # --- Погода --- if weather_data is not None: - current = weather_data.get("current_condition", [{}])[0] - if current: - temp = current.get("temp_C", "—") - feels_like = current.get("FeelsLikeC", "—") - description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) - humidity = current.get("humidity", "—") - wind_kmh = current.get("windspeedKmph", "—") - try: - wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—" - except (ValueError, TypeError): - wind = "—" - pressure_mb = current.get("pressure", "—") - pressure_mm = pressure_to_mmhg(pressure_mb) - + formatted = format_weather_data_for_console(weather_data) + if formatted: print(f"**Погода в Магнитогорске:**") - print(f"Температура: {temp}°C (ощущается как {feels_like}°C)") - print(f"Описание: {description}") - print(f"Влажность: {humidity}%") - print(f"Ветер: {wind} м/с") - print(f"Давление: {pressure_mm} мм рт. ст.") + for line in formatted: + print(line) else: print("Не удалось получить данные о погоде.") else: diff --git a/console_commands/pogoda.py b/console_commands/pogoda.py index f161ade..52b88cb 100644 --- a/console_commands/pogoda.py +++ b/console_commands/pogoda.py @@ -1,4 +1,4 @@ -from utils.pogoda import fetch_weather, fetch_open_meteo, wmo_to_russian, translate_weather, pressure_to_mmhg +from utils.pogoda import fetch_weather, format_weather_data_for_console async def pogoda(stop_event, bot): @@ -10,25 +10,10 @@ async def pogoda(stop_event, bot): print("Не удалось получить данные о погоде.") return - current = data.get("current_condition", [{}])[0] - if not current: + formatted = format_weather_data_for_console(data) + if not formatted: print("Не удалось получить данные о погоде.") return - temp = current.get("temp_C", "—") - feels_like = current.get("FeelsLikeC", "—") - description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) - humidity = current.get("humidity", "—") - wind_kmh = current.get("windspeedKmph", "—") - try: - wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—" - except (ValueError, TypeError): - wind = "—" - pressure_mb = current.get("pressure", "—") - pressure_mm = pressure_to_mmhg(pressure_mb) - - print(f"Температура: {temp}°C (ощущается как {feels_like}°C)") - print(f"Описание: {description}") - print(f"Влажность: {humidity}%") - print(f"Ветер: {wind} м/с") - print(f"Давление: {pressure_mm} мм рт. ст.") + for line in formatted: + print(line) diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..f3ad056 --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,54 @@ +""" +Тесты для bot.py — проверка обработки ошибок запуска бота. + +Покрывают пункт 1.2 из PLAN_OF_WORKS.md: +- raise_exception=True в bot.run() +- Логирование и обработка исключений (LoginFailure, HTTPException) +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Добавляем корень проекта в путь импорта +ROOT_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT_DIR)) + + +class TestBotInit: + """Тесты для инициализации бота.""" + + def test_bot_created_with_default_prefix(self): + """Проверка, что бот создан с правильным префиксом команд.""" + import bot + + assert bot.bot.command_prefix == "!", ( + f"Команда должна быть с префиксом '!', а не '{bot.bot.command_prefix}'" + ) + + +class TestBotErrorHandlingCodeExists: + """Тесты для проверки наличия кода обработки ошибок в bot.py.""" + + def test_error_handling_code_exists(self): + """Проверка, что код обработки ошибок существует в файле bot.py.""" + with open(ROOT_DIR / "bot.py", encoding="utf-8") as f: + content = f.read() + + # Проверяем наличие raise_exception=True + assert "raise_exception=True" in content, ( + "В bot.py должен присутствовать параметр raise_exception=True" + ) + + # Проверяем наличие обработки LoginFailure + assert "LoginFailure" in content, "В bot.py должна быть обработка LoginFailure" + + # Проверяем наличие обработки HTTPException + assert "HTTPException" in content, "В bot.py должна быть обработка HTTPException" + + # Проверяем наличие логирования ошибок + assert "logger.critical" in content, "В bot.py должно быть критическое логирование" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_morning_runner.py b/tests/test_morning_runner.py index 974526c..2f6d79f 100644 --- a/tests/test_morning_runner.py +++ b/tests/test_morning_runner.py @@ -57,7 +57,7 @@ class TestSchedulerCalculateNextRun: next_run = scheduler._calculate_next_run() assert next_run == datetime(2026, 5, 29, 14, 0, 0) - def test_next_run_tomorrow_after_time(self): + def test_next_run_tomorning_after_time(self): """Если сейчас позже времени — вернуть завтра.""" bot = AsyncMock() scheduler = Scheduler(bot, "14:00") @@ -156,3 +156,65 @@ class TestRunMorning: call_args = channel.send.call_args[1] assert "embed" in call_args assert call_args["embed"] is not None + + +class TestRunMorningWithFallback: + """Тесты fallback в пустом embed.""" + + @pytest.mark.asyncio + async def test_run_morning_empty_embed_fallback(self): + """run_morning должен добавлять fallback сообщение при пустых данных.""" + bot = AsyncMock() + channel = AsyncMock() + channel.name = "test-channel" + channel.guild.me = MagicMock() + channel.permissions_for.return_value.send_messages = True + + # Все API возвращают None/пусто + with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=None)), \ + patch("utils.morning_runner.fetch_rss", new=AsyncMock(return_value=None)), \ + patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)), \ + patch("utils.morning_runner.discord.Embed") as mock_embed_class: + + embed_mock = AsyncMock() + mock_embed_class.return_value = embed_mock + + await run_morning(bot, channel) + + # Убедимся, что send был вызван + channel.send.assert_called_once() + call_args = channel.send.call_args[1] + assert "embed" in call_args + + # Проверяем, что description содержит fallback сообщение + embed_description = call_args["embed"].description + assert "Не удалось получить данные из внешних источников" in embed_description + + @pytest.mark.asyncio + async def test_run_morning_only_weather_data(self): + """run_morning должен корректно обрабатывать только погоду без новостей.""" + bot = AsyncMock() + channel = AsyncMock() + channel.name = "test-channel" + channel.guild.me = MagicMock() + channel.permissions_for.return_value.send_messages = True + + weather_data = { + "current_condition": [ + {"temp_C": "20", "FeelsLikeC": "22", "weatherDesc": [{"value": "Clear"}], "humidity": "50", "windspeedKmph": "10", "pressure": "1013"} + ] + } + + with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \ + patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[None, None])), \ + patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value=None)): + await run_morning(bot, channel) + + channel.send.assert_called_once() + call_args = channel.send.call_args[1] + assert "embed" in call_args + + # Проверяем, что в embed есть только погода и нет fallback сообщения + embed_description = call_args["embed"].description + assert "Погода в Магнитогорске" in embed_description + assert "Не удалось получить данные из внешних источников" not in embed_description diff --git a/tests/test_pogoda.py b/tests/test_pogoda.py index 96f575e..8043e27 100644 --- a/tests/test_pogoda.py +++ b/tests/test_pogoda.py @@ -1,9 +1,110 @@ import pytest -from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian +from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian, format_weather_data_for_console + + +class TestFormatWeatherDataForConsole: + """Тесты функции format_weather_data_for_console().""" + + def test_format_valid_data(self): + """Полные данные должны быть отформатированы корректно.""" + data = { + "current_condition": [{ + "temp_C": "25", + "FeelsLikeC": "26", + "weatherDesc": [{"value": "Clear"}], + "humidity": "45", + "windspeedKmph": "10", + "pressure": "1013", + }] + } + + result = format_weather_data_for_console(data) + + assert isinstance(result, list), "Результат должен быть списком строк" + assert len(result) == 5, "Должно быть 5 полей погоды" + assert "Температура: 25°C (ощущается как 26°C)" in result[0] + assert "Описание: Ясно" in result[1] + assert "Влажность: 45%" in result[2] + assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8 + assert "Давление: 759.8 мм рт. ст." in result[4] + + def test_format_empty_data(self): + """Пустые данные должны возвращать None.""" + data = { + "current_condition": [{}] + } + + result = format_weather_data_for_console(data) + + assert result is None, "Пустые данные должны возвращать None" + + def test_format_missing_current_condition(self): + """Отсутствие current_condition должно вернуть None.""" + data = {} + + result = format_weather_data_for_console(data) + + assert result is None, "Отсутствие current_condition должно вернуть None" + + def test_format_with_dashes(self): + """Неизвестные значения должны отображаться как '—'.""" + data = { + "current_condition": [{ + "temp_C": "—", + "FeelsLikeC": "—", + "weatherDesc": [{"value": "—"}], + "humidity": "—", + "windspeedKmph": "—", + "pressure": "—", + }] + } + + result = format_weather_data_for_console(data) + + assert isinstance(result, list), "Результат должен быть списком строк" + assert "Температура: —°C (ощущается как —°C)" in result[0] + assert "Описание: —" in result[1] + assert "Влажность: —%" in result[2] + assert "Ветер: — м/с" in result[3] + assert "Давление: — мм рт. ст." in result[4] + + def test_format_wind_conversion(self): + """Проверка конвертации ветра из км/ч в м/с.""" + data = { + "current_condition": [{ + "temp_C": "20", + "FeelsLikeC": "19", + "weatherDesc": [{"value": "Cloudy"}], + "humidity": "60", + "windspeedKmph": "36", + "pressure": "1000", + }] + } + + result = format_weather_data_for_console(data) + # 36 / 3.6 = 10.0 + assert "Ветер: 10.0 м/с" in result[3] + + def test_format_negative_temperature(self): + """Отрицательная температура должна отображаться корректно.""" + data = { + "current_condition": [{ + "temp_C": "-5", + "FeelsLikeC": "-10", + "weatherDesc": [{"value": "Snow"}], + "humidity": "80", + "windspeedKmph": "20", + "pressure": "980", + }] + } + + result = format_weather_data_for_console(data) + + assert isinstance(result, list), "Результат должен быть списком строк" + assert "Температура: -5°C (ощущается как -10°C)" in result[0] class TestTranslateWeather: - """Тесты функции translate_weather() — перевод описания погоды из английского в русский.""" @pytest.mark.parametrize( "english, expected", diff --git a/utils/morning_runner.py b/utils/morning_runner.py index bc6af1c..2cbe3c9 100644 --- a/utils/morning_runner.py +++ b/utils/morning_runner.py @@ -37,11 +37,13 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel): embed.set_thumbnail(url=cat_url) description_lines = [] + has_real_data = False # --- Погода --- if weather_data is not None: current = weather_data.get("current_condition", [{}])[0] if current: + has_real_data = True temp = current.get("temp_C", "—") feels_like = current.get("FeelsLikeC", "—") description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) @@ -72,6 +74,7 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel): # --- Новости: статьи --- if articles is not None: if articles: + has_real_data = True lines = format_articles(articles, "Лучшие статьи за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/") @@ -86,6 +89,7 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel): # --- Новости: посты --- if posts is not None: if posts: + has_real_data = True lines = format_articles(posts, "Лучшие новости за сутки / Искусственный интеллект / Хабr", "https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/") @@ -95,6 +99,13 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel): else: description_lines.append("Не удалось получить новости.") + # Fallback для пустых данных + if not has_real_data: + description_lines = [ + "Не удалось получить данные из внешних источников.", + "Проверьте доступность API и повторите попытку позже." + ] + embed.description = "\n".join(description_lines) await channel.send(embed=embed) logger.info("✅ Утренний дайджест отправлен в #%s", channel.name) diff --git a/utils/pogoda.py b/utils/pogoda.py index 020614a..c1ee894 100644 --- a/utils/pogoda.py +++ b/utils/pogoda.py @@ -136,6 +136,38 @@ def translate_weather(en): return en +def format_weather_data_for_console(data): + """ + Форматировать погодные данные для консольного вывода. + + :param data: Ответ от API (dict) + :return: Строки с отформатированной погодой + """ + current = data.get("current_condition", [{}])[0] + if not current: + return None + + temp = current.get("temp_C", "—") + feels_like = current.get("FeelsLikeC", "—") + description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "—")) + humidity = current.get("humidity", "—") + wind_kmh = current.get("windspeedKmph", "—") + try: + wind = round(int(wind_kmh) / 3.6, 1) if wind_kmh != "—" else "—" + except (ValueError, TypeError): + wind = "—" + pressure_mb = current.get("pressure", "—") + pressure_mm = pressure_to_mmhg(pressure_mb) + + return [ + f"Температура: {temp}°C (ощущается как {feels_like}°C)", + f"Описание: {description}", + f"Влажность: {humidity}%", + f"Ветер: {wind} м/с", + f"Давление: {pressure_mm} мм рт. ст.", + ] + + def pressure_to_mmhg(mb): if mb == "—" or not mb: return "—"