خطاهای این پروژه خاموشاند: نامِ اشتباهِ collection یا فیلترِ نخورده نتیجهی خالی میدهد نه استثنا، و دستیار فقط بدتر جواب میدهد. این تستها نگهبانِ همانجاهایند. TextbookLayoutTests بررسی میکند رشتهی هر فصل بینِ ingest و retrieval یکی باشد و بازهی صفحهها پیوسته بماند. IngestBookResumeTests مطمئن میشود صفحهی بیمسئله دوبار پول نمیگیرد، صفحهی خطادار در اجرای بعد دوباره سراغش میروند، و upsertِ شکستخورده rollback میشود. LessonContextResolutionTests نگهبانِ نرمالسازیِ «پایه نهم» → «ریاضی نهم» است و اینکه مسیرِ gradeId و مسیرِ lessonId به یک collection برسند. GradeAwarePromptTests جلوی برگشتنِ لحنِ دبستانی برای پایههای بالا را میگیرد. FollowUpStaysGroundedTests پیامِ دومِ یک گفتگو را هم مستند نگه میدارد. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
221 lines
11 KiB
Python
221 lines
11 KiB
Python
"""
|
||
تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد.
|
||
|
||
یک کاربر از صفر تا آخر میرود: ورود → انتخاب پایه و اسم → نقشهی مهارت →
|
||
درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همانجا
|
||
گیر میکند؛ این تست دقیقاً همان زنجیره را میبندد.
|
||
|
||
عمداً از 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'], 'متنِ درس')
|