initial commit (backend)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests package for Filwords PL backend."""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Общие фикстуры для тестов Filwords PL.
|
||||
Использует реальную БД (dev), но с изолированными тестовыми данными.
|
||||
После каждого теста данные удаляются.
|
||||
"""
|
||||
import pytest
|
||||
from config import DB_CONFIG
|
||||
from database import get_pool, init_db
|
||||
|
||||
|
||||
# Тестовые ID — чтобы не пересекаться с реальными данными
|
||||
TEST_USER_ID = 999999999
|
||||
TEST_TOPIC_ID = "test_topic_zzz"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
async def setup_database():
|
||||
"""Один раз на сессию: инициализирует БД (миграции)."""
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def conn():
|
||||
"""Соединение с БД для одного теста."""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as connection:
|
||||
yield connection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(conn):
|
||||
"""Создаёт тестового юзера, удаляет после теста."""
|
||||
await conn.execute("""
|
||||
INSERT INTO users (telegram_id, username, app_language, current_level)
|
||||
VALUES ($1, 'test_user', 'ru', 'A2')
|
||||
ON CONFLICT (telegram_id) DO NOTHING
|
||||
""", TEST_USER_ID)
|
||||
|
||||
yield TEST_USER_ID
|
||||
|
||||
# Cleanup — удаляем всё, что связано с юзером.
|
||||
# ВАЖНО: порядок — сначала дочерние таблицы (FK), потом родительские.
|
||||
await conn.execute("DELETE FROM user_skill_mastery WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_learning_answers WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_lesson_progress WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_topic_progress WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM learning_sessions WHERE user_id = $1", TEST_USER_ID)
|
||||
await conn.execute("DELETE FROM users WHERE telegram_id = $1", TEST_USER_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_session(conn, test_user):
|
||||
"""Тестовая сессия обучения (для FK в user_learning_answers)."""
|
||||
row = await conn.fetchrow("""
|
||||
INSERT INTO learning_sessions (user_id, level_code, topic_id, session_type)
|
||||
VALUES ($1, 'A2', 'test_topic', 'lesson')
|
||||
RETURNING id
|
||||
""", test_user)
|
||||
|
||||
session_id = row["id"]
|
||||
yield session_id
|
||||
|
||||
# 🔧 ФИКС: сначала удаляем ответы, ссылающиеся на сессию (FK),
|
||||
# только потом саму сессию
|
||||
await conn.execute("DELETE FROM user_learning_answers WHERE session_id = $1", session_id)
|
||||
await conn.execute("DELETE FROM learning_sessions WHERE id = $1", session_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_topic(conn):
|
||||
"""Создаёт тестовый топик, удаляет после теста."""
|
||||
await conn.execute("""
|
||||
INSERT INTO learning_topics (topic_id, level_code, order_number, name_pl, name_ru, name_uk, name_en)
|
||||
VALUES ($1, 'A2', 999, 'Test Topic', 'Тестовый топик', 'Тестовий топік', 'Test Topic')
|
||||
ON CONFLICT (topic_id) DO NOTHING
|
||||
""", TEST_TOPIC_ID)
|
||||
|
||||
yield TEST_TOPIC_ID
|
||||
|
||||
await conn.execute("DELETE FROM learning_lessons WHERE topic_id = $1", TEST_TOPIC_ID)
|
||||
await conn.execute("DELETE FROM learning_topics WHERE topic_id = $1", TEST_TOPIC_ID)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def create_test_lesson(conn, test_topic):
|
||||
"""
|
||||
Фабрика: создаёт урок с произвольным skill_tag.
|
||||
Использование: await create_test_lesson("test_skill")
|
||||
"""
|
||||
created_ids = []
|
||||
|
||||
async def _create(skill_tag: str, order: int = 1) -> str:
|
||||
lesson_id = f"test_lesson_{skill_tag}"
|
||||
await conn.execute("""
|
||||
INSERT INTO learning_lessons
|
||||
(lesson_id, topic_id, order_number, skill_tag,
|
||||
title_pl, title_ru, title_uk, title_en,
|
||||
explanation_pl, explanation_ru, explanation_uk, explanation_en)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
'Test Lesson', 'Тестовый урок', 'Тестовий урок', 'Test Lesson',
|
||||
'Test', 'Test', 'Test', 'Test')
|
||||
ON CONFLICT (lesson_id) DO NOTHING
|
||||
""", lesson_id, test_topic, order, skill_tag)
|
||||
created_ids.append(lesson_id)
|
||||
return lesson_id
|
||||
|
||||
yield _create
|
||||
|
||||
for lid in created_ids:
|
||||
await conn.execute("DELETE FROM learning_lessons WHERE lesson_id = $1", lid)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def clean_mastery(conn, test_user):
|
||||
"""Очищает user_skill_mastery для тестового юзера до и после теста."""
|
||||
await conn.execute("DELETE FROM user_skill_mastery WHERE user_id = $1", test_user)
|
||||
yield
|
||||
await conn.execute("DELETE FROM user_skill_mastery WHERE user_id = $1", test_user)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Тесты для check_and_award_achievements.
|
||||
Проверяют выдачу ачивок без дублей.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
async def test_achievement_not_awarded_twice(conn, test_user):
|
||||
"""
|
||||
Если ачивка уже выдана — второй раз не выдаётся.
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
# Чистим ачивки тестового юзера
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
# Первый раз
|
||||
result1 = await check_and_award_achievements(test_user, "levels_completed", 1000)
|
||||
|
||||
# Второй раз с тем же значением
|
||||
result2 = await check_and_award_achievements(test_user, "levels_completed", 1000)
|
||||
|
||||
# Второй раз должен вернуть пустой список
|
||||
assert len(result2) == 0
|
||||
|
||||
# Cleanup
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
|
||||
async def test_achievement_awarded_for_matching_value(conn, test_user):
|
||||
"""
|
||||
Ачивка выдаётся, если value соответствует требованию.
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
# Уровень 1 завершён — ачивка "first_level" (levels_completed >= 1)
|
||||
result = await check_and_award_achievements(test_user, "levels_completed", 1)
|
||||
|
||||
# Должна быть минимум одна ачивка (first_level)
|
||||
assert len(result) >= 1
|
||||
assert any("first" in a["code"].lower() or "level" in a["code"].lower() for a in result)
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
|
||||
async def test_achievement_returns_localized_name(conn, test_user):
|
||||
"""
|
||||
Название ачивки возвращается на языке пользователя (RU).
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
await conn.execute("UPDATE users SET app_language = 'ru' WHERE telegram_id = $1", test_user)
|
||||
|
||||
result = await check_and_award_achievements(test_user, "levels_completed", 1)
|
||||
|
||||
assert len(result) >= 1
|
||||
# Название должно быть непустой строкой (RU)
|
||||
assert result[0]["name"] # не пусто
|
||||
assert isinstance(result[0]["name"], str)
|
||||
|
||||
await conn.execute("DELETE FROM user_achievements WHERE user_id = $1", test_user)
|
||||
|
||||
|
||||
async def test_achievement_unknown_type_returns_empty(conn, test_user):
|
||||
"""
|
||||
Неизвестный тип требования → пустой список.
|
||||
"""
|
||||
from routers.achievements import check_and_award_achievements
|
||||
|
||||
result = await check_and_award_achievements(test_user, "nonexistent_type_xyz", 999)
|
||||
|
||||
assert result == []
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Тесты для update_skill_mastery.
|
||||
Проверяют формулы расчёта mastery, retention, trend.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ХЕЛПЕР: вставить ответ напрямую в БД
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def insert_answer(
|
||||
conn,
|
||||
user_id: int,
|
||||
session_id: int,
|
||||
skill_tag: str,
|
||||
correct: bool,
|
||||
question_type: str = "recognition",
|
||||
wrong_because: str | None = None,
|
||||
days_ago: int = 0,
|
||||
):
|
||||
"""Вставляет один ответ в user_learning_answers."""
|
||||
created_at = datetime.now() - timedelta(days=days_ago)
|
||||
await conn.execute("""
|
||||
INSERT INTO user_learning_answers (
|
||||
user_id, session_id, level_code, topic_id, question_id,
|
||||
selected_answer, correct, skill_tag, question_type,
|
||||
context_type, difficulty, time_taken_ms, hint_used,
|
||||
wrong_because, is_final_answer, created_at
|
||||
) VALUES (
|
||||
$1, $2, 'A2', 'test_topic', 'test_q',
|
||||
'x', $3, $4, $5,
|
||||
'lesson_practice', 1, 5000, FALSE,
|
||||
$6, TRUE, $7
|
||||
)
|
||||
""", user_id, session_id, correct, skill_tag, question_type, wrong_because, created_at)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ТЕСТЫ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def test_mastery_no_answers_returns_none(conn, test_user, clean_mastery):
|
||||
"""Если у юзера нет ответов по skill_tag — функция возвращает None."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "nonexistent_skill")
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_mastery_100_percent_accuracy(conn, test_user, test_session, clean_mastery):
|
||||
"""Если все ответы правильные — accuracy = 100, mastery = высокая."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
for _ in range(5):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result is not None
|
||||
assert result["attempts"] == 5
|
||||
assert result["accuracy"] == 100.0
|
||||
assert result["mastery"] >= 90.0
|
||||
|
||||
|
||||
async def test_mastery_0_percent_accuracy(conn, test_user, test_session, clean_mastery):
|
||||
"""Если все ответы неправильные — accuracy = 0, mastery = низкая."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
for _ in range(5):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=False, wrong_because="wrong_case")
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result is not None
|
||||
assert result["accuracy"] == 0.0
|
||||
assert result["mastery"] <= 30.0
|
||||
|
||||
|
||||
async def test_mastery_50_percent_accuracy(conn, test_user, test_session, clean_mastery):
|
||||
"""50% accuracy → mastery в среднем диапазоне."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
for i in range(6):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=(i % 2 == 0))
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result is not None
|
||||
assert 40.0 <= result["accuracy"] <= 60.0
|
||||
assert 40.0 <= result["mastery"] <= 80.0
|
||||
|
||||
|
||||
async def test_mastery_retention_fresh(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последний ответ был сегодня — retention_status = 'fresh'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=0)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=0)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["retention_status"] == "fresh"
|
||||
|
||||
|
||||
async def test_mastery_retention_needs_review(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последний ответ 10 дней назад — retention_status = 'needs_review'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=10)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=10)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["retention_status"] == "needs_review"
|
||||
|
||||
|
||||
async def test_mastery_retention_overdue(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последний ответ 20 дней назад — retention_status = 'overdue'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=20)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=20)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["retention_status"] == "overdue"
|
||||
|
||||
|
||||
async def test_mastery_trend_improving(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последние 3 ответа лучше, чем общая точность — trend = 'improving'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
# 7 старых неправильных, 3 свежих правильных
|
||||
for _ in range(7):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=False, days_ago=5)
|
||||
for _ in range(3):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=0)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
# 3 правильных из 10 = 30% accuracy, но recent_accuracy = 100% → improving
|
||||
assert result["trend"] == "improving"
|
||||
|
||||
|
||||
async def test_mastery_trend_declining(conn, test_user, test_session, clean_mastery):
|
||||
"""Если последние 3 ответа хуже, чем общая точность — trend = 'declining'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
# 7 старых правильных, 3 свежих неправильных
|
||||
for _ in range(7):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True, days_ago=5)
|
||||
for _ in range(3):
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=False, days_ago=0)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["trend"] == "declining"
|
||||
|
||||
|
||||
async def test_mastery_trend_insufficient_data(conn, test_user, test_session, clean_mastery):
|
||||
"""Если меньше 3 ответов — trend = 'insufficient_data'."""
|
||||
from routers.learning import update_skill_mastery
|
||||
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True)
|
||||
await insert_answer(conn, test_user, test_session, "test_skill", correct=True)
|
||||
|
||||
result = await update_skill_mastery(conn, test_user, "test_skill")
|
||||
|
||||
assert result["trend"] == "insufficient_data"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Тесты для get_next_lesson.
|
||||
Проверяют приоритеты: overdue → weak → test → next.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ХЕЛПЕРЫ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def set_user_level(conn, user_id: int, level: str):
|
||||
await conn.execute("UPDATE users SET current_level = $1 WHERE telegram_id = $2", level, user_id)
|
||||
|
||||
|
||||
async def insert_mastery(
|
||||
conn,
|
||||
user_id: int,
|
||||
skill_tag: str,
|
||||
mastery: float,
|
||||
retention: str = "fresh",
|
||||
attempts: int = 3,
|
||||
days_since: int = 0,
|
||||
):
|
||||
"""Вставляет запись в user_skill_mastery."""
|
||||
last_attempt = datetime.now() - timedelta(days=days_since)
|
||||
await conn.execute("""
|
||||
INSERT INTO user_skill_mastery (
|
||||
user_id, skill_tag, mastery_score, retention_status,
|
||||
attempts_count, last_attempt_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, skill_tag) DO UPDATE SET
|
||||
mastery_score = $3,
|
||||
retention_status = $4,
|
||||
attempts_count = $5,
|
||||
last_attempt_at = $6
|
||||
""", user_id, skill_tag, mastery, retention, attempts, last_attempt)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# ТЕСТЫ
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
async def test_orchestrator_prioritizes_overdue(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Если есть overdue навык — он выбирается первым."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("test_skill")
|
||||
await insert_mastery(conn, test_user, "test_skill", mastery=50.0, retention="overdue", days_since=20)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["type"] == "review"
|
||||
assert result["reason"] == "overdue"
|
||||
|
||||
|
||||
async def test_orchestrator_prioritizes_weak(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Если нет overdue, но есть weak (mastery < 75) — выбирается он."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("test_skill")
|
||||
await insert_mastery(conn, test_user, "test_skill", mastery=50.0, retention="fresh", days_since=1)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["type"] == "review"
|
||||
assert result["reason"] == "weak_mastery"
|
||||
|
||||
|
||||
async def test_orchestrator_prefers_overdue_over_weak(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Если есть и overdue, и weak — overdue важнее."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("weak_skill", order=1)
|
||||
await create_test_lesson("overdue_skill", order=2)
|
||||
|
||||
await insert_mastery(conn, test_user, "weak_skill", mastery=40.0, retention="fresh", days_since=1)
|
||||
await insert_mastery(conn, test_user, "overdue_skill", mastery=60.0, retention="overdue", days_since=20)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["reason"] == "overdue"
|
||||
|
||||
|
||||
async def test_orchestrator_weak_requires_min_attempts(conn, test_user, create_test_lesson, clean_mastery):
|
||||
"""Weak skill требует минимум 2 попытки — если меньше, он не выбирается."""
|
||||
from routers.orchestrator import get_next_lesson
|
||||
|
||||
await set_user_level(conn, test_user, "A2")
|
||||
await create_test_lesson("test_skill")
|
||||
await insert_mastery(conn, test_user, "test_skill", mastery=50.0, retention="fresh", attempts=1)
|
||||
|
||||
result = await get_next_lesson(conn, test_user)
|
||||
|
||||
assert result["reason"] != "weak_mastery"
|
||||
Reference in New Issue
Block a user