""" پایانِ درس و جزیره بی‌جواب و روی جزیره‌ی قفل پذیرفته نمی‌شود (۲۰۲۶-۱۰-۰۷). تا پیش از این: کسی که قلب داشت و هیچ سوالی جواب نداده بود جزیره را تمام می‌کرد، حتی جزیره‌ی قفل را (با correctCount=0)؛ نقشه هم وضعیتِ «باز» را از روی درس‌های تمام‌شده حساب می‌کرد، پس تمام‌کردنِ درسِ جزیره‌ی قفل آن را باز می‌کرد. فقط قلبِ صفر جلوی بی‌جواب‌تمام‌کردن را می‌گرفت. """ from django.core.cache import cache from django.test import TestCase, override_settings from rest_framework.test import APIClient from accounts.authentication import issue_access_token from accounts.models import User from progress.models import UserHearts, UserStats from .models import ( Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion, UserNodeCompletion, ) from .testing import record_answers from .views import node_is_unlocked class _World: def build(self, phone): cache.clear() self.grade = Grade.objects.create(title='پایه نهم', icon='', color='') self.unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1) self.other_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2) self.n1 = self.node(self.unit, 'یک', 1) self.n2 = self.node(self.unit, 'دو', 2) self.n3 = self.node(self.unit, 'سه', 3) self.m1 = self.node(self.other_unit, 'الف', 1) self.user = User.objects.create(phone=phone, grade_id=self.grade.id) self.client = APIClient() self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') def node(self, unit, title, order, lessons=2): node = Node.objects.create(unit=unit, title=title, order=order) for i in range(lessons): lesson = Lesson.objects.create(node=node, title=f'{title} {i}') q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟') Choice.objects.create(question=q, value='درست', is_correct=True) Choice.objects.create(question=q, value='غلط', is_correct=False) return node def finish_node(self, node, correct=0, total=2): return self.client.post(f'/api/nodes/{node.id}/quiz/complete', {'correctCount': correct, 'totalQuestions': total}, format='json') def finish_lesson(self, lesson): return self.client.post(f'/api/lessons/{lesson.id}/complete', {'correctCount': 0, 'totalQuestions': 1}, format='json') def statuses(self): units = self.client.get('/api/home').json()['units'] return {n['title']: n['status'] for u in units for n in u['nodes']} class FinishWithoutAnswersTests(_World, TestCase): def setUp(self): self.build('09120007801') def tearDown(self): cache.clear() def test_a_user_with_hearts_and_no_answers_cannot_finish_the_island(self): """همان جمله‌ای که باز مانده بود: قلب دارد، هیچ سوالی جواب نداده.""" self.assertEqual(UserHearts.objects.filter(user_id=self.user.id).count(), 0) # قلبِ پیش‌فرضِ پر response = self.finish_node(self.n1) self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing')) self.assertEqual((response.json()['answered'], response.json()['required']), (0, 2)) self.assertFalse(UserNodeCompletion.objects.exists()) self.assertFalse(NodeProgress.objects.filter(status='completed').exists()) self.assertEqual(self.statuses()['دو'], 'locked') def test_the_locked_island_cannot_be_finished_even_after_answering_everything(self): record_answers(self.user.id, node=self.n2) response = self.finish_node(self.n2, correct=2) self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) self.assertFalse(UserNodeCompletion.objects.exists()) self.assertFalse(UserStats.objects.filter(user_id=self.user.id, xp__gt=0).exists()) def test_the_locked_island_cannot_be_finished_with_zero_answers_either(self): response = self.finish_node(self.n3) self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) def test_finishing_the_lessons_of_a_locked_island_does_not_unlock_it(self): """بدونِ قفلِ درس، تمام‌کردنِ یک درسِ جزیره‌ی قفل وضعیتِ آن را «باز» می‌کرد.""" lesson = self.n2.lessons.first() record_answers(self.user.id, lesson=lesson) response = self.finish_lesson(lesson) self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) self.assertFalse(UserLessonCompletion.objects.exists()) self.assertEqual(self.statuses()['دو'], 'locked') def test_a_lesson_cannot_be_finished_without_answers(self): lesson = self.n1.lessons.first() response = self.finish_lesson(lesson) self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing')) self.assertFalse(UserLessonCompletion.objects.exists()) def test_a_lesson_with_some_answers_missing_is_refused(self): lesson = self.n1.lessons.first() second = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='دوم؟') Choice.objects.create(question=second, value='x', is_correct=True) first = lesson.questions.exclude(pk=second.pk).get() record_answers(self.user.id, lesson=lesson) from .models import UserAnswer UserAnswer.objects.filter(question=second).delete() # فقط سوالِ اول جواب داده شده response = self.finish_lesson(lesson) self.assertEqual((response.status_code, response.json()['answered']), (409, 1)) record_answers(self.user.id, lesson=lesson) self.assertEqual(self.finish_lesson(lesson).status_code, 200) self.assertTrue(first.answers.exists()) def test_a_lesson_without_questions_in_an_open_island_still_passes_and_earns_nothing(self): empty = Lesson.objects.create(node=self.n1, title='بی‌سوال') response = self.finish_lesson(empty) self.assertEqual(response.status_code, 200, response.content) self.assertEqual((response.json()['xpEarned'], response.json()['stars']), (0, 0)) def test_a_lesson_without_questions_in_a_locked_island_is_still_locked(self): empty = Lesson.objects.create(node=self.n3, title='بی‌سوالِ قفل') response = self.finish_lesson(empty) self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked')) self.assertFalse(UserLessonCompletion.objects.exists()) def test_the_normal_path_still_works_in_order(self): """جزیره‌ی اول، بعد دوم، بعد سوم؛ و اولین جزیره‌ی فصلِ دیگر از اول باز است.""" self.assertEqual(self.statuses(), {'یک': 'unlocked', 'دو': 'locked', 'سه': 'locked', 'الف': 'unlocked'}) for node in (self.n1, self.n2, self.n3, self.m1): record_answers(self.user.id, node=node) response = self.finish_node(node, correct=2) self.assertEqual(response.status_code, 200, (node.title, response.content)) self.assertEqual(set(self.statuses().values()), {'completed'}) def test_replaying_a_finished_island_still_works(self): record_answers(self.user.id, node=self.n1) self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200) self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200) def test_an_idempotent_retry_of_a_finished_island_returns_the_first_result(self): record_answers(self.user.id, node=self.n1) kwargs = {'HTTP_IDEMPOTENCY_KEY': 'k-1'} first = self.client.post(f'/api/nodes/{self.n1.id}/quiz/complete', {'correctCount': 2, 'totalQuestions': 2}, format='json', **kwargs) again = self.client.post(f'/api/nodes/{self.n1.id}/quiz/complete', {'correctCount': 2, 'totalQuestions': 2}, format='json', **kwargs) self.assertEqual((first.status_code, again.status_code), (200, 200)) self.assertEqual(first.json()['xpEarned'], again.json()['xpEarned']) def test_unanswered_with_zero_hearts_still_gets_the_hearts_page_not_a_generic_error(self): from datetime import timedelta from django.utils import timezone now = timezone.now() UserHearts.objects.create(user_id=self.user.id, current_hearts=0, next_refill_at=now + timedelta(hours=4), full_refill_at=now + timedelta(hours=20)) with override_settings(HEARTS_ENFORCED=True): response = self.finish_node(self.n1) self.assertEqual((response.status_code, response.json()['code']), (403, 'no_hearts')) class NodeIsUnlockedMatchesHomeTests(_World, TestCase): """قاعده‌ی قفلِ سرور باید دقیقاً همانی باشد که نقشه (`/api/home`) نشان می‌دهد.""" def setUp(self): self.build('09120007802') def _assert_matches_home(self, label): home = self.statuses() for node in (self.n1, self.n2, self.n3, self.m1): self.assertEqual( node_is_unlocked(self.user.id, node), home[node.title] != 'locked', f'{label}: {node.title} (نقشه: {home[node.title]})', ) def test_matches_at_every_step(self): self._assert_matches_home('شروع') record_answers(self.user.id, node=self.n1) self.assertEqual(self.finish_node(self.n1, correct=2).status_code, 200) self._assert_matches_home('بعد از جزیره‌ی یک') # تمام‌کردنِ درس‌های جزیره‌ی دو (از راهِ درس، نه آزمون) for lesson in self.n2.lessons.all(): record_answers(self.user.id, lesson=lesson) self.assertEqual(self.finish_lesson(lesson).status_code, 200) self._assert_matches_home('بعد از درس‌های جزیره‌ی دو') self.assertEqual(self.statuses()['سه'], 'unlocked') def test_a_node_missing_from_the_tree_is_locked_and_logged(self): ghost = Node(id=999999, unit=self.unit, title='شبح', order=9) with self.assertLogs('content.views', level='WARNING'): self.assertFalse(node_is_unlocked(self.user.id, ghost))