339 lines
16 KiB
Python
339 lines
16 KiB
Python
"""
|
||
تستهای `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())
|