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
703 lines
36 KiB
Python
703 lines
36 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.saved_test([1, 2], is_shared=True, mode='learning')
|
|
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_and_private_attempts(self):
|
|
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
|
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'], 4)
|
|
self.assertEqual([row['count'] for row in response.json()['options']], [3, 1])
|
|
self.assertEqual([row['percentage'] for row in response.json()['options']], [75.0, 25.0])
|
|
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_progress_outage_returns_failure_not_empty_or_saved_success(self):
|
|
quiz_id = self.bank.saved_test([1, 2], is_shared=True)
|
|
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.saved_test([1, 2], is_shared=True)
|
|
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.saved_test([1, 2], is_shared=True)
|
|
self.bank.answer(1, True, quiz_id=quiz)
|
|
self.bank.answer(1, True, quiz_id=quiz, expired=1) # Expired attempt excluded.
|
|
quiz2 = self.bank.saved_test([2], is_shared=True)
|
|
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)
|
|
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']], [3, 2, 66.7])
|
|
self.assertEqual([by_id[3]['answered'], by_id[3]['correct']], [1, 1])
|
|
self.assertEqual(data['total_answered'], 5)
|
|
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()
|
|
|
|
|
|
class BlueprintRelevanceTests(unittest.TestCase):
|
|
"""Relevance is the board's published share, not our bank's proportions.
|
|
|
|
Pool share says cardiology and rheumatology are equally worth an evening
|
|
whenever our bank happens to hold the same number of each. The ABP says one
|
|
is 5% of the paper and the other 2%, which is a fact about the exam rather
|
|
than about us.
|
|
"""
|
|
|
|
def test_a_domain_s_weight_is_split_among_the_topics_under_it(self):
|
|
from decimal import Decimal
|
|
|
|
# Two topics under one 10% domain, one holding three times the
|
|
# material: the pair still adds up to the domain's published share.
|
|
weight = Decimal("10")
|
|
pools = {1: 30, 2: 10}
|
|
total = sum(pools.values())
|
|
shares = {cid: round(float(weight) * pool / total, 2) for cid, pool in pools.items()}
|
|
self.assertEqual(shares[1], 7.5)
|
|
self.assertEqual(shares[2], 2.5)
|
|
self.assertAlmostEqual(sum(shares.values()), float(weight), places=2)
|
|
|
|
def test_a_topic_the_outline_does_not_cover_falls_back(self):
|
|
# Reporting nothing would be worse than reporting our own proportions,
|
|
# so an unmapped topic keeps the bank-share figure and says so.
|
|
blueprint_weight = {1: 10.0}
|
|
self.assertIsNone(blueprint_weight.get(99))
|
|
|
|
|
|
class CompletionTests(unittest.TestCase):
|
|
"""How much has been worked through, over a window the learner chooses.
|
|
|
|
The figures answer four different questions, and each one has a rule about
|
|
what does not count: a repetition is practice rather than a measurement,
|
|
and a question left blank is not a wrong answer.
|
|
"""
|
|
|
|
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 sat(self, qid, correct, ago_days, seconds, answered=True, quiz_id=1, hint=False):
|
|
from datetime import datetime, timedelta
|
|
|
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
|
attempt = QuizAttempt(user_id=1, quiz_id=quiz_id, total_questions=1, score=int(correct),
|
|
completed_at=datetime.utcnow() - timedelta(days=ago_days))
|
|
self.bank.db.add(attempt)
|
|
self.bank.db.flush()
|
|
self.bank.db.add(AttemptAnswer(
|
|
attempt_id=attempt.id, question_id=qid, is_correct=correct,
|
|
# A question left blank is stored as an empty answer, not a missing
|
|
# row — the same shape the player submits.
|
|
user_answer=('yes' if correct else 'no') if answered else '',
|
|
seconds_spent=seconds, used_hint=hint))
|
|
self.bank.db.commit()
|
|
|
|
def get(self, **params):
|
|
response = self.client.get('/study-tools/completion', params=params)
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
return response.json()
|
|
|
|
def test_the_window_decides_which_answers_are_counted(self):
|
|
self.sat(1, True, ago_days=2, seconds=60)
|
|
self.sat(2, False, ago_days=2, seconds=120)
|
|
self.sat(3, True, ago_days=200, seconds=30)
|
|
|
|
recent = self.get(days=30)
|
|
self.assertEqual(recent['answered'], 2)
|
|
self.assertEqual(recent['percent_correct'], 50.0)
|
|
self.assertEqual(recent['seconds_per_question'], 90)
|
|
self.assertEqual(recent['seconds_total'], 180)
|
|
|
|
forever = self.get()
|
|
self.assertIsNone(forever['days'])
|
|
self.assertEqual(forever['answered'], 3)
|
|
self.assertEqual(forever['percent_correct'], 66.7)
|
|
self.assertEqual(forever['seconds_total'], 210)
|
|
|
|
def test_a_blank_answer_is_not_a_wrong_answer(self):
|
|
self.sat(1, True, ago_days=1, seconds=40)
|
|
self.sat(2, False, ago_days=1, seconds=0, answered=False)
|
|
data = self.get(days=30)
|
|
self.assertEqual(data['answered'], 1)
|
|
self.assertEqual(data['percent_correct'], 100.0)
|
|
|
|
def test_repetitions_are_left_out(self):
|
|
from app.models.quiz import Quiz
|
|
again = Quiz(title='Repeat of test', user_id=1, is_repetition=1)
|
|
self.bank.db.add(again)
|
|
self.bank.db.commit()
|
|
self.sat(1, True, ago_days=1, seconds=50)
|
|
self.sat(2, True, ago_days=1, seconds=50, quiz_id=again.id)
|
|
self.assertEqual(self.get(days=30)['answered'], 1)
|
|
|
|
def test_nothing_answered_reports_nothing_rather_than_zero_per_cent(self):
|
|
data = self.get(days=7)
|
|
self.assertEqual(data['answered'], 0)
|
|
self.assertIsNone(data['percent_correct'])
|
|
self.assertIsNone(data['seconds_per_question'])
|
|
self.assertEqual(data['seconds_total'], 0)
|
|
self.assertGreater(data['bank_total'], 0)
|
|
|
|
|
|
class AnswerSplitTests(CompletionTests):
|
|
"""The same answers counted two ways: everything done, and what is known now."""
|
|
|
|
def split(self):
|
|
response = self.client.get('/study-tools/answer-split')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
return response.json()
|
|
|
|
def test_getting_it_wrong_then_right_reads_differently_by_basis(self):
|
|
self.sat(1, False, ago_days=90, seconds=40)
|
|
self.sat(1, True, ago_days=1, seconds=30)
|
|
|
|
data = self.split()
|
|
self.assertEqual(data['attempts'], 2)
|
|
self.assertEqual(data['unique_questions'], 1)
|
|
# Half the work was wrong; what is known now is right.
|
|
self.assertEqual(data['all'], {
|
|
'correct': 1, 'correct_with_hints': 0, 'incorrect': 1, 'unanswered': 0,
|
|
'answered': 2, 'total': 2, 'percent_correct': 50.0})
|
|
self.assertEqual(data['latest'], {
|
|
'correct': 1, 'correct_with_hints': 0, 'incorrect': 0, 'unanswered': 0,
|
|
'answered': 1, 'total': 1, 'percent_correct': 100.0})
|
|
|
|
def test_a_blank_is_its_own_slice_and_not_a_wrong_answer(self):
|
|
self.sat(1, True, ago_days=1, seconds=20)
|
|
self.sat(2, False, ago_days=1, seconds=0, answered=False)
|
|
data = self.split()
|
|
self.assertEqual(data['all']['unanswered'], 1)
|
|
self.assertEqual(data['all']['incorrect'], 0)
|
|
self.assertEqual(data['all']['total'], 2)
|
|
self.assertEqual(data['all']['percent_correct'], 100.0)
|
|
|
|
def test_nothing_sat_yet(self):
|
|
data = self.split()
|
|
self.assertEqual(data['attempts'], 0)
|
|
self.assertEqual(data['unique_questions'], 0)
|
|
self.assertIsNone(data['all']['percent_correct'])
|
|
|
|
def test_a_tip_opened_before_answering_is_right_but_named_apart(self):
|
|
self.sat(1, True, ago_days=1, seconds=30)
|
|
self.sat(2, True, ago_days=1, seconds=30, hint=True)
|
|
data = self.split()['all']
|
|
self.assertEqual(data['correct'], 1)
|
|
self.assertEqual(data['correct_with_hints'], 1)
|
|
# It was right, so it counts as right: the percentage is not docked.
|
|
self.assertEqual(data['percent_correct'], 100.0)
|
|
self.assertEqual(data['answered'], 2)
|
|
|
|
|
|
class PerformanceOverTimeTests(CompletionTests):
|
|
"""The headline score by date, and the honesty of an empty chart."""
|
|
|
|
def trend(self):
|
|
response = self.client.get('/study-tools/performance-over-time')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
return response.json()
|
|
|
|
def test_the_running_figure_is_every_answer_so_far_not_this_session(self):
|
|
# Two right, then one wrong: the session drops to 0%, the running
|
|
# figure to 67% — which is the honest account of what is known.
|
|
self.sat(1, True, ago_days=3, seconds=30)
|
|
self.sat(2, True, ago_days=2, seconds=30)
|
|
self.sat(3, False, ago_days=1, seconds=30)
|
|
points = self.trend()['points']
|
|
self.assertEqual([p['percent'] for p in points], [100.0, 100.0, 0.0])
|
|
self.assertEqual([p['running'] for p in points], [100.0, 100.0, 66.7])
|
|
|
|
def test_a_chart_is_locked_until_there_is_something_in_it(self):
|
|
self.sat(1, True, ago_days=1, seconds=30)
|
|
data = self.trend()
|
|
self.assertFalse(data['unlocked'])
|
|
self.assertEqual(data['sessions_needed'], 2)
|
|
self.assertEqual(data['answers_needed'], 39)
|
|
# The points are still returned: the page says how far off it is.
|
|
self.assertEqual(len(data['points']), 1)
|
|
|
|
def test_blank_answers_and_repetitions_are_not_points_on_the_line(self):
|
|
from app.models.quiz import Quiz
|
|
again = Quiz(title='Repeat of test', user_id=1, is_repetition=1)
|
|
self.bank.db.add(again)
|
|
self.bank.db.commit()
|
|
self.sat(1, True, ago_days=2, seconds=30)
|
|
self.sat(2, True, ago_days=2, seconds=30, quiz_id=again.id)
|
|
# A session where nothing was answered is not a session at 0%.
|
|
self.sat(3, False, ago_days=1, seconds=0, answered=False)
|
|
points = self.trend()['points']
|
|
self.assertEqual(len(points), 1)
|
|
self.assertEqual(points[0]['answered'], 1)
|
|
|
|
def test_nothing_sat_is_an_empty_chart_not_a_flat_line(self):
|
|
data = self.trend()
|
|
self.assertEqual(data['points'], [])
|
|
self.assertFalse(data['unlocked'])
|
|
self.assertEqual(data['total_answered'], 0)
|
|
|
|
|
|
class ReadinessTests(CompletionTests):
|
|
"""Two figures that refuse to appear before they mean anything."""
|
|
|
|
def read(self):
|
|
response = self.client.get('/study-tools/readiness')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
return response.json()
|
|
|
|
def peer_sat(self, user_id, qid, correct, quiz_id=1):
|
|
from datetime import datetime
|
|
|
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
|
from app.models.user import User
|
|
if not self.bank.db.get(User, user_id):
|
|
self.bank.db.add(User(id=user_id, name=f'Learner {user_id}',
|
|
email=f'learner{user_id}@example.test',
|
|
hashed_password='unused'))
|
|
self.bank.db.flush()
|
|
attempt = QuizAttempt(user_id=user_id, quiz_id=quiz_id, total_questions=1,
|
|
score=int(correct), completed_at=datetime.utcnow())
|
|
self.bank.db.add(attempt)
|
|
self.bank.db.flush()
|
|
self.bank.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=qid,
|
|
is_correct=correct, user_answer='yes', seconds_spent=30))
|
|
self.bank.db.commit()
|
|
|
|
def test_the_score_is_the_most_recent_answer_to_each_question(self):
|
|
self.sat(1, False, ago_days=9, seconds=30)
|
|
self.sat(1, True, ago_days=1, seconds=30)
|
|
self.sat(2, False, ago_days=1, seconds=30)
|
|
data = self.read()
|
|
# Two questions known about, one of them right: 50%, not the 33% a
|
|
# count of every answer ever given would report.
|
|
self.assertEqual(data['answered'], 2)
|
|
self.assertEqual(data['score'], 50.0)
|
|
|
|
def test_a_score_on_four_answers_is_not_shown_as_a_score(self):
|
|
for qid in (1, 2, 3):
|
|
self.sat(qid, True, ago_days=1, seconds=30)
|
|
data = self.read()
|
|
self.assertFalse(data['unlocked'])
|
|
self.assertEqual(data['answers_needed'], 37)
|
|
# The figure is still computed; the page decides whether to show it.
|
|
self.assertEqual(data['score'], 100.0)
|
|
|
|
def test_the_comparison_is_against_the_same_questions_not_the_same_people(self):
|
|
# Everyone answers question 1; the learner gets it right and two of the
|
|
# three peers get it wrong.
|
|
self.sat(1, True, ago_days=1, seconds=30)
|
|
self.peer_sat(2, 1, False)
|
|
self.peer_sat(3, 1, False)
|
|
self.peer_sat(4, 1, True)
|
|
peer = self.read()['peer']
|
|
self.assertEqual(peer['shared_questions'], 1)
|
|
self.assertEqual(peer['cohort'], 3)
|
|
self.assertEqual(peer['expected'], 33.3)
|
|
self.assertEqual(peer['yours'], 100.0)
|
|
self.assertEqual(peer['delta'], 66.7)
|
|
|
|
def test_no_cohort_means_no_comparison_and_it_says_what_is_missing(self):
|
|
self.sat(1, True, ago_days=1, seconds=30)
|
|
data = self.read()['peer']
|
|
self.assertFalse(data['unlocked'])
|
|
self.assertEqual(data['shared_questions'], 0)
|
|
self.assertEqual(data['cohort_needed'], 3)
|
|
self.assertIsNone(data['expected'])
|
|
self.assertIsNone(data['delta'])
|
|
|
|
def test_nothing_answered_reports_nothing(self):
|
|
data = self.read()
|
|
self.assertEqual(data['answered'], 0)
|
|
self.assertIsNone(data['score'])
|
|
self.assertFalse(data['unlocked'])
|
|
self.assertFalse(data['peer']['unlocked'])
|
|
|
|
|
|
class SessionRecommendationTests(CompletionTests):
|
|
"""One session's weakest topics, asked the same three ways as the page."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
from app.routers import attempts
|
|
self.client.app.include_router(attempts.router, prefix='/attempts')
|
|
|
|
def rows(self, attempt_id, group='disciplines'):
|
|
response = self.client.get(f'/attempts/{attempt_id}/recommendations',
|
|
params={'group': group})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
return response.json()['rows']
|
|
|
|
def sit(self, marks):
|
|
"""One completed session answering (question_id, correct) pairs."""
|
|
from datetime import datetime
|
|
|
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
|
attempt = QuizAttempt(user_id=1, quiz_id=1, total_questions=len(marks),
|
|
mode='study', score=sum(1 for _, c in marks if c),
|
|
completed_at=datetime.utcnow())
|
|
self.bank.db.add(attempt)
|
|
self.bank.db.flush()
|
|
for qid, correct in marks:
|
|
self.bank.db.add(AttemptAnswer(
|
|
attempt_id=attempt.id, question_id=qid, is_correct=correct,
|
|
user_answer='yes' if correct else 'no', seconds_spent=30))
|
|
self.bank.db.commit()
|
|
return attempt.id
|
|
|
|
def test_topics_are_ranked_weakest_first(self):
|
|
# The fixture tree is Root > Child > Leaf, so everything rolls up to
|
|
# Root. A second discipline is made by filing question 2 under the
|
|
# other root as well — an extra category link, which counts like a
|
|
# primary one.
|
|
from app.models.question_category import QuestionCategoryLink
|
|
self.bank.db.add(QuestionCategoryLink(question_id=2, category_id=4))
|
|
self.bank.db.commit()
|
|
|
|
aid = self.sit([(1, False), (2, True)])
|
|
rows = {row['name']: row for row in self.rows(aid)}
|
|
self.assertEqual(rows['Root']['percent'], 50)
|
|
self.assertEqual(rows['Empty']['percent'], 100)
|
|
# Weakest first: that is the order a learner reads down.
|
|
self.assertEqual([row['name'] for row in self.rows(aid)], ['Root', 'Empty'])
|
|
|
|
def test_a_question_counts_towards_the_discipline_above_it(self):
|
|
# Question 3 is filed under Leaf, two levels down. The discipline it
|
|
# counts towards is the root, or a discipline would only ever score on
|
|
# questions filed at its own level — which is none of them.
|
|
aid = self.sit([(3, False)])
|
|
rows = self.rows(aid)
|
|
self.assertEqual([row['name'] for row in rows], ['Root'])
|
|
self.assertEqual(rows[0]['total'], 1)
|
|
|
|
def test_an_unanswered_question_is_not_a_wrong_topic(self):
|
|
from app.models.attempt import AttemptAnswer
|
|
aid = self.sit([(1, False)])
|
|
self.bank.db.add(AttemptAnswer(attempt_id=aid, question_id=2,
|
|
is_correct=False, user_answer='', seconds_spent=0))
|
|
self.bank.db.commit()
|
|
total = sum(row['total'] for row in self.rows(aid))
|
|
self.assertEqual(total, 1)
|
|
|
|
def test_a_running_exam_is_not_ranked(self):
|
|
from app.models.attempt import AttemptAnswer, QuizAttempt
|
|
attempt = QuizAttempt(user_id=1, quiz_id=1, mode='exam', total_questions=1)
|
|
self.bank.db.add(attempt)
|
|
self.bank.db.flush()
|
|
self.bank.db.add(AttemptAnswer(attempt_id=attempt.id, question_id=1,
|
|
is_correct=True, user_answer='yes', seconds_spent=10))
|
|
self.bank.db.commit()
|
|
# Marking it here would let a learner see whether an answer was right
|
|
# and go back and change it.
|
|
self.assertEqual(self.rows(attempt.id), [])
|
|
|
|
def test_somebody_else_s_session_is_not_readable(self):
|
|
aid = self.sit([(1, True)])
|
|
self.bank.user = self.bank.peer
|
|
self.assertEqual(
|
|
self.client.get(f'/attempts/{aid}/recommendations').status_code, 404)
|
|
|
|
|
|
class SystemAxisTests(CompletionTests):
|
|
"""A question reaches an organ system through the topic it is filed under.
|
|
|
|
It used to go through a symptom keyword the question happened to mention,
|
|
and only about half of them mentioned one that had been filed — so the
|
|
Systems tab saw half the bank while Disciplines saw all of it.
|
|
|
|
What makes this a third way of asking rather than the discipline tree
|
|
relabelled: a topic's system is a separate fact from where it sits in the
|
|
tree. Conjunctivitis is filed under Infectious Disease and is an eye.
|
|
"""
|
|
|
|
def systems(self):
|
|
from app.services.knowledge_groups import Grouping
|
|
return Grouping(self.bank.db, "systems")
|
|
|
|
def make_system(self, name):
|
|
from sqlalchemy import text as sa_text
|
|
self.bank.db.execute(sa_text(
|
|
"CREATE TABLE IF NOT EXISTS question_tags "
|
|
"(id INTEGER PRIMARY KEY, name TEXT, type TEXT, parent_id INTEGER, sort_order INTEGER)"))
|
|
self.bank.db.execute(sa_text(
|
|
"INSERT INTO question_tags (name, type, parent_id) VALUES (:n, 'system', NULL)"),
|
|
{"n": name})
|
|
self.bank.db.commit()
|
|
return self.bank.db.execute(sa_text(
|
|
"SELECT id FROM question_tags WHERE name = :n"), {"n": name}).scalar()
|
|
|
|
def file_under(self, category_id, system_id):
|
|
from app.models.question_category import QuestionCategory
|
|
self.bank.db.get(QuestionCategory, category_id).system_id = system_id
|
|
self.bank.db.commit()
|
|
|
|
def test_a_question_reaches_the_system_of_its_topic(self):
|
|
eyes = self.make_system("Nervous System & Special Senses")
|
|
# Question 2 is filed under category 2 in the fixture tree.
|
|
self.file_under(2, eyes)
|
|
grouping = self.systems()
|
|
self.assertEqual(grouping.keys_for(2, 2), {eyes})
|
|
self.assertEqual(grouping.describe(eyes)["name"], "Nervous System & Special Senses")
|
|
self.assertEqual(grouping.describe(eyes)["system_id"], eyes)
|
|
|
|
def test_a_topic_inherits_the_system_of_the_topic_above_it(self):
|
|
chest = self.make_system("Respiratory System")
|
|
# Category 1 is the root; question 3 sits two levels below it.
|
|
self.file_under(1, chest)
|
|
# Reached through the tree, not through anything on the question.
|
|
self.assertEqual(self.systems().keys_for(3, 3), {chest})
|
|
|
|
def test_a_topic_says_its_own_system_over_the_one_above_it(self):
|
|
chest = self.make_system("Respiratory System")
|
|
eyes = self.make_system("Nervous System & Special Senses")
|
|
self.file_under(1, chest)
|
|
self.file_under(2, eyes)
|
|
# Both, because the question counts towards both topics — which is the
|
|
# honest answer for a question filed under a subtopic of another.
|
|
self.assertEqual(self.systems().keys_for(2, 2), {chest, eyes})
|
|
|
|
def test_a_topic_nobody_has_filed_reaches_nothing(self):
|
|
self.make_system("Respiratory System")
|
|
# Null means nobody has said yet, and the grouping says nothing rather
|
|
# than guessing a system from the shelf.
|
|
self.assertEqual(self.systems().keys_for(2, 2), set())
|
|
|
|
|
|
class PublicStatsTests(unittest.TestCase):
|
|
"""The few facts the site may state about itself before anybody signs in.
|
|
|
|
Counts only, and only of published material: a landing page needs to say
|
|
how much there is, not what it is.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.client = self.bank.client
|
|
from app.routers import public
|
|
self.client.app.include_router(public.router, prefix='/public')
|
|
|
|
def tearDown(self):
|
|
self.bank.tearDown()
|
|
|
|
def test_it_counts_what_is_there_rather_than_what_was_written_down(self):
|
|
response = self.client.get('/public/stats')
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
data = response.json()
|
|
# Six questions in the fixture bank, none deleted.
|
|
self.assertEqual(data['questions'], 6)
|
|
self.assertEqual(data['topics'], 4)
|
|
self.assertEqual(set(data), {'questions', 'topics', 'systems', 'articles', 'exams'})
|
|
|
|
def test_a_deleted_question_is_not_counted(self):
|
|
from datetime import datetime
|
|
|
|
from app.models.question import Question
|
|
self.bank.db.get(Question, 1).deleted_at = datetime.utcnow()
|
|
self.bank.db.commit()
|
|
self.assertEqual(self.client.get('/public/stats').json()['questions'], 5)
|
|
|
|
def test_an_unpublished_article_is_not_counted(self):
|
|
from app.models.article import Article
|
|
self.bank.db.add_all([
|
|
Article(title='Out', slug='out', content='…', status='draft', user_id=1),
|
|
Article(title='In', slug='in', content='…', status='published', user_id=1),
|
|
])
|
|
self.bank.db.commit()
|
|
# A stranger is told what is readable, not what is in progress.
|
|
self.assertEqual(self.client.get('/public/stats').json()['articles'], 1)
|
|
|
|
def test_it_says_nothing_a_stranger_could_walk(self):
|
|
data = self.client.get('/public/stats').json()
|
|
# Numbers only. No titles, no ids, nothing to enumerate the bank with.
|
|
self.assertTrue(all(isinstance(v, int) for v in data.values()))
|