diff --git a/backend/app/scripts/migrate_md.py b/backend/app/scripts/migrate_md.py index 086086e..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 = [] @@ -111,6 +109,92 @@ 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]: + """Добавляет карточки в БД, пропуская дубли по паре (вопрос, категория). + Возвращает (добавлено, пропущено как дубли). + Карточки с пустым вопросом или ответом не считаются ни добавленными, ни дублями. + """ + 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") @@ -122,9 +206,13 @@ 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)}") + Base.metadata.create_all(bind=engine) db = SessionLocal() try: if args.clear: @@ -132,23 +220,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_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): diff --git a/backend/tests/test_migrate_md.py b/backend/tests/test_migrate_md.py new file mode 100644 index 0000000..f5488c0 --- /dev/null +++ b/backend/tests/test_migrate_md.py @@ -0,0 +1,179 @@ +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 +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( + 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"] == "# комментарий" + + +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 + + +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] + + +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() 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/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() 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 diff --git a/bot/tests/test_cards.py b/bot/tests/test_cards.py index 67775f2..a01a840 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 @@ -13,11 +11,7 @@ format_card, format_card_discord, 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 @@ -187,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 6bcdce6..12b033d 100644 --- a/bot/tests/test_cards_db.py +++ b/bot/tests/test_cards_db.py @@ -1,14 +1,9 @@ -import os -import sys - import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event 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 cards import get_next_cards, get_random_card from models import Card @@ -61,3 +56,26 @@ 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() 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