Key points on questions link into article sections (AMBOSS-style) with samples; difficulty tagging with builder/bank filters; adaptive session algorithm prefers unanswered questions then recycles older incorrect ones, weakest categories first with damping; question create/edit is now admin/educator only; expired exams no longer auto-submit on resume; exam suspend messaging updated. Migrations k4f5a6b7c819, l5a6b7c8d920, m6a7b8c9d031. 63 backend and 97 frontend tests pass.
103 lines
5.7 KiB
Python
103 lines
5.7 KiB
Python
"""Per-option explanation validation and serialization."""
|
|
import unittest
|
|
|
|
import test_quiz_builder as fixtures
|
|
from app.models.question import Question
|
|
from app.routers import questions, quizzes
|
|
|
|
|
|
class OptionExplanationTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.bank = fixtures.BuilderTests()
|
|
self.bank.setUp()
|
|
self.client = self.bank.client
|
|
|
|
def tearDown(self):
|
|
self.bank.tearDown()
|
|
|
|
def create_payload(self, **overrides):
|
|
payload = {"question_text": "Explained", "question_type": "mcq",
|
|
"options": ["yes", "no"], "correct_answer": "yes",
|
|
"option_explanations": {"yes": "Right because", "no": "Wrong because"}}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
def test_create_validates_and_serializes_explanations(self):
|
|
self.bank.user = self.bank.mod
|
|
created = self.client.post('/questions/create', json=self.create_payload())
|
|
self.assertIn(created.status_code, (200, 201), created.text)
|
|
question_id = created.json()['id']
|
|
bank = self.client.get('/questions/bank', params={'q': 'Explained'}).json()
|
|
row = next(r for r in bank['questions'] if r['id'] == question_id)
|
|
self.assertEqual(row['option_explanations'], {"yes": "Right because", "no": "Wrong because"})
|
|
for bad in (
|
|
{"option_explanations": {"nope": "x"}},
|
|
{"option_explanations": "not-a-dict"},
|
|
{"option_explanations": {"yes": "x" * 2001}},
|
|
):
|
|
response = self.client.post('/questions/create', json=self.create_payload(**bad))
|
|
self.assertIn(response.status_code, (400, 422), bad)
|
|
|
|
def test_edit_requires_keys_to_match_current_options(self):
|
|
self.bank.user = self.bank.mod
|
|
response = self.client.patch('/questions/3', json={"option_explanations": {"yes": "Correct path", "no": "Incorrect path"}})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(self.bank.db.get(Question, 3).option_explanations, {"yes": "Correct path", "no": "Incorrect path"})
|
|
# Changing options without updating keys is rejected atomically.
|
|
response = self.client.patch('/questions/3', json={"options": ["a", "b"], "correct_answer": "a",
|
|
"option_explanations": {"yes": "stale"}})
|
|
self.assertEqual(response.status_code, 400, response.text)
|
|
self.assertEqual(self.bank.db.get(Question, 3).options, ["yes", "no"])
|
|
|
|
def test_key_points_validate_serialize_and_smart_link(self):
|
|
from app.models.article import Article
|
|
article = Article(slug='kp-target', title='Key point target', content='Intro',
|
|
sections=[{'id': 'e' * 32, 'slug': 'kp', 'title': 'KP section', 'content': 'Body'}],
|
|
user_id=3, status='published')
|
|
self.bank.db.add(article)
|
|
self.bank.db.commit()
|
|
self.bank.user = self.bank.mod
|
|
good = [{'text': 'Brief take-away', 'article_id': article.id, 'article_section_id': 'e' * 32}]
|
|
created = self.client.post('/questions/create', json=self.create_payload(key_points=good))
|
|
self.assertIn(created.status_code, (200, 201), created.text)
|
|
question_id = created.json()['id']
|
|
bank = self.client.get('/questions/bank', params={'q': 'Explained'}).json()
|
|
row = next(r for r in bank['questions'] if r['id'] == question_id)
|
|
self.assertEqual(row['key_points'][0]['article_id'], article.id)
|
|
for bad in (
|
|
[{'text': 'x', 'article_id': 999}],
|
|
[{'text': 'x', 'article_id': article.id, 'article_section_id': 'bad'}],
|
|
[{'text': ''}],
|
|
'not-a-list',
|
|
[{'text': 'x'} for _ in range(13)],
|
|
):
|
|
response = self.client.patch(f'/questions/{question_id}', json={'key_points': bad})
|
|
self.assertIn(response.status_code, (400, 422), bad)
|
|
response = self.client.patch(f'/questions/{question_id}', json={'key_points': []})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertIsNone(self.bank.db.get(fixtures.Question, question_id).key_points or None)
|
|
# Study reveal carries key points.
|
|
self.bank.user = self.bank.mod
|
|
self.client.patch('/questions/1', json={'key_points': good})
|
|
self.bank.user = self.bank.owner
|
|
attempt = self.client.post('/attempts/start?quiz_id=1&mode=study').json()['id']
|
|
detail = self.client.get(f'/quizzes/1?attempt_id={attempt}').json()
|
|
self.assertEqual(detail['questions'][0]['key_points'][0]['article_section_id'], 'e' * 32)
|
|
|
|
def test_quiz_reveal_and_editor_route_carry_explanations(self):
|
|
self.bank.user = self.bank.mod
|
|
self.client.patch('/questions/1', json={"option_explanations": {"yes": "Preferred response", "no": "Distractor"}})
|
|
self.bank.user = self.bank.owner
|
|
attempt = self.client.post('/attempts/start?quiz_id=1&mode=study').json()['id']
|
|
detail = self.client.get(f'/quizzes/1?attempt_id={attempt}').json()
|
|
self.assertEqual(detail['questions'][0]['option_explanations'], {"yes": "Preferred response", "no": "Distractor"})
|
|
self.bank.user = self.bank.mod
|
|
response = self.client.patch('/quizzes/1/questions/1', json={"option_explanations": {"nope": "bad"}})
|
|
self.assertEqual(response.status_code, 400, response.text)
|
|
response = self.client.patch('/quizzes/1/questions/1', json={"option_explanations": {"no": "Updated distractor"}})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(self.bank.db.get(Question, 1).option_explanations, {"no": "Updated distractor"})
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|