Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
241 lines
16 KiB
Python
241 lines
16 KiB
Python
import json
|
|
import sys
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
import test_quiz_builder as fixtures
|
|
from app.models.attempt import QuizAttempt
|
|
from app.models.quiz import Quiz
|
|
from app.routers import study_tools
|
|
|
|
|
|
class StudyToolTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.client = self.bank.client
|
|
self.client.app.include_router(study_tools.router, prefix='/study-tools')
|
|
|
|
def tearDown(self):
|
|
self.bank.tearDown()
|
|
|
|
def start(self, quiz_id, mode):
|
|
response = self.client.post(f'/attempts/start?quiz_id={quiz_id}&mode={mode}&fresh=true')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
return response.json()['id']
|
|
|
|
def test_attempt_mode_controls_answers_and_stats_not_query_flags(self):
|
|
quiz_id = self.bank.generate(is_shared=True, category_ids=[1], mode='learning').json()['id']
|
|
initial = self.client.get(f'/quizzes/{quiz_id}').json()
|
|
self.assertNotIn('correct_answer', initial['questions'][0])
|
|
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?study=true').status_code, 403)
|
|
exam = self.start(quiz_id, 'exam')
|
|
data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}').json()
|
|
self.assertEqual(data['attempt_mode'], 'exam')
|
|
self.assertNotIn('correct_answer', data['questions'][0])
|
|
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={exam}&study=true').status_code, 403)
|
|
stats_url = f'/study-tools/attempts/{exam}/questions/1/responses'
|
|
self.assertEqual(self.client.get(stats_url).status_code, 403)
|
|
redis = Mock()
|
|
with patch.dict(sys.modules, {'redis': redis}):
|
|
self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': exam, 'answers': {}, 'current_idx': 0, 'mode': 'study'})
|
|
stored = json.loads(redis.from_url.return_value.setex.call_args_list[-1].args[2])
|
|
self.assertEqual(stored['mode'], 'exam')
|
|
study = self.start(quiz_id, 'study')
|
|
data = self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').json()
|
|
self.assertEqual(data['attempt_mode'], 'study')
|
|
self.assertEqual(data['questions'][0]['correct_answer'], 'yes')
|
|
self.assertTrue(data['questions'][0]['category_breadcrumbs'])
|
|
self.assertEqual(self.client.get('/quizzes/1', params={'attempt_id': study}).status_code, 404)
|
|
self.bank.user = self.bank.peer
|
|
self.assertEqual(self.client.get(f'/quizzes/{quiz_id}?attempt_id={study}').status_code, 404)
|
|
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
|
|
|
|
def test_real_response_counts_exclude_skips_expiry_course_and_private_attempts(self):
|
|
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
|
study = self.start(quiz_id, 'study')
|
|
self.bank.answer(1, True)
|
|
self.bank.answer(1, True, day=1)
|
|
self.bank.answer(1, False, day=2)
|
|
self.bank.answer(1, True, expired=1)
|
|
self.bank.answer(1, True, quiz_id=2)
|
|
self.bank.answer(1, True, completed=False)
|
|
other_private = Quiz(title='Private peer test', user_id=2, is_shared=0, is_published=0)
|
|
self.bank.db.add(other_private)
|
|
self.bank.db.commit()
|
|
self.bank.answer(1, True, quiz_id=other_private.id)
|
|
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()['sample_size'], 3)
|
|
self.assertEqual([row['count'] for row in response.json()['options']], [2, 1])
|
|
self.assertEqual([row['percentage'] for row in response.json()['options']], [66.7, 33.3])
|
|
attempt = self.bank.db.get(QuizAttempt, study)
|
|
attempt.selected_question_ids = [2]
|
|
self.bank.db.commit()
|
|
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
|
|
empty = self.client.get(f'/study-tools/attempts/{study}/questions/2/responses').json()
|
|
self.assertEqual(empty['sample_size'], 0)
|
|
self.assertTrue(all(row['percentage'] == 0 for row in empty['options']))
|
|
|
|
def test_nullable_review_setting_serializes_and_course_stays_exam_only(self):
|
|
self.bank.db.query(Quiz).filter(Quiz.id == 1).update({'allow_review': None})
|
|
self.bank.db.query(Quiz).filter(Quiz.id == 2).update({'allow_review': None, 'mode': 'learning', 'questions_count': 1})
|
|
self.bank.db.add(fixtures.CourseEnrollment(course_id=1, user_id=2))
|
|
self.bank.db.commit()
|
|
self.assertEqual(self.client.get('/quizzes/').status_code, 200)
|
|
response = self.client.get('/quizzes/1')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertIsNone(response.json()['allow_review'])
|
|
self.bank.user = self.bank.peer
|
|
aid = self.start(2, 'study')
|
|
self.assertEqual(self.bank.db.get(QuizAttempt, aid).mode, 'exam')
|
|
response = self.client.get(f'/quizzes/2?attempt_id={aid}')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertIsNone(response.json()['allow_review'])
|
|
self.assertNotIn('correct_answer', response.json()['questions'][0])
|
|
self.assertEqual(self.client.get(f'/quizzes/2?attempt_id={aid}&study=true').status_code, 403)
|
|
|
|
def test_progress_outage_returns_failure_not_empty_or_saved_success(self):
|
|
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
|
aid = self.start(quiz_id, 'study')
|
|
redis = Mock()
|
|
redis.from_url.return_value.get.side_effect = ConnectionError('Synthetic cache outage')
|
|
redis.from_url.return_value.setex.side_effect = ConnectionError('Synthetic cache outage')
|
|
with patch.dict(sys.modules, {'redis': redis}):
|
|
self.assertEqual(self.client.get(f'/attempts/progress?quiz_id={quiz_id}').status_code, 503)
|
|
response = self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': aid,
|
|
'answers': {'1': 'yes'}, 'current_idx': 0, 'mode': 'study'})
|
|
self.assertEqual(response.status_code, 503, response.text)
|
|
self.assertIsNone(self.bank.db.get(QuizAttempt, aid).completed_at)
|
|
malformed = self.client.post('/attempts/progress', json={'quiz_id': quiz_id, 'attempt_id': aid,
|
|
'answers': {'1': {'unexpected': 'object'}}, 'current_idx': 0, 'mode': 'study'})
|
|
self.assertEqual(malformed.status_code, 422)
|
|
|
|
def test_statistics_match_insensitively_dedupe_and_exclude_obsolete(self):
|
|
quiz_id = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
|
study = self.start(quiz_id, 'study')
|
|
# Case/whitespace variants of the same option match one bucket.
|
|
self.bank.answer(1, True)
|
|
self.bank.db.query(fixtures.AttemptAnswer).filter_by(is_correct=True).update({'user_answer': ' YES '})
|
|
self.bank.db.commit()
|
|
# An answer that no longer matches any current option is excluded from the sample.
|
|
self.bank.answer(1, False)
|
|
self.bank.db.query(fixtures.AttemptAnswer).filter_by(is_correct=False).update({'user_answer': 'Obsolete option'})
|
|
self.bank.db.commit()
|
|
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').json()
|
|
self.assertEqual(response['sample_size'], 1)
|
|
self.assertEqual([row['count'] for row in response['options']], [1, 0])
|
|
self.assertEqual(response['options'][0]['percentage'], 100.0)
|
|
# Duplicate option strings must not double-count; case variants collapse too.
|
|
question = self.bank.db.get(fixtures.Question, 1)
|
|
question.options = ['yes', 'YES']
|
|
self.bank.db.commit()
|
|
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').json()
|
|
self.assertEqual(response['sample_size'], 1)
|
|
self.assertEqual(len(response['options']), 1)
|
|
|
|
|
|
|
|
def test_own_private_quiz_attempt_counts_only_for_its_owner(self):
|
|
private = self.client.post('/questions/from-bank', json={'title': 'Private set', 'question_ids': [1]}).json()['id']
|
|
study = self.start(private, 'study')
|
|
self.bank.answer(1, True, quiz_id=private)
|
|
response = self.client.get(f'/study-tools/attempts/{study}/questions/1/responses')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()['sample_size'], 1)
|
|
self.bank.user = self.bank.peer
|
|
self.assertEqual(self.client.get(f'/study-tools/attempts/{study}/questions/1/responses').status_code, 404)
|
|
other_quiz = self.client.post('/questions/from-bank', json={'title': 'Shared set', 'question_ids': [1], 'is_shared': True}).json()['id']
|
|
peer_study = self.start(other_quiz, 'study')
|
|
self.bank.answer(1, True, quiz_id=private)
|
|
response = self.client.get(f'/study-tools/attempts/{peer_study}/questions/1/responses').json()
|
|
self.assertEqual(response['sample_size'], 0)
|
|
|
|
def test_performance_by_category_expands_links_and_excludes_irrelevant(self):
|
|
quiz = self.bank.generate(is_shared=True, category_ids=[1]).json()['id']
|
|
self.bank.answer(1, True, quiz_id=quiz)
|
|
self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded.
|
|
quiz2 = self.bank.generate(is_shared=True, category_ids=[2], count=1).json()['id']
|
|
self.bank.answer(2, False, quiz_id=quiz2)
|
|
self.bank.user = self.bank.mod
|
|
self.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Educators manage questions.
|
|
self.bank.user = self.bank.owner
|
|
quiz3 = self.bank.generate(category_ids=[3], count=1).json()['id'] # Private owner test: own attempts still count.
|
|
self.bank.answer(3, True, quiz_id=quiz3)
|
|
self.bank.answer(5, True, quiz_id=2) # Course quiz excluded.
|
|
data = self.client.get('/study-tools/performance-by-category').json()
|
|
by_id = {row['category_id']: row for row in data['categories']}
|
|
self.assertEqual([by_id[1]['answered'], by_id[1]['correct'], by_id[1]['accuracy']], [1, 1, 100.0])
|
|
self.assertEqual([by_id[2]['answered'], by_id[2]['correct'], by_id[2]['accuracy']], [2, 1, 50.0])
|
|
self.assertEqual([by_id[3]['answered'], by_id[3]['correct']], [1, 1])
|
|
self.assertEqual(data['total_answered'], 4)
|
|
self.assertEqual(data['categories'][0]['category_id'], 2) # Most answered first.
|
|
|
|
def test_lab_article_deep_links_and_card_links(self):
|
|
from app.models.article import Article
|
|
from app.models.flashcard import Flashcard, FlashcardDeck
|
|
article = Article(slug='lab-source', title='Lab source article', content='Intro',
|
|
sections=[{'id': 'd' * 32, 'slug': 'ranges', 'title': 'Ranges', 'content': 'Body'}],
|
|
user_id=3, status='published')
|
|
deck = FlashcardDeck(user_id=3, title='Sodium cards', is_shared=0)
|
|
self.bank.db.add_all([article, deck])
|
|
self.bank.db.flush()
|
|
card = Flashcard(deck_id=deck.id, front='Sodium card front', back='back')
|
|
self.bank.db.add(card)
|
|
self.bank.db.commit()
|
|
self.bank.user = self.bank.mod
|
|
payload = dict(name='Deep linked', group='Blood', reference_range='1-2', units='u',
|
|
age_group='a', specimen='s', source='src', article_id=article.id,
|
|
article_section_id='d' * 32, is_published=True)
|
|
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': 999}).status_code, 400)
|
|
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_section_id': 'bad'}).status_code, 400)
|
|
self.assertEqual(self.client.post('/study-tools/lab-values', json={**payload, 'article_id': None, 'article_section_id': 'd' * 32}).status_code, 400)
|
|
created = self.client.post('/study-tools/lab-values', json=payload)
|
|
self.assertEqual(created.status_code, 201, created.text)
|
|
entry_id = created.json()['id']
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/999').status_code, 404)
|
|
second = self.client.post('/study-tools/lab-values', json={**payload, 'age_group': 'z', 'article_id': None, 'article_section_id': None})
|
|
self.assertEqual(second.status_code, 201, second.text)
|
|
# Same-name rows keep insertion (logical age) order, not alphabetical age order.
|
|
names_in_order = [r['name'] + ':' + r['age_group'] for r in self.client.get('/study-tools/lab-values').json() if r['name'] == 'Deep linked']
|
|
self.assertEqual(names_in_order, ['Deep linked:a', 'Deep linked:z'])
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], True)
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').json()['linked'], False)
|
|
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)
|
|
self.assertEqual(row['article_title'], 'Lab source article')
|
|
self.assertEqual(row['article_section_title'], 'Ranges')
|
|
self.assertEqual([c['front'] for c in row['cards']], ['Sodium card front'])
|
|
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 204)
|
|
row = next(r for r in self.client.get('/study-tools/lab-values').json() if r['id'] == entry_id)
|
|
self.assertEqual(row['cards'], [])
|
|
self.bank.user = self.bank.owner
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}/cards/{card.id}').status_code, 403)
|
|
|
|
def test_lab_reference_permissions_validation_and_publication(self):
|
|
payload = dict(name='Example test', group='Blood', reference_range='Example interval', units='example units',
|
|
age_group='Defined study population', specimen='Serum', source='Educator-supplied source', source_url='https://example.test/reference')
|
|
self.assertEqual(self.client.post('/study-tools/lab-values', json=payload).status_code, 403)
|
|
self.assertEqual(self.client.get('/study-tools/lab-values?include_drafts=true').status_code, 403)
|
|
self.bank.user = self.bank.mod
|
|
response = self.client.post('/study-tools/lab-values', json=payload)
|
|
self.assertEqual(response.status_code, 201, response.text)
|
|
entry_id = response.json()['id']
|
|
self.assertFalse(response.json()['is_published'])
|
|
self.assertEqual(self.client.get('/study-tools/lab-values').json(), [])
|
|
self.assertEqual(len(self.client.get('/study-tools/lab-values?include_drafts=true').json()), 1)
|
|
for bad in ({'source': ' '}, {'source_url': 'javascript:alert(1)'}, {'source_url': 'https://example.com/' + 'a' * 2000}, {'units': ''}):
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, **bad}).status_code, 422)
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json={**payload, 'is_published': True}).status_code, 200)
|
|
self.bank.user = self.bank.peer
|
|
rows = self.client.get('/study-tools/lab-values').json()
|
|
self.assertEqual(rows[0]['source'], payload['source'])
|
|
self.assertEqual(rows[0]['age_group'], payload['age_group'])
|
|
self.assertEqual(self.client.put(f'/study-tools/lab-values/{entry_id}', json=payload).status_code, 403)
|
|
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 403)
|
|
self.bank.user = self.bank.mod
|
|
self.assertEqual(self.client.delete(f'/study-tools/lab-values/{entry_id}').status_code, 204)
|
|
self.assertEqual(self.client.get('/study-tools/lab-values').json(), [])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|