- Scheduler больше не опрашивает каждую секунду (tasks.loop мог пропустить момент срабатывания из-за неточности тайминга). Теперь спит ровно до целевого времени через asyncio.sleep, затем запускает morning и повторяет. - Добавлено логирование ожидания и срабатывания расписания. - Перенес инициализацию планировщика из on_ready в on_guild_available — гарантирует, что кэш гильдии загружен до запуска Scheduler. - Обновлены тесты под новый Scheduler (asyncio.create_task вместо tasks.loop). - README.md: исправлены пути admin.py → console_commands/admin.py для docker exec. - docker-compose.yml: добавлены переменные LOG_LEVEL и rate-limit конфиги. - console_commands/__init__.py: переименованы help → console_help, reload → reload_cogs (избежал конфликта с built-in и уточнил имена).
193 lines
8.1 KiB
Python
193 lines
8.1 KiB
Python
"""Тесты для utils/morning_runner.py — Scheduler и run_morning."""
|
||
|
||
import asyncio
|
||
from datetime import date, datetime
|
||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||
|
||
import discord
|
||
import pytest
|
||
|
||
from utils.morning_runner import Scheduler, run_morning
|
||
|
||
|
||
class TestSchedulerInit:
|
||
"""Тесты инициализации Scheduler."""
|
||
|
||
def test_init_sets_morning_time(self):
|
||
"""Инициализация должна устанавливать время."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task") as mock_task:
|
||
scheduler = Scheduler(bot, "08:30")
|
||
assert scheduler.morning_time == "08:30"
|
||
|
||
def test_init_default_morning_time(self):
|
||
"""Инициализация с дефолтным временем."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task") as mock_task:
|
||
scheduler = Scheduler(bot)
|
||
assert scheduler.morning_time == "07:00"
|
||
|
||
def test_init_creates_task(self):
|
||
"""Инициализация должна создавать asyncio.Task."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task") as mock_task:
|
||
scheduler = Scheduler(bot)
|
||
mock_task.assert_called_once()
|
||
assert scheduler._task is not None
|
||
|
||
|
||
class TestSchedulerCalculateNextRun:
|
||
"""Тесты расчёта следующего запуска."""
|
||
|
||
def test_next_run_today_before_time(self):
|
||
"""Если сейчас раньше времени — вернуть сегодня."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task"):
|
||
scheduler = Scheduler(bot, "14:00")
|
||
|
||
now = datetime(2026, 5, 29, 10, 0, 0)
|
||
next_run = scheduler._calculate_next_run(now)
|
||
assert next_run == datetime(2026, 5, 29, 14, 0, 0)
|
||
|
||
def test_next_run_tomorrow_after_time(self):
|
||
"""Если сейчас позже времени — вернуть завтра."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task"):
|
||
scheduler = Scheduler(bot, "14:00")
|
||
|
||
now = datetime(2026, 5, 29, 15, 0, 0)
|
||
next_run = scheduler._calculate_next_run(now)
|
||
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
||
|
||
def test_next_run_exact_time(self):
|
||
"""Если сейчас ровно время — вернуть завтра."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task"):
|
||
scheduler = Scheduler(bot, "14:00")
|
||
|
||
now = datetime(2026, 5, 29, 14, 0, 0)
|
||
next_run = scheduler._calculate_next_run(now)
|
||
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
||
|
||
|
||
class TestSchedulerStartStop:
|
||
"""Тесты запуска/остановки планировщика."""
|
||
|
||
def test_start_starts_task(self):
|
||
"""start() должен запустить task."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task"):
|
||
scheduler = Scheduler(bot)
|
||
assert scheduler._running is True
|
||
|
||
def test_stop_stops_task(self):
|
||
"""stop() должен остановить task."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task"):
|
||
scheduler = Scheduler(bot)
|
||
scheduler.stop()
|
||
assert scheduler._running is False
|
||
|
||
def test_double_start_no_duplicate(self):
|
||
"""Повторный start не должен создавать второй task."""
|
||
bot = AsyncMock()
|
||
with patch("asyncio.create_task") as mock_task:
|
||
scheduler = Scheduler(bot)
|
||
scheduler.start() # второй вызов
|
||
# create_task вызван только при инициализации
|
||
assert mock_task.call_count == 1
|
||
|
||
|
||
class TestRunMorning:
|
||
"""Тесты run_morning."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_run_morning_sends_embed(self):
|
||
"""run_morning должен отправлять embed в канал."""
|
||
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"}
|
||
]
|
||
}
|
||
articles = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
|
||
posts = [{"title": "Test", "link": "http://test.com", "pub_date": "Mon, 01 Jan 2026 00:00:00 GMT", "creator": "", "tags": []}]
|
||
|
||
with patch("utils.morning_runner.fetch_weather", new=AsyncMock(return_value=weather_data)), \
|
||
patch("utils.morning_runner.fetch_rss", new=AsyncMock(side_effect=[articles, posts])), \
|
||
patch("utils.morning_runner.fetch_cat", new=AsyncMock(return_value="http://cat.jpg")), \
|
||
patch("utils.morning_runner.discord.Embed") as mock_embed:
|
||
await run_morning(bot, channel)
|
||
|
||
channel.send.assert_called_once()
|
||
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
|