Files
MathApp/content/tests_seed_content.py
Zareian 9c3db68cbb carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:20:26 +03:30

196 lines
8.6 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': [{
'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) # مسیرِ حل‌نشده خطا می‌دهد