""" تست‌های `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_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())