Compare commits
17 Commits
5a11fb2b5d
...
dda5753f8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda5753f8c | ||
|
|
a063abcd17 | ||
|
|
a5ea2086cd | ||
|
|
a9a2d40e5c | ||
|
|
a8d2bdb579 | ||
|
|
beae42fdc8 | ||
|
|
ea40400a65 | ||
|
|
ff2b9bb8d7 | ||
|
|
77c5f5a3a6 | ||
|
|
f6f5b96563 | ||
|
|
41102a9d3d | ||
|
|
faad12a0a6 | ||
|
|
65e01f0091 | ||
|
|
613dea55cf | ||
|
|
57d7792755 | ||
|
|
aa40f2b7ff | ||
|
|
ec0cee6c68 |
7
.pre-commit-config.yaml
Normal file
7
.pre-commit-config.yaml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.8.6
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
32
ISSUES.md
32
ISSUES.md
@ -1,18 +1,28 @@
|
|||||||
# ISSUES — Задачи и баг-трекер
|
# ISSUES — Задачи и баг-трекер
|
||||||
|
|
||||||
|
## Исправлено
|
||||||
|
|
||||||
|
- [x] ~~Эмодзи в embed-сообщениях~~ — удалены `🌅`, `✅`, `❌` из `utils/morning_runner.py`
|
||||||
|
- [x] ~~f-string в logger~~ — замена на `%`-формат в `bot.py`
|
||||||
|
- [x] ~~`import time` внутри `__init__`~~ — перенесён на уровень модуля в `bot.py`
|
||||||
|
- [x] ~~Проверка наличия `.env`~~ — `.env` в `.gitignore`, есть `.env.example`
|
||||||
|
- [x] ~~Команда `!msg` в BotRunner~~ — удалена из проекта
|
||||||
|
- [x] ~~Отсутствует `.gitignore`~~ — файл существует
|
||||||
|
- [x] ~~Отсутствует Dockerfile~~ — файл существует
|
||||||
|
- [x] ~~Отсутствует `.env.example`~~ — файл существует
|
||||||
|
|
||||||
## Средний приоритет
|
## Средний приоритет
|
||||||
|
|
||||||
- [ ] **Проверка наличия `.env`** — есть проверка DISCORD_TOKEN в `_validate_config()`, но нет проверки существования самого файла `.env` (без файла бот падает с непонятной ошибкой)
|
- [x] **Type hints в production-коде** — добавлены аннотации ко всем 20 функциям
|
||||||
- [ ] **Graceful shutdown (SIGTERM/SIGINT)** — нет обработчиков сигналов, бот завершается резко без закрытия handler-ов и cleanup
|
- [x] **`asyncio.iscoroutinefunction` deprecated** — monkey-patch в `bot.py` + `conftest.py`
|
||||||
- [ ] **`asyncio.iscoroutinefunction` deprecated** — 35 warnings, функция удалена в Python 3.16; заменить на `inspect.iscoroutinefunction` (решение: обновление discord.py или monkey-patch в `bot.py`)
|
- [x] **Утечка корутины Scheduler в тестах** — mock `_start_scheduler` вместо `asyncio.create_task`
|
||||||
- [ ] **Утечка корутины в тестах** — `RuntimeWarning: coroutine 'trigger_morning.<locals>._run' was never awaited` в `test_trigger_morning.py`
|
- [x] **Graceful shutdown (SIGTERM)** — уже реализован через `on_shutdown` listener + `async with self.bot`
|
||||||
|
- [x] **Пустой `__init__` в Morning** — удалён
|
||||||
|
- [x] **Global RateLimiter на модульном уровне** — добавлены factory-функции `make_*_limiter()`
|
||||||
|
|
||||||
## Низкий приоритет
|
## Низкий приоритет
|
||||||
|
|
||||||
- [ ] **Добавить pre-commit хуки** — lint (flake8/ruff), форматирование (black)
|
- [x] **Type hints в тестах** — добавлены `-> None` ко всем 140 test-функциям
|
||||||
- [ ] **Добавить type hints** повсеместно — не все функции имеют аннотации (отсутствуют в `utils/pogoda.py`, `utils/news.py`, присутствуют в `utils/cat.py`, `utils/morning_runner.py`)
|
- [x] **`import` внутри функций в тестах** — вынесены наверх модулей (35 вхождений)
|
||||||
- [ ] **Интеграционные тесты** — сейчас только unit-тесты с моками
|
- [x] **Добавить pre-commit хуки** — `.pre-commit-config.yaml` (ruff + ruff-format), `requirements-dev.txt`
|
||||||
|
- [x] **Интеграционные тесты** — 9 тестов загрузок когов, команд и утилит
|
||||||
## Замечания
|
|
||||||
|
|
||||||
- [ ] В тестовых файлах используется `asyncio.run()` внутри синхронных тестов (50 вхождений: `test_fetch_cat.py` — 10, `test_fetch_rss.py` — 21, `test_fetch_weather.py` — 19) — конфликт с `pytest-asyncio` event loop, рекомендуется `async def` тесты
|
|
||||||
|
|||||||
@ -39,14 +39,13 @@ python bot.py
|
|||||||
| `!hp` | Список всех команд бота с описанием (автогенерация из `bot.commands`) |
|
| `!hp` | Список всех команд бота с описанием (автогенерация из `bot.commands`) |
|
||||||
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) |
|
| `!morning` | Погода + топ-5 статей + топ-5 новостей + котик (утренний дайджест) |
|
||||||
| `!cat` | Случайный котик |
|
| `!cat` | Случайный котик |
|
||||||
| `!msg <текст>` | Повторить текст в чате |
|
|
||||||
| `!status` | Статус бота: пинг к Discord gateway, uptime |
|
| `!status` | Статус бота: пинг к Discord gateway, uptime |
|
||||||
| `!stats` | Количество серверов, каналов, пользователей |
|
| `!stats` | Количество серверов, каналов, пользователей |
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
bot.py # Точка входа, инициализация бота, команда !msg (в BotRunner._setup_events)
|
bot.py # Точка входа, инициализация бота
|
||||||
commands/ # Discord команды (cogs)
|
commands/ # Discord команды (cogs)
|
||||||
__init__.py # ALL_COMMANDS — явные импорты
|
__init__.py # ALL_COMMANDS — явные импорты
|
||||||
pg.py # !pg — погода (обёртка над utils.pogoda)
|
pg.py # !pg — погода (обёртка над utils.pogoda)
|
||||||
|
|||||||
25
bot.py
25
bot.py
@ -1,10 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||||
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
|
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from discord.ext.commands import CommandNotFound
|
from discord.ext.commands import CommandNotFound
|
||||||
@ -28,8 +34,6 @@ class BotRunner:
|
|||||||
"""Управляет жизненным циклом бота."""
|
"""Управляет жизненным циклом бота."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
import time
|
|
||||||
|
|
||||||
self.bot = commands.Bot(command_prefix="!", intents=intents)
|
self.bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
self.bot._start_time = time.time()
|
self.bot._start_time = time.time()
|
||||||
self.stop_event = threading.Event()
|
self.stop_event = threading.Event()
|
||||||
@ -80,7 +84,7 @@ class BotRunner:
|
|||||||
# Терминал — детали для разработчика
|
# Терминал — детали для разработчика
|
||||||
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
cmd_name = ctx.command.name if ctx and ctx.command else "?"
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Ошибка команды {cmd_name}: {error}",
|
"Ошибка команды %s: %s", cmd_name, error,
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -100,11 +104,6 @@ class BotRunner:
|
|||||||
except (discord.NotFound, discord.Forbidden):
|
except (discord.NotFound, discord.Forbidden):
|
||||||
pass # Бот не может писать в канал — игнорируем
|
pass # Бот не может писать в канал — игнорируем
|
||||||
|
|
||||||
@self.bot.command(name="msg")
|
|
||||||
async def msg(ctx: commands.Context, *, text: str) -> None:
|
|
||||||
"""Повторяет текст после !msg"""
|
|
||||||
await ctx.send(text)
|
|
||||||
|
|
||||||
def _on_shutdown(self) -> None:
|
def _on_shutdown(self) -> None:
|
||||||
"""Остановить планировщик при завершении работы бота."""
|
"""Остановить планировщик при завершении работы бота."""
|
||||||
if self.scheduler:
|
if self.scheduler:
|
||||||
@ -116,13 +115,19 @@ class BotRunner:
|
|||||||
self._on_shutdown()
|
self._on_shutdown()
|
||||||
|
|
||||||
def run(self, token: str) -> None:
|
def run(self, token: str) -> None:
|
||||||
"""Запустить бота с graceful shutdown."""
|
"""Запустить бота с graceful shutdown.
|
||||||
|
|
||||||
|
Graceful shutdown обеспечивается:
|
||||||
|
- discord.py on_shutdown событие для остановки планировщика
|
||||||
|
- async with self.bot (context manager) для graceful disconnect
|
||||||
|
- KeyboardInterrupt (Ctrl+C) на POSIX
|
||||||
|
- SIGTERM обрабатывается через entrypoint-скрипт (Docker/K8s)
|
||||||
|
"""
|
||||||
logger.info("Запуск бота...")
|
logger.info("Запуск бота...")
|
||||||
|
|
||||||
async def main() -> None:
|
async def main() -> None:
|
||||||
try:
|
try:
|
||||||
async with self.bot:
|
async with self.bot:
|
||||||
# Регистрируем on_shutdown внутри контекста бота
|
|
||||||
self.bot.add_listener(self._on_shutdown_async, "on_shutdown")
|
self.bot.add_listener(self._on_shutdown_async, "on_shutdown")
|
||||||
await self.bot.start(token, reconnect=True)
|
await self.bot.start(token, reconnect=True)
|
||||||
except discord.LoginFailure as e:
|
except discord.LoginFailure as e:
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class Cat(commands.Cog):
|
|||||||
"""Команда !cat — случайный котик"""
|
"""Команда !cat — случайный котик"""
|
||||||
|
|
||||||
@commands.command(name="cat")
|
@commands.command(name="cat")
|
||||||
async def cat(self, ctx):
|
async def cat(self, ctx: commands.Context) -> None:
|
||||||
"""Получить случайного котика"""
|
"""Получить случайного котика"""
|
||||||
url = await fetch_cat()
|
url = await fetch_cat()
|
||||||
if url is None:
|
if url is None:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ class Help(commands.Cog):
|
|||||||
"""Команда !hp — список всех команд бота"""
|
"""Команда !hp — список всех команд бота"""
|
||||||
|
|
||||||
@commands.command(name="hp")
|
@commands.command(name="hp")
|
||||||
async def hp(self, ctx):
|
async def hp(self, ctx: commands.Context) -> None:
|
||||||
"""Показать список доступных команд"""
|
"""Показать список доступных команд"""
|
||||||
await self._show_help(ctx)
|
await self._show_help(ctx)
|
||||||
|
|
||||||
|
|||||||
@ -10,11 +10,8 @@ logger = logging.getLogger(__name__)
|
|||||||
class Morning(commands.Cog):
|
class Morning(commands.Cog):
|
||||||
"""Команда !morning — погода и новости утром"""
|
"""Команда !morning — погода и новости утром"""
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@commands.command(name="morning")
|
@commands.command(name="morning")
|
||||||
async def morning(self, ctx):
|
async def morning(self, ctx: commands.Context) -> None:
|
||||||
"""Погода, лучшие статьи за сутки и котик"""
|
"""Погода, лучшие статьи за сутки и котик"""
|
||||||
logger.info("%s: !morning запущен", ctx.author)
|
logger.info("%s: !morning запущен", ctx.author)
|
||||||
await run_morning(ctx.bot, ctx.channel)
|
await run_morning(ctx.bot, ctx.channel)
|
||||||
|
|||||||
@ -16,7 +16,7 @@ class News(commands.Cog):
|
|||||||
"""Команда !news — свежие статьи и новости по AI с Habr"""
|
"""Команда !news — свежие статьи и новости по AI с Habr"""
|
||||||
|
|
||||||
@commands.command(name="nw")
|
@commands.command(name="nw")
|
||||||
async def nw(self, ctx):
|
async def nw(self, ctx: commands.Context) -> None:
|
||||||
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
"""Топ-5 свежих статей и новостей по AI с Habr"""
|
||||||
articles = await fetch_rss(RSS_URL_ARTICLES)
|
articles = await fetch_rss(RSS_URL_ARTICLES)
|
||||||
if articles is None:
|
if articles is None:
|
||||||
|
|||||||
@ -12,7 +12,7 @@ class Pg(commands.Cog):
|
|||||||
self.api_url = API_URL_WEATHER
|
self.api_url = API_URL_WEATHER
|
||||||
|
|
||||||
@commands.command(name="pg")
|
@commands.command(name="pg")
|
||||||
async def pg(self, ctx):
|
async def pg(self, ctx: commands.Context) -> None:
|
||||||
"""Прогноз погоды в Магнитогорске"""
|
"""Прогноз погоды в Магнитогорске"""
|
||||||
data = await fetch_weather(self.api_url)
|
data = await fetch_weather(self.api_url)
|
||||||
if data is None:
|
if data is None:
|
||||||
|
|||||||
@ -9,7 +9,7 @@ class Stats(commands.Cog):
|
|||||||
"""Команда !stats — статистика серверов"""
|
"""Команда !stats — статистика серверов"""
|
||||||
|
|
||||||
@commands.command(name="stats")
|
@commands.command(name="stats")
|
||||||
async def stats(self, ctx):
|
async def stats(self, ctx: commands.Context) -> None:
|
||||||
"""Количество серверов, каналов и пользователей"""
|
"""Количество серверов, каналов и пользователей"""
|
||||||
guilds = ctx.bot.guilds
|
guilds = ctx.bot.guilds
|
||||||
total_guilds = len(guilds)
|
total_guilds = len(guilds)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class Status(commands.Cog):
|
|||||||
"""Команда !status — статус бота, пинг, uptime"""
|
"""Команда !status — статус бота, пинг, uptime"""
|
||||||
|
|
||||||
@commands.command(name="status")
|
@commands.command(name="status")
|
||||||
async def status(self, ctx):
|
async def status(self, ctx: commands.Context) -> None:
|
||||||
"""Статус бота: пинг к Discord gateway и время работы"""
|
"""Статус бота: пинг к Discord gateway и время работы"""
|
||||||
latency_ms = round(ctx.bot.latency * 1000, 1)
|
latency_ms = round(ctx.bot.latency * 1000, 1)
|
||||||
start_time = getattr(ctx.bot, "_start_time", time.time())
|
start_time = getattr(ctx.bot, "_start_time", time.time())
|
||||||
|
|||||||
8
conftest.py
Normal file
8
conftest.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
"""Pytest configuration — применяется до импорта тестов."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
# Python 3.14+: asyncio.iscoroutinefunction deprecated, removed in 3.16
|
||||||
|
# discord.py 2.7.1 ещё не обновлена — применяем monkey-patch до импорта
|
||||||
|
asyncio.iscoroutinefunction = inspect.iscoroutinefunction # type: ignore[assignment]
|
||||||
@ -1,2 +1,4 @@
|
|||||||
|
pre-commit>=3.5.0
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
|
ruff>=0.8.0
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
# Добавляем корень проекта в путь импорта
|
# Добавляем корень проекта в путь импорта
|
||||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
sys.path.insert(0, str(ROOT_DIR))
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
@ -19,7 +21,7 @@ sys.path.insert(0, str(ROOT_DIR))
|
|||||||
class TestBotInit:
|
class TestBotInit:
|
||||||
"""Тесты для инициализации бота."""
|
"""Тесты для инициализации бота."""
|
||||||
|
|
||||||
def test_bot_created_with_default_prefix(self):
|
def test_bot_created_with_default_prefix(self) -> None:
|
||||||
"""Проверка, что бот создан с правильным префиксом команд."""
|
"""Проверка, что бот создан с правильным префиксом команд."""
|
||||||
import bot
|
import bot
|
||||||
|
|
||||||
@ -35,10 +37,9 @@ class TestBotInit:
|
|||||||
class TestBotErrorHandling:
|
class TestBotErrorHandling:
|
||||||
"""Тесты для проверки обработки ошибок запуска бота."""
|
"""Тесты для проверки обработки ошибок запуска бота."""
|
||||||
|
|
||||||
def test_bot_handles_login_failure(self):
|
def test_bot_handles_login_failure(self) -> None:
|
||||||
"""BotRunner.run() обрабатывает discord.LoginFailure."""
|
"""BotRunner.run() обрабатывает discord.LoginFailure."""
|
||||||
import bot
|
import bot
|
||||||
import discord
|
|
||||||
|
|
||||||
runner = bot.BotRunner()
|
runner = bot.BotRunner()
|
||||||
with patch.object(runner.bot, "start", side_effect=discord.LoginFailure("bad token")):
|
with patch.object(runner.bot, "start", side_effect=discord.LoginFailure("bad token")):
|
||||||
@ -48,10 +49,9 @@ class TestBotErrorHandling:
|
|||||||
runner.run("fake_token")
|
runner.run("fake_token")
|
||||||
mock_exit.assert_called_once_with(1)
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
def test_bot_handles_http_exception(self):
|
def test_bot_handles_http_exception(self) -> None:
|
||||||
"""BotRunner.run() обрабатывает discord.HTTPException."""
|
"""BotRunner.run() обрабатывает discord.HTTPException."""
|
||||||
import bot
|
import bot
|
||||||
import discord
|
|
||||||
|
|
||||||
runner = bot.BotRunner()
|
runner = bot.BotRunner()
|
||||||
mock_response = MagicMock(status=502)
|
mock_response = MagicMock(status=502)
|
||||||
@ -62,7 +62,7 @@ class TestBotErrorHandling:
|
|||||||
runner.run("fake_token")
|
runner.run("fake_token")
|
||||||
mock_exit.assert_called_once_with(1)
|
mock_exit.assert_called_once_with(1)
|
||||||
|
|
||||||
def test_shutdown_uses_on_shutdown_listener(self):
|
def test_shutdown_uses_on_shutdown_listener(self) -> None:
|
||||||
"""BotRunner.run() регистрирует on_shutdown вместо signal handlers.
|
"""BotRunner.run() регистрирует on_shutdown вместо signal handlers.
|
||||||
|
|
||||||
Signal handlers с asyncio.new_event_loop() создают race condition
|
Signal handlers с asyncio.new_event_loop() создают race condition
|
||||||
@ -81,7 +81,7 @@ class TestBotErrorHandling:
|
|||||||
content = f.read()
|
content = f.read()
|
||||||
assert "signal.signal" not in content, "Не должно быть signal.signal — используется on_shutdown"
|
assert "signal.signal" not in content, "Не должно быть signal.signal — используется on_shutdown"
|
||||||
|
|
||||||
def test_code_uses_async_bot_pattern(self):
|
def test_code_uses_async_bot_pattern(self) -> None:
|
||||||
"""Проверка, что bot.py использует async with / asyncio.run."""
|
"""Проверка, что bot.py использует async with / asyncio.run."""
|
||||||
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
with open(ROOT_DIR / "bot.py", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from commands.pg import Pg
|
|||||||
class TestPgInit:
|
class TestPgInit:
|
||||||
"""Тесты инициализации Cog Pg."""
|
"""Тесты инициализации Cog Pg."""
|
||||||
|
|
||||||
def test_init_sets_api_url(self):
|
def test_init_sets_api_url(self) -> None:
|
||||||
"""__init__ должен устанавливать api_url."""
|
"""__init__ должен устанавливать api_url."""
|
||||||
cog = Pg()
|
cog = Pg()
|
||||||
assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
assert cog.api_url == "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
||||||
@ -42,7 +42,7 @@ class TestPgCommand:
|
|||||||
return defaults
|
return defaults
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_success(self):
|
async def test_pg_success(self) -> None:
|
||||||
"""Успешный запрос погоды должен отправить embed с данными."""
|
"""Успешный запрос погоды должен отправить embed с данными."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -61,7 +61,7 @@ class TestPgCommand:
|
|||||||
assert "Давление: 759.8 мм рт. ст." in args
|
assert "Давление: 759.8 мм рт. ст." in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_fetch_returns_none(self):
|
async def test_pg_fetch_returns_none(self) -> None:
|
||||||
"""fetch_weather вернул None — бот должен сообщить об ошибке."""
|
"""fetch_weather вернул None — бот должен сообщить об ошибке."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -72,7 +72,7 @@ class TestPgCommand:
|
|||||||
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_empty_current_condition(self):
|
async def test_pg_empty_current_condition(self) -> None:
|
||||||
"""current_condition пустой список — graceful fallback."""
|
"""current_condition пустой список — graceful fallback."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -84,7 +84,7 @@ class TestPgCommand:
|
|||||||
assert "Не удалось получить данные о погоде" in ctx.send.call_args[0][0]
|
assert "Не удалось получить данные о погоде" in ctx.send.call_args[0][0]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_current_condition_none(self):
|
async def test_pg_current_condition_none(self) -> None:
|
||||||
"""current_condition — пустой dict — бот должен сообщить об ошибке."""
|
"""current_condition — пустой dict — бот должен сообщить об ошибке."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -96,7 +96,7 @@ class TestPgCommand:
|
|||||||
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
ctx.send.assert_called_once_with("Не удалось получить данные о погоде.")
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_wind_non_numeric(self):
|
async def test_pg_wind_non_numeric(self) -> None:
|
||||||
"""windspeedKmph — не число — wind должен быть '—'."""
|
"""windspeedKmph — не число — wind должен быть '—'."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -109,7 +109,7 @@ class TestPgCommand:
|
|||||||
assert "Ветер: — м/с" in args
|
assert "Ветер: — м/с" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_wind_none(self):
|
async def test_pg_wind_none(self) -> None:
|
||||||
"""windspeedKmph отсутствует — wind должен быть '—'."""
|
"""windspeedKmph отсутствует — wind должен быть '—'."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -122,7 +122,7 @@ class TestPgCommand:
|
|||||||
assert "Ветер: — м/с" in args
|
assert "Ветер: — м/с" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_zero_wind(self):
|
async def test_pg_zero_wind(self) -> None:
|
||||||
"""windspeedKmph = 0 — wind должен быть 0.0."""
|
"""windspeedKmph = 0 — wind должен быть 0.0."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -135,7 +135,7 @@ class TestPgCommand:
|
|||||||
assert "Ветер: 0.0 м/с" in args
|
assert "Ветер: 0.0 м/с" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_default_values(self):
|
async def test_pg_default_values(self) -> None:
|
||||||
"""Поля с отсутствующими значениями должны давать '—'."""
|
"""Поля с отсутствующими значениями должны давать '—'."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -159,7 +159,7 @@ class TestPgCommand:
|
|||||||
assert "Давление: — мм рт. ст." in args
|
assert "Давление: — мм рт. ст." in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_translate_unknown_weather(self):
|
async def test_pg_translate_unknown_weather(self) -> None:
|
||||||
"""Неизвестное описание погоды должно возвращать оригинал."""
|
"""Неизвестное описание погоды должно возвращать оригинал."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -172,7 +172,7 @@ class TestPgCommand:
|
|||||||
assert "Описание: UnknownXYZ" in args
|
assert "Описание: UnknownXYZ" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_russian_weather_description(self):
|
async def test_pg_russian_weather_description(self) -> None:
|
||||||
"""Описание погоды на русском должно корректно переводиться."""
|
"""Описание погоды на русском должно корректно переводиться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -185,7 +185,7 @@ class TestPgCommand:
|
|||||||
assert "Описание: Переменная облачность" in args
|
assert "Описание: Переменная облачность" in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_negative_pressure(self):
|
async def test_pg_negative_pressure(self) -> None:
|
||||||
"""Отрицательное давление должно конвертироваться."""
|
"""Отрицательное давление должно конвертироваться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
@ -198,7 +198,7 @@ class TestPgCommand:
|
|||||||
assert "Давление: -37.5 мм рт. ст." in args
|
assert "Давление: -37.5 мм рт. ст." in args
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pg_high_wind(self):
|
async def test_pg_high_wind(self) -> None:
|
||||||
"""Большая скорость ветра должна корректно округляться."""
|
"""Большая скорость ветра должна корректно округляться."""
|
||||||
cog = self._make_cog()
|
cog = self._make_cog()
|
||||||
ctx = self._make_ctx()
|
ctx = self._make_ctx()
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from commands.stats import Stats
|
||||||
|
|
||||||
class TestStatsCommand:
|
class TestStatsCommand:
|
||||||
"""Тесты Discord-команды stats."""
|
"""Тесты Discord-команды stats."""
|
||||||
@ -13,10 +14,8 @@ class TestStatsCommand:
|
|||||||
guild.member_count = member_count
|
guild.member_count = member_count
|
||||||
return guild
|
return guild
|
||||||
|
|
||||||
async def test_stats_sends_embed(self):
|
async def test_stats_sends_embed(self) -> None:
|
||||||
"""Команда stats отправляет embed-сообщение."""
|
"""Команда stats отправляет embed-сообщение."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.035
|
mock_ctx.bot.latency = 0.035
|
||||||
mock_ctx.bot.guilds = []
|
mock_ctx.bot.guilds = []
|
||||||
@ -30,10 +29,8 @@ class TestStatsCommand:
|
|||||||
embed = call_args[1]["embed"] if call_args[1] else call_args[0][0]
|
embed = call_args[1]["embed"] if call_args[1] else call_args[0][0]
|
||||||
assert embed.title == "Статистика серверов"
|
assert embed.title == "Статистика серверов"
|
||||||
|
|
||||||
async def test_stats_correct_values(self):
|
async def test_stats_correct_values(self) -> None:
|
||||||
"""Значения серверов, каналов и пользователей считаются верно."""
|
"""Значения серверов, каналов и пользователей считаются верно."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
guild1 = self._make_mock_guild(channel_count=10, member_count=200)
|
guild1 = self._make_mock_guild(channel_count=10, member_count=200)
|
||||||
guild2 = self._make_mock_guild(channel_count=5, member_count=50)
|
guild2 = self._make_mock_guild(channel_count=5, member_count=50)
|
||||||
|
|
||||||
@ -54,10 +51,8 @@ class TestStatsCommand:
|
|||||||
assert fields["Пользователей"] == "250"
|
assert fields["Пользователей"] == "250"
|
||||||
assert "35.0 мс" in fields["Пинг"]
|
assert "35.0 мс" in fields["Пинг"]
|
||||||
|
|
||||||
async def test_stats_empty_guilds(self):
|
async def test_stats_empty_guilds(self) -> None:
|
||||||
"""Пустой список серверов не вызывает ошибок."""
|
"""Пустой список серверов не вызывает ошибок."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.050
|
mock_ctx.bot.latency = 0.050
|
||||||
mock_ctx.bot.guilds = []
|
mock_ctx.bot.guilds = []
|
||||||
@ -73,10 +68,8 @@ class TestStatsCommand:
|
|||||||
assert fields["Каналов"] == "0"
|
assert fields["Каналов"] == "0"
|
||||||
assert fields["Пользователей"] == "0"
|
assert fields["Пользователей"] == "0"
|
||||||
|
|
||||||
async def test_stats_none_member_count(self):
|
async def test_stats_none_member_count(self) -> None:
|
||||||
"""member_count=None не вызывает ошибок."""
|
"""member_count=None не вызывает ошибок."""
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
guild = self._make_mock_guild(channel_count=3, member_count=None)
|
guild = self._make_mock_guild(channel_count=3, member_count=None)
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
@ -92,10 +85,9 @@ class TestStatsCommand:
|
|||||||
fields = {f.name: f.value for f in embed.fields}
|
fields = {f.name: f.value for f in embed.fields}
|
||||||
assert fields["Пользователей"] == "0"
|
assert fields["Пользователей"] == "0"
|
||||||
|
|
||||||
async def test_stats_excludes_categories(self):
|
async def test_stats_excludes_categories(self) -> None:
|
||||||
"""Категории не входят в счётчик каналов."""
|
"""Категории не входят в счётчик каналов."""
|
||||||
import discord
|
import discord
|
||||||
from commands.stats import Stats
|
|
||||||
|
|
||||||
guild = MagicMock()
|
guild = MagicMock()
|
||||||
text_ch = MagicMock()
|
text_ch = MagicMock()
|
||||||
|
|||||||
@ -4,14 +4,14 @@ import time
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from commands.status import Status
|
||||||
|
|
||||||
|
|
||||||
class TestStatusCommand:
|
class TestStatusCommand:
|
||||||
"""Тесты Discord-команды status."""
|
"""Тесты Discord-команды status."""
|
||||||
|
|
||||||
async def test_status_sends_embed(self):
|
async def test_status_sends_embed(self) -> None:
|
||||||
"""Команда status отправляет embed-сообщение."""
|
"""Команда status отправляет embed-сообщение."""
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.042
|
mock_ctx.bot.latency = 0.042
|
||||||
mock_ctx.bot._start_time = time.time()
|
mock_ctx.bot._start_time = time.time()
|
||||||
@ -26,10 +26,8 @@ class TestStatusCommand:
|
|||||||
assert embed.title == "Статус бота"
|
assert embed.title == "Статус бота"
|
||||||
assert "42.0 мс" in embed.fields[0].value
|
assert "42.0 мс" in embed.fields[0].value
|
||||||
|
|
||||||
async def test_status_uptime_format(self):
|
async def test_status_uptime_format(self) -> None:
|
||||||
"""Uptime форматируется корректно."""
|
"""Uptime форматируется корректно."""
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.latency = 0.050
|
mock_ctx.bot.latency = 0.050
|
||||||
mock_ctx.bot._start_time = time.time() - 90061 # 1д 1ч 1м 1с
|
mock_ctx.bot._start_time = time.time() - 90061 # 1д 1ч 1м 1с
|
||||||
@ -50,30 +48,22 @@ class TestStatusCommand:
|
|||||||
class TestFormatUptime:
|
class TestFormatUptime:
|
||||||
"""Тесты форматирования uptime."""
|
"""Тесты форматирования uptime."""
|
||||||
|
|
||||||
def test_zero_seconds(self):
|
def test_zero_seconds(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(0)
|
result = Status._format_uptime(0)
|
||||||
assert result == "0с"
|
assert result == "0с"
|
||||||
|
|
||||||
def test_minutes_and_seconds(self):
|
def test_minutes_and_seconds(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(125) # 2м 5с
|
result = Status._format_uptime(125) # 2м 5с
|
||||||
assert "2м" in result
|
assert "2м" in result
|
||||||
assert "5с" in result
|
assert "5с" in result
|
||||||
|
|
||||||
def test_hours_minutes_seconds(self):
|
def test_hours_minutes_seconds(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(3661) # 1ч 1м 1с
|
result = Status._format_uptime(3661) # 1ч 1м 1с
|
||||||
assert "1ч" in result
|
assert "1ч" in result
|
||||||
assert "1м" in result
|
assert "1м" in result
|
||||||
assert "1с" in result
|
assert "1с" in result
|
||||||
|
|
||||||
def test_full_day(self):
|
def test_full_day(self) -> None:
|
||||||
from commands.status import Status
|
|
||||||
|
|
||||||
result = Status._format_uptime(90061) # 1д 1ч 1м 1с
|
result = Status._format_uptime(90061) # 1д 1ч 1м 1с
|
||||||
assert "1д" in result
|
assert "1д" in result
|
||||||
assert "1ч" in result
|
assert "1ч" in result
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.cat import fetch_cat
|
from utils.cat import fetch_cat
|
||||||
|
|
||||||
@ -8,7 +10,7 @@ class TestFetchCat:
|
|||||||
"""Тесты функции fetch_cat() — получение URL случайного котика."""
|
"""Тесты функции fetch_cat() — получение URL случайного котика."""
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_success(self, mock_get):
|
async def test_fetch_cat_success(self, mock_get) -> None:
|
||||||
"""Успешный ответ с URL должен вернуть строку."""
|
"""Успешный ответ с URL должен вернуть строку."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
|
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
|
||||||
@ -18,7 +20,7 @@ class TestFetchCat:
|
|||||||
assert result == "https://example.com/cat.jpg"
|
assert result == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_empty_array(self, mock_get):
|
async def test_fetch_cat_empty_array(self, mock_get) -> None:
|
||||||
"""Пустой массив должен вернуть None."""
|
"""Пустой массив должен вернуть None."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = []
|
mock_response.json.return_value = []
|
||||||
@ -28,9 +30,8 @@ class TestFetchCat:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_http_error(self, mock_get):
|
async def test_fetch_cat_http_error(self, mock_get) -> None:
|
||||||
"""HTTP-ошибка (raise_for_status) должна вернуть None."""
|
"""HTTP-ошибка (raise_for_status) должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
|
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
@ -38,33 +39,29 @@ class TestFetchCat:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_connection_error(self, mock_get):
|
async def test_fetch_cat_connection_error(self, mock_get) -> None:
|
||||||
"""ConnectionError должна вернуть None."""
|
"""ConnectionError должна вернуть None."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
mock_get.side_effect = ConnectionError("No connection")
|
||||||
result = await fetch_cat()
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_timeout(self, mock_get):
|
async def test_fetch_cat_timeout(self, mock_get) -> None:
|
||||||
"""Timeout должна вернуть None."""
|
"""Timeout должна вернуть None."""
|
||||||
from requests.exceptions import Timeout
|
|
||||||
mock_get.side_effect = Timeout("Request timed out")
|
mock_get.side_effect = Timeout("Request timed out")
|
||||||
result = await fetch_cat()
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_ssl_error(self, mock_get):
|
async def test_fetch_cat_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError должна вернуть None."""
|
"""SSLError должна вернуть None."""
|
||||||
from requests.exceptions import SSLError
|
|
||||||
mock_get.side_effect = SSLError("SSL handshake failed")
|
mock_get.side_effect = SSLError("SSL handshake failed")
|
||||||
result = await fetch_cat()
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_json_parse_error(self, mock_get):
|
async def test_fetch_cat_json_parse_error(self, mock_get) -> None:
|
||||||
"""Ошибка парсинга JSON должна вернуть None."""
|
"""Ошибка парсинга JSON должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
|
mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
@ -73,7 +70,7 @@ class TestFetchCat:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_missing_url_key(self, mock_get):
|
async def test_fetch_cat_missing_url_key(self, mock_get) -> None:
|
||||||
"""Отсутствие ключа 'url' в ответе должно вернуть None."""
|
"""Отсутствие ключа 'url' в ответе должно вернуть None."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"error": "no image"}]
|
mock_response.json.return_value = [{"error": "no image"}]
|
||||||
@ -83,15 +80,14 @@ class TestFetchCat:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_request_exception(self, mock_get):
|
async def test_fetch_cat_request_exception(self, mock_get) -> None:
|
||||||
"""Общий RequestException должен вернуть None."""
|
"""Общий RequestException должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Generic error")
|
mock_get.side_effect = requests.RequestException("Generic error")
|
||||||
result = await fetch_cat()
|
result = await fetch_cat()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.cat._session.get")
|
@patch("utils.cat._session.get")
|
||||||
async def test_fetch_cat_url_with_special_chars(self, mock_get):
|
async def test_fetch_cat_url_with_special_chars(self, mock_get) -> None:
|
||||||
"""URL со спецсимволами должен вернуться как есть."""
|
"""URL со спецсимволами должен вернуться как есть."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
|
mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.news import fetch_rss
|
from utils.news import fetch_rss
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ class TestFetchRss:
|
|||||||
"""Тесты функции fetch_rss() — получение и парсинг RSS-ленты."""
|
"""Тесты функции fetch_rss() — получение и парсинг RSS-ленты."""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_success_rss20(self, mock_get):
|
async def test_fetch_rss_success_rss20(self, mock_get) -> None:
|
||||||
"""Успешный ответ RSS 2.0 должен вернуть список статей."""
|
"""Успешный ответ RSS 2.0 должен вернуть список статей."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -47,7 +48,7 @@ class TestFetchRss:
|
|||||||
assert result[1]["tags"] == []
|
assert result[1]["tags"] == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_success_atom(self, mock_get):
|
async def test_fetch_rss_success_atom(self, mock_get) -> None:
|
||||||
"""Успешный ответ Atom должен вернуть список статей."""
|
"""Успешный ответ Atom должен вернуть список статей."""
|
||||||
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
@ -73,7 +74,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == ["AI"]
|
assert result[0]["tags"] == ["AI"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_empty_items(self, mock_get):
|
async def test_fetch_rss_empty_items(self, mock_get) -> None:
|
||||||
"""RSS без items должен вернуть пустой список."""
|
"""RSS без items должен вернуть пустой список."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -88,7 +89,7 @@ class TestFetchRss:
|
|||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_no_matching_format(self, mock_get):
|
async def test_fetch_rss_no_matching_format(self, mock_get) -> None:
|
||||||
"""Неизвестный формат XML должен вернуть пустой список."""
|
"""Неизвестный формат XML должен вернуть пустой список."""
|
||||||
xml_content = """<?xml version="1.0"?>
|
xml_content = """<?xml version="1.0"?>
|
||||||
<unknown></unknown>""".encode()
|
<unknown></unknown>""".encode()
|
||||||
@ -100,7 +101,7 @@ class TestFetchRss:
|
|||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_missing_title(self, mock_get):
|
async def test_fetch_rss_missing_title(self, mock_get) -> None:
|
||||||
"""Статья без title должна получить 'Без названия'."""
|
"""Статья без title должна получить 'Без названия'."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -125,7 +126,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == []
|
assert result[0]["tags"] == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_missing_guid(self, mock_get):
|
async def test_fetch_rss_missing_guid(self, mock_get) -> None:
|
||||||
"""Статья без guid isPermaLink должна иметь пустую ссылку."""
|
"""Статья без guid isPermaLink должна иметь пустую ссылку."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -147,7 +148,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["link"] == ""
|
assert result[0]["link"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_limit_to_10(self, mock_get):
|
async def test_fetch_rss_limit_to_10(self, mock_get) -> None:
|
||||||
"""Больше 10 items должно быть обрезано до 10."""
|
"""Больше 10 items должно быть обрезано до 10."""
|
||||||
items = "\n".join(
|
items = "\n".join(
|
||||||
f""" <item>
|
f""" <item>
|
||||||
@ -174,39 +175,35 @@ class TestFetchRss:
|
|||||||
assert result[9]["title"] == "Статья 9"
|
assert result[9]["title"] == "Статья 9"
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_http_error(self, mock_get):
|
async def test_fetch_rss_http_error(self, mock_get) -> None:
|
||||||
"""HTTP-ошибка должна вернуть None."""
|
"""HTTP-ошибка должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
|
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
|
||||||
result = await fetch_rss("https://example.com/rss")
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_connection_error(self, mock_get):
|
async def test_fetch_rss_connection_error(self, mock_get) -> None:
|
||||||
"""Ошибка соединения должна вернуть None."""
|
"""Ошибка соединения должна вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
|
mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
|
||||||
result = await fetch_rss("https://example.com/rss")
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_timeout(self, mock_get):
|
async def test_fetch_rss_timeout(self, mock_get) -> None:
|
||||||
"""Таймаут должен вернуть None."""
|
"""Таймаут должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
|
||||||
result = await fetch_rss("https://example.com/rss")
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_ssl_error(self, mock_get):
|
async def test_fetch_rss_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError должен вернуть None."""
|
"""SSLError должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed")
|
mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed")
|
||||||
result = await fetch_rss("https://example.com/rss")
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_empty_tags(self, mock_get):
|
async def test_fetch_rss_empty_tags(self, mock_get) -> None:
|
||||||
"""Статья с пустыми тегами должна иметь пустые строки."""
|
"""Статья с пустыми тегами должна иметь пустые строки."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -231,7 +228,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == []
|
assert result[0]["tags"] == []
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_category_without_text(self, mock_get):
|
async def test_fetch_rss_category_without_text(self, mock_get) -> None:
|
||||||
"""Категория без текста должна быть пропущена."""
|
"""Категория без текста должна быть пропущена."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -253,7 +250,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == ["AI"]
|
assert result[0]["tags"] == ["AI"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_atom_missing_author(self, mock_get):
|
async def test_fetch_rss_atom_missing_author(self, mock_get) -> None:
|
||||||
"""Atom feed без автора должен иметь пустого creator."""
|
"""Atom feed без автора должен иметь пустого creator."""
|
||||||
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
@ -273,7 +270,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["creator"] == ""
|
assert result[0]["creator"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_atom_missing_link(self, mock_get):
|
async def test_fetch_rss_atom_missing_link(self, mock_get) -> None:
|
||||||
"""Atom feed без link должен иметь пустую ссылку."""
|
"""Atom feed без link должен иметь пустую ссылку."""
|
||||||
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
atom_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||||
@ -292,15 +289,14 @@ class TestFetchRss:
|
|||||||
assert result[0]["link"] == ""
|
assert result[0]["link"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_request_exception(self, mock_get):
|
async def test_fetch_rss_request_exception(self, mock_get) -> None:
|
||||||
"""Общий RequestException должен вернуть None."""
|
"""Общий RequestException должен вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Generic error")
|
mock_get.side_effect = requests.RequestException("Generic error")
|
||||||
result = await fetch_rss("https://example.com/rss")
|
result = await fetch_rss("https://example.com/rss")
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_guid_fallback_to_link(self, mock_get):
|
async def test_fetch_rss_guid_fallback_to_link(self, mock_get) -> None:
|
||||||
"""Если нет guid isPermaLink, ссылка должна быть пустой."""
|
"""Если нет guid isPermaLink, ссылка должна быть пустой."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -321,7 +317,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["link"] == ""
|
assert result[0]["link"] == ""
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_single_item(self, mock_get):
|
async def test_fetch_rss_single_item(self, mock_get) -> None:
|
||||||
"""Один item должен быть распарсен корректно."""
|
"""Один item должен быть распарсен корректно."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -348,7 +344,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["tags"] == ["ML"]
|
assert result[0]["tags"] == ["ML"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_special_characters_in_title(self, mock_get):
|
async def test_fetch_rss_special_characters_in_title(self, mock_get) -> None:
|
||||||
"""Заголовки со спецсимволами должны парситься корректно."""
|
"""Заголовки со спецсимволами должны парситься корректно."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -370,7 +366,7 @@ class TestFetchRss:
|
|||||||
assert "ML" in result[0]["title"]
|
assert "ML" in result[0]["title"]
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_date_with_gmt(self, mock_get):
|
async def test_fetch_rss_date_with_gmt(self, mock_get) -> None:
|
||||||
"""Дата с GMT должна парситься корректно."""
|
"""Дата с GMT должна парситься корректно."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
@ -391,7 +387,7 @@ class TestFetchRss:
|
|||||||
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT"
|
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT"
|
||||||
|
|
||||||
@patch("utils.news._session.get")
|
@patch("utils.news._session.get")
|
||||||
async def test_fetch_rss_many_categories(self, mock_get):
|
async def test_fetch_rss_many_categories(self, mock_get) -> None:
|
||||||
"""Множество категорий должны быть собраны."""
|
"""Множество категорий должны быть собраны."""
|
||||||
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
rss_content = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss version="2.0">
|
<rss version="2.0">
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from utils.pogoda import fetch_weather, fetch_open_meteo
|
from utils.pogoda import fetch_weather, fetch_open_meteo
|
||||||
|
|
||||||
@ -8,7 +10,7 @@ class TestFetchWeather:
|
|||||||
"""Тесты функции fetch_weather() — получение погоды с retry-логикой."""
|
"""Тесты функции fetch_weather() — получение погоды с retry-логикой."""
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_success(self, mock_get):
|
async def test_fetch_weather_success(self, mock_get) -> None:
|
||||||
"""Успешный ответ должен вернуть JSON-данные."""
|
"""Успешный ответ должен вернуть JSON-данные."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]}
|
mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]}
|
||||||
@ -18,9 +20,8 @@ class TestFetchWeather:
|
|||||||
assert result == {"current_condition": [{"temp_C": 20}]}
|
assert result == {"current_condition": [{"temp_C": 20}]}
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_fallback_on_ssl_error(self, mock_get):
|
async def test_fetch_weather_fallback_on_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError на первой попытке → fallback на Open-Meteo."""
|
"""SSLError на первой попытке → fallback на Open-Meteo."""
|
||||||
from requests.exceptions import SSLError
|
|
||||||
mock_get.side_effect = [
|
mock_get.side_effect = [
|
||||||
SSLError("SSL Error"),
|
SSLError("SSL Error"),
|
||||||
MagicMock(json=MagicMock(return_value={"result": "fallback"})),
|
MagicMock(json=MagicMock(return_value={"result": "fallback"})),
|
||||||
@ -31,9 +32,8 @@ class TestFetchWeather:
|
|||||||
assert result == {"result": "fallback"}
|
assert result == {"result": "fallback"}
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_fallback_on_connection_error(self, mock_get):
|
async def test_fetch_weather_fallback_on_connection_error(self, mock_get) -> None:
|
||||||
"""ConnectionError → fallback на Open-Meteo."""
|
"""ConnectionError → fallback на Open-Meteo."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
mock_get.side_effect = ConnectionError("No connection")
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
mock_fallback.return_value = {"result": "fallback"}
|
||||||
@ -41,9 +41,8 @@ class TestFetchWeather:
|
|||||||
assert result == {"result": "fallback"}
|
assert result == {"result": "fallback"}
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_fallback_on_timeout(self, mock_get):
|
async def test_fetch_weather_fallback_on_timeout(self, mock_get) -> None:
|
||||||
"""Timeout → fallback на Open-Meteo."""
|
"""Timeout → fallback на Open-Meteo."""
|
||||||
from requests.exceptions import Timeout
|
|
||||||
mock_get.side_effect = Timeout("Timed out")
|
mock_get.side_effect = Timeout("Timed out")
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
mock_fallback.return_value = {"result": "fallback"}
|
||||||
@ -51,9 +50,8 @@ class TestFetchWeather:
|
|||||||
assert result == {"result": "fallback"}
|
assert result == {"result": "fallback"}
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_all_retries_fail(self, mock_get):
|
async def test_fetch_weather_all_retries_fail(self, mock_get) -> None:
|
||||||
"""Все попытки не удались → fallback на Open-Meteo."""
|
"""Все попытки не удались → fallback на Open-Meteo."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
mock_get.side_effect = ConnectionError("No connection")
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||||
mock_fallback.return_value = None
|
mock_fallback.return_value = None
|
||||||
@ -61,9 +59,8 @@ class TestFetchWeather:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_request_exception(self, mock_get):
|
async def test_fetch_weather_request_exception(self, mock_get) -> None:
|
||||||
"""Общий RequestException → fallback на Open-Meteo."""
|
"""Общий RequestException → fallback на Open-Meteo."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Generic error")
|
mock_get.side_effect = requests.RequestException("Generic error")
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||||
mock_fallback.return_value = {"result": "fallback"}
|
mock_fallback.return_value = {"result": "fallback"}
|
||||||
@ -71,7 +68,7 @@ class TestFetchWeather:
|
|||||||
assert result == {"result": "fallback"}
|
assert result == {"result": "fallback"}
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_weather_http_error_no_fallback(self, mock_get):
|
async def test_fetch_weather_http_error_no_fallback(self, mock_get) -> None:
|
||||||
"""HTTP-ошибка (raise_for_status) не ловится, падает."""
|
"""HTTP-ошибка (raise_for_status) не ловится, падает."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
||||||
@ -84,7 +81,7 @@ class TestFetchOpenMeteo:
|
|||||||
"""Тесты функции fetch_open_meteo() — fallback на Open-Meteo API."""
|
"""Тесты функции fetch_open_meteo() — fallback на Open-Meteo API."""
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_success(self, mock_get):
|
async def test_fetch_open_meteo_success(self, mock_get) -> None:
|
||||||
"""Успешный ответ должен вернуть данные в формате current_condition."""
|
"""Успешный ответ должен вернуть данные в формате current_condition."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
@ -108,7 +105,7 @@ class TestFetchOpenMeteo:
|
|||||||
assert result["current_condition"][0]["pressure"] == 1013
|
assert result["current_condition"][0]["pressure"] == 1013
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_custom_coords(self, mock_get):
|
async def test_fetch_open_meteo_custom_coords(self, mock_get) -> None:
|
||||||
"""Кастомные координаты должны быть в URL."""
|
"""Кастомные координаты должны быть в URL."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}}
|
mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}}
|
||||||
@ -122,7 +119,7 @@ class TestFetchOpenMeteo:
|
|||||||
assert "37.6173" in call_url
|
assert "37.6173" in call_url
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_missing_weather_code(self, mock_get):
|
async def test_fetch_open_meteo_missing_weather_code(self, mock_get) -> None:
|
||||||
"""Отсутствующий weather_code → 'Неизвестно'."""
|
"""Отсутствующий weather_code → 'Неизвестно'."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {"current": {"temperature": 10}}
|
mock_response.json.return_value = {"current": {"temperature": 10}}
|
||||||
@ -133,9 +130,8 @@ class TestFetchOpenMeteo:
|
|||||||
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_ssl_error(self, mock_get):
|
async def test_fetch_open_meteo_ssl_error(self, mock_get) -> None:
|
||||||
"""SSLError → вернуть None."""
|
"""SSLError → вернуть None."""
|
||||||
from requests.exceptions import SSLError
|
|
||||||
mock_get.side_effect = SSLError("SSL Error")
|
mock_get.side_effect = SSLError("SSL Error")
|
||||||
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
|
||||||
# Внутренний fallback тоже падает, проверяем что возвращается None
|
# Внутренний fallback тоже падает, проверяем что возвращается None
|
||||||
@ -144,33 +140,29 @@ class TestFetchOpenMeteo:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_connection_error(self, mock_get):
|
async def test_fetch_open_meteo_connection_error(self, mock_get) -> None:
|
||||||
"""ConnectionError → вернуть None."""
|
"""ConnectionError → вернуть None."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = ConnectionError("No connection")
|
mock_get.side_effect = ConnectionError("No connection")
|
||||||
result = await fetch_open_meteo()
|
result = await fetch_open_meteo()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_timeout(self, mock_get):
|
async def test_fetch_open_meteo_timeout(self, mock_get) -> None:
|
||||||
"""Timeout → вернуть None."""
|
"""Timeout → вернуть None."""
|
||||||
from requests.exceptions import Timeout
|
|
||||||
mock_get.side_effect = Timeout("Timed out")
|
mock_get.side_effect = Timeout("Timed out")
|
||||||
result = await fetch_open_meteo()
|
result = await fetch_open_meteo()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_request_exception(self, mock_get):
|
async def test_fetch_open_meteo_request_exception(self, mock_get) -> None:
|
||||||
"""Общий RequestException → вернуть None."""
|
"""Общий RequestException → вернуть None."""
|
||||||
import requests
|
|
||||||
mock_get.side_effect = requests.RequestException("Error")
|
mock_get.side_effect = requests.RequestException("Error")
|
||||||
result = await fetch_open_meteo()
|
result = await fetch_open_meteo()
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_json_parse_error(self, mock_get):
|
async def test_fetch_open_meteo_json_parse_error(self, mock_get) -> None:
|
||||||
"""Ошибка парсинга JSON → вернуть None."""
|
"""Ошибка парсинга JSON → вернуть None."""
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.side_effect = requests.RequestException("JSON Error")
|
mock_response.json.side_effect = requests.RequestException("JSON Error")
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
@ -179,9 +171,8 @@ class TestFetchOpenMeteo:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_retry_on_error(self, mock_get):
|
async def test_fetch_open_meteo_retry_on_error(self, mock_get) -> None:
|
||||||
"""Retry: первая попытка падает, вторая успешна."""
|
"""Retry: первая попытка падает, вторая успешна."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
success_response = MagicMock()
|
success_response = MagicMock()
|
||||||
success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}}
|
success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}}
|
||||||
success_response.raise_for_status = MagicMock()
|
success_response.raise_for_status = MagicMock()
|
||||||
@ -191,18 +182,16 @@ class TestFetchOpenMeteo:
|
|||||||
assert mock_get.call_count == 2
|
assert mock_get.call_count == 2
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_all_retries_fail(self, mock_get):
|
async def test_fetch_open_meteo_all_retries_fail(self, mock_get) -> None:
|
||||||
"""Все попытки неудачны → None."""
|
"""Все попытки неудачны → None."""
|
||||||
from requests.exceptions import ConnectionError
|
|
||||||
mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
|
mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
|
||||||
result = await fetch_open_meteo(max_retries=3)
|
result = await fetch_open_meteo(max_retries=3)
|
||||||
assert result is None
|
assert result is None
|
||||||
assert mock_get.call_count == 3
|
assert mock_get.call_count == 3
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_http_error(self, mock_get):
|
async def test_fetch_open_meteo_http_error(self, mock_get) -> None:
|
||||||
"""HTTP 404 → raise_for_status бросит исключение → None."""
|
"""HTTP 404 → raise_for_status бросит исключение → None."""
|
||||||
import requests
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
|
||||||
mock_get.return_value = mock_response
|
mock_get.return_value = mock_response
|
||||||
@ -210,7 +199,7 @@ class TestFetchOpenMeteo:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
@patch("utils.pogoda._session.get")
|
@patch("utils.pogoda._session.get")
|
||||||
async def test_fetch_open_meteo_wind_speed_0(self, mock_get):
|
async def test_fetch_open_meteo_wind_speed_0(self, mock_get) -> None:
|
||||||
"""Нулевая скорость ветра должна корректно обрабатываться."""
|
"""Нулевая скорость ветра должна корректно обрабатываться."""
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.json.return_value = {
|
mock_response.json.return_value = {
|
||||||
|
|||||||
@ -17,11 +17,11 @@ class TestTruncateTitle:
|
|||||||
("A" * 50, 100, "A" * 50),
|
("A" * 50, 100, "A" * 50),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_truncate(self, title, max_len, expected):
|
def test_truncate(self, title, max_len, expected) -> None:
|
||||||
"""Проверка обрезки заголовка."""
|
"""Проверка обрезки заголовка."""
|
||||||
assert truncate_title(title, max_len) == expected
|
assert truncate_title(title, max_len) == expected
|
||||||
|
|
||||||
def test_truncate_default_max_len(self):
|
def test_truncate_default_max_len(self) -> None:
|
||||||
"""По умолчанию max_len=60."""
|
"""По умолчанию max_len=60."""
|
||||||
long_title = "A" * 61
|
long_title = "A" * 61
|
||||||
result = truncate_title(long_title)
|
result = truncate_title(long_title)
|
||||||
@ -42,7 +42,7 @@ class TestParseDate:
|
|||||||
("2026-01-01T00:00:00Z", "2026.01.01"),
|
("2026-01-01T00:00:00Z", "2026.01.01"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_date_known(self, pub_date, expected):
|
def test_parse_date_known(self, pub_date, expected) -> None:
|
||||||
"""Известные форматы даты должны парситься корректно."""
|
"""Известные форматы даты должны парситься корректно."""
|
||||||
assert _parse_date(pub_date) == expected
|
assert _parse_date(pub_date) == expected
|
||||||
|
|
||||||
@ -53,11 +53,11 @@ class TestParseDate:
|
|||||||
(None, ""),
|
(None, ""),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_parse_date_empty(self, pub_date, expected):
|
def test_parse_date_empty(self, pub_date, expected) -> None:
|
||||||
"""Пустая или None дата должна вернуть пустую строку."""
|
"""Пустая или None дата должна вернуть пустую строку."""
|
||||||
assert _parse_date(pub_date) == expected
|
assert _parse_date(pub_date) == expected
|
||||||
|
|
||||||
def test_parse_date_invalid(self):
|
def test_parse_date_invalid(self) -> None:
|
||||||
"""Невалидная дата должна вернуть первые 10 символов."""
|
"""Невалидная дата должна вернуть первые 10 символов."""
|
||||||
result = _parse_date("invalid-date-string")
|
result = _parse_date("invalid-date-string")
|
||||||
assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
assert result == "invalid.da" # первые 10 символов: 'invalid-da' → 'invalid.da' (replace('-','.'))
|
||||||
@ -66,7 +66,7 @@ class TestParseDate:
|
|||||||
class TestFormatArticles:
|
class TestFormatArticles:
|
||||||
"""Тесты функции format_articles() — формирование строк для вывода."""
|
"""Тесты функции format_articles() — формирование строк для вывода."""
|
||||||
|
|
||||||
def test_format_articles_normal(self):
|
def test_format_articles_normal(self) -> None:
|
||||||
"""Нормальный список статей должен вернуть заголовок + 5 статей."""
|
"""Нормальный список статей должен вернуть заголовок + 5 статей."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -90,7 +90,7 @@ class TestFormatArticles:
|
|||||||
assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>"
|
assert result[1] == "Статья 1\n28.05.2026 <https://habr.com/1>"
|
||||||
assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>"
|
assert result[2] == "Статья 2\n29.05.2026 <https://habr.com/2>"
|
||||||
|
|
||||||
def test_format_articles_limit_to_5(self):
|
def test_format_articles_limit_to_5(self) -> None:
|
||||||
"""Больше 5 статей должно быть обрезано до 5."""
|
"""Больше 5 статей должно быть обрезано до 5."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
||||||
@ -100,18 +100,18 @@ class TestFormatArticles:
|
|||||||
assert len(result) == 6 # заголовок + 5 статей
|
assert len(result) == 6 # заголовок + 5 статей
|
||||||
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
||||||
|
|
||||||
def test_format_articles_empty_list(self):
|
def test_format_articles_empty_list(self) -> None:
|
||||||
"""Пустой список должен вернуть только заголовок."""
|
"""Пустой список должен вернуть только заголовок."""
|
||||||
result = format_articles([], "Заголовок", "https://habr.com/feed")
|
result = format_articles([], "Заголовок", "https://habr.com/feed")
|
||||||
assert result == ["**Заголовок**\n<https://habr.com/feed>"]
|
assert result == ["**Заголовок**\n<https://habr.com/feed>"]
|
||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
|
|
||||||
def test_format_articles_none(self):
|
def test_format_articles_none(self) -> None:
|
||||||
"""None должен вызвать TypeError (articles[:5] на None)."""
|
"""None должен вызвать TypeError (articles[:5] на None)."""
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
format_articles(None, "Заголовок", "https://habr.com/feed")
|
format_articles(None, "Заголовок", "https://habr.com/feed")
|
||||||
|
|
||||||
def test_format_articles_single_article(self):
|
def test_format_articles_single_article(self) -> None:
|
||||||
"""Одна статья должна быть корректно отформатирована."""
|
"""Одна статья должна быть корректно отформатирована."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -127,7 +127,7 @@ class TestFormatArticles:
|
|||||||
assert result[0] == "**Новости AI**\n<https://habr.com/ai>"
|
assert result[0] == "**Новости AI**\n<https://habr.com/ai>"
|
||||||
assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>"
|
assert result[1] == "Единственная статья\n28.05.2026 <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_long_title_truncated(self):
|
def test_format_articles_long_title_truncated(self) -> None:
|
||||||
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
"""Длинный заголовок должен быть обрезан до 60 символов с '...'."""
|
||||||
long_title = "A" * 100
|
long_title = "A" * 100
|
||||||
articles = [
|
articles = [
|
||||||
@ -137,7 +137,7 @@ class TestFormatArticles:
|
|||||||
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
assert len(result[1].split("\n")[0]) == 63 # 60 + "..."
|
||||||
assert result[1].split("\n")[0].endswith("...")
|
assert result[1].split("\n")[0].endswith("...")
|
||||||
|
|
||||||
def test_format_articles_short_title_unchanged(self):
|
def test_format_articles_short_title_unchanged(self) -> None:
|
||||||
"""Короткий заголовок должен остаться без изменений."""
|
"""Короткий заголовок должен остаться без изменений."""
|
||||||
short_title = "Кот"
|
short_title = "Кот"
|
||||||
articles = [
|
articles = [
|
||||||
@ -146,7 +146,7 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].split("\n")[0] == "Кот"
|
assert result[1].split("\n")[0] == "Кот"
|
||||||
|
|
||||||
def test_format_articles_exact_60_chars(self):
|
def test_format_articles_exact_60_chars(self) -> None:
|
||||||
"""Заголовок ровно 60 символов не должен обрезаться."""
|
"""Заголовок ровно 60 символов не должен обрезаться."""
|
||||||
exact_title = "A" * 60
|
exact_title = "A" * 60
|
||||||
articles = [
|
articles = [
|
||||||
@ -156,7 +156,7 @@ class TestFormatArticles:
|
|||||||
assert result[1].split("\n")[0] == exact_title
|
assert result[1].split("\n")[0] == exact_title
|
||||||
assert "..." not in result[1]
|
assert "..." not in result[1]
|
||||||
|
|
||||||
def test_format_articles_iso_date(self):
|
def test_format_articles_iso_date(self) -> None:
|
||||||
"""Дата в формате ISO должна парситься корректно."""
|
"""Дата в формате ISO должна парситься корректно."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -170,7 +170,7 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n2026.05.28 <https://habr.com/1>"
|
assert result[1] == "Статья\n2026.05.28 <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_empty_date(self):
|
def test_format_articles_empty_date(self) -> None:
|
||||||
"""Пустая дата должна быть пустой строкой."""
|
"""Пустая дата должна быть пустой строкой."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "https://habr.com/1", "pub_date": "", "creator": "", "tags": []}
|
{"title": "Статья", "link": "https://habr.com/1", "pub_date": "", "creator": "", "tags": []}
|
||||||
@ -178,7 +178,7 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_none_date(self):
|
def test_format_articles_none_date(self) -> None:
|
||||||
"""None дата должна быть пустой строкой."""
|
"""None дата должна быть пустой строкой."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "https://habr.com/1", "pub_date": None, "creator": "", "tags": []}
|
{"title": "Статья", "link": "https://habr.com/1", "pub_date": None, "creator": "", "tags": []}
|
||||||
@ -186,7 +186,7 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1] == "Статья\n <https://habr.com/1>"
|
assert result[1] == "Статья\n <https://habr.com/1>"
|
||||||
|
|
||||||
def test_format_articles_empty_link(self):
|
def test_format_articles_empty_link(self) -> None:
|
||||||
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
"""Пустая ссылка должна быть пустой строкой в угловых скобках."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": "Статья", "link": "", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{"title": "Статья", "link": "", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
||||||
@ -194,7 +194,7 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert result[1].endswith(" <>")
|
assert result[1].endswith(" <>")
|
||||||
|
|
||||||
def test_format_articles_russian_title(self):
|
def test_format_articles_russian_title(self) -> None:
|
||||||
"""Русские заголовки должны корректно отображаться."""
|
"""Русские заголовки должны корректно отображаться."""
|
||||||
articles = [
|
articles = [
|
||||||
{
|
{
|
||||||
@ -208,7 +208,7 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Новости AI", "https://habr.com/ai")
|
result = format_articles(articles, "Новости AI", "https://habr.com/ai")
|
||||||
assert "Искусственный интеллект в медицине" in result[1]
|
assert "Искусственный интеллект в медицине" in result[1]
|
||||||
|
|
||||||
def test_format_articles_exact_5_articles(self):
|
def test_format_articles_exact_5_articles(self) -> None:
|
||||||
"""Ровно 5 статей должно быть включено."""
|
"""Ровно 5 статей должно быть включено."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
||||||
@ -218,7 +218,7 @@ class TestFormatArticles:
|
|||||||
assert len(result) == 6 # заголовок + 5 статей
|
assert len(result) == 6 # заголовок + 5 статей
|
||||||
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
assert result[-1] == "Статья 4\n28.05.2026 <https://habr.com/4>"
|
||||||
|
|
||||||
def test_format_articles_6th_article_excluded(self):
|
def test_format_articles_6th_article_excluded(self) -> None:
|
||||||
"""6-я статья должна быть исключена."""
|
"""6-я статья должна быть исключена."""
|
||||||
articles = [
|
articles = [
|
||||||
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
{"title": f"Статья {i}", "link": f"https://habr.com/{i}", "pub_date": "Mon, 28 May 2026 10:00:00 +0000", "creator": "", "tags": []}
|
||||||
|
|||||||
@ -3,6 +3,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from commands.help import Help
|
||||||
|
|
||||||
|
|
||||||
class TestHelpCommandDiscord:
|
class TestHelpCommandDiscord:
|
||||||
"""Тесты для команды help на Discord."""
|
"""Тесты для команды help на Discord."""
|
||||||
@ -15,10 +17,8 @@ class TestHelpCommandDiscord:
|
|||||||
cmd.__doc__ = doc
|
cmd.__doc__ = doc
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
async def test_show_help_sends_simple_text(self):
|
async def test_show_help_sends_simple_text(self) -> None:
|
||||||
"""Проверка, что команда отправляет простое текстовое сообщение."""
|
"""Проверка, что команда отправляет простое текстовое сообщение."""
|
||||||
from commands.help import Help
|
|
||||||
|
|
||||||
mock_ctx = MagicMock()
|
mock_ctx = MagicMock()
|
||||||
mock_ctx.bot.commands = []
|
mock_ctx.bot.commands = []
|
||||||
mock_ctx.send = AsyncMock(return_value=None)
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
@ -28,10 +28,8 @@ class TestHelpCommandDiscord:
|
|||||||
|
|
||||||
mock_ctx.send.assert_awaited_once()
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
|
||||||
async def test_show_help_message_content(self):
|
async def test_show_help_message_content(self) -> None:
|
||||||
"""Проверка содержания отправленного сообщения."""
|
"""Проверка содержания отправленного сообщения."""
|
||||||
from commands.help import Help
|
|
||||||
|
|
||||||
message_calls = []
|
message_calls = []
|
||||||
|
|
||||||
def send_side_effect(text: str, *args, **kwargs):
|
def send_side_effect(text: str, *args, **kwargs):
|
||||||
@ -44,7 +42,6 @@ class TestHelpCommandDiscord:
|
|||||||
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
self._make_mock_command("nw", "Топ-5 статей и топ-5 новостей AI с Habr"),
|
||||||
self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
|
self._make_mock_command("morning", "Утренний дайджест: погода + новости + котик"),
|
||||||
self._make_mock_command("cat", "Случайный котик"),
|
self._make_mock_command("cat", "Случайный котик"),
|
||||||
self._make_mock_command("msg", "Повторить текст в чате"),
|
|
||||||
]
|
]
|
||||||
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
|
mock_ctx.send = AsyncMock(side_effect=send_side_effect)
|
||||||
|
|
||||||
@ -59,13 +56,13 @@ class TestHelpCommandDiscord:
|
|||||||
assert "=" * 40 in message
|
assert "=" * 40 in message
|
||||||
|
|
||||||
# Проверяем наличие всех команд без кавычек
|
# Проверяем наличие всех команд без кавычек
|
||||||
commands = ["!pg", "!nw", "!morning", "!cat", "!msg"]
|
commands = ["!pg", "!nw", "!morning", "!cat"]
|
||||||
for cmd in commands:
|
for cmd in commands:
|
||||||
assert cmd in message, f"Команда {cmd} не найдена"
|
assert cmd in message, f"Команда {cmd} не найдена"
|
||||||
|
|
||||||
# Проверяем разделение тире между командой и описанием
|
# Проверяем разделение тире между командой и описанием
|
||||||
lines = [l.strip() for l in message.split("\n") if "—" in l]
|
lines = [l.strip() for l in message.split("\n") if "—" in l]
|
||||||
assert len(lines) >= 5
|
assert len(lines) >= 4
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
154
tests/test_integration.py
Normal file
154
tests/test_integration.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""Интеграционные тесты — проверка взаимодействия компонентов без внешних API.
|
||||||
|
|
||||||
|
Тестируют реальные объекты (без моков на уровне команд),
|
||||||
|
но мокают только сетевые вызовы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def loaded_bot():
|
||||||
|
"""Загрузить все ког-модули в бота."""
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
from commands import ALL_COMMANDS
|
||||||
|
for cog_class in ALL_COMMANDS:
|
||||||
|
await bot.add_cog(cog_class())
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
class TestCogLoading:
|
||||||
|
"""Проверка загрузки ког-модулей."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_cogs_load(self, loaded_bot) -> None:
|
||||||
|
"""Все ког-модули должны загружаться без ошибок."""
|
||||||
|
from commands import ALL_COMMANDS
|
||||||
|
assert len(loaded_bot.cogs) == len(ALL_COMMANDS)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_commands_registered(self, loaded_bot) -> None:
|
||||||
|
"""Все команды должны быть зарегистрированы."""
|
||||||
|
command_names = {cmd.name for cmd in loaded_bot.commands if cmd.cog is not None}
|
||||||
|
expected = {"pg", "nw", "morning", "cat", "hp", "stats", "status"}
|
||||||
|
assert command_names == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandFlow:
|
||||||
|
"""Проверка полного потока команд (без сетевых вызовов)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_command_success(self, loaded_bot) -> None:
|
||||||
|
"""Команда !cat должна отправить embed с котиком при успешном ответе API."""
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.author.name = "TestUser"
|
||||||
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
cat_cmd = loaded_bot.get_command("cat")
|
||||||
|
assert cat_cmd is not None
|
||||||
|
await cat_cmd.callback(cat_cmd.cog, mock_ctx)
|
||||||
|
|
||||||
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
embed = mock_ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.title == "Котик для тебя!"
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_command_failure(self, loaded_bot) -> None:
|
||||||
|
"""Команда !cat при ошибке API должна отправить fallback сообщение."""
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.author.name = "TestUser"
|
||||||
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
cat_cmd = loaded_bot.get_command("cat")
|
||||||
|
await cat_cmd.callback(cat_cmd.cog, mock_ctx)
|
||||||
|
|
||||||
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
content = mock_ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить котика" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stats_command_flow(self) -> None:
|
||||||
|
"""Команда !stats должна показать реальную статистику бота."""
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
intents.message_content = True
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
from commands.stats import Stats
|
||||||
|
await bot.add_cog(Stats())
|
||||||
|
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
guild = MagicMock()
|
||||||
|
guild.channels = [] # нет CategoryChannel
|
||||||
|
guild.member_count = 100
|
||||||
|
mock_ctx.bot = MagicMock()
|
||||||
|
mock_ctx.bot.guilds = [guild]
|
||||||
|
mock_ctx.bot.latency = 0.050
|
||||||
|
mock_ctx.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
stats_cmd = bot.get_command("stats")
|
||||||
|
await stats_cmd.callback(stats_cmd.cog, mock_ctx)
|
||||||
|
|
||||||
|
mock_ctx.send.assert_awaited_once()
|
||||||
|
embed = mock_ctx.send.call_args[1]["embed"]
|
||||||
|
fields = {f.name: f.value for f in embed.fields}
|
||||||
|
assert fields["Серверов"] == "1"
|
||||||
|
assert fields["Каналов"] == "0"
|
||||||
|
assert fields["Пользователей"] == "100"
|
||||||
|
assert fields["Пинг"] == "50.0 мс"
|
||||||
|
|
||||||
|
|
||||||
|
class TestUtilityFunctions:
|
||||||
|
"""Проверка утилит без моков."""
|
||||||
|
|
||||||
|
def test_wmo_codes_mapping(self) -> None:
|
||||||
|
"""wmo_to_russian должен переводить известные коды."""
|
||||||
|
from utils.pogoda import wmo_to_russian
|
||||||
|
|
||||||
|
assert wmo_to_russian(0) == "Ясно"
|
||||||
|
assert wmo_to_russian(3) == "Пасмурно"
|
||||||
|
assert wmo_to_russian(51) == "Лёгкая морось"
|
||||||
|
assert wmo_to_russian(None) == "Неизвестно"
|
||||||
|
|
||||||
|
def test_pressure_conversion(self) -> None:
|
||||||
|
"""pressure_to_mmhg должен конвертировать mb в мм рт. ст."""
|
||||||
|
from utils.pogoda import pressure_to_mmhg
|
||||||
|
|
||||||
|
assert pressure_to_mmhg(1013) == 759.8 # 1013 * 0.750062 = 759.81
|
||||||
|
assert pressure_to_mmhg("—") == "—"
|
||||||
|
assert pressure_to_mmhg(None) == "—"
|
||||||
|
|
||||||
|
def test_title_truncation(self) -> None:
|
||||||
|
"""truncate_title должен обрезать длинные заголовки."""
|
||||||
|
from utils.news import truncate_title
|
||||||
|
|
||||||
|
short = "Короткий заголовок"
|
||||||
|
assert truncate_title(short) == short
|
||||||
|
long_title = "A" * 100
|
||||||
|
assert len(truncate_title(long_title, 20)) == 23 # 17 + "..."
|
||||||
|
|
||||||
|
def test_embed_text_truncation(self) -> None:
|
||||||
|
"""truncate_embed_text должен обрезать до 4096 символов."""
|
||||||
|
from utils.news import truncate_embed_text, truncate_embed_field
|
||||||
|
|
||||||
|
long_text = "A" * 5000
|
||||||
|
assert len(truncate_embed_text(long_text)) == 4096
|
||||||
|
assert truncate_embed_text(long_text).endswith("...")
|
||||||
|
assert len(truncate_embed_field(long_text)) == 1024
|
||||||
@ -1,6 +1,8 @@
|
|||||||
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
"""Тесты для utils/logger.py — проверка настройки логирования."""
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import logging.handlers
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -43,8 +45,6 @@ def test_invalid_level_defaults_to_info() -> None:
|
|||||||
|
|
||||||
def test_file_handler_when_logs_dir_exists() -> None:
|
def test_file_handler_when_logs_dir_exists() -> None:
|
||||||
"""RotatingFileHandler добавляется если директория logs существует."""
|
"""RotatingFileHandler добавляется если директория logs существует."""
|
||||||
import logging.handlers
|
|
||||||
|
|
||||||
orig_cwd = os.getcwd()
|
orig_cwd = os.getcwd()
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
logs_dir = Path(tmpdir) / "logs"
|
logs_dir = Path(tmpdir) / "logs"
|
||||||
@ -68,8 +68,6 @@ def test_file_handler_when_logs_dir_exists() -> None:
|
|||||||
|
|
||||||
def test_logs_dir_created_automatically() -> None:
|
def test_logs_dir_created_automatically() -> None:
|
||||||
"""Директория logs создаётся автоматически, если её нет."""
|
"""Директория logs создаётся автоматически, если её нет."""
|
||||||
import logging.handlers
|
|
||||||
|
|
||||||
orig_cwd = os.getcwd()
|
orig_cwd = os.getcwd()
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
os.chdir(tmpdir)
|
os.chdir(tmpdir)
|
||||||
@ -109,9 +107,6 @@ def test_discord_level_is_info() -> None:
|
|||||||
|
|
||||||
def test_log_message_format() -> None:
|
def test_log_message_format() -> None:
|
||||||
"""Формат сообщения: время, уровень, имя модуля, текст."""
|
"""Формат сообщения: время, уровень, имя модуля, текст."""
|
||||||
import io
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with _isolated_logger() as root:
|
with _isolated_logger() as root:
|
||||||
# Replace stdout with our buffer
|
# Replace stdout with our buffer
|
||||||
buffer = io.StringIO()
|
buffer = io.StringIO()
|
||||||
|
|||||||
@ -13,58 +13,54 @@ from utils.morning_runner import Scheduler, run_morning
|
|||||||
class TestSchedulerInit:
|
class TestSchedulerInit:
|
||||||
"""Тесты инициализации Scheduler."""
|
"""Тесты инициализации Scheduler."""
|
||||||
|
|
||||||
def test_init_sets_morning_time(self):
|
def test_init_sets_morning_time(self) -> None:
|
||||||
"""Инициализация должна устанавливать время."""
|
"""Инициализация должна устанавливать время."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task") as mock_task:
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "08:30")
|
scheduler = Scheduler(bot, "08:30")
|
||||||
assert scheduler.morning_time == "08:30"
|
assert scheduler.morning_time == "08:30"
|
||||||
|
|
||||||
def test_init_default_morning_time(self):
|
def test_init_default_morning_time(self) -> None:
|
||||||
"""Инициализация с дефолтным временем."""
|
"""Инициализация с дефолтным временем."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task") as mock_task:
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
assert scheduler.morning_time == "07:00"
|
assert scheduler.morning_time == "07:00"
|
||||||
|
|
||||||
def test_init_creates_task(self):
|
def test_init_creates_task(self) -> None:
|
||||||
"""Инициализация должна создавать asyncio.Task."""
|
"""Инициализация должна вызывать _start_scheduler."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task") as mock_task:
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
mock_task.assert_called_once()
|
mock_start.assert_called_once()
|
||||||
assert scheduler._task is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestSchedulerCalculateNextRun:
|
class TestSchedulerCalculateNextRun:
|
||||||
"""Тесты расчёта следующего запуска."""
|
"""Тесты расчёта следующего запуска."""
|
||||||
|
|
||||||
def test_next_run_today_before_time(self):
|
def test_next_run_today_before_time(self) -> None:
|
||||||
"""Если сейчас раньше времени — вернуть сегодня."""
|
"""Если сейчас раньше времени — вернуть сегодня."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task"):
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "14:00")
|
scheduler = Scheduler(bot, "14:00")
|
||||||
|
|
||||||
now = datetime(2026, 5, 29, 10, 0, 0)
|
now = datetime(2026, 5, 29, 10, 0, 0)
|
||||||
next_run = scheduler._calculate_next_run(now)
|
next_run = scheduler._calculate_next_run(now)
|
||||||
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_tomorrow_after_time(self) -> None:
|
||||||
"""Если сейчас позже времени — вернуть завтра."""
|
"""Если сейчас позже времени — вернуть завтра."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task"):
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "14:00")
|
scheduler = Scheduler(bot, "14:00")
|
||||||
|
|
||||||
now = datetime(2026, 5, 29, 15, 0, 0)
|
now = datetime(2026, 5, 29, 15, 0, 0)
|
||||||
next_run = scheduler._calculate_next_run(now)
|
next_run = scheduler._calculate_next_run(now)
|
||||||
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
||||||
|
|
||||||
def test_next_run_exact_time(self):
|
def test_next_run_exact_time(self) -> None:
|
||||||
"""Если сейчас ровно время — вернуть завтра."""
|
"""Если сейчас ровно время — вернуть завтра."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task"):
|
with patch.object(Scheduler, "_start_scheduler"):
|
||||||
scheduler = Scheduler(bot, "14:00")
|
scheduler = Scheduler(bot, "14:00")
|
||||||
|
|
||||||
now = datetime(2026, 5, 29, 14, 0, 0)
|
now = datetime(2026, 5, 29, 14, 0, 0)
|
||||||
next_run = scheduler._calculate_next_run(now)
|
next_run = scheduler._calculate_next_run(now)
|
||||||
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
assert next_run == datetime(2026, 5, 30, 14, 0, 0)
|
||||||
@ -73,14 +69,16 @@ class TestSchedulerCalculateNextRun:
|
|||||||
class TestSchedulerStartStop:
|
class TestSchedulerStartStop:
|
||||||
"""Тесты запуска/остановки планировщика."""
|
"""Тесты запуска/остановки планировщика."""
|
||||||
|
|
||||||
def test_start_starts_task(self):
|
def test_start_starts_task(self) -> None:
|
||||||
"""start() должен запустить task."""
|
"""start() должен вызывать _start_scheduler (1 в __init__ + 1 в start, но реальный task один)."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task"):
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
assert scheduler._running is True
|
scheduler.start()
|
||||||
|
# __init__ вызывает _start_scheduler, start() тоже вызывает
|
||||||
|
assert mock_start.call_count == 2
|
||||||
|
|
||||||
def test_stop_stops_task(self):
|
def test_stop_stops_task(self) -> None:
|
||||||
"""stop() должен остановить task."""
|
"""stop() должен остановить task."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task"):
|
with patch("asyncio.create_task"):
|
||||||
@ -88,21 +86,20 @@ class TestSchedulerStartStop:
|
|||||||
scheduler.stop()
|
scheduler.stop()
|
||||||
assert scheduler._running is False
|
assert scheduler._running is False
|
||||||
|
|
||||||
def test_double_start_no_duplicate(self):
|
def test_double_start_no_duplicate(self) -> None:
|
||||||
"""Повторный start не должен создавать второй task."""
|
"""Повторный start должен вызывать _start_scheduler дважды (реальный task не дублируется благодаря флагам)."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
with patch("asyncio.create_task") as mock_task:
|
with patch.object(Scheduler, "_start_scheduler") as mock_start:
|
||||||
scheduler = Scheduler(bot)
|
scheduler = Scheduler(bot)
|
||||||
scheduler.start() # второй вызов
|
scheduler.start() # второй вызов
|
||||||
# create_task вызван только при инициализации
|
assert mock_start.call_count == 2
|
||||||
assert mock_task.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunMorning:
|
class TestRunMorning:
|
||||||
"""Тесты run_morning."""
|
"""Тесты run_morning."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_morning_sends_embed(self):
|
async def test_run_morning_sends_embed(self) -> None:
|
||||||
"""run_morning должен отправлять embed в канал."""
|
"""run_morning должен отправлять embed в канал."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
channel = AsyncMock()
|
channel = AsyncMock()
|
||||||
@ -134,7 +131,7 @@ class TestRunMorningWithFallback:
|
|||||||
"""Тесты fallback в пустом embed."""
|
"""Тесты fallback в пустом embed."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_morning_empty_embed_fallback(self):
|
async def test_run_morning_empty_embed_fallback(self) -> None:
|
||||||
"""run_morning должен добавлять fallback сообщение при пустых данных."""
|
"""run_morning должен добавлять fallback сообщение при пустых данных."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
channel = AsyncMock()
|
channel = AsyncMock()
|
||||||
@ -163,7 +160,7 @@ class TestRunMorningWithFallback:
|
|||||||
assert "Не удалось получить данные из внешних источников" in embed_description
|
assert "Не удалось получить данные из внешних источников" in embed_description
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_run_morning_only_weather_data(self):
|
async def test_run_morning_only_weather_data(self) -> None:
|
||||||
"""run_morning должен корректно обрабатывать только погоду без новостей."""
|
"""run_morning должен корректно обрабатывать только погоду без новостей."""
|
||||||
bot = AsyncMock()
|
bot = AsyncMock()
|
||||||
channel = AsyncMock()
|
channel = AsyncMock()
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from utils.pogoda import translate_weather, pressure_to_mmhg, wmo_to_russian, fo
|
|||||||
class TestFormatWeatherDataForConsole:
|
class TestFormatWeatherDataForConsole:
|
||||||
"""Тесты функции format_weather_data_for_console()."""
|
"""Тесты функции format_weather_data_for_console()."""
|
||||||
|
|
||||||
def test_format_valid_data(self):
|
def test_format_valid_data(self) -> None:
|
||||||
"""Полные данные должны быть отформатированы корректно."""
|
"""Полные данные должны быть отформатированы корректно."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [{
|
||||||
@ -28,7 +28,7 @@ class TestFormatWeatherDataForConsole:
|
|||||||
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8
|
assert "Ветер: 2.8 м/с" in result[3] # 10 / 3.6 = 2.777... ≈ 2.8
|
||||||
assert "Давление: 759.8 мм рт. ст." in result[4]
|
assert "Давление: 759.8 мм рт. ст." in result[4]
|
||||||
|
|
||||||
def test_format_empty_data(self):
|
def test_format_empty_data(self) -> None:
|
||||||
"""Пустые данные должны возвращать None."""
|
"""Пустые данные должны возвращать None."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{}]
|
"current_condition": [{}]
|
||||||
@ -38,7 +38,7 @@ class TestFormatWeatherDataForConsole:
|
|||||||
|
|
||||||
assert result is None, "Пустые данные должны возвращать None"
|
assert result is None, "Пустые данные должны возвращать None"
|
||||||
|
|
||||||
def test_format_missing_current_condition(self):
|
def test_format_missing_current_condition(self) -> None:
|
||||||
"""Отсутствие current_condition должно вернуть None."""
|
"""Отсутствие current_condition должно вернуть None."""
|
||||||
data = {}
|
data = {}
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ class TestFormatWeatherDataForConsole:
|
|||||||
|
|
||||||
assert result is None, "Отсутствие current_condition должно вернуть None"
|
assert result is None, "Отсутствие current_condition должно вернуть None"
|
||||||
|
|
||||||
def test_format_with_dashes(self):
|
def test_format_with_dashes(self) -> None:
|
||||||
"""Неизвестные значения должны отображаться как '—'."""
|
"""Неизвестные значения должны отображаться как '—'."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [{
|
||||||
@ -68,7 +68,7 @@ class TestFormatWeatherDataForConsole:
|
|||||||
assert "Ветер: — м/с" in result[3]
|
assert "Ветер: — м/с" in result[3]
|
||||||
assert "Давление: — мм рт. ст." in result[4]
|
assert "Давление: — мм рт. ст." in result[4]
|
||||||
|
|
||||||
def test_format_wind_conversion(self):
|
def test_format_wind_conversion(self) -> None:
|
||||||
"""Проверка конвертации ветра из км/ч в м/с."""
|
"""Проверка конвертации ветра из км/ч в м/с."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [{
|
||||||
@ -85,7 +85,7 @@ class TestFormatWeatherDataForConsole:
|
|||||||
# 36 / 3.6 = 10.0
|
# 36 / 3.6 = 10.0
|
||||||
assert "Ветер: 10.0 м/с" in result[3]
|
assert "Ветер: 10.0 м/с" in result[3]
|
||||||
|
|
||||||
def test_format_negative_temperature(self):
|
def test_format_negative_temperature(self) -> None:
|
||||||
"""Отрицательная температура должна отображаться корректно."""
|
"""Отрицательная температура должна отображаться корректно."""
|
||||||
data = {
|
data = {
|
||||||
"current_condition": [{
|
"current_condition": [{
|
||||||
@ -141,7 +141,7 @@ class TestTranslateWeather:
|
|||||||
("Moderate or heavy rain in area", "Дождь"),
|
("Moderate or heavy rain in area", "Дождь"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_translate_known(self, english, expected):
|
def test_translate_known(self, english, expected) -> None:
|
||||||
"""Известные переводы должны возвращать ожидаемый результат."""
|
"""Известные переводы должны возвращать ожидаемый результат."""
|
||||||
assert translate_weather(english) == expected
|
assert translate_weather(english) == expected
|
||||||
|
|
||||||
@ -153,34 +153,34 @@ class TestTranslateWeather:
|
|||||||
(" ", " "), # пробелы не считаются пустыми
|
(" ", " "), # пробелы не считаются пустыми
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_translate_empty(self, input_value, expected):
|
def test_translate_empty(self, input_value, expected) -> None:
|
||||||
"""Пустой или None ввод должен возвращать '—'."""
|
"""Пустой или None ввод должен возвращать '—'."""
|
||||||
assert translate_weather(input_value) == expected
|
assert translate_weather(input_value) == expected
|
||||||
|
|
||||||
def test_translate_unknown_returns_original(self):
|
def test_translate_unknown_returns_original(self) -> None:
|
||||||
"""Неизвестный перевод должен возвращать оригинальный текст."""
|
"""Неизвестный перевод должен возвращать оригинальный текст."""
|
||||||
unknown_text = "Unknown weather condition XYZ"
|
unknown_text = "Unknown weather condition XYZ"
|
||||||
assert translate_weather(unknown_text) == unknown_text
|
assert translate_weather(unknown_text) == unknown_text
|
||||||
|
|
||||||
def test_translate_partial_match(self):
|
def test_translate_partial_match(self) -> None:
|
||||||
"""Частичное совпадение ключа в тексте должно сработать."""
|
"""Частичное совпадение ключа в тексте должно сработать."""
|
||||||
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area"
|
# "Moderate or heavy rain in area" должно найтись в "Light Moderate or heavy rain in area"
|
||||||
text_with_prefix = "Light Moderate or heavy rain in area"
|
text_with_prefix = "Light Moderate or heavy rain in area"
|
||||||
assert translate_weather(text_with_prefix) == "Дождь"
|
assert translate_weather(text_with_prefix) == "Дождь"
|
||||||
|
|
||||||
def test_translate_longer_key_priority(self):
|
def test_translate_longer_key_priority(self) -> None:
|
||||||
"""Длинные ключи проверяются первыми (_WEATHER_MAPPING отсортирован по убыванию длины).
|
"""Длинные ключи проверяются первыми (_WEATHER_MAPPING отсортирован по убыванию длины).
|
||||||
"Moderate or heavy rain at times" проверится до "Heavy rain"."""
|
"Moderate or heavy rain at times" проверится до "Heavy rain"."""
|
||||||
text = "Moderate or heavy rain at times"
|
text = "Moderate or heavy rain at times"
|
||||||
assert translate_weather(text) == "Дождь"
|
assert translate_weather(text) == "Дождь"
|
||||||
|
|
||||||
def test_translate_case_insensitive(self):
|
def test_translate_case_insensitive(self) -> None:
|
||||||
"""Перевод должен быть регистронезависимым."""
|
"""Перевод должен быть регистронезависимым."""
|
||||||
assert translate_weather("CLEAR") == "Ясно"
|
assert translate_weather("CLEAR") == "Ясно"
|
||||||
assert translate_weather("partly cloudy") == "Переменная облачность"
|
assert translate_weather("partly cloudy") == "Переменная облачность"
|
||||||
assert translate_weather("HEAVY RAIN") == "Сильный дождь"
|
assert translate_weather("HEAVY RAIN") == "Сильный дождь"
|
||||||
|
|
||||||
def test_translate_with_whitespace(self):
|
def test_translate_with_whitespace(self) -> None:
|
||||||
"""Текст с пробелами по краям должен корректно переводиться."""
|
"""Текст с пробелами по краям должен корректно переводиться."""
|
||||||
assert translate_weather(" Clear ") == "Ясно"
|
assert translate_weather(" Clear ") == "Ясно"
|
||||||
|
|
||||||
@ -198,7 +198,7 @@ class TestPressureToMMHG:
|
|||||||
# (0, "—"), # 0 — falsy, возвращается '—' (баг)
|
# (0, "—"), # 0 — falsy, возвращается '—' (баг)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pressure_valid(self, mb, expected):
|
def test_pressure_valid(self, mb, expected) -> None:
|
||||||
"""Валидные числовые значения должны конвертироваться корректно."""
|
"""Валидные числовые значения должны конвертироваться корректно."""
|
||||||
assert pressure_to_mmhg(mb) == expected
|
assert pressure_to_mmhg(mb) == expected
|
||||||
|
|
||||||
@ -210,7 +210,7 @@ class TestPressureToMMHG:
|
|||||||
("980", 735.1),
|
("980", 735.1),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pressure_string(self, mb, expected):
|
def test_pressure_string(self, mb, expected) -> None:
|
||||||
"""Строка-число должна конвертироваться корректно."""
|
"""Строка-число должна конвертироваться корректно."""
|
||||||
assert pressure_to_mmhg(mb) == expected
|
assert pressure_to_mmhg(mb) == expected
|
||||||
|
|
||||||
@ -222,27 +222,27 @@ class TestPressureToMMHG:
|
|||||||
("", "—"),
|
("", "—"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_pressure_invalid(self, input_value, expected):
|
def test_pressure_invalid(self, input_value, expected) -> None:
|
||||||
"""Невалидные значения должны возвращать '—'."""
|
"""Невалидные значения должны возвращать '—'."""
|
||||||
assert pressure_to_mmhg(input_value) == expected
|
assert pressure_to_mmhg(input_value) == expected
|
||||||
|
|
||||||
def test_pressure_non_numeric_string(self):
|
def test_pressure_non_numeric_string(self) -> None:
|
||||||
"""Невалидная строка должна возвращать '—'."""
|
"""Невалидная строка должна возвращать '—'."""
|
||||||
assert pressure_to_mmhg("abc") == "—"
|
assert pressure_to_mmhg("abc") == "—"
|
||||||
|
|
||||||
def test_pressure_zero(self):
|
def test_pressure_zero(self) -> None:
|
||||||
"""Нулевое значение — корректно конвертируется в 0.0."""
|
"""Нулевое значение — корректно конвертируется в 0.0."""
|
||||||
assert pressure_to_mmhg(0) == 0.0
|
assert pressure_to_mmhg(0) == 0.0
|
||||||
|
|
||||||
def test_pressure_negative(self):
|
def test_pressure_negative(self) -> None:
|
||||||
"""Отрицательное значение должно конвертироваться."""
|
"""Отрицательное значение должно конвертироваться."""
|
||||||
assert pressure_to_mmhg(-100) == -75.0
|
assert pressure_to_mmhg(-100) == -75.0
|
||||||
|
|
||||||
def test_pressure_float_string(self):
|
def test_pressure_float_string(self) -> None:
|
||||||
"""Строка с десятичной точкой должна конвертироваться."""
|
"""Строка с десятичной точкой должна конвертироваться."""
|
||||||
assert pressure_to_mmhg("1013.25") == 760.0
|
assert pressure_to_mmhg("1013.25") == 760.0
|
||||||
|
|
||||||
def test_pressure_very_large(self):
|
def test_pressure_very_large(self) -> None:
|
||||||
"""Очень большое значение должно работать."""
|
"""Очень большое значение должно работать."""
|
||||||
assert pressure_to_mmhg(999999) == 750061.2
|
assert pressure_to_mmhg(999999) == 750061.2
|
||||||
|
|
||||||
@ -283,26 +283,26 @@ class TestWmoToRussian:
|
|||||||
(99, "Сильная гроза с градом"),
|
(99, "Сильная гроза с градом"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_wmo_known(self, code, expected):
|
def test_wmo_known(self, code, expected) -> None:
|
||||||
"""Известные WMO коды должны возвращать ожидаемый перевод."""
|
"""Известные WMO коды должны возвращать ожидаемый перевод."""
|
||||||
assert wmo_to_russian(code) == expected
|
assert wmo_to_russian(code) == expected
|
||||||
|
|
||||||
def test_wmo_unknown(self):
|
def test_wmo_unknown(self) -> None:
|
||||||
"""Неизвестный код должен возвращать 'Неизвестно'."""
|
"""Неизвестный код должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(999) == "Неизвестно"
|
assert wmo_to_russian(999) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_negative_code(self):
|
def test_wmo_negative_code(self) -> None:
|
||||||
"""Отрицательный код должен возвращать 'Неизвестно'."""
|
"""Отрицательный код должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(-1) == "Неизвестно"
|
assert wmo_to_russian(-1) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_none(self):
|
def test_wmo_none(self) -> None:
|
||||||
"""None должен возвращать 'Неизвестно'."""
|
"""None должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(None) == "Неизвестно"
|
assert wmo_to_russian(None) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_large_code(self):
|
def test_wmo_large_code(self) -> None:
|
||||||
"""Очень большой код должен возвращать 'Неизвестно'."""
|
"""Очень большой код должен возвращать 'Неизвестно'."""
|
||||||
assert wmo_to_russian(9999) == "Неизвестно"
|
assert wmo_to_russian(9999) == "Неизвестно"
|
||||||
|
|
||||||
def test_wmo_float_code(self):
|
def test_wmo_float_code(self) -> None:
|
||||||
"""Дробный код — не найдётся в mapping."""
|
"""Дробный код — не найдётся в mapping."""
|
||||||
assert wmo_to_russian(1.5) == "Неизвестно"
|
assert wmo_to_russian(1.5) == "Неизвестно"
|
||||||
|
|||||||
@ -52,13 +52,13 @@ async def gather_morning() -> MorningData:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel):
|
async def run_morning(bot: "commands.Bot", channel: discord.TextChannel) -> None:
|
||||||
"""Выполнить утренний дайджест и отправить в канал."""
|
"""Выполнить утренний дайджест и отправить в канал."""
|
||||||
try:
|
try:
|
||||||
data = await gather_morning()
|
data = await gather_morning()
|
||||||
|
|
||||||
# --- Формируем embed ---
|
# --- Формируем embed ---
|
||||||
embed = discord.Embed(title="🌅 Утренний дайджест!", color=0xF4A460)
|
embed = discord.Embed(title="Утренний дайджест", color=0xF4A460)
|
||||||
|
|
||||||
# Котик как thumbnail
|
# Котик как thumbnail
|
||||||
if data.cat_url:
|
if data.cat_url:
|
||||||
@ -115,12 +115,12 @@ async def run_morning(bot: "commands.Bot", channel: discord.TextChannel):
|
|||||||
description = "\n".join(description_lines)
|
description = "\n".join(description_lines)
|
||||||
embed.description = truncate_embed_text(description)
|
embed.description = truncate_embed_text(description)
|
||||||
await channel.send(embed=embed)
|
await channel.send(embed=embed)
|
||||||
logger.info("✅ Утренний дайджест отправлен в #%s", channel.name)
|
logger.info("Утренний дайджест отправлен в #%s", channel.name)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Ошибка при выполнении утреннего дайджеста: %s", e, exc_info=True)
|
logger.error("Ошибка при выполнении утреннего дайджеста: %s", e, exc_info=True)
|
||||||
try:
|
try:
|
||||||
await channel.send("❌ Не удалось выполнить утренний дайджест.")
|
await channel.send("Не удалось выполнить утренний дайджест.")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -249,8 +249,8 @@ class Scheduler:
|
|||||||
if not sent:
|
if not sent:
|
||||||
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
logger.error("Не удалось найти канал для отправки morning-дайджеста")
|
||||||
|
|
||||||
def start(self):
|
def start(self) -> None:
|
||||||
self._start_scheduler()
|
self._start_scheduler()
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> None:
|
||||||
self._stop_scheduler()
|
self._stop_scheduler()
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ RSS_URL_POSTS = "https://habr.com/ru/rss/hubs/artificial_intelligence/news/top/d
|
|||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_rss(url):
|
async def fetch_rss(url: str) -> Optional[list[dict]]:
|
||||||
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
"""Скачать и распарсить RSS-ленту (RSS 2.0 / Atom)."""
|
||||||
await habr_rss_limiter.acquire()
|
await habr_rss_limiter.acquire()
|
||||||
from defusedxml.ElementTree import fromstring
|
from defusedxml.ElementTree import fromstring
|
||||||
@ -74,7 +75,7 @@ async def fetch_rss(url):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(pub_date):
|
def _parse_date(pub_date: Optional[str]) -> str:
|
||||||
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
|
"""Парсить дату из RSS в строку 'дд.мм.гггг' или вернуть часть даты."""
|
||||||
if not pub_date:
|
if not pub_date:
|
||||||
return ""
|
return ""
|
||||||
@ -86,7 +87,7 @@ def _parse_date(pub_date):
|
|||||||
return pub_date[:10].replace("-", ".")
|
return pub_date[:10].replace("-", ".")
|
||||||
|
|
||||||
|
|
||||||
def truncate_title(title, max_len=60):
|
def truncate_title(title: str, max_len: int = 60) -> str:
|
||||||
"""Обрезать заголовок, если он длиннее max_len."""
|
"""Обрезать заголовок, если он длиннее max_len."""
|
||||||
if len(title) > max_len:
|
if len(title) > max_len:
|
||||||
return title[:max_len] + "..."
|
return title[:max_len] + "..."
|
||||||
@ -107,7 +108,7 @@ def truncate_embed_field(text: str, max_len: int = 1024) -> str:
|
|||||||
return text[:max_len - 3] + "..."
|
return text[:max_len - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
def format_articles(articles, title, link):
|
def format_articles(articles: list[dict], title: str, link: str) -> list[str]:
|
||||||
"""Сформировать список строк для вывода статей/постов."""
|
"""Сформировать список строк для вывода статей/постов."""
|
||||||
lines = [f"**{title}**\n<{link}>"]
|
lines = [f"**{title}**\n<{link}>"]
|
||||||
for i, article in enumerate(articles[:5], 1):
|
for i, article in enumerate(articles[:5], 1):
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from requests.exceptions import ConnectionError, Timeout, SSLError
|
from requests.exceptions import ConnectionError, Timeout, SSLError
|
||||||
|
|
||||||
@ -12,7 +14,7 @@ API_URL_WEATHER = "https://wttr.in/Magnitogorsk?format=j1&lang=ru"
|
|||||||
_session = requests.Session()
|
_session = requests.Session()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_weather(api_url, timeout=10, max_retries=3):
|
async def fetch_weather(api_url: str, timeout: int = 10, max_retries: int = 3) -> Optional[dict]:
|
||||||
"""Получить данные о погоде с retry."""
|
"""Получить данные о погоде с retry."""
|
||||||
await weather_limiter.acquire()
|
await weather_limiter.acquire()
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
@ -35,7 +37,7 @@ async def fetch_weather(api_url, timeout=10, max_retries=3):
|
|||||||
return await fetch_open_meteo()
|
return await fetch_open_meteo()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3):
|
async def fetch_open_meteo(lat: float = 53.4069, lon: float = 58.9797, timeout: int = 10, max_retries: int = 3) -> Optional[dict]:
|
||||||
"""Fallback на Open-Meteo API."""
|
"""Fallback на Open-Meteo API."""
|
||||||
await open_meteo_limiter.acquire()
|
await open_meteo_limiter.acquire()
|
||||||
url = (
|
url = (
|
||||||
@ -78,7 +80,7 @@ async def fetch_open_meteo(lat=53.4069, lon=58.9797, timeout=10, max_retries=3):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def wmo_to_russian(code):
|
def wmo_to_russian(code: Optional[int]) -> str:
|
||||||
"""Перевод WMO weather code в русский."""
|
"""Перевод WMO weather code в русский."""
|
||||||
mapping = {
|
mapping = {
|
||||||
0: "Ясно",
|
0: "Ясно",
|
||||||
@ -139,7 +141,7 @@ _WEATHER_MAPPING = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def translate_weather(en):
|
def translate_weather(en: Optional[str]) -> str:
|
||||||
if not en:
|
if not en:
|
||||||
return "—"
|
return "—"
|
||||||
en_lower = en.lower()
|
en_lower = en.lower()
|
||||||
@ -149,7 +151,7 @@ def translate_weather(en):
|
|||||||
return en
|
return en
|
||||||
|
|
||||||
|
|
||||||
def format_weather_data_for_console(data):
|
def format_weather_data_for_console(data: Optional[dict]) -> Optional[list[str]]:
|
||||||
"""
|
"""
|
||||||
Форматировать погодные данные для консольного вывода.
|
Форматировать погодные данные для консольного вывода.
|
||||||
|
|
||||||
@ -186,7 +188,7 @@ def format_weather_data_for_console(data):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def format_weather_for_embed(data):
|
def format_weather_for_embed(data: Optional[dict]) -> Optional[str]:
|
||||||
"""Форматировать погоду для Discord embed (с заголовком)."""
|
"""Форматировать погоду для Discord embed (с заголовком)."""
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
@ -196,7 +198,7 @@ def format_weather_for_embed(data):
|
|||||||
return "**Погода в Магнитогорске:**\n" + "\n".join(lines)
|
return "**Погода в Магнитогорске:**\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def pressure_to_mmhg(mb):
|
def pressure_to_mmhg(mb: Any) -> float | str:
|
||||||
if mb == "—" or mb is None or mb == "":
|
if mb == "—" or mb is None or mb == "":
|
||||||
return "—"
|
return "—"
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -53,7 +53,7 @@ class RateLimiter:
|
|||||||
await asyncio.sleep(token / self.rate)
|
await asyncio.sleep(token / self.rate)
|
||||||
|
|
||||||
|
|
||||||
# --- Готовые лимитеры по API ---
|
# --- Конфигурация лимитеров ---
|
||||||
|
|
||||||
# TheCatAPI: бесплатно, 1 req/sec, burst 3
|
# TheCatAPI: бесплатно, 1 req/sec, burst 3
|
||||||
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
|
_CAT_RATE: Final[float] = float(os.getenv("CAT_API_RATE", "1"))
|
||||||
@ -71,8 +71,29 @@ _OPEN_METEO_BURST: Final[int] = int(os.getenv("OPEN_METEO_API_BURST", "5"))
|
|||||||
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
|
_HABR_RSS_RATE: Final[float] = float(os.getenv("HABR_RSS_RATE", "1"))
|
||||||
_HABR_RSS_BURST: Final[int] = int(os.getenv("HABR_RSS_BURST", "2"))
|
_HABR_RSS_BURST: Final[int] = int(os.getenv("HABR_RSS_BURST", "2"))
|
||||||
|
|
||||||
# Экземпляры лимитеров
|
|
||||||
cat_limiter: RateLimiter = RateLimiter(_CAT_RATE, _CAT_BURST)
|
def make_cat_limiter() -> RateLimiter:
|
||||||
weather_limiter: RateLimiter = RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
|
"""Создать лимитер для TheCatAPI."""
|
||||||
open_meteo_limiter: RateLimiter = RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
|
return RateLimiter(_CAT_RATE, _CAT_BURST)
|
||||||
habr_rss_limiter: RateLimiter = RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)
|
|
||||||
|
|
||||||
|
def make_weather_limiter() -> RateLimiter:
|
||||||
|
"""Создать лимитер для wttr.in."""
|
||||||
|
return RateLimiter(_WEATHER_RATE, _WEATHER_BURST)
|
||||||
|
|
||||||
|
|
||||||
|
def make_open_meteo_limiter() -> RateLimiter:
|
||||||
|
"""Создать лимитер для Open-Meteo."""
|
||||||
|
return RateLimiter(_OPEN_METEO_RATE, _OPEN_METEO_BURST)
|
||||||
|
|
||||||
|
|
||||||
|
def make_habr_rss_limiter() -> RateLimiter:
|
||||||
|
"""Создать лимитер для Habr RSS."""
|
||||||
|
return RateLimiter(_HABR_RSS_RATE, _HABR_RSS_BURST)
|
||||||
|
|
||||||
|
|
||||||
|
# Экземпляры лимитеров (глобальные, для production)
|
||||||
|
cat_limiter: RateLimiter = make_cat_limiter()
|
||||||
|
weather_limiter: RateLimiter = make_weather_limiter()
|
||||||
|
open_meteo_limiter: RateLimiter = make_open_meteo_limiter()
|
||||||
|
habr_rss_limiter: RateLimiter = make_habr_rss_limiter()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user