Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 6 additions & 25 deletions bot/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
_CODE_FENCE_CLOSE = "\n```"
_PRE_OPEN = '<pre><code class="language-python">'
_PRE_CLOSE = "</code></pre>"
_TELEGRAM_MAX_LENGTH = 4096


def _difficulty_emoji(difficulty: str) -> str:
Expand Down Expand Up @@ -66,7 +67,7 @@ def format_card(card: Card) -> list[str]:
if tags:
tags_line = " ".join(f"<code>{escape(t)}</code>" 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):
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand All @@ -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
25 changes: 14 additions & 11 deletions bot/tests/test_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from cards import (
_PRE_CLOSE,
_PRE_OPEN,
_split_telegram,
_split_text,
format_card,
format_card_discord,
get_next_cards,
Expand Down Expand Up @@ -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:
Expand Down
137 changes: 137 additions & 0 deletions bot/tests/test_main.py
Original file line number Diff line number Diff line change
@@ -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]
Loading