Lab references deep-link to article sections or external sources, show linked cards with study links, and educators can attach cards and article targets. Grouped panel layout. Migration i2d3e4f5a607. 57 backend and 93 frontend tests pass.
234 lines
15 KiB
Python
234 lines
15 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.
|
|
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.client.patch('/questions/3', json={'additional_category_ids': [2]}) # Owner edits own question.
|
|
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)
|
|
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()
|