Добавлены тесты для Cat, News, Morning когов, TextHelpCommand и truncate-функций (282 теста, все проходят)
This commit is contained in:
parent
f417a9abbc
commit
bc843abc63
14
README.md
14
README.md
@ -68,8 +68,12 @@ tests/ # pytest-тесты
|
|||||||
test_fetch_weather.py # fetch_weather, fetch_open_meteo
|
test_fetch_weather.py # fetch_weather, fetch_open_meteo
|
||||||
test_format_articles.py # truncate_title, _parse_date, format_articles
|
test_format_articles.py # truncate_title, _parse_date, format_articles
|
||||||
test_commands_pg.py # Pg cog
|
test_commands_pg.py # Pg cog
|
||||||
test_bot.py # инициализация бота
|
test_commands_cat.py # Cat cog, команда !cat
|
||||||
|
test_commands_news.py # News cog, команда !nw
|
||||||
|
test_commands_morning.py # Morning cog, команда !morning
|
||||||
|
test_bot.py # инициализация бота, обработка ошибок запуска
|
||||||
test_morning_runner.py# тесты morning runner-а
|
test_morning_runner.py# тесты morning runner-а
|
||||||
|
test_help_command.py # TextHelpCommand — текстовая справка по командам
|
||||||
test_logger.py # setup_logging — уровни, обработчики, формат
|
test_logger.py # setup_logging — уровни, обработчики, формат
|
||||||
test_commands_status.py # команда !status — embed и uptime
|
test_commands_status.py # команда !status — embed и uptime
|
||||||
test_commands_stats.py # команда !stats — подсчёт серверов и каналов
|
test_commands_stats.py # команда !stats — подсчёт серверов и каналов
|
||||||
@ -106,14 +110,18 @@ python -m pytest tests/ -v
|
|||||||
| `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 19 |
|
| `test_fetch_weather.py` | `fetch_weather()`, `fetch_open_meteo()` | 19 |
|
||||||
| `test_format_articles.py` | `truncate_title()`, `_parse_date()`, `format_articles()` | 20 |
|
| `test_format_articles.py` | `truncate_title()`, `_parse_date()`, `format_articles()` | 20 |
|
||||||
| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 |
|
| `test_commands_pg.py` | `Pg` cog, команда `!pg` | 13 |
|
||||||
| `test_bot.py` | инициализация бота | 5 |
|
| `test_commands_cat.py` | `Cat` cog, команда `!cat` (embed, fallback) | 8 |
|
||||||
|
| `test_commands_news.py` | `News` cog, команда `!nw` (статьи, посты, fallback) | 8 |
|
||||||
|
| `test_commands_morning.py` | `Morning` cog, команда `!morning` (run_morning) | 5 |
|
||||||
|
| `test_bot.py` | инициализация бота, обработка ошибок запуска | 5 |
|
||||||
| `test_morning_runner.py` | morning runner | 12 |
|
| `test_morning_runner.py` | morning runner | 12 |
|
||||||
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
|
| `test_logger.py` | `setup_logging` (уровни, обработчики, формат) | 9 |
|
||||||
|
| `test_help_command.py` | `TextHelpCommand` (справка, алиасы, скрытые команды) | 11 |
|
||||||
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
|
| `test_rate_limiter.py` | `RateLimiter` (токен-бакет) | 5 |
|
||||||
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
|
| `test_commands_status.py` | команда `!status` (embed, uptime) | 6 |
|
||||||
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 5 |
|
| `test_commands_stats.py` | команда `!stats` (серверы, каналы) | 5 |
|
||||||
| `test_integration.py` | загрузка когов, поток команд (моки API) | 9 |
|
| `test_integration.py` | загрузка когов, поток команд (моки API) | 9 |
|
||||||
**Итого: 161 тест.**
|
**Итого: 206 функций (282 тестов с учётом parametrized).**
|
||||||
|
|
||||||
## Запуск в Docker
|
## Запуск в Docker
|
||||||
|
|
||||||
|
|||||||
136
tests/test_commands_cat.py
Normal file
136
tests/test_commands_cat.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
"""Тесты для commands/cat.py — команда !cat."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def make_context() -> MagicMock:
|
||||||
|
"""Создать мокированный ctx."""
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.author.name = "TestUser"
|
||||||
|
ctx.send = AsyncMock(return_value=None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatInit:
|
||||||
|
"""Тесты инициализации Cat cog."""
|
||||||
|
|
||||||
|
def test_cat_cog_instantiates(self) -> None:
|
||||||
|
"""Cat должен создаваться без параметров."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
assert cog is not None
|
||||||
|
|
||||||
|
def test_cat_has_command(self) -> None:
|
||||||
|
"""Cat должен содержать команду cat."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
assert cog.cat.name == "cat"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCatCommand:
|
||||||
|
"""Тесты команды !cat."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_success(self) -> None:
|
||||||
|
"""Успешный ответ API -> embed с котиком."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
embed = 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_api_returns_none(self) -> None:
|
||||||
|
"""API вернул None -> fallback сообщение."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = None
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить котика" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_embed_color_orange(self) -> None:
|
||||||
|
"""Embed должен быть оранжевого цвета."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.color == discord.Color.orange()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_image_set_via_set_image(self) -> None:
|
||||||
|
"""URL должен быть установлен через embed.set_image."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.image is not None
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_with_special_url(self) -> None:
|
||||||
|
"""URL со спецсимволами должен корректно встраиваться."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg?size=large&format=webp"
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
embed = ctx.send.call_args[1]["embed"]
|
||||||
|
assert embed.image.url == "https://example.com/cat.jpg?size=large&format=webp"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cat_send_raises(self) -> None:
|
||||||
|
"""Ошибка при отправке — fetch_cat вызывался."""
|
||||||
|
from commands.cat import Cat
|
||||||
|
|
||||||
|
cog = Cat()
|
||||||
|
ctx = make_context()
|
||||||
|
ctx.send.side_effect = Exception("channel error")
|
||||||
|
|
||||||
|
with patch("commands.cat.fetch_cat", new_callable=AsyncMock) as mock:
|
||||||
|
mock.return_value = "https://example.com/cat.jpg"
|
||||||
|
with pytest.raises(Exception, match="channel error"):
|
||||||
|
await cog.cat(cog, ctx)
|
||||||
|
|
||||||
|
mock.assert_awaited_once()
|
||||||
84
tests/test_commands_morning.py
Normal file
84
tests/test_commands_morning.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""Тесты для commands/morning.py — команда !morning."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def make_context() -> MagicMock:
|
||||||
|
"""Создать мокированный ctx."""
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.author.name = "TestUser"
|
||||||
|
ctx.send = AsyncMock(return_value=None)
|
||||||
|
ctx.bot = MagicMock()
|
||||||
|
ctx.channel = MagicMock()
|
||||||
|
ctx.channel.name = "test-channel"
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TestMorningInit:
|
||||||
|
"""Тесты инициализации Morning cog."""
|
||||||
|
|
||||||
|
def test_morning_cog_instantiates(self) -> None:
|
||||||
|
"""Morning должен создаваться без параметров."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
assert cog is not None
|
||||||
|
|
||||||
|
def test_morning_has_command(self) -> None:
|
||||||
|
"""Morning должен содержать команду morning."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
assert cog.morning.name == "morning"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMorningCommand:
|
||||||
|
"""Тесты команды !morning."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_morning_calls_run_morning(self) -> None:
|
||||||
|
"""!morning должен вызвать run_morning."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
|
||||||
|
await cog.morning(cog, ctx)
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once_with(ctx.bot, ctx.channel)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_morning_passes_bot_and_channel(self) -> None:
|
||||||
|
"""!morning передаёт ctx.bot и ctx.channel в run_morning."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
|
||||||
|
await cog.morning(cog, ctx)
|
||||||
|
|
||||||
|
call_args = mock_run.call_args
|
||||||
|
assert call_args[0][0] is ctx.bot
|
||||||
|
assert call_args[0][1] is ctx.channel
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_morning_run_morning_raises(self) -> None:
|
||||||
|
"""Ошибка в run_morning должна пробрасываться."""
|
||||||
|
from commands.morning import Morning
|
||||||
|
|
||||||
|
cog = Morning()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.morning.run_morning", new_callable=AsyncMock) as mock_run:
|
||||||
|
mock_run.side_effect = Exception("api error")
|
||||||
|
with pytest.raises(Exception, match="api error"):
|
||||||
|
await cog.morning(cog, ctx)
|
||||||
151
tests/test_commands_news.py
Normal file
151
tests/test_commands_news.py
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"""Тесты для commands/news.py — команда !nw."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
def make_context() -> MagicMock:
|
||||||
|
"""Создать мокированный ctx."""
|
||||||
|
ctx = MagicMock()
|
||||||
|
ctx.author.name = "TestUser"
|
||||||
|
ctx.send = AsyncMock(return_value=None)
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewsInit:
|
||||||
|
"""Тесты инициализации News cog."""
|
||||||
|
|
||||||
|
def test_news_cog_instantiates(self) -> None:
|
||||||
|
"""News должен создаваться без параметров."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
assert cog is not None
|
||||||
|
|
||||||
|
def test_news_has_command(self) -> None:
|
||||||
|
"""News должен содержать команду nw."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
assert cog.nw.name == "nw"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewsCommand:
|
||||||
|
"""Тесты команды !nw."""
|
||||||
|
|
||||||
|
def _make_articles(self, count: int = 3) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{"title": f"Статья {i}", "link": f"https://habr.com/article/{i}", "pub_date": f"Mon, 28 May 2026 10:00:00 +0000", "creator": "author", "tags": []}
|
||||||
|
for i in range(1, count + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_success_articles_and_posts(self) -> None:
|
||||||
|
"""Успешный ответ API для статей и постов."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(3)
|
||||||
|
posts = self._make_articles(2)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, posts]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
call_count = mock_rss.await_count
|
||||||
|
assert call_count == 2 # статьи + посты
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_articles_none(self) -> None:
|
||||||
|
"""fetch_rss вернул None для статей -> fallback сообщение."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.return_value = None
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить новости" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_empty_articles(self) -> None:
|
||||||
|
"""Пустой список статей -> 'Новостей пока нет'."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.return_value = []
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Новостей пока нет" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_posts_none(self) -> None:
|
||||||
|
"""fetch_rss вернул None для постов -> fallback в сообщение."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(2)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, None]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
assert "Не удалось получить новости" in content
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_posts_empty(self) -> None:
|
||||||
|
"""Пустой список постов -> 'Новостей пока нет' для постов."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(2)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, []]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
# Second "Новостей пока нет" for posts section
|
||||||
|
count = content.count("Новостей пока нет")
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_nw_limits_articles_to_5(self) -> None:
|
||||||
|
"""Более 5 статей -> truncate_message обрежет."""
|
||||||
|
from commands.news import News
|
||||||
|
|
||||||
|
cog = News()
|
||||||
|
ctx = make_context()
|
||||||
|
articles = self._make_articles(20)
|
||||||
|
posts = self._make_articles(20)
|
||||||
|
|
||||||
|
with patch("commands.news.fetch_rss", new_callable=AsyncMock) as mock_rss:
|
||||||
|
mock_rss.side_effect = [articles, posts]
|
||||||
|
await cog.nw(cog, ctx)
|
||||||
|
|
||||||
|
ctx.send.assert_awaited_once()
|
||||||
|
content = ctx.send.call_args[0][0]
|
||||||
|
# truncate_message limits to 2000 chars for plain text
|
||||||
|
assert len(content) <= 2003 # 2000 + "..."
|
||||||
@ -1,5 +1,12 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from utils.news import format_articles, truncate_title, _parse_date
|
from utils.news import (
|
||||||
|
format_articles,
|
||||||
|
truncate_embed_field,
|
||||||
|
truncate_embed_text,
|
||||||
|
truncate_message,
|
||||||
|
truncate_title,
|
||||||
|
_parse_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestTruncateTitle:
|
class TestTruncateTitle:
|
||||||
@ -287,3 +294,93 @@ class TestFormatArticles:
|
|||||||
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
result = format_articles(articles, "Заголовок", "https://habr.com/feed")
|
||||||
assert len(result) == 6 # заголовок + 5 статей
|
assert len(result) == 6 # заголовок + 5 статей
|
||||||
assert "Статья 5" not in result[5]
|
assert "Статья 5" not in result[5]
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateMessage:
|
||||||
|
"""Тесты функции truncate_message() — обрезка plain text сообщений."""
|
||||||
|
|
||||||
|
def test_short_message_unchanged(self) -> None:
|
||||||
|
"""Короткое сообщение не обрезается."""
|
||||||
|
text = "Короткое сообщение"
|
||||||
|
assert truncate_message(text) == text
|
||||||
|
|
||||||
|
def test_exact_limit_unchanged(self) -> None:
|
||||||
|
"""Текст ровно 2000 символов не обрезается."""
|
||||||
|
text = "A" * 2000
|
||||||
|
assert truncate_message(text) == text
|
||||||
|
|
||||||
|
def test_over_limit_truncated(self) -> None:
|
||||||
|
"""Текст больше 2000 символов обрезается."""
|
||||||
|
text = "A" * 2500
|
||||||
|
result = truncate_message(text)
|
||||||
|
assert len(result) == 2000
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max_len(self) -> None:
|
||||||
|
"""Кастомный max_len."""
|
||||||
|
text = "A" * 150
|
||||||
|
result = truncate_message(text, max_len=100)
|
||||||
|
assert len(result) == 100
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_under_custom_max_len(self) -> None:
|
||||||
|
"""Текст меньше кастомного max_len не обрезается."""
|
||||||
|
text = "A" * 50
|
||||||
|
result = truncate_message(text, max_len=100)
|
||||||
|
assert result == text
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateEmbedText:
|
||||||
|
"""Тесты функции truncate_embed_text() — обрезка embed.description."""
|
||||||
|
|
||||||
|
def test_short_text_unchanged(self) -> None:
|
||||||
|
"""Короткий текст не обрезается."""
|
||||||
|
text = "Short"
|
||||||
|
assert truncate_embed_text(text) == text
|
||||||
|
|
||||||
|
def test_exact_limit_unchanged(self) -> None:
|
||||||
|
"""Текст ровно 4096 символов не обрезается."""
|
||||||
|
text = "A" * 4096
|
||||||
|
assert truncate_embed_text(text) == text
|
||||||
|
|
||||||
|
def test_over_limit_truncated(self) -> None:
|
||||||
|
"""Текст больше 4096 символов обрезается."""
|
||||||
|
text = "A" * 5000
|
||||||
|
result = truncate_embed_text(text)
|
||||||
|
assert len(result) == 4096
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max_len(self) -> None:
|
||||||
|
"""Кастомный max_len."""
|
||||||
|
text = "A" * 200
|
||||||
|
result = truncate_embed_text(text, max_len=100)
|
||||||
|
assert len(result) == 100
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncateEmbedField:
|
||||||
|
"""Тесты функции truncate_embed_field() — обрезка embed field value."""
|
||||||
|
|
||||||
|
def test_short_text_unchanged(self) -> None:
|
||||||
|
"""Короткий текст не обрезается."""
|
||||||
|
text = "Short"
|
||||||
|
assert truncate_embed_field(text) == text
|
||||||
|
|
||||||
|
def test_exact_limit_unchanged(self) -> None:
|
||||||
|
"""Текст ровно 1024 символа не обрезается."""
|
||||||
|
text = "A" * 1024
|
||||||
|
assert truncate_embed_field(text) == text
|
||||||
|
|
||||||
|
def test_over_limit_truncated(self) -> None:
|
||||||
|
"""Текст больше 1024 символов обрезается."""
|
||||||
|
text = "A" * 2000
|
||||||
|
result = truncate_embed_field(text)
|
||||||
|
assert len(result) == 1024
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_custom_max_len(self) -> None:
|
||||||
|
"""Кастомный max_len."""
|
||||||
|
text = "A" * 150
|
||||||
|
result = truncate_embed_field(text, max_len=100)
|
||||||
|
assert len(result) == 100
|
||||||
|
assert result.endswith("...")
|
||||||
|
|||||||
216
tests/test_help_command.py
Normal file
216
tests/test_help_command.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
"""Тесты для bot.TextHelpCommand — текстовая справка по командам."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def help_command() -> "bot.TextHelpCommand":
|
||||||
|
"""Создать экземпляр TextHelpCommand."""
|
||||||
|
import bot
|
||||||
|
return bot.TextHelpCommand()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCommandSignature:
|
||||||
|
"""Тесты get_command_signature."""
|
||||||
|
|
||||||
|
def test_simple_command_no_signature(self, help_command) -> None:
|
||||||
|
"""Команда без параметров."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "pg"
|
||||||
|
cmd.signature = ""
|
||||||
|
result = help_command.get_command_signature(cmd)
|
||||||
|
assert result == "!pg "
|
||||||
|
|
||||||
|
def test_command_with_signature(self, help_command) -> None:
|
||||||
|
"""Команда с параметрами."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "search"
|
||||||
|
cmd.signature = "<query>"
|
||||||
|
result = help_command.get_command_signature(cmd)
|
||||||
|
assert result == "!search <query>"
|
||||||
|
|
||||||
|
def test_group_command(self, help_command) -> None:
|
||||||
|
"""Групповая команда."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "mod ban"
|
||||||
|
cmd.signature = "<user>"
|
||||||
|
result = help_command.get_command_signature(cmd)
|
||||||
|
assert result == "!mod ban <user>"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendBotHelp:
|
||||||
|
"""Тесты send_bot_help."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bot_help_shows_commands(self, help_command) -> None:
|
||||||
|
"""Должен показать список команд по когам."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "pg"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = "Прогноз погоды"
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
cog.qualified_name = "Pg"
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {cog: [cmd], None: []}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once()
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "Доступные команды:" in message
|
||||||
|
assert "!pg - Прогноз погоды" in message
|
||||||
|
assert "!<название команды>" in message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bot_help_skips_hidden(self, help_command) -> None:
|
||||||
|
"""Скрытые команды не должны показываться."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "hidden_cmd"
|
||||||
|
cmd.hidden = True
|
||||||
|
cmd.short_doc = "Скрытая"
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {cog: [cmd]}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once()
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "hidden_cmd" not in message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bot_help_skips_none_cog(self, help_command) -> None:
|
||||||
|
"""Команды без cog (None) должны пропускаться."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "built_in"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = "Встроенная"
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {None: [cmd]}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once()
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "built_in" not in message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_bot_help_empty_doc(self, help_command) -> None:
|
||||||
|
"""Команда без doc -> пустая строка описания."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "cat"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = ""
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
mapping = {cog: [cmd]}
|
||||||
|
await help_command.send_bot_help(mapping)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "!cat - " in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendCommandHelp:
|
||||||
|
"""Тесты send_command_help."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_command_help_basic(self, help_command) -> None:
|
||||||
|
"""Базовая справка по команде."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "pg"
|
||||||
|
cmd.signature = ""
|
||||||
|
cmd.doc = "Прогноз погоды"
|
||||||
|
cmd.short_doc = "Прогноз погоды"
|
||||||
|
cmd.aliases = []
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_command_help(cmd)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "!pg " in message
|
||||||
|
assert "Прогноз погоды" in message
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_command_help_with_aliases(self, help_command) -> None:
|
||||||
|
"""Команда с алиасами."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.qualified_name = "pg"
|
||||||
|
cmd.signature = ""
|
||||||
|
cmd.doc = "Погода"
|
||||||
|
cmd.short_doc = "Погода"
|
||||||
|
cmd.aliases = ["weather", "w"]
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_command_help(cmd)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "!weather, !w" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendCogHelp:
|
||||||
|
"""Тесты send_cog_help."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_cog_help(self, help_command) -> None:
|
||||||
|
"""Справка по когу."""
|
||||||
|
cmd = MagicMock()
|
||||||
|
cmd.name = "pg"
|
||||||
|
cmd.hidden = False
|
||||||
|
cmd.short_doc = "Погода"
|
||||||
|
|
||||||
|
cog = MagicMock()
|
||||||
|
cog.qualified_name = "Pg"
|
||||||
|
cog.get_commands.return_value = [cmd]
|
||||||
|
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_cog_help(cog)
|
||||||
|
|
||||||
|
message = destination.send.call_args[0][0]
|
||||||
|
assert "[Pg]:" in message
|
||||||
|
assert "!pg - Погода" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendErrorMessage:
|
||||||
|
"""Тесты send_error_message."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_error_message(self, help_command) -> None:
|
||||||
|
"""Сообщение об ошибке."""
|
||||||
|
destination = MagicMock()
|
||||||
|
destination.send = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
with patch.object(help_command, "get_destination", return_value=destination):
|
||||||
|
await help_command.send_error_message("Command not found")
|
||||||
|
|
||||||
|
destination.send.assert_awaited_once_with("Command not found")
|
||||||
Loading…
x
Reference in New Issue
Block a user