Files
MathApp/content/tests_seed_content.py

339 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
تست‌های `seed_content` — محتوایی که از ریپو روی سرور بالا می‌آید.
چیزی که اینجا محافظت می‌شود، سه شکستِ خاموش است: سوالی که به درسِ اشتباه
بچسبد چون idها بینِ دو دیتابیس یکی نیستند، دیپلویِ دوم که همه‌چیز را تکراری
کند، و ردیفِ نمونه‌ای که از دیتابیسِ لوکالِ ما سر از production دربیاورد.
"""
import json
import tempfile
from pathlib import Path
from unittest import mock
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from content.management.commands import seed_content
from content.models import Choice, Grade, Lesson, Node, Question, Unit
CURRICULUM = {
'grades': [{
'title': 'پایه نهم', 'icon': 'grade-9', 'color': '#FF9800',
'units': [{
'subject': 'ریاضی', 'title': 'فصل ۴: توان و ریشه', 'order': 4,
'nodes': [{
'title': 'توان صحیح', 'order': 1,
'lessons': [
{'title': 'ضرب توان‌ها', 'introText': 'پایه‌ها را نگه دار.'},
{'title': 'تقسیم توان‌ها', 'introText': ''},
],
}],
}],
}],
}
QUESTIONS = {
'questions': [{
'path': ['پایه نهم', 'فصل ۴: توان و ریشه', 'توان صحیح', 'تقسیم توان‌ها'],
'prompt': 'حاصل ۲^۵ ÷ ۲^۳ چند است؟',
'type': 'multiple_choice',
'choices': ['۴', '۸', '۱۶'],
'correct': 0,
}],
}
class SeedContentTests(TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.curriculum_file = self.dir / 'curriculum.json'
self.questions_file = self.dir / 'questions.json'
self._write(CURRICULUM, QUESTIONS)
patches = [
mock.patch.object(seed_content, 'SEED_DIR', self.dir),
mock.patch.object(seed_content, 'CURRICULUM_FILE', self.curriculum_file),
mock.patch.object(seed_content, 'QUESTIONS_FILE', self.questions_file),
]
for patch in patches:
patch.start()
self.addCleanup(patch.stop)
def _write(self, curriculum, questions):
self.curriculum_file.write_text(
json.dumps(curriculum, ensure_ascii=False), encoding='utf-8')
self.questions_file.write_text(
json.dumps(questions, ensure_ascii=False), encoding='utf-8')
def test_loads_curriculum_and_questions_into_empty_database(self):
call_command('seed_content')
self.assertEqual(Grade.objects.count(), 1)
self.assertEqual(Lesson.objects.count(), 2)
question = Question.objects.get()
self.assertEqual(question.lesson.title, 'تقسیم توان‌ها')
self.assertEqual(question.choices.count(), 3)
self.assertEqual(question.choices.get(is_correct=True).value, '۴')
def test_book_order_wins_over_creation_order(self):
"""درسی که بعداً در وسطِ جزیره اضافه می‌شود id بزرگ‌تر دارد؛ ترتیبِ نمایش باید ترتیبِ کتاب باشد،
و ترتیبِ جابه‌جاشده‌ی فصل و جزیره هم با اجرای بعدیِ seed به کتاب برمی‌گردد."""
call_command('seed_content')
lessons = CURRICULUM['grades'][0]['units'][0]['nodes'][0]['lessons']
lessons.insert(1, {'title': 'درسِ تازه در وسط', 'introText': 'x'})
Unit.objects.update(order=99)
Node.objects.update(order=99)
self._write(CURRICULUM, QUESTIONS)
call_command('seed_content')
node = Node.objects.get()
self.assertEqual([l.title for l in node.lessons.all()],
['ضرب توان‌ها', 'درسِ تازه در وسط', 'تقسیم توان‌ها'])
self.assertEqual((Unit.objects.get().order, node.order), (4, 1))
lessons.pop(1) # فایلِ مشترکِ تست را برای تست‌های بعدی برمی‌گرداند
def test_second_run_changes_nothing(self):
"""دیپلویِ دوم نباید چیزی را تکراری کند — این کلِ ایمنیِ خودکار بودنش است."""
call_command('seed_content')
call_command('seed_content')
self.assertEqual(Grade.objects.count(), 1)
self.assertEqual(Lesson.objects.count(), 2)
self.assertEqual(Question.objects.count(), 1)
self.assertEqual(Choice.objects.count(), 3)
def test_question_follows_the_title_path_not_the_id(self):
"""
بازتولیدِ همان تله‌ای که این فایل برایش ساخته شد: روی سرورِ خالی idها
از ۱ شروع می‌شوند، ولی لوکالِ ما idهای پراکنده دارد. اگر سوال با id
وصل می‌شد، اینجا به درسِ اشتباه می‌چسبید — بی هیچ خطایی.
"""
grade = Grade.objects.create(title='پایه دیگر', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل پرت', order=1)
node = Node.objects.create(unit=unit, title='مبحث پرت', order=1)
for index in range(5):
Lesson.objects.create(node=node, title=f'درسِ پرت {index}')
call_command('seed_content')
question = Question.objects.get()
self.assertEqual(question.lesson.title, 'تقسیم توان‌ها')
self.assertEqual(question.lesson.node.unit.grade.title, 'پایه نهم')
def test_unknown_path_fails_loudly_and_writes_nothing(self):
self._write(CURRICULUM, {'questions': [{
**QUESTIONS['questions'][0],
'path': ['پایه نهم', 'فصل ۴: توان و ریشه', 'توان صحیح', 'درسی که نیست'],
}]})
with self.assertRaises(CommandError):
call_command('seed_content')
self.assertEqual(Grade.objects.count(), 0)
self.assertEqual(Question.objects.count(), 0)
def test_dry_run_reports_without_writing(self):
call_command('seed_content', '--dry-run')
self.assertEqual(Grade.objects.count(), 0)
self.assertEqual(Question.objects.count(), 0)
def test_editing_a_seeded_question_survives_the_next_deploy(self):
"""
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بدونِ اینکه دیپلویِ بعدی
نسخه‌ی خودش را برگرداند یا کنارش یک تکراری بسازد.
"""
call_command('seed_content')
question = Question.objects.get()
question.choices.filter(is_correct=True).update(value='چهار')
call_command('seed_content')
self.assertEqual(Question.objects.count(), 1)
self.assertEqual(question.choices.get(is_correct=True).value, 'چهار')
# ── کارِ ادمین با دیپلویِ بعدی برنمی‌گردد (۲۰۲۶-۱۰-۰۳، content/seed_sync.py) ──
def test_a_question_the_admin_deleted_stays_deleted(self):
call_command('seed_content')
Question.objects.get().delete()
call_command('seed_content')
self.assertEqual(Question.objects.count(), 0)
def test_fixing_a_question_prompt_does_not_bring_the_old_one_back(self):
call_command('seed_content')
Question.objects.update(prompt='حاصل ۲^۵ ÷ ۲^۳ چند می‌شود؟')
call_command('seed_content')
self.assertEqual(list(Question.objects.values_list('prompt', flat=True)),
['حاصل ۲^۵ ÷ ۲^۳ چند می‌شود؟'])
def test_renaming_a_unit_does_not_duplicate_it(self):
call_command('seed_content')
Unit.objects.update(title='فصل ۴: توان')
call_command('seed_content')
self.assertEqual(Unit.objects.count(), 1)
self.assertEqual(Lesson.objects.count(), 2)
self.assertEqual(Question.objects.count(), 1)
def test_lesson_text_edited_in_admin_is_kept(self):
call_command('seed_content')
Lesson.objects.filter(title='ضرب توان‌ها').update(intro_text='متنِ ادمین')
call_command('seed_content')
self.assertEqual(Lesson.objects.get(title='ضرب توان‌ها').intro_text, 'متنِ ادمین')
def test_lesson_text_changed_in_the_file_still_arrives_when_untouched(self):
call_command('seed_content')
curriculum = json.loads(json.dumps(CURRICULUM))
curriculum['grades'][0]['units'][0]['nodes'][0]['lessons'][0]['introText'] = 'متنِ تازه‌ی فایل'
self._write(curriculum, QUESTIONS)
call_command('seed_content')
self.assertEqual(Lesson.objects.get(title='ضرب توان‌ها').intro_text, 'متنِ تازه‌ی فایل')
def test_new_content_in_the_file_still_arrives(self):
call_command('seed_content')
questions = {'questions': QUESTIONS['questions'] + [{
**QUESTIONS['questions'][0], 'prompt': 'سوالِ تازه', 'choices': ['۱', '۲', '۳'],
}]}
self._write(CURRICULUM, questions)
call_command('seed_content')
self.assertEqual(Question.objects.count(), 2)
def test_export_leaves_out_demo_rows(self):
"""
[DEMO]/[SEED] در دیتابیسِ لوکال زندگی می‌کنند و نباید در فایلی بنشینند
که مستقیم روی production لود می‌شود.
"""
demo = Grade.objects.create(title='[DEMO] پایهٔ نمونه', icon='', color='')
demo_unit = Unit.objects.create(grade=demo, title='واحد ۱ — جمع', order=1)
demo_node = Node.objects.create(unit=demo_unit, title='جمع تا ۱۰', order=1)
demo_lesson = Lesson.objects.create(node=demo_node, title='گرم‌کردن')
demo_question = Question.objects.create(
lesson=demo_lesson, type='multiple_choice', prompt='۲ + ۳ = ?')
Choice.objects.create(question=demo_question, value='۵', is_correct=True)
Choice.objects.create(question=demo_question, value='۴', is_correct=False)
call_command('seed_content') # یک پایه‌ی واقعی هم وجود داشته باشد
call_command('seed_content', '--export')
written = json.loads(self.curriculum_file.read_text(encoding='utf-8'))
titles = [grade['title'] for grade in written['grades']]
self.assertEqual(titles, ['پایه نهم'])
rows = json.loads(self.questions_file.read_text(encoding='utf-8'))['questions']
self.assertTrue(all(row['path'][0] == 'پایه نهم' for row in rows))
def test_export_then_load_round_trips(self):
call_command('seed_content')
call_command('seed_content', '--export')
Question.objects.all().delete()
Grade.objects.all().delete()
call_command('seed_content')
self.assertEqual(Lesson.objects.count(), 2)
self.assertEqual(Question.objects.get().lesson.title, 'تقسیم توان‌ها')
class SeedFilesInRepoTests(TestCase):
"""فایل‌هایی که واقعاً کامیت شده‌اند باید سالم و بی‌ردیفِ نمونه باشند."""
def test_committed_curriculum_parses_and_has_no_demo_rows(self):
from content import curriculum_io
grades = curriculum_io.parse(seed_content.CURRICULUM_FILE.read_bytes())
self.assertTrue(grades)
for grade in grades:
self.assertFalse(grade['title'].startswith(('[DEMO]', '[SEED]')))
def test_committed_questions_all_point_at_a_real_lesson(self):
from content import question_io
rows = json.loads(
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
if not rows:
self.skipTest('هنوز سوالِ واقعی‌ای در ریپو نیست.')
call_command('seed_content')
question_io.resolve_paths(rows) # مسیرِ حل‌نشده خطا می‌دهد
def test_committed_questions_are_well_formed(self):
rows = json.loads(
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
if not rows:
self.skipTest('هنوز سوالِ واقعی‌ای در ریپو نیست.')
for index, row in enumerate(rows):
where = f'سوال {index + 1}'
self.assertTrue(row['prompt'].strip(), where)
self.assertEqual(len(row['choices']), 4, where)
self.assertEqual(len(set(row['choices'])), 4, where)
self.assertIn(row['correct'], range(4), where)
def test_every_topic_has_at_least_one_question(self):
"""
هدفِ این مجموعه «نمونه در چند جا» نیست: هر مبحث باید دستِ‌کم یک سوال
داشته باشد، وگرنه ادمین در مرورگرِ محتوا به ستون‌های خالی می‌رسد و
نمی‌داند محتوا نیست یا چیزی خراب است.
"""
call_command('seed_content')
empty = Node.objects.filter(lessons__questions__isnull=True).distinct()
empty = [node for node in empty if not Question.objects.filter(
lesson__node=node).exists()]
self.assertEqual(empty, [], f'{len(empty)} مبحث بدونِ سوال ماند.')
def test_the_right_answer_is_not_always_the_first_choice(self):
"""جوابِ درست اگر همیشه گزینهٔ اول باشد، آزمون سنجشِ چیزی نیست."""
rows = json.loads(
seed_content.QUESTIONS_FILE.read_text(encoding='utf-8'))['questions']
if len(rows) < 10:
self.skipTest('برای این بررسی سوالِ کافی نیست.')
positions = {row['correct'] for row in rows}
self.assertGreater(len(positions), 2)
class PracticeRefreshTests(TestCase):
"""اصلاحِ فایلِ جزیره‌ی تمرین به سوالِ موجود می‌رسد؛ سوالِ دستیِ ادمین دست نمی‌خورد."""
def _rows(self, choices, correct, prompt='۲ + ۳ = ؟'):
return [{'path': ['پایه سوم', 'فصل ۱', 'تمرین: جمع', 'درس'], 'prompt': prompt, 'type': 'multiple_choice',
'choices': choices, 'correct': correct}]
def _grades(self):
return [{'title': 'پایه سوم', 'icon': '', 'color': '', 'units': [{
'title': 'فصل ۱', 'order': 1, 'subject': 'ریاضی', 'blurb': '', 'nodes': [{
'title': 'تمرین: جمع', 'order': 1, 'lessons': [{
'title': 'درس', 'intro_text': 'متن', 'example': '', 'steps': []}]}]}]}]
def test_changed_choices_update_in_place_and_stale_is_removed(self):
from content import seed_sync
from content.models import Choice, Question
seed_sync.sync(self._grades(), self._rows(['۴', '۵', '۶', '۷'], 1))
q = Question.objects.get()
ids = list(q.choices.order_by('pk').values_list('pk', flat=True))
seed_sync.sync(self._grades(), self._rows(['۴', '۵', '۸', '۷'], 3))
q.refresh_from_db()
got = list(q.choices.order_by('pk').values_list('pk', 'value', 'is_correct'))
self.assertEqual([g[0] for g in got], ids)
self.assertEqual(got[3][1:], ('۷', True))
# سوالی که از فایل رفته و جوابی ندارد پاک می‌شود؛ سوالِ دستی نه
manual = Question.objects.create(lesson=q.lesson, type='multiple_choice', prompt='دستی')
Choice.objects.create(question=manual, value='الف', is_correct=True)
seed_sync.sync(self._grades(), self._rows(['۱', '۲'], 0, prompt='سوالِ دیگر'))
self.assertFalse(Question.objects.filter(prompt='۲ + ۳ = ؟').exists())
self.assertTrue(Question.objects.filter(prompt='دستی').exists())