Files
MathApp/content/tests_flow.py
Zareian 5f3aae0756 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>
2026-09-13 19:01:24 +03:30

221 lines
11 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.
"""
تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد.
یک کاربر از صفر تا آخر می‌رود: ورود → انتخاب پایه و اسم → نقشه‌ی مهارت →
درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همان‌جا
گیر می‌کند؛ این تست دقیقاً همان زنجیره را می‌بندد.
عمداً از APIهای واقعی می‌رود (نه از ORM)، چون شکافی که ماه پیش پیدا شد
(«/api/home بدونِ ?grade= خالی برمی‌گشت») فقط از همین زاویه دیده می‌شد.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.models import User
from content.models import Choice, Grade, Lesson, Node, Question, Unit
class BasicLearningFlowTests(TestCase):
def setUp(self):
cache.clear()
self.client = APIClient()
self.grade = Grade.objects.create(title='پایه نهم', icon='grade-9', color='#FF9800')
unit = Unit.objects.create(grade=self.grade, title='فصل ۴: توان و ریشه', order=4)
node = Node.objects.create(unit=unit, title='توان صحیح', order=1)
self.lesson = Lesson.objects.create(
node=node, title='محاسبه توان منفی',
intro_text='۱. معکوسِ پایه را بگیر. ۲. توان را مثبت کن.',
mascot_animation='explain',
)
question = Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt='۲ به توان منفی ۳ چند است؟',
)
self.correct = Choice.objects.create(question=question, value='۱/۸', is_correct=True)
Choice.objects.create(question=question, value='-۸', is_correct=False)
Choice.objects.create(question=question, value='۸', is_correct=False)
Choice.objects.create(question=question, value='-۱/۸', is_correct=False)
self.question = question
def tearDown(self):
cache.clear()
def _auth(self, token):
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
def test_a_new_user_can_go_from_signup_to_finishing_a_lesson(self):
# ۱. ورودِ مهمان — سریع‌ترین مسیرِ ورود که اپ می‌تواند بگیرد.
response = self.client.post(
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-1'}, format='json',
)
self.assertEqual(response.status_code, 200, response.data)
self._auth(response.data['token'])
# ۲. صفحه‌ی «بیا باهم آشنا بشیم»: اسم + پایه.
response = self.client.patch('/api/user/profile', {'name': 'سارا'}, format='json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['name'], 'سارا')
grades = self.client.get('/api/grades').data['grades']
self.assertIn('پایه نهم', [g['title'] for g in grades])
response = self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
self.assertEqual(response.status_code, 200)
# ۳. صفحه‌ی خانه — بدونِ پارامتر، از پایه‌ی خودِ کاربر.
home = self.client.get('/api/home')
self.assertEqual(home.status_code, 200)
self.assertEqual(len(home.data['units']), 1)
node = home.data['units'][0]['nodes'][0]
self.assertEqual(node['status'], 'unlocked')
self.assertIn(self.lesson.id, node['lessonIds'])
# ۴. صفحه‌ی درس.
intro = self.client.get(f'/api/lessons/{self.lesson.id}/intro')
self.assertEqual(intro.status_code, 200)
self.assertTrue(intro.data['introText'])
self.assertTrue(intro.data['mascotAnimation'])
questions = self.client.get(f'/api/lessons/{self.lesson.id}/questions').data['questions']
self.assertEqual(len(questions), 1)
self.assertEqual(len(questions[0]['options']), 4)
# ۵. پاسخِ درست.
answer = self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.correct.id}, format='json',
)
self.assertEqual(answer.status_code, 200, answer.data)
self.assertTrue(answer.data['correct'])
self.assertEqual(str(answer.data['correctOptionId']), str(self.correct.id))
self.assertGreater(answer.data['xpEarned'], 0)
self.assertEqual(answer.data['mascotMood'], 'happy')
# ۶. اتمامِ درس. correctCount/totalQuestions را کلاینت می‌فرستد، ولی
# سرور آن را با تعدادِ واقعیِ سوال‌های درس سقف می‌زند.
complete = self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 1}, format='json',
)
self.assertEqual(complete.status_code, 200, complete.data)
self.assertGreater(complete.data['stars'], 0)
# ۷. آمار باید پیشرفت را نشان دهد و گره کامل شده باشد.
summary = self.client.get('/api/user/summary')
self.assertGreater(summary.data['xp'], 0)
self.assertEqual(summary.data['name'], 'سارا')
home = self.client.get('/api/home')
self.assertEqual(home.data['units'][0]['nodes'][0]['status'], 'completed')
self.assertGreater(home.data['stats']['progressPercent'], 0)
def test_completing_twice_with_the_same_key_does_not_double_count(self):
from content.models import UserLessonCompletion
response = self.client.post(
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-2'}, format='json',
)
self._auth(response.data['token'])
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.correct.id}, format='json',
)
body = {'correctCount': 1, 'totalQuestions': 1}
first = self.client.post(
f'/api/lessons/{self.lesson.id}/complete', body, format='json',
HTTP_IDEMPOTENCY_KEY='same-key',
)
second = self.client.post(
f'/api/lessons/{self.lesson.id}/complete', body, format='json',
HTTP_IDEMPOTENCY_KEY='same-key',
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
# قطعیِ شبکه + دکمه‌ی دوباره نباید دوبار امتیاز بدهد.
self.assertEqual(
UserLessonCompletion.objects.filter(lesson=self.lesson).count(), 1,
)
summary = self.client.get('/api/user/summary')
self.assertEqual(summary.data['xp'], first.data['xpEarned'])
def test_answer_xp_lands_in_the_total_only_after_the_lesson_is_completed(self):
"""
قراردادی که فرانت باید بداند: `xpEarned` در پاسخِ هر سوال، ارزشِ همان
سوال است و بلافاصله در مجموعِ کاربر نمی‌نشیند؛ مجموع موقعِ اتمامِ درس
به‌روز می‌شود. بدونِ دانستنِ این، توسعه‌دهنده‌ی فرانت دنبالِ باگی
می‌گردد که وجود ندارد.
"""
response = self.client.post(
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-4'}, format='json',
)
self._auth(response.data['token'])
self.client.post('/api/user/grade', {'gradeId': self.grade.id}, format='json')
answer = self.client.post(
f'/api/questions/{self.question.id}/answer',
{'selectedOptionId': self.correct.id}, format='json',
)
self.assertGreater(answer.data['xpEarned'], 0)
self.assertEqual(self.client.get('/api/user/summary').data['xp'], 0)
self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': 1, 'totalQuestions': 1}, format='json',
)
self.assertGreater(self.client.get('/api/user/summary').data['xp'], 0)
def test_the_map_is_empty_but_healthy_before_a_grade_is_chosen(self):
response = self.client.post(
'/api/auth/session', {'type': 'guest', 'deviceId': 'flow-device-3'}, format='json',
)
self._auth(response.data['token'])
home = self.client.get('/api/home')
self.assertEqual(home.status_code, 200)
self.assertEqual(home.data['units'], [])
class LessonWithoutQuestionsTests(TestCase):
"""
درسی که هنوز سوال ندارد نباید بن‌بستِ خاموش باشد.
الان همه‌ی درس‌های واردشده از کتاب در همین حالت‌اند (ساختِ سوال به اعتبارِ
API نیاز دارد)، پس فرانت باید بتواند قبل از باز کردنِ کویز بفهمد.
"""
def setUp(self):
cache.clear()
self.client = APIClient()
grade = Grade.objects.create(title='پایه نهم', icon='g', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
self.empty = Lesson.objects.create(node=node, title='بدونِ سوال', intro_text='متنِ درس')
self.filled = Lesson.objects.create(node=node, title='با سوال', intro_text='متنِ درس')
question = Question.objects.create(
lesson=self.filled, type='multiple_choice', prompt='؟',
)
Choice.objects.create(question=question, value='الف', is_correct=True)
user = User.objects.create(device_id='empty-lesson', is_guest=True, grade_id=grade.id)
from accounts.authentication import issue_access_token
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def tearDown(self):
cache.clear()
def test_intro_reports_the_question_count(self):
self.assertEqual(
self.client.get(f'/api/lessons/{self.empty.id}/intro').data['questionCount'], 0,
)
self.assertEqual(
self.client.get(f'/api/lessons/{self.filled.id}/intro').data['questionCount'], 1,
)
def test_intro_still_serves_the_teaching_text(self):
response = self.client.get(f'/api/lessons/{self.empty.id}/intro')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['introText'], 'متنِ درس')