""" تستِ سرتاسریِ «بیسیکِ کامل» — همان چیزی که سینیور خواست کامل و اوکی باشد. یک کاربر از صفر تا آخر می‌رود: ورود → انتخاب پایه و اسم → نقشه‌ی مهارت → درس → سوال → پاسخ → اتمام درس → پرسش از دستیار. هرجا زنجیره بشکند، اپ همان‌جا گیر می‌کند؛ این تست دقیقاً همان زنجیره را می‌بندد. عمداً از 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'], 'متنِ درس') class NodeQuizTests(TestCase): """ آزمونِ مبحث: واحدِ بازی در اپ «مبحث» است نه «درس». درس در این پروژه ریزترین دانه است (یک نوعِ مسئله از کتاب) و معمولاً یکی-دو سوال دارد؛ اپ برای هر جزیره چهار سوال می‌خواهد. بدونِ این اندپوینت، فرانت باید درس‌به‌درس می‌گشت و خودش سرِ هم می‌کرد. """ def setUp(self): grade = Grade.objects.create(title='پایه نهم', icon='', color='') unit = Unit.objects.create(grade=grade, title='فصل ۴', order=1) self.node = Node.objects.create(unit=unit, title='توان', order=1) for i in range(6): lesson = Lesson.objects.create(node=self.node, title=f'درس {i}') question = Question.objects.create( lesson=lesson, type='multiple_choice', prompt=f'سوال {i}') Choice.objects.create(question=question, value='الف', is_correct=True) Choice.objects.create(question=question, value='ب', is_correct=False) def test_returns_four_questions_by_default(self): body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json() self.assertEqual(body['questionCount'], 4) self.assertEqual(len(body['questions']), 4) self.assertEqual(body['nodeTitle'], 'توان') def test_each_question_says_which_lesson_it_came_from(self): """برای اینکه بعدِ جوابِ غلط بشود گفت کدام مهارت را مرور کند.""" body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json() for question in body['questions']: self.assertTrue(question['lessonId']) self.assertTrue(question['lessonTitle']) def test_it_spreads_across_lessons_instead_of_drilling_one(self): body = self.client.get(f'/api/nodes/{self.node.pk}/quiz').json() lesson_ids = [q['lessonId'] for q in body['questions']] self.assertEqual(len(set(lesson_ids)), len(lesson_ids)) def test_the_same_seed_gives_the_same_quiz(self): """رفرشِ صفحه نباید آزمونِ تازه‌ای بدهد و پیشرفت را بی‌معنا کند.""" first = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json() again = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=abc').json() self.assertEqual([q['id'] for q in first['questions']], [q['id'] for q in again['questions']]) def test_a_different_seed_gives_a_different_order(self): a = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=one').json() b = self.client.get(f'/api/nodes/{self.node.pk}/quiz?seed=two').json() self.assertNotEqual([q['id'] for q in a['questions']], [q['id'] for q in b['questions']]) def test_a_thin_node_reports_what_it_actually_has(self): """ مبحثی که هنوز چهار سوال ندارد نباید وانمود کند دارد — فرانت باید بتواند تفاوتِ «آزمونِ کوتاه» و «خطا» را بفهمد. """ thin_unit = Unit.objects.create( grade=self.node.unit.grade, title='فصل ۵', order=2) thin = Node.objects.create(unit=thin_unit, title='کم‌سوال', order=1) lesson = Lesson.objects.create(node=thin, title='تنها درس') question = Question.objects.create( lesson=lesson, type='multiple_choice', prompt='تنها سوال') Choice.objects.create(question=question, value='الف', is_correct=True) body = self.client.get(f'/api/nodes/{thin.pk}/quiz').json() self.assertEqual(body['questionCount'], 1) self.assertEqual(body['requested'], 4) def test_size_is_capped_so_one_request_cannot_pull_everything(self): body = self.client.get(f'/api/nodes/{self.node.pk}/quiz?size=999').json() self.assertLessEqual(body['questionCount'], 20) def test_unknown_node_is_a_clean_404(self): self.assertEqual(self.client.get('/api/nodes/999999/quiz').status_code, 404)