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 341269b..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,29 +45,32 @@ 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 = "слово " * 1000
- parts = _split_telegram(text)
- assert "".join(parts).replace(" ", "") == text.replace(" ", "")
+ text = "\n".join(f"строка {i}" for i in range(1000))
+ 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 + "\n"
- text = line * 50
- parts = _split_telegram(text, limit=512)
+ line = "а" * 100
+ text = (line + "\n") * 50
+ parts = _split_text(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:
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]