pdf-quiz-generator/backend/tests/test_ai_practice.py
Daniel 3279e14bb2 refactor: remove the LMS
There will be no courses. What was there: one draft called "jk" with two empty
lessons, and 4,000 lines of code around it — courses, modules, lessons,
enrolments, per-lesson progress, SCORM, BigBlueButton, completion certificates,
three React pages, a router, two models.

Its real cost was everywhere else. Every query that measured practice had to
remember `Quiz.course_id.is_(None)`, and forgetting it in one place would have
silently mixed course attempts into a learner's analytics; the bank predicate
carried a subquery to exclude a course's own questions from every search,
recommendation and share; quiz access had a second, parallel rule about
enrolment. All of that is gone, so the remaining rules say what they mean.

`quizzes.allow_review` goes with it. It was only ever enforced for a course
quiz, so it had become a promise nothing keeps — the public session page was
still offering "no answer review" about sessions that review fine.

The fixtures' question 5 lived in a course quiz and stood for "a question that
exists but is not in your bank". There is no such thing now — a question is in
the bank unless it is deleted — so the counts it kept out of the numbers are
back in, and the tests that turned on it now turn on deletion or on the
attempt that actually holds a question.

Files the LMS uploaded stay on disk and stay protected: LEGACY_LMS_PREFIXES in
app/utils/upload_access.py is what keeps them unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 23:27:51 +02:00

88 lines
4.2 KiB
Python

"""Turning a chat turn into a session.
The chat is for finding out what you do not know; the point of finding out is
to go and practise it. What is under test is which questions that lands on,
and that a chat cannot reach a question the learner could not otherwise see.
"""
import unittest
from datetime import datetime
from unittest.mock import patch
import test_quiz_builder as fixtures
from app.models.conversation import Conversation, ConversationMessage
from app.routers import ai_mode
class PracticeFromChatTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.db = self.bank.db
self.client.app.include_router(ai_mode.router, prefix='/ai')
def tearDown(self):
self.bank.tearDown()
def thread(self, citations, asked='What causes stridor?'):
conversation = Conversation(user_id=1, title='Stridor')
self.db.add(conversation)
self.db.flush()
self.db.add(ConversationMessage(conversation_id=conversation.id, role='user',
content=asked, citations=[]))
answer = ConversationMessage(conversation_id=conversation.id, role='assistant',
content='Croup, mostly.', citations=citations)
self.db.add(answer)
self.db.commit()
return conversation.id, answer.id
def practise(self, conversation_id, **body):
with patch('app.services.search_service.hybrid_ids', return_value=([], {})):
return self.client.post(f'/ai/conversations/{conversation_id}/practice', json=body)
def test_a_cited_question_is_the_session(self):
cid, _ = self.thread([{'marker': '[[question:1]]', 'kind': 'question', 'id': 1,
'title': 'Question #1'}])
response = self.practise(cid)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json()['count'], 1)
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
self.assertEqual([q['id'] for q in quiz['questions']], [1])
# Study mode, never exam: this is reading followed by practice.
self.assertEqual(quiz['mode'], 'learning')
def test_a_cited_article_brings_the_questions_filed_under_its_topic(self):
from app.models.article import Article
# The fixture files questions 1 and 2 under categories 1 and 2.
self.db.add(Article(id=5, title='Croup', slug='croup', content='',
status='published', user_id=3, category_id=2))
self.db.commit()
cid, _ = self.thread([{'marker': '[[article:5]]', 'kind': 'article', 'id': 5,
'title': 'Croup'}])
response = self.practise(cid)
self.assertEqual(response.status_code, 200, response.text)
quiz = self.client.get(f"/quizzes/{response.json()['quiz_id']}").json()
self.assertEqual(sorted(q['id'] for q in quiz['questions']), [2, 4, 5])
def test_a_chat_cannot_reach_a_question_the_learner_may_not_see(self):
# Question 4 has been removed from the bank. A citation could only name
# it by mistake; the bank's own rules answer anyway.
self.bank.db.get(fixtures.Question, 4).deleted_at = datetime(2026, 1, 1)
self.bank.db.commit()
cid, _ = self.thread([{'marker': '[[question:4]]', 'kind': 'question', 'id': 4,
'title': 'Question #4'}])
self.assertEqual(self.practise(cid).status_code, 404)
def test_nothing_to_practise_says_so(self):
cid, _ = self.thread([])
response = self.practise(cid)
self.assertEqual(response.status_code, 404)
self.assertIn('No questions', response.json()['detail'])
def test_one_turn_can_be_named_and_another_learner_cannot_ask(self):
cid, answer_id = self.thread([{'marker': '[[question:1]]', 'kind': 'question',
'id': 1, 'title': 'Question #1'}])
self.assertEqual(self.practise(cid, message_id=answer_id).status_code, 200)
self.assertEqual(self.practise(cid, message_id=99999).status_code, 404)
self.bank.user = self.bank.peer
self.assertEqual(self.practise(cid).status_code, 404)