Add persian-tutor: Gradio-based GCSE Persian language learning app
Vocabulary study with FSRS spaced repetition, AI tutoring (Ollama/Claude), essay marking, idioms browser, Anki export, and dashboard. 918 vocabulary entries across 39 categories. 41 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
151
python/persian-tutor/tests/test_db.py
Normal file
151
python/persian-tutor/tests/test_db.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for db.py — SQLite database layer with FSRS integration."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import fsrs
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def temp_db(tmp_path):
|
||||
"""Use a temporary database for each test."""
|
||||
import db as db_mod
|
||||
|
||||
db_mod._conn = None
|
||||
db_mod.DB_PATH = tmp_path / "test.db"
|
||||
db_mod.init_db()
|
||||
yield db_mod
|
||||
db_mod.close()
|
||||
|
||||
|
||||
def test_init_db_creates_tables(temp_db):
|
||||
"""init_db should create all required tables."""
|
||||
conn = temp_db.get_connection()
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
table_names = {row["name"] for row in tables}
|
||||
assert "word_progress" in table_names
|
||||
assert "quiz_sessions" in table_names
|
||||
assert "essays" in table_names
|
||||
assert "tutor_sessions" in table_names
|
||||
|
||||
|
||||
def test_get_word_progress_nonexistent(temp_db):
|
||||
"""Should return None for a word that hasn't been reviewed."""
|
||||
assert temp_db.get_word_progress("nonexistent") is None
|
||||
|
||||
|
||||
def test_update_and_get_word_progress(temp_db):
|
||||
"""update_word_progress should create and update progress."""
|
||||
card = temp_db.update_word_progress("verb_go", fsrs.Rating.Good)
|
||||
assert card is not None
|
||||
assert card.stability is not None
|
||||
|
||||
progress = temp_db.get_word_progress("verb_go")
|
||||
assert progress is not None
|
||||
assert progress["word_id"] == "verb_go"
|
||||
assert progress["reps"] == 1
|
||||
assert progress["fsrs_state"] is not None
|
||||
|
||||
|
||||
def test_update_word_progress_increments_reps(temp_db):
|
||||
"""Reviewing the same word multiple times should increment reps."""
|
||||
temp_db.update_word_progress("verb_go", fsrs.Rating.Good)
|
||||
temp_db.update_word_progress("verb_go", fsrs.Rating.Easy)
|
||||
progress = temp_db.get_word_progress("verb_go")
|
||||
assert progress["reps"] == 2
|
||||
|
||||
|
||||
def test_get_due_words(temp_db):
|
||||
"""get_due_words should return words that are due for review."""
|
||||
# A newly reviewed word with Rating.Again should be due soon
|
||||
temp_db.update_word_progress("verb_go", fsrs.Rating.Again)
|
||||
# An easy word should have a later due date
|
||||
temp_db.update_word_progress("verb_eat", fsrs.Rating.Easy)
|
||||
|
||||
# Due words depend on timing; at minimum both should be in the system
|
||||
all_progress = temp_db.get_connection().execute(
|
||||
"SELECT word_id FROM word_progress"
|
||||
).fetchall()
|
||||
assert len(all_progress) == 2
|
||||
|
||||
|
||||
def test_get_word_counts(temp_db):
|
||||
"""get_word_counts should return correct counts."""
|
||||
counts = temp_db.get_word_counts(total_vocab_size=100)
|
||||
assert counts["total"] == 100
|
||||
assert counts["seen"] == 0
|
||||
assert counts["mastered"] == 0
|
||||
assert counts["due"] == 0
|
||||
|
||||
temp_db.update_word_progress("verb_go", fsrs.Rating.Good)
|
||||
counts = temp_db.get_word_counts(total_vocab_size=100)
|
||||
assert counts["seen"] == 1
|
||||
|
||||
|
||||
def test_record_quiz_session(temp_db):
|
||||
"""record_quiz_session should insert a quiz record."""
|
||||
temp_db.record_quiz_session("Common verbs", 10, 7, 120)
|
||||
rows = temp_db.get_connection().execute(
|
||||
"SELECT * FROM quiz_sessions"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["correct"] == 7
|
||||
assert rows[0]["total_questions"] == 10
|
||||
|
||||
|
||||
def test_save_essay(temp_db):
|
||||
"""save_essay should store the essay and feedback."""
|
||||
temp_db.save_essay("متن آزمایشی", "B1", "Good effort!", "Identity and culture")
|
||||
essays = temp_db.get_recent_essays()
|
||||
assert len(essays) == 1
|
||||
assert essays[0]["grade"] == "B1"
|
||||
|
||||
|
||||
def test_save_tutor_session(temp_db):
|
||||
"""save_tutor_session should store the conversation."""
|
||||
messages = [
|
||||
{"role": "user", "content": "سلام"},
|
||||
{"role": "assistant", "content": "سلام! حالت چطوره؟"},
|
||||
]
|
||||
temp_db.save_tutor_session("Identity and culture", messages, 300)
|
||||
rows = temp_db.get_connection().execute(
|
||||
"SELECT * FROM tutor_sessions"
|
||||
).fetchall()
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["theme"] == "Identity and culture"
|
||||
|
||||
|
||||
def test_get_stats(temp_db):
|
||||
"""get_stats should return aggregated stats."""
|
||||
stats = temp_db.get_stats()
|
||||
assert stats["total_reviews"] == 0
|
||||
assert stats["total_quizzes"] == 0
|
||||
assert stats["streak"] == 0
|
||||
assert isinstance(stats["recent_quizzes"], list)
|
||||
|
||||
|
||||
def test_close_and_reopen(temp_db):
|
||||
"""Closing and reopening should preserve data."""
|
||||
temp_db.update_word_progress("verb_go", fsrs.Rating.Good)
|
||||
db_path = temp_db.DB_PATH
|
||||
|
||||
temp_db.close()
|
||||
|
||||
# Reopen
|
||||
temp_db._conn = None
|
||||
temp_db.DB_PATH = db_path
|
||||
temp_db.init_db()
|
||||
|
||||
progress = temp_db.get_word_progress("verb_go")
|
||||
assert progress is not None
|
||||
assert progress["reps"] == 1
|
||||
Reference in New Issue
Block a user