`Question.is_shared` defaulted to 1 and was only ever set by a route nothing called, so in practice it divided the bank into "everything" and "everything, plus your own private ones" — a distinction that cost every recommendation denominator a join and never changed an answer. Who may reach the bank is the site's own access rules; who may manage a question is the category grant tree. So the two predicates the whole bank was built on are now the same thing, and say what they actually mean: a question is out of reach if it has been deleted or belongs to a course. Nothing else. The column is dropped, the route that set it is gone, the bulk "share" action with it, and the Private tile and pill go from the question manager. The tests that turned on it have been rewritten rather than deleted, because the rule they were really about survives: revoking a question still revokes every session carrying it — by deleting it, which is the only revocation left. Several others named a category holding exactly two reachable questions and then answered two particular ids; that category holds four now, so they name the pair instead. A session's own sharing flag is untouched — that is a different thing, and it is still how a session is handed to somebody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
732 lines
37 KiB
Python
732 lines
37 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_course_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'], 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.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) # 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()
|
|
|
|
|
|
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, a
|
|
course quiz belongs to its course, 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_and_course_quizzes_are_left_out(self):
|
|
from app.models.course import Course
|
|
from app.models.quiz import Quiz
|
|
module = Course(title='Neonatology', user_id=1)
|
|
self.bank.db.add(module)
|
|
self.bank.db.flush()
|
|
again = Quiz(title='Repeat of test', user_id=1, is_repetition=1)
|
|
course = Quiz(title='Course quiz', user_id=1, course_id=module.id)
|
|
self.bank.db.add_all([again, course])
|
|
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.sat(3, True, ago_days=1, seconds=50, quiz_id=course.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.course import Course
|
|
from app.models.quiz import Quiz
|
|
course = Course(title='Neonatology', user_id=1)
|
|
self.bank.db.add(course)
|
|
self.bank.db.flush()
|
|
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()))
|