From eae1a027199067490fe8ecfc75f1781b6acf95b9 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 15:54:33 +0300 Subject: [PATCH 1/3] =?UTF-8?q?test:=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20se?= =?UTF-8?q?nd=5Fscheduled=5Fcards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/tests/test_main.py | 137 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 bot/tests/test_main.py diff --git a/bot/tests/test_main.py b/bot/tests/test_main.py new file mode 100644 index 0000000..a9f3865 --- /dev/null +++ b/bot/tests/test_main.py @@ -0,0 +1,137 @@ +import asyncio +import os +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + + +@pytest.fixture(scope="module") +def main_module(): + """Импортирует main с подменённым aiogram.Bot: токен не проверяется, сети нет.""" + with patch("aiogram.Bot"): + import main + return main + + +def make_card(card_id: int) -> SimpleNamespace: + return SimpleNamespace(id=card_id) + + +class Env: + """Подменяет всё внешнее в main и записывает, что произошло.""" + + def __init__(self, main, monkeypatch) -> None: + self.main = main + self.monkeypatch = monkeypatch + self.saved: list[int] = [] + self.retries: list[int] = [] + self.sleeps: list[float] = [] + self.next_calls: list[int] = [] + self.discord = MagicMock() + self.discord.send = AsyncMock(return_value=True) + self.tg = AsyncMock() + + async def fake_sleep(delay): + self.sleeps.append(delay) + + monkeypatch.setattr(main, "send_lock", asyncio.Lock()) + monkeypatch.setattr(main, "SessionLocal", MagicMock()) + monkeypatch.setattr(main, "load_last_id", lambda: 0) + monkeypatch.setattr(main, "save_last_id", self.saved.append) + monkeypatch.setattr(main, "format_card_discord", lambda card: ["текст"]) + monkeypatch.setattr(main, "_send_tg_to_all", self.tg) + monkeypatch.setattr( + main, "_schedule_retry", lambda scheduler, discord: self.retries.append(1) + ) + monkeypatch.setattr(main.settings, "pause_between_cards_seconds", 7) + monkeypatch.setattr(main.settings, "telegram_enabled", True) + monkeypatch.setattr(main.asyncio, "sleep", fake_sleep) + + def use_cards(self, cards) -> None: + def fake_get_next_cards(db, count, last_id): + self.next_calls.append(last_id) + return cards + + self.monkeypatch.setattr(self.main, "get_next_cards", fake_get_next_cards) + + async def session(self, cards) -> None: + self.use_cards(cards) + await self.main.send_scheduled_cards(MagicMock(), self.discord) + + def run(self, cards) -> None: + asyncio.run(self.session(cards)) + + +@pytest.fixture +def env(main_module, monkeypatch) -> Env: + return Env(main_module, monkeypatch) + + +class TestSendScheduledCards: + def test_skips_when_session_already_running(self, env): + async def scenario(): + async with env.main.send_lock: + await env.session([make_card(1)]) + + asyncio.run(scenario()) + assert env.next_calls == [] + assert env.saved == [] + + def test_no_cards_does_nothing(self, env): + env.run([]) + assert env.saved == [] + assert env.discord.send.await_count == 0 + assert env.tg.await_count == 0 + + def test_both_channels_send_every_card_and_save_progress(self, env): + env.run([make_card(1), make_card(2), make_card(3)]) + assert env.saved == [1, 2, 3] + assert env.discord.send.await_count == 3 + assert env.tg.await_count == 3 + assert env.sleeps == [7, 7] + assert env.retries == [] + + def test_discord_only_mode_skips_telegram(self, env): + env.monkeypatch.setattr(env.main.settings, "telegram_enabled", False) + env.run([make_card(1), make_card(2)]) + assert env.saved == [1, 2] + assert env.discord.send.await_count == 2 + assert env.tg.await_count == 0 + + def test_telegram_failure_saves_progress_and_schedules_retry(self, env): + env.tg.side_effect = [None, RuntimeError("down"), None] + env.run([make_card(1), make_card(2), make_card(3)]) + assert env.saved == [1, 1] + assert env.retries == [1] + assert env.tg.await_count == 2 + assert env.discord.send.await_count == 2 + assert env.sleeps == [7] + + def test_telegram_failure_on_first_card_keeps_old_last_id(self, env): + env.monkeypatch.setattr(env.main, "load_last_id", lambda: 5) + env.tg.side_effect = RuntimeError("down") + env.run([make_card(6), make_card(7)]) + assert env.saved == [5] + assert env.retries == [1] + assert env.sleeps == [] + + @pytest.mark.parametrize( + "discord_kwargs", + [{"return_value": False}, {"side_effect": RuntimeError("boom")}], + ) + def test_discord_failure_does_not_stop_session(self, env, discord_kwargs): + env.discord.send = AsyncMock(**discord_kwargs) + env.run([make_card(1), make_card(2)]) + assert env.saved == [1, 2] + assert env.tg.await_count == 2 + assert env.retries == [] + + def test_discord_only_failure_does_not_stop_session(self, env): + env.monkeypatch.setattr(env.main.settings, "telegram_enabled", False) + env.discord.send = AsyncMock(return_value=False) + env.run([make_card(1), make_card(2)]) + assert env.saved == [1, 2] From 24c04b52e7e8af1f6feffb411a17a571cb34c67c Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 16:15:35 +0300 Subject: [PATCH 2/3] =?UTF-8?q?test:=20=D1=83=D1=81=D0=B8=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B1=D0=B8=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D0=B0=20=D0=BD=D0=B0=20=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/tests/test_cards.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index 341269b..b1c74d5 100644 --- a/bot/tests/test_cards.py +++ b/bot/tests/test_cards.py @@ -54,20 +54,23 @@ def test_long_message_split(self): assert all(len(p) <= 4096 for p in parts) def test_split_preserves_content(self): - text = "слово " * 1000 - parts = _split_telegram(text) - assert "".join(parts).replace(" ", "") == text.replace(" ", "") + text = "\n".join(f"строка {i}" for i in range(1000)) + parts = _split_telegram(text, limit=500) + assert len(parts) > 1 + assert "\n".join(parts) == text def test_exact_limit_not_split(self): text = "а" * 4096 assert _split_telegram(text) == [text] def test_split_prefers_newline(self): - line = "а" * 100 + "\n" - text = line * 50 + line = "а" * 100 + text = (line + "\n") * 50 parts = _split_telegram(text, limit=512) + assert len(parts) > 1 + assert all(len(part) <= 512 for part in parts) for part in parts: - assert len(part) <= 512 + assert all(item == line for item in part.split("\n") if item) class TestFormatCard: From 05ce8227f97d5ff9e9e99cb54d76a63bd0def110 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 16:24:30 +0300 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20=5Fsplit=5Ftelegram=20=D0=B8=20?= =?UTF-8?q?=5Fsplit=5Fdiscord=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B2=20=5Fsplit=5Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cards.py | 31 ++++++------------------------- bot/tests/test_cards.py | 12 ++++++------ 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/bot/cards.py b/bot/cards.py index 2840ffc..db90b3e 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -14,6 +14,7 @@ _CODE_FENCE_CLOSE = "\n```" _PRE_OPEN = '
'
 _PRE_CLOSE = "
" +_TELEGRAM_MAX_LENGTH = 4096 def _difficulty_emoji(difficulty: str) -> str: @@ -66,7 +67,7 @@ def format_card(card: Card) -> list[str]: if tags: tags_line = " ".join(f"{escape(t)}" for t in tags) text += f"\n\n🏷 {tags_line}" - parts = _split_telegram(text) + parts = _split_text(text, _TELEGRAM_MAX_LENGTH) if card.code_example: for code_part in _split_code_telegram(card.code_example): @@ -100,7 +101,8 @@ def _split_code_telegram(code: str, limit: int = 4096) -> list[str]: return parts -def _split_telegram(text: str, limit: int = 4096) -> list[str]: +def _split_text(text: str, limit: int) -> list[str]: + """Режет текст на части не длиннее limit, предпочитая границы строк.""" if len(text) <= limit: return [text] @@ -131,7 +133,7 @@ def format_card_discord(card: Card) -> list[str]: if tags: text += "\n\n🏷 " + " ".join(f"`{t}`" for t in tags) - parts = _split_discord(text) + parts = _split_text(text, settings.discord_max_length) if card.code_example: _append_discord_code(card.code_example, parts) return parts @@ -146,26 +148,5 @@ def _append_discord_code(code: str, parts: list[str]) -> None: parts.append(f"{_CODE_FENCE_OPEN}{code}{_CODE_FENCE_CLOSE}") return - for chunk in _split_discord(code, limit=chunk_limit): + for chunk in _split_text(code, chunk_limit): parts.append(f"{_CODE_FENCE_OPEN}{chunk}{_CODE_FENCE_CLOSE}") - - -def _split_discord(text: str, limit: int | None = None) -> list[str]: - if limit is None: - limit = settings.discord_max_length - - if len(text) <= limit: - return [text] - - parts = [] - while text: - if len(text) <= limit: - parts.append(text) - break - split_at = text.rfind("\n", 0, limit) - if split_at == -1: - split_at = limit - parts.append(text[:split_at]) - text = text[split_at:].lstrip() - - return parts diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index b1c74d5..67775f2 100644 --- a/bot/tests/test_cards.py +++ b/bot/tests/test_cards.py @@ -9,7 +9,7 @@ from cards import ( _PRE_CLOSE, _PRE_OPEN, - _split_telegram, + _split_text, format_card, format_card_discord, get_next_cards, @@ -45,28 +45,28 @@ def make_card(**kwargs) -> Card: class TestSplitMessage: def test_short_message_not_split(self): text = "короткое сообщение" - assert _split_telegram(text) == [text] + assert _split_text(text, 4096) == [text] def test_long_message_split(self): text = "а" * 5000 - parts = _split_telegram(text) + parts = _split_text(text, 4096) assert len(parts) > 1 assert all(len(p) <= 4096 for p in parts) def test_split_preserves_content(self): text = "\n".join(f"строка {i}" for i in range(1000)) - parts = _split_telegram(text, limit=500) + parts = _split_text(text, limit=500) assert len(parts) > 1 assert "\n".join(parts) == text def test_exact_limit_not_split(self): text = "а" * 4096 - assert _split_telegram(text) == [text] + assert _split_text(text, 4096) == [text] def test_split_prefers_newline(self): line = "а" * 100 text = (line + "\n") * 50 - parts = _split_telegram(text, limit=512) + parts = _split_text(text, limit=512) assert len(parts) > 1 assert all(len(part) <= 512 for part in parts) for part in parts: