تا امروز کوریکولوم بود و سوال نبود: هر ۱۴ سوالِ دیتابیسِ لوکال روی ردیفهای [DEMO] نشسته بودند و در export کنار گذاشته میشدند. یعنی سینیور میتوانست در مرورگرِ محتوا پایین برود ولی هیچ درسی آزمونی نداشت — که از «محتوا نداریم» بدتر است، چون شبیهِ خرابی بهنظر میرسد. حالا هر ۱۶۸ مبحث دستِکم یک سوالِ چهارگزینهای دارد: ۲۳ برای پایه نهم، ۴۶ ششم، ۴۶ هفتم، ۵۳ سوم. هر سوال به درسی وصل است که عنوانش همان موضوع را میگوید، نه به یک درسِ دلخواه. جای گزینهٔ درست با seed ثابت جابهجا شده (۴۴/۴۹/۳۵/۴۰ در چهار موقعیت). اگر جوابِ درست همیشه گزینهٔ اول باشد، آزمون دیگر چیزی را نمیسنجد و آمارِ «درصدِ غلط» هم بیمعنا میشود. یک تست همین را نگه میدارد. اینها نمونهاند نه محتوای نهایی: یک سوال برای هر مبحث فقط نشان میدهد مسیرِ درس → آزمون → XP کار میکند. سوالهای واقعیِ هر درس کارِ `generate_questions_from_chunks` است که به اعتبارِ AI نیاز دارد، و از پنلِ ادمین هم میشود دستی اضافه کرد. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
10 KiB
Python
232 lines
10 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': [{
|
||
'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, 'چهار')
|
||
|
||
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)
|