From bd2377d2038e8041fd7e5f39de80d8ec357bbab3 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 21:40:11 +0300 Subject: [PATCH 01/10] =?UTF-8?q?test:=20404=20=D0=B4=D0=BB=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B5=D0=B9=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20?= =?UTF-8?q?per=5Fpage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_cards.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/tests/test_cards.py b/backend/tests/test_cards.py index 8c2f042..c4c03bc 100644 --- a/backend/tests/test_cards.py +++ b/backend/tests/test_cards.py @@ -140,6 +140,15 @@ def test_tags_filter_treats_underscore_literally(self, client, db): assert data["total"] == 1 assert data["items"][0]["question"] == "A" + def test_per_page_zero_rejected(self, client, db): + assert client.get("/api/cards?per_page=0").status_code == 422 + + def test_per_page_above_limit_rejected(self, client, db): + assert client.get("/api/cards?per_page=501").status_code == 422 + + def test_per_page_at_limit_accepted(self, client, db): + assert client.get("/api/cards?per_page=500").status_code == 200 + class TestGetCardById: def test_returns_card(self, client, db): @@ -159,6 +168,9 @@ def test_correct_id_returned(self, client, db): data = client.get(f"/api/cards/{card.id}").json() assert data["id"] == card.id + def test_unknown_id_returns_404(self, client, db): + assert client.get("/api/cards/999").status_code == 404 + class TestGetCategories: def test_returns_categories(self, client, db): From 9ed317123cf20bac40a6501fb6935fc68a50970e Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 21:47:06 +0300 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=91=D0=BD=20=D0=BD=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D1=83=D0=B5=D0=BC=D1=8B=D0=B9=20get=5Fdb=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/database.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bot/database.py b/bot/database.py index b75641d..871fe2a 100644 --- a/bot/database.py +++ b/bot/database.py @@ -9,11 +9,3 @@ ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() - - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() From 79334a6e468e3977d05ea74f0d3fb36fad33a8df Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 21:54:18 +0300 Subject: [PATCH 03/10] =?UTF-8?q?chore:=20pytest.ini=20=D0=B2=20=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B5=20(pythonpath=20=D0=B8=20testpaths)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/pytest.ini | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bot/pytest.ini diff --git a/bot/pytest.ini b/bot/pytest.ini new file mode 100644 index 0000000..c7b23ec --- /dev/null +++ b/bot/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = . +testpaths = tests From 2b0698844002b0d0e8bf44df9db3c9c8562fe002 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:00:06 +0300 Subject: [PATCH 04/10] =?UTF-8?q?test:=20sys.path.insert=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=91=D0=BD=20=D0=BD=D0=B0=20pythonpath=20?= =?UTF-8?q?=D0=B2=20pytest.ini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/tests/test_cards.py | 5 ----- bot/tests/test_cards_db.py | 5 ----- bot/tests/test_config.py | 5 ----- bot/tests/test_discord_sender.py | 4 ---- bot/tests/test_main.py | 4 ---- bot/tests/test_state.py | 3 --- 6 files changed, 26 deletions(-) diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index 67775f2..008462f 100644 --- a/bot/tests/test_cards.py +++ b/bot/tests/test_cards.py @@ -1,6 +1,4 @@ import json -import os -import sys from html import unescape from unittest.mock import MagicMock @@ -15,9 +13,6 @@ get_next_cards, get_random_card, ) - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - from config import settings from models import Card diff --git a/bot/tests/test_cards_db.py b/bot/tests/test_cards_db.py index 6bcdce6..692e013 100644 --- a/bot/tests/test_cards_db.py +++ b/bot/tests/test_cards_db.py @@ -1,13 +1,8 @@ -import os -import sys - import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - from cards import get_next_cards from models import Card diff --git a/bot/tests/test_config.py b/bot/tests/test_config.py index b96a024..a390d8d 100644 --- a/bot/tests/test_config.py +++ b/bot/tests/test_config.py @@ -1,10 +1,5 @@ -import os -import sys - import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - import config diff --git a/bot/tests/test_discord_sender.py b/bot/tests/test_discord_sender.py index b519aef..c8432e2 100644 --- a/bot/tests/test_discord_sender.py +++ b/bot/tests/test_discord_sender.py @@ -1,12 +1,8 @@ import asyncio import logging -import os -import sys import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - from discord_sender import _MAX_ATTEMPTS, DiscordSender diff --git a/bot/tests/test_main.py b/bot/tests/test_main.py index a9f3865..4aa7a1c 100644 --- a/bot/tests/test_main.py +++ b/bot/tests/test_main.py @@ -1,13 +1,9 @@ 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(): diff --git a/bot/tests/test_state.py b/bot/tests/test_state.py index bff71be..fa8de60 100644 --- a/bot/tests/test_state.py +++ b/bot/tests/test_state.py @@ -1,10 +1,7 @@ import os -import sys import pytest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) - import state From db3acefab3037ee6152bf32a51dcdf293415cb47 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:06:34 +0300 Subject: [PATCH 05/10] =?UTF-8?q?test:=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?parse=5Fcards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_migrate_md.py | 74 ++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 backend/tests/test_migrate_md.py diff --git a/backend/tests/test_migrate_md.py b/backend/tests/test_migrate_md.py new file mode 100644 index 0000000..5f7f199 --- /dev/null +++ b/backend/tests/test_migrate_md.py @@ -0,0 +1,74 @@ +from app.scripts.migrate_md import parse_cards + + +def md(*lines: str) -> str: + return "\n".join(lines) + + +class TestParseCards: + def test_single_card(self): + cards = parse_cards( + md("# Python", "#### Что такое list?", "Изменяемая последовательность.") + ) + assert cards == [ + { + "question": "Что такое list?", + "answer": "Изменяемая последовательность.", + "code_example": None, + "category": "Python", + "tags": [], + "difficulty": "normal", + } + ] + + def test_default_category_when_no_header(self): + cards = parse_cards(md("### Вопрос?", "Ответ.")) + assert cards[0]["category"] == "General" + + def test_h2_changes_category(self): + cards = parse_cards( + md("# Python", "## Функции", "### Что такое lambda?", "Ответ.") + ) + assert cards[0]["category"] == "Функции" + + def test_several_cards_in_order(self): + cards = parse_cards( + md("# A", "### Q1", "a1", "### Q2", "a2", "# B", "### Q3", "a3") + ) + assert [(c["question"], c["category"]) for c in cards] == [ + ("Q1", "A"), + ("Q2", "A"), + ("Q3", "B"), + ] + + def test_text_before_first_question_ignored(self): + cards = parse_cards(md("вступление", "# A", "### Q", "ответ")) + assert len(cards) == 1 + assert cards[0]["answer"] == "ответ" + + def test_single_code_block(self): + cards = parse_cards(md("### Q", "текст", "```python", "print(1)", "```")) + assert cards[0]["answer"] == "текст" + assert cards[0]["code_example"] == "print(1)" + + def test_several_code_blocks_numbered(self): + cards = parse_cards( + md( + "### Q", + "текст", + "```python", + "print(1)", + "```", + "```python", + "print(2)", + "```", + ) + ) + assert cards[0]["code_example"] == ( + "# Пример 1\n\nprint(1)\n\n# Пример 2\n\nprint(2)" + ) + + def test_header_inside_code_block_is_not_a_question(self): + cards = parse_cards(md("### Q", "текст", "```python", "# комментарий", "```")) + assert len(cards) == 1 + assert cards[0]["code_example"] == "# комментарий" From 38fc81b1894df8a6ee3bf3b68a5dd2d8423d7ef3 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:23:29 +0300 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=B5=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=20=D0=BF=D0=BE=20(=D0=B2=D0=BE?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81,=20=D0=BA=D0=B0=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/scripts/migrate_md.py | 50 ++++++++++++++++++++----------- backend/tests/test_migrate_md.py | 45 ++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/backend/app/scripts/migrate_md.py b/backend/app/scripts/migrate_md.py index 086086e..6a70a36 100644 --- a/backend/app/scripts/migrate_md.py +++ b/backend/app/scripts/migrate_md.py @@ -111,6 +111,37 @@ def flush_card(): return cards +def import_cards(db, cards_data: list[dict]) -> tuple[int, int]: + """Добавляет карточки в БД, пропуская дубли по паре (вопрос, категория). + Возвращает (добавлено, пропущено как дубли). + Карточки с пустым вопросом или ответом не считаются ни добавленными, ни дублями. + """ + seen = set(db.query(Card.question, Card.category).all()) + inserted = 0 + skipped = 0 + for data in cards_data: + if not data["question"] or not data["answer"]: + continue + key = (data["question"], data["category"]) + if key in seen: + skipped += 1 + continue + seen.add(key) + db.add( + Card( + question=data["question"], + answer=data["answer"], + code_example=data["code_example"], + category=data["category"], + tags=json.dumps(data["tags"], ensure_ascii=False), + difficulty=data["difficulty"], + ) + ) + inserted += 1 + db.commit() + return inserted, skipped + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--file", required=True, help="Path to questions.md") @@ -132,23 +163,8 @@ def main(): db.commit() print("Старые карточки удалены") - inserted = 0 - for data in cards_data: - if not data["question"] or not data["answer"]: - continue - card = Card( - question=data["question"], - answer=data["answer"], - code_example=data["code_example"], - category=data["category"], - tags=json.dumps(data["tags"], ensure_ascii=False), - difficulty=data["difficulty"], - ) - db.add(card) - inserted += 1 - - db.commit() - print(f"Импортировано: {inserted} карточек") + inserted, skipped = import_cards(db, cards_data) + print(f"Импортировано: {inserted} карточек, пропущено дублей: {skipped}") finally: db.close() diff --git a/backend/tests/test_migrate_md.py b/backend/tests/test_migrate_md.py index 5f7f199..fcaa9d4 100644 --- a/backend/tests/test_migrate_md.py +++ b/backend/tests/test_migrate_md.py @@ -1,10 +1,24 @@ +from app.models import Card +from app.scripts import migrate_md from app.scripts.migrate_md import parse_cards +from tests.conftest import make_card def md(*lines: str) -> str: return "\n".join(lines) +def card_data(question: str, category: str = "Python", answer: str = "Ответ.") -> dict: + return { + "question": question, + "answer": answer, + "code_example": None, + "category": category, + "tags": [], + "difficulty": "normal", + } + + class TestParseCards: def test_single_card(self): cards = parse_cards( @@ -72,3 +86,34 @@ def test_header_inside_code_block_is_not_a_question(self): cards = parse_cards(md("### Q", "текст", "```python", "# комментарий", "```")) assert len(cards) == 1 assert cards[0]["code_example"] == "# комментарий" + + +class TestImportCards: + def test_inserts_new_cards(self, db): + result = migrate_md.import_cards(db, [card_data("Q1"), card_data("Q2")]) + assert result == (2, 0) + assert db.query(Card).count() == 2 + + def test_second_run_skips_duplicates(self, db): + data = [card_data("Q1"), card_data("Q2")] + migrate_md.import_cards(db, data) + assert migrate_md.import_cards(db, data) == (0, 2) + assert db.query(Card).count() == 2 + + def test_existing_card_in_db_skipped(self, db): + make_card(db, question="Q", category="Python") + assert migrate_md.import_cards(db, [card_data("Q")]) == (0, 1) + assert db.query(Card).count() == 1 + + def test_same_question_in_other_category_is_not_duplicate(self, db): + data = [card_data("Q", "Python"), card_data("Q", "Django")] + assert migrate_md.import_cards(db, data) == (2, 0) + + def test_duplicates_inside_file_skipped(self, db): + assert migrate_md.import_cards(db, [card_data("Q"), card_data("Q")]) == (1, 1) + assert db.query(Card).count() == 1 + + def test_empty_question_or_answer_not_imported(self, db): + data = [card_data(""), card_data("Q", answer="")] + assert migrate_md.import_cards(db, data) == (0, 0) + assert db.query(Card).count() == 0 From 3ee2e3dcc32820e7aa7a495f3da413f8f514a28a Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:32:52 +0300 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D1=8B=D1=85?= =?UTF-8?q?=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B0=D1=85=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/scripts/migrate_md.py | 58 +++++++++++++++++++++++++++++++ backend/tests/test_migrate_md.py | 50 ++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/backend/app/scripts/migrate_md.py b/backend/app/scripts/migrate_md.py index 6a70a36..f80f0d4 100644 --- a/backend/app/scripts/migrate_md.py +++ b/backend/app/scripts/migrate_md.py @@ -111,6 +111,61 @@ def flush_card(): return cards +def find_unclosed_code(text: str) -> list[str]: + """Ищет незакрытые блоки кода и вопросы, потерянные из-за них. + Это эвристика: ограда с языком внутри блока считается началом нового блока. + """ + warnings: list[str] = [] + category = "General" + question = None + in_code = False + code_owner: tuple[str | None, str] = (None, category) + lost: list[str] = [] + + def report_unclosed() -> None: + q, c = code_owner + warnings.append( + f'Незакрытый блок кода в вопросе "{q or "?"}" (категория "{c}"): ' + "проверьте карточку" + ) + warnings.extend( + f'Заголовок "{title}" внутри незакрытого блока: карточка не будет создана' + for title in lost + ) + + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + if not in_code: + in_code = True + code_owner = (question, category) + elif len(stripped) > 3: + report_unclosed() + code_owner = (question, category) + else: + in_code = False + lost = [] + continue + + header = re.match(r"^(#{1,6})\s+(.+)$", line) + if in_code: + if header and len(header.group(1)) >= 3: + lost.append(header.group(2).strip()) + continue + + if header: + title = header.group(2).strip() + if len(header.group(1)) <= 2: + category = title + question = None + else: + question = title + + if in_code: + report_unclosed() + return warnings + + def import_cards(db, cards_data: list[dict]) -> tuple[int, int]: """Добавляет карточки в БД, пропуская дубли по паре (вопрос, категория). Возвращает (добавлено, пропущено как дубли). @@ -153,6 +208,9 @@ def main(): with open(args.file, encoding="utf-8") as f: text = f.read() + for warning in find_unclosed_code(text): + print(f"ВНИМАНИЕ: {warning}") + cards_data = parse_cards(text) print(f"Найдено карточек: {len(cards_data)}") diff --git a/backend/tests/test_migrate_md.py b/backend/tests/test_migrate_md.py index fcaa9d4..5def355 100644 --- a/backend/tests/test_migrate_md.py +++ b/backend/tests/test_migrate_md.py @@ -117,3 +117,53 @@ def test_empty_question_or_answer_not_imported(self, db): data = [card_data(""), card_data("Q", answer="")] assert migrate_md.import_cards(db, data) == (0, 0) assert db.query(Card).count() == 0 + + +class TestFindUnclosedCode: + def test_closed_blocks_give_no_warnings(self): + text = md( + "# Python", + "### Q", + "```python", + "print(1)", + "```", + "```python", + "print(2)", + "```", + ) + assert migrate_md.find_unclosed_code(text) == [] + + def test_header_inside_closed_block_is_not_a_problem(self): + text = md("### Q", "```python", "# комментарий", "```") + assert migrate_md.find_unclosed_code(text) == [] + + def test_unclosed_at_end_of_file(self): + text = md("# Python", "### Что такое lambda?", "текст", "```python", "x = 1") + warnings = migrate_md.find_unclosed_code(text) + assert len(warnings) == 1 + assert "Что такое lambda?" in warnings[0] + assert "Python" in warnings[0] + + def test_unclosed_in_middle_reports_question_and_lost_header(self): + text = md( + "# Python", + "### Q1", + "```python", + "x = 1", + "### Q2", + "ответ", + "```python", + "y = 2", + "```", + ) + warnings = migrate_md.find_unclosed_code(text) + assert len(warnings) == 2 + assert "Q1" in warnings[0] + assert "Q2" in warnings[1] + + def test_unclosed_at_end_reports_lost_header_too(self): + text = md("### Q1", "```python", "x = 1", "### Q2", "текст") + warnings = migrate_md.find_unclosed_code(text) + assert len(warnings) == 2 + assert "Q1" in warnings[0] + assert "Q2" in warnings[1] From 9a41cf982614b127857e6e1a5dfc97f11d9f9c8d Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:43:25 +0300 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20create=5Fall=20=D0=B2=20migra?= =?UTF-8?q?te=5Fmd=20=D0=B2=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D1=8F=D0=B5?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D1=83=D1=81=D0=BA=D0=B5,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/scripts/migrate_md.py | 3 +-- backend/tests/test_migrate_md.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/backend/app/scripts/migrate_md.py b/backend/app/scripts/migrate_md.py index f80f0d4..316808f 100644 --- a/backend/app/scripts/migrate_md.py +++ b/backend/app/scripts/migrate_md.py @@ -23,8 +23,6 @@ from app.database import SessionLocal, engine from app.models import Base, Card -Base.metadata.create_all(bind=engine) - def parse_cards(text: str) -> list[dict]: cards = [] @@ -214,6 +212,7 @@ def main(): cards_data = parse_cards(text) print(f"Найдено карточек: {len(cards_data)}") + Base.metadata.create_all(bind=engine) db = SessionLocal() try: if args.clear: diff --git a/backend/tests/test_migrate_md.py b/backend/tests/test_migrate_md.py index 5def355..f5488c0 100644 --- a/backend/tests/test_migrate_md.py +++ b/backend/tests/test_migrate_md.py @@ -1,3 +1,6 @@ +import importlib +from unittest.mock import patch + from app.models import Card from app.scripts import migrate_md from app.scripts.migrate_md import parse_cards @@ -167,3 +170,10 @@ def test_unclosed_at_end_reports_lost_header_too(self): assert len(warnings) == 2 assert "Q1" in warnings[0] assert "Q2" in warnings[1] + + +class TestImportHasNoSideEffects: + def test_import_does_not_create_tables(self): + with patch("app.models.Base.metadata.create_all") as create_all: + importlib.reload(migrate_md) + create_all.assert_not_called() From 70595e012e6697d86115cf2b758bf8b6a5ca524f Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:49:02 +0300 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20get=5Frandom=5Fcard=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B1=D0=B8=D1=80=D0=B0=D0=B5=D1=82=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D1=83=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83=20=D0=B2=20SQL,?= =?UTF-8?q?=20=D0=B0=20=D0=BD=D0=B5=20=D0=B3=D1=80=D1=83=D0=B7=D0=B8=D1=82?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cards.py | 7 ++----- bot/tests/test_cards.py | 16 ---------------- bot/tests/test_cards_db.py | 26 ++++++++++++++++++++++++-- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/bot/cards.py b/bot/cards.py index db90b3e..fe4ed42 100644 --- a/bot/cards.py +++ b/bot/cards.py @@ -1,8 +1,8 @@ import json import logging -import random from html import escape +from sqlalchemy import func from sqlalchemy.orm import Session from config import settings @@ -47,10 +47,7 @@ def get_next_cards(db: Session, count: int, last_id: int = 0) -> list[Card]: def get_random_card(db: Session) -> Card | None: - cards = db.query(Card).all() - if not cards: - return None - return random.choice(cards) + return db.query(Card).order_by(func.random()).first() def format_card(card: Card) -> list[str]: diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index 008462f..a01a840 100644 --- a/bot/tests/test_cards.py +++ b/bot/tests/test_cards.py @@ -11,7 +11,6 @@ format_card, format_card_discord, get_next_cards, - get_random_card, ) from config import settings from models import Card @@ -182,21 +181,6 @@ def test_empty_db_returns_empty(self): assert result == [] -class TestGetRandomCard: - def test_returns_card(self): - db = MagicMock() - cards = [make_card(id=1), make_card(id=2)] - db.query().all.return_value = cards - result = get_random_card(db) - assert result in cards - - def test_returns_none_for_empty_db(self): - db = MagicMock() - db.query().all.return_value = [] - result = get_random_card(db) - assert result is None - - class TestFormatCardDiscord: def test_long_code_parts_within_discord_limit(self): """Каждое сообщение вместе с обёрткой ```python не длиннее лимита Discord.""" diff --git a/bot/tests/test_cards_db.py b/bot/tests/test_cards_db.py index 692e013..173c417 100644 --- a/bot/tests/test_cards_db.py +++ b/bot/tests/test_cards_db.py @@ -1,9 +1,9 @@ import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool -from cards import get_next_cards +from cards import get_next_cards, get_random_card from models import Card @@ -56,3 +56,25 @@ def test_last_id_beyond_max_starts_from_beginning(self, db): def test_empty_db_returns_empty(self, db): assert get_next_cards(db, 3, last_id=0) == [] + +class TestGetRandomCardRealDb: + def test_returns_one_of_the_cards(self, db): + add_cards(db, 3) + assert get_random_card(db).id in {1, 2, 3} + + def test_empty_db_returns_none(self, db): + assert get_random_card(db) is None + + def test_selects_single_row_in_sql(self, db): + add_cards(db, 5) + statements: list[str] = [] + event.listen( + db.get_bind(), + "before_cursor_execute", + lambda conn, cursor, statement, params, context, many: statements.append( + statement + ), + ) + get_random_card(db) + assert len(statements) == 1 + assert "LIMIT" in statements[0].upper() From 9a57a4547fbda90fc59c3ae6d4e434bf4fb7d934 Mon Sep 17 00:00:00 2001 From: Wlwool Date: Tue, 6 Oct 2026 22:56:10 +0300 Subject: [PATCH 10/10] =?UTF-8?q?style:=20=D0=BF=D1=83=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=20TestGetRandomCardRealDb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/tests/test_cards_db.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/tests/test_cards_db.py b/bot/tests/test_cards_db.py index 173c417..12b033d 100644 --- a/bot/tests/test_cards_db.py +++ b/bot/tests/test_cards_db.py @@ -57,6 +57,7 @@ def test_last_id_beyond_max_starts_from_beginning(self, db): def test_empty_db_returns_empty(self, db): assert get_next_cards(db, 3, last_id=0) == [] + class TestGetRandomCardRealDb: def test_returns_one_of_the_cards(self, db): add_cards(db, 3)