cover the new paths, especially the ones that fail silently

خطاهای این پروژه خاموش‌اند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجه‌ی
خالی می‌دهد نه استثنا، و دستیار فقط بدتر جواب می‌دهد. این تست‌ها نگهبانِ
همان‌جاهایند.

TextbookLayoutTests بررسی می‌کند رشته‌ی هر فصل بینِ ingest و retrieval یکی
باشد و بازه‌ی صفحه‌ها پیوسته بماند. IngestBookResumeTests مطمئن می‌شود
صفحه‌ی بی‌مسئله دوبار پول نمی‌گیرد، صفحه‌ی خطادار در اجرای بعد دوباره
سراغش می‌روند، و upsertِ شکست‌خورده rollback می‌شود.
LessonContextResolutionTests نگهبانِ نرمال‌سازیِ «پایه نهم» → «ریاضی نهم»
است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند.
GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایه‌های بالا را
می‌گیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه
می‌دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 19:01:24 +03:30
parent ac1b1af398
commit 5f3aae0756
4 changed files with 859 additions and 0 deletions

View File

@@ -355,3 +355,508 @@ class AskAIAnswerCacheTests(TestCase):
# ولی پیام و کوتا برای هر دو درخواست ثبت/مصرف شده
self.assertEqual(Message.objects.filter(conversation__user_id=self.user.id, role='user').count(), 2)
self.assertGreater(r1.data['remainingQuota'], r2.data['remainingQuota'])
class MathPayloadShapeTests(TestCase):
"""
نقاطِ ریاضیِ ما باید هم‌شکلِ نقاطِ موجودِ تیمِ دیگر باشند. مهم‌ترینش نبودِ
`summary` است: با آن، retrieval به‌جای راه‌حل، متنِ صافِ «Type: …» را
به‌عنوان context به مدل می‌دهد و کیفیت بی‌سروصدا افت می‌کند.
"""
def _math_chunk(self, **over):
data = dict(
lesson_id=None, subject='ریاضی', lesson_number=4, chapter=4,
chapter_title='فصل ۴: توان و ریشه', grade_title='نهم', order=1,
title='فصل ۴: توان و ریشه', original_text='Type: توان صحیح',
summary='Type: توان صحیح', collection='ریاضی نهم', source_page=67,
problem_type='توان صحیح', keywords=['توان'],
general_solution='پایه را در خودش ضرب کن.',
problem='۲ به توان ۳ چند است؟', solution='۸',
)
data.update(over)
return LessonChunk(**data)
def test_math_payload_has_no_summary_or_lesson_title(self):
from rag.services.ingest import _payload
payload = _payload(self._math_chunk())
self.assertNotIn('summary', payload)
self.assertNotIn('lesson_title', payload)
self.assertEqual(payload['chapter'], 'فصل ۴: توان و ریشه')
self.assertEqual(payload['grade'], 'نهم')
self.assertEqual(payload['source_page'], 67)
def test_retrieval_reads_solution_not_the_flattened_text(self):
from rag.services.ingest import _payload
from rag.services.retrieval import _payload_text
text = _payload_text(_payload(self._math_chunk()))
self.assertIn('پایه را در خودش ضرب کن.', text)
self.assertNotIn('Type:', text)
def test_text_subject_still_gets_summary(self):
from rag.services.ingest import _payload
payload = _payload(self._math_chunk(
problem_type='', keywords=[], general_solution='', problem='', solution='',
subject='علوم تجربی', summary='خلاصه‌ی درس', title='زنگ علوم',
))
self.assertEqual(payload['summary'], 'خلاصه‌ی درس')
self.assertEqual(payload['lesson_title'], 'زنگ علوم')
class TextbookLayoutTests(TestCase):
def test_every_chapter_title_is_registered_for_retrieval(self):
"""عنوانی که ingest می‌نویسد باید همانی باشد که retrieval می‌سازد."""
from rag.services.known_collections import resolve_math_chapter
from rag.services.textbooks import TEXTBOOKS
for book in TEXTBOOKS.values():
for chapter in book.chapters:
self.assertEqual(
resolve_math_chapter(book.subject, book.grade, chapter.number),
chapter.title,
f'{book.key} فصل {chapter.number}',
)
def test_page_ranges_are_contiguous_for_books_we_ingest_ourselves(self):
"""
فقط کتاب‌هایی که فایلِ PDF دارند. پایه‌های ششم و هفتم را تیمِ دیگر
ingest کرده و نقاطشان شماره‌ی صفحه ندارند، پس بازه‌ی صفحه برایشان صفر
است و معنایی ندارد.
"""
from rag.services.textbooks import INGESTABLE
self.assertTrue(INGESTABLE, 'هیچ کتابِ قابلِ ingest ثبت نشده')
for book in INGESTABLE.values():
previous = None
for chapter in book.chapters:
self.assertLess(0, chapter.first_page, book.key)
self.assertLessEqual(chapter.first_page, chapter.last_page, book.key)
if previous is not None:
# بازه‌ها نباید همپوشانی یا حفره داشته باشند، وگرنه صفحه‌ای
# دو بار ingest می‌شود یا اصلاً دیده نمی‌شود.
self.assertEqual(chapter.first_page, previous.last_page + 1, book.key)
previous = chapter
def test_pdf_pages_apply_the_offset(self):
from rag.services.textbooks import MATH_9
first, last = MATH_9.pdf_pages(MATH_9.chapter(1))
self.assertEqual((first, last), (9, 25))
class GradeAwarePromptTests(TestCase):
def test_secondary_grade_is_not_addressed_as_an_elementary_pupil(self):
from rag.services.generation import system_prompt
self.assertIn('نهم', system_prompt('نهم'))
self.assertNotIn('دبستان', system_prompt('نهم'))
self.assertIn('دبستان', system_prompt('سوم'))
@override_settings(**RAG_ON)
class AnswerAnnotationTests(TestCase):
"""پاسخ باید با متادیتای تولید ثبت شود، وگرنه لایک/دیسلایک بعداً قابلِ تحلیل نیست."""
def setUp(self):
cache.clear()
reset_clients_cache()
self.user = User.objects.create(phone='09120000777', is_guest=False, grade_id=1)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def tearDown(self):
cache.clear()
reset_clients_cache()
def test_assistant_message_records_model_prompt_version_and_retrieval(self):
from ai_tutor.models import Message
from rag.services.generation import PROMPT_VERSION
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_title': 'کسر', 'lesson_number': 2}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
response = self.client.post('/api/ai/ask', {
'userQuestion': 'کسر چیست؟', 'subject': 'ریاضی',
'grade': 'ششم', 'lessonNumber': 2,
}, format='json')
self.assertEqual(response.status_code, 200)
message = Message.objects.get(pk=response.data['messageId'])
self.assertEqual(message.prompt_version, PROMPT_VERSION)
self.assertTrue(message.model_name)
self.assertEqual(message.grade_title, 'ششم')
self.assertEqual(message.retrieval_collection, 'ریاضی ششم')
self.assertEqual(message.chunk_count, 1)
self.assertFalse(message.from_cache)
def test_second_identical_question_is_marked_as_cached(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_title': 'کسر', 'lesson_number': 2}])
body = {'userQuestion': 'همان سوال', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2}
with override_settings(RAG_ANSWER_CACHE_TTL=600), \
patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
self.client.post('/api/ai/ask', body, format='json')
second = self.client.post('/api/ai/ask', body, format='json')
message = Message.objects.get(pk=second.data['messageId'])
self.assertTrue(message.from_cache)
# متادیتای تولید از cache هم می‌آید، پس نباید خالی شود.
self.assertTrue(message.prompt_version)
def test_feedback_stores_optional_reason(self):
from ai_tutor.models import MessageFeedback
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_number': 2}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai()), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
ask = self.client.post('/api/ai/ask', {
'userQuestion': 'سوال', 'subject': 'ریاضی', 'grade': 'ششم', 'lessonNumber': 2,
}, format='json')
response = self.client.post(
f"/api/ai/messages/{ask.data['messageId']}/feedback",
{'isHelpful': False, 'reason': 'جواب ربطی به سوال نداشت'}, format='json',
)
self.assertEqual(response.status_code, 200)
feedback = MessageFeedback.objects.get(message_id=ask.data['messageId'])
self.assertFalse(feedback.is_helpful)
self.assertEqual(feedback.reason, 'جواب ربطی به سوال نداشت')
@override_settings(**RAG_ON)
class IngestBookResumeTests(TestCase):
"""
ازسرگیریِ ingestِ کتاب. مهم‌ترین حالت، صفحه‌ی بی‌مسئله است: بدونِ نشانه‌ی
IngestedPage هر اجرای دوباره یک فراخوانیِ vision دیگر برایش خرج می‌کند.
"""
def setUp(self):
reset_clients_cache()
self.calls = []
def tearDown(self):
reset_clients_cache()
def _run(self, pages_content, **kwargs):
"""pages_content: {pdf_page: [problemType, ...]}؛ صفحه‌ی غایب یعنی بی‌مسئله."""
from rag.services import ingest as ingest_service
def fake_images(path, first_page=1, dpi=200, last_page=None):
return [f'image-p{first_page}']
def fake_extract(image, grade, chapter_name):
page = int(str(image).split('p')[-1])
self.calls.append(page)
return {'grade': grade, 'chapter': chapter_name,
'problemTypes': pages_content.get(page, [])}
with patch('rag.services.pdf.extract_page_images', side_effect=fake_images), \
patch('rag.services.pdf.extract_problem_types', side_effect=fake_extract), \
patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.upsert_points'), \
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536):
return ingest_service.ingest_book(subject='ریاضی', grade='نهم', **kwargs)
def test_pages_without_problems_are_not_billed_twice(self):
problem = {'type': 'مجموعه', 'keywords': ['مجموعه'], 'generalSolution': 'حل',
'exampleFromImage': {'problem': 'سوال', 'solution': 'جواب'}}
first = self._run({10: [problem]}, chapters=[1], max_pages=3)
self.assertEqual(first['pages_processed'], 3)
self.assertEqual(first['chunks_created'], 1)
self.assertEqual(self.calls, [9, 10, 11])
# اجرای دوم: هر سه صفحه — شاملِ ۹ و ۱۱ که chunk نساختند — رد می‌شوند.
self.calls.clear()
second = self._run({10: [problem]}, chapters=[1], max_pages=3)
self.assertEqual(self.calls, [12, 13, 14])
self.assertEqual(second['pages_skipped'], 3)
def test_a_failing_page_is_retried_on_the_next_run(self):
from rag.services import ingest as ingest_service
def fake_images(path, first_page=1, dpi=200, last_page=None):
return [f'image-p{first_page}']
def boom(image, grade, chapter_name):
raise RuntimeError('provider down')
with patch('rag.services.pdf.extract_page_images', side_effect=fake_images), \
patch('rag.services.pdf.extract_problem_types', side_effect=boom), \
patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.upsert_points'):
result = ingest_service.ingest_book(
subject='ریاضی', grade='نهم', chapters=[1], max_pages=2,
)
self.assertEqual(result['status'], 'partial')
self.assertEqual(result['failed_pages'], [9, 10])
self.assertEqual(result['pages_processed'], 0)
# هیچ نشانه‌ای زده نشده، پس اجرای بعدی دوباره سراغشان می‌رود.
self.calls.clear()
self._run({}, chapters=[1], max_pages=2)
self.assertEqual(self.calls, [9, 10])
def test_chapter_title_comes_from_our_catalog_not_from_the_model(self):
from rag.models import LessonChunk
# مدل عنوانِ فصلِ متفاوتی برمی‌گرداند؛ باید نادیده گرفته شود، وگرنه
# فیلترِ retrieval روی رشته‌ی دقیق دیگر نمی‌خورد.
problem = {'type': 'مجموعه', 'keywords': [], 'generalSolution': 'حل',
'exampleFromImage': {'problem': 'س', 'solution': 'ج'}}
with patch('rag.services.pdf.extract_page_images',
side_effect=lambda path, first_page=1, dpi=200, last_page=None: [f'image-p{first_page}']), \
patch('rag.services.pdf.extract_problem_types',
return_value={'grade': 'چیزِ دیگر', 'chapter': 'فصلِ اشتباه',
'problemTypes': [problem]}), \
patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.upsert_points'), \
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536):
from rag.services import ingest as ingest_service
ingest_service.ingest_book(subject='ریاضی', grade='نهم', chapters=[1], max_pages=1)
chunk = LessonChunk.objects.get()
self.assertEqual(chunk.chapter_title, 'فصل ۱: مجموعه‌ها')
self.assertEqual(chunk.grade_title, 'نهم')
class LessonContextResolutionTests(TestCase):
"""
اپ فقط lessonId دارد؛ نباید مجبور باشد نامِ collectionِ Qdrant را بداند.
"""
def setUp(self):
from content.models import Grade, Lesson, Node, Unit
grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
unit = Unit.objects.create(grade=grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(node=node, title='محاسبه توان منفی', intro_text='.')
other_unit = Unit.objects.create(grade=grade, title='یک عنوانِ ناشناخته', order=9)
other_node = Node.objects.create(unit=other_unit, title='درس', order=1)
self.orphan_lesson = Lesson.objects.create(node=other_node, title='x', intro_text='.')
def test_derives_subject_grade_and_chapter(self):
from rag.services.lesson_context import resolve_from_lesson
self.assertEqual(
resolve_from_lesson(self.lesson.id),
{'subject': 'ریاضی', 'gradeTitle': 'نهم', 'chapter': 4},
)
def test_unknown_chapter_title_still_yields_the_grade(self):
from rag.services.lesson_context import resolve_from_lesson
self.assertEqual(resolve_from_lesson(self.orphan_lesson.id), {'gradeTitle': 'نهم'})
def test_bad_input_is_not_an_error(self):
from rag.services.lesson_context import resolve_from_lesson
for value in (None, '', 'abc', 999999):
self.assertEqual(resolve_from_lesson(value), {})
def test_grade_prefix_variants_normalize_to_the_collection_form(self):
from rag.services.lesson_context import normalize_grade_title
self.assertEqual(normalize_grade_title('پایه نهم'), 'نهم')
self.assertEqual(normalize_grade_title('پایهٔ دهم'), 'دهم')
self.assertEqual(normalize_grade_title('ششم'), 'ششم')
@override_settings(**RAG_ON)
class AskDerivesContextFromLessonTests(TestCase):
def setUp(self):
from content.models import Grade, Lesson, Node, Unit
cache.clear()
reset_clients_cache()
self.user = User.objects.create(device_id='dev-ctx-1', is_guest=True)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(node=node, title='توان منفی', intro_text='.')
def tearDown(self):
cache.clear()
reset_clients_cache()
def test_lesson_id_alone_is_enough_to_hit_the_right_collection(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'generalSolution': 'معکوسِ پایه را بگیر.',
'chapter': 'فصل ۴: توان و ریشه'}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
response = self.client.post('/api/ai/ask', {
'userQuestion': 'چرا توان منفی معکوس می‌شود؟',
'lessonId': str(self.lesson.id),
}, format='json')
self.assertEqual(response.status_code, 200)
message = Message.objects.get(pk=response.data['messageId'])
# بدونِ اینکه اپ subject/grade/lessonNumber فرستاده باشد:
self.assertEqual(message.retrieval_collection, 'ریاضی نهم')
self.assertEqual(message.retrieval_filter_value, 'فصل ۴: توان و ریشه')
self.assertEqual(message.grade_title, 'نهم')
def test_explicit_values_still_win_over_the_derived_ones(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'summary': 'خلاصه', 'lesson_number': 2}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai()), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
response = self.client.post('/api/ai/ask', {
'userQuestion': 'سوال', 'lessonId': str(self.lesson.id),
'subject': 'علوم تجربی', 'grade': 'سوم', 'lessonNumber': 2,
}, format='json')
message = Message.objects.get(pk=response.data['messageId'])
self.assertEqual(message.retrieval_collection, 'علوم تجربی سوم')
class NonIngestableBookTests(TestCase):
def test_a_book_without_a_pdf_is_rejected_by_ingest_not_silently_skipped(self):
from rag.services.ingest import ingest_book
# پایه‌ی ششم فقط ساختار دارد (دادهٔ واقعی‌اش روی Qdrantِ تیمِ دیگر است).
with self.assertRaises(FileNotFoundError):
ingest_book(subject='ریاضی', grade='ششم', chapters=[1], max_pages=1)
@override_settings(**RAG_ON)
class FollowUpStaysGroundedTests(TestCase):
"""
پیامِ دومِ مکالمه هم باید به کتاب مستند بماند.
اپ در ادامه فقط `conversationId` می‌فرستد. چون Conversation فیلدی برای
عنوانِ پایه ندارد، grade_title خالی می‌شد و retrieval بی‌هیچ خطایی رد
می‌شد — یعنی فقط جوابِ اول از کتاب می‌آمد.
"""
def setUp(self):
from content.models import Grade, Lesson, Node, Unit
cache.clear()
reset_clients_cache()
self.user = User.objects.create(device_id='dev-follow-1', is_guest=True)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(node=node, title='توان منفی', intro_text='.')
def tearDown(self):
cache.clear()
reset_clients_cache()
def test_second_message_of_a_conversation_still_hits_the_collection(self):
from ai_tutor.models import Message
qdrant = _FakeQdrant(points=[{'generalSolution': 'معکوسِ پایه.',
'chapter': 'فصل ۴: توان و ریشه'}])
with patch('rag.services.clients.get_openai_client', return_value=_fake_openai(chat_reply='پاسخ')), \
patch('rag.services.clients.get_qdrant_client', return_value=qdrant):
first = self.client.post('/api/ai/ask', {
'userQuestion': 'سوالِ اول', 'lessonId': str(self.lesson.id),
}, format='json')
# پیامِ دوم: فقط conversationId، همان‌طور که اپ می‌فرستد.
second = self.client.post('/api/ai/ask', {
'userQuestion': 'سوالِ دوم، متفاوت',
'conversationId': str(first.data['conversationId']),
}, format='json')
self.assertEqual(second.status_code, 200, second.data)
message = Message.objects.get(pk=second.data['messageId'])
self.assertEqual(message.retrieval_collection, 'ریاضی نهم')
self.assertEqual(message.retrieval_filter_value, 'فصل ۴: توان و ریشه')
self.assertEqual(message.chunk_count, 1)
class ReviewFixRegressionTests(TestCase):
"""نگهبانِ باگ‌هایی که مرورِ کد پیدا کرد؛ هرکدام قبلاً بی‌سروصدا خراب می‌کردند."""
def test_a_chunk_with_only_a_problem_type_still_counts_as_structured(self):
from rag.services.ingest import _payload
chunk = LessonChunk(
lesson_id=None, subject='ریاضی', lesson_number=1, chapter=1,
chapter_title='فصل ۱: مجموعه‌ها', grade_title='نهم', order=1,
title='فصل ۱: مجموعه‌ها', original_text='Type: مجموعه', summary='Type: مجموعه',
collection='ریاضی نهم', problem_type='مجموعه', keywords=['مجموعه'],
general_solution='', problem='', solution='',
)
payload = _payload(chunk)
self.assertNotIn('summary', payload)
self.assertEqual(payload['type'], 'مجموعه')
def test_cache_key_separates_grades(self):
from rag.services.answer_cache import make_key
self.assertNotEqual(
make_key(None, None, 'کسر چیست؟', 'نهم'),
make_key(None, None, 'کسر چیست؟', 'سوم'),
)
def test_grade_id_path_builds_the_same_collection_as_the_lesson_path(self):
from content.models import Grade
from rag.services.config import build_collection_name, resolve_grade_title
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
# «پایه نهم» نباید به «ریاضی پایه نهم» تبدیل شود.
self.assertEqual(resolve_grade_title(grade.id), 'نهم')
self.assertEqual(
build_collection_name('ریاضی', resolve_grade_title(grade.id)), 'ریاضی نهم',
)
def test_feedback_reason_survives_a_second_vote_without_one(self):
from ai_tutor.models import Conversation, Message, MessageFeedback
user = User.objects.create(device_id='dev-reason', is_guest=True)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
conversation = Conversation.objects.create(user_id=user.id)
message = Message.objects.create(conversation=conversation, role='assistant', text='پاسخ')
client.post(f'/api/ai/messages/{message.id}/feedback',
{'isHelpful': False, 'reason': 'ربطی نداشت'}, format='json')
client.post(f'/api/ai/messages/{message.id}/feedback',
{'isHelpful': True}, format='json')
feedback = MessageFeedback.objects.get(message=message)
self.assertTrue(feedback.is_helpful)
self.assertEqual(feedback.reason, 'ربطی نداشت')
def test_a_failed_upsert_rolls_back_so_the_page_is_retried(self):
from rag.services import ingest as ingest_service
def boom(*args, **kwargs):
raise RuntimeError('qdrant down')
rows = [{'order': 1, 'source_page': 42, 'original_text': 'x', 'summary': 'x',
'structured': {'chapter_title': 'فصل ۱: مجموعه‌ها', 'grade_title': 'نهم',
'problem_type': 'مجموعه'}}]
with patch('rag.services.ingest.ensure_collection'), \
patch('rag.services.ingest.embed_text', return_value=[0.01] * 1536), \
patch('rag.services.ingest.upsert_points', side_effect=boom):
with self.assertRaises(RuntimeError):
ingest_service._persist_and_upsert(
rows, lesson_id=None, grade_id=None, subject='ریاضی',
lesson_number=1, chapter=1, title='فصل ۱: مجموعه‌ها',
collection='ریاضی نهم',
)
# هیچ ردیفی نباید بماند، وگرنه _done_pages آن صفحه را انجام‌شده می‌بیند
# و chunkها برای همیشه در DB می‌مانند بدونِ اینکه به Qdrant برسند.
self.assertEqual(LessonChunk.objects.filter(source_page=42).count(), 0)