pdf-quiz-generator/backend/tests/test_study_tools.py
Daniel 5d59e00144 refactor: remove per-question sharing
`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
2026-09-12 08:42:51 +02:00

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()))