feat: закрываю Sprint 1 — все 3 задачи выполнены

Задачи 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
This commit is contained in:
deadzilla 2026-06-01 16:32:02 +05:00
parent 1a7f2d75b8
commit 71bcd66794
8 changed files with 286 additions and 44 deletions

14
bot.py
View File

@ -131,7 +131,19 @@ if __name__ == "__main__":
thread.start() thread.start()
try: 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: except KeyboardInterrupt:
print("\nОстановка бота...") print("\nОстановка бота...")
stop_event.set() stop_event.set()

View File

@ -1,6 +1,6 @@
import asyncio 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.news import fetch_rss, format_articles, RSS_URL_ARTICLES, RSS_URL_POSTS
from utils.cat import fetch_cat from utils.cat import fetch_cat
@ -27,26 +27,11 @@ async def morning(stop_event, bot):
# --- Погода --- # --- Погода ---
if weather_data is not None: if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0] formatted = format_weather_data_for_console(weather_data)
if current: if formatted:
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"**Погода в Магнитогорске:**") print(f"**Погода в Магнитогорске:**")
print(f"Температура: {temp}°C (ощущается как {feels_like}°C)") for line in formatted:
print(f"Описание: {description}") print(line)
print(f"Влажность: {humidity}%")
print(f"Ветер: {wind} м/с")
print(f"Давление: {pressure_mm} мм рт. ст.")
else: else:
print("Не удалось получить данные о погоде.") print("Не удалось получить данные о погоде.")
else: else:

View File

@ -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): async def pogoda(stop_event, bot):
@ -10,25 +10,10 @@ async def pogoda(stop_event, bot):
print("Не удалось получить данные о погоде.") print("Не удалось получить данные о погоде.")
return return
current = data.get("current_condition", [{}])[0] formatted = format_weather_data_for_console(data)
if not current: if not formatted:
print("Не удалось получить данные о погоде.") print("Не удалось получить данные о погоде.")
return return
temp = current.get("temp_C", "") for line in formatted:
feels_like = current.get("FeelsLikeC", "") print(line)
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} мм рт. ст.")

54
tests/test_bot.py Normal file
View File

@ -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"])

View File

@ -57,7 +57,7 @@ class TestSchedulerCalculateNextRun:
next_run = scheduler._calculate_next_run() next_run = scheduler._calculate_next_run()
assert next_run == datetime(2026, 5, 29, 14, 0, 0) 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() bot = AsyncMock()
scheduler = Scheduler(bot, "14:00") scheduler = Scheduler(bot, "14:00")
@ -156,3 +156,65 @@ class TestRunMorning:
call_args = channel.send.call_args[1] call_args = channel.send.call_args[1]
assert "embed" in call_args assert "embed" in call_args
assert call_args["embed"] is not None 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

View File

@ -1,9 +1,110 @@
import pytest 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: class TestTranslateWeather:
"""Тесты функции translate_weather() — перевод описания погоды из английского в русский."""
@pytest.mark.parametrize( @pytest.mark.parametrize(
"english, expected", "english, expected",

View File

@ -37,11 +37,13 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel):
embed.set_thumbnail(url=cat_url) embed.set_thumbnail(url=cat_url)
description_lines = [] description_lines = []
has_real_data = False
# --- Погода --- # --- Погода ---
if weather_data is not None: if weather_data is not None:
current = weather_data.get("current_condition", [{}])[0] current = weather_data.get("current_condition", [{}])[0]
if current: if current:
has_real_data = True
temp = current.get("temp_C", "") temp = current.get("temp_C", "")
feels_like = current.get("FeelsLikeC", "") feels_like = current.get("FeelsLikeC", "")
description = translate_weather(current.get("weatherDesc", [{}])[0].get("value", "")) 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 is not None:
if articles: if articles:
has_real_data = True
lines = format_articles(articles, lines = format_articles(articles,
"Лучшие статьи за сутки / Искусственный интеллект / Хабr", "Лучшие статьи за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/articles/top/daily/") "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 is not None:
if posts: if posts:
has_real_data = True
lines = format_articles(posts, lines = format_articles(posts,
"Лучшие новости за сутки / Искусственный интеллект / Хабr", "Лучшие новости за сутки / Искусственный интеллект / Хабr",
"https://habr.com/ru/hubs/artificial_intelligence/news/top/daily/") "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: else:
description_lines.append("Не удалось получить новости.") description_lines.append("Не удалось получить новости.")
# Fallback для пустых данных
if not has_real_data:
description_lines = [
"Не удалось получить данные из внешних источников.",
"Проверьте доступность API и повторите попытку позже."
]
embed.description = "\n".join(description_lines) embed.description = "\n".join(description_lines)
await channel.send(embed=embed) await channel.send(embed=embed)
logger.info("✅ Утренний дайджест отправлен в #%s", channel.name) logger.info("✅ Утренний дайджест отправлен в #%s", channel.name)

View File

@ -136,6 +136,38 @@ def translate_weather(en):
return 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): def pressure_to_mmhg(mb):
if mb == "" or not mb: if mb == "" or not mb:
return "" return ""