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>
This commit is contained in:
195
content/tests_seed_content.py
Normal file
195
content/tests_seed_content.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
تستهای `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) # مسیرِ حلنشده خطا میدهد
|
||||
Reference in New Issue
Block a user