268 lines
15 KiB
Python
268 lines
15 KiB
Python
"""
|
||
پایانِ آزمونِ جزیره → جزیرهی بعدی باز میشود.
|
||
|
||
شکافی که این تستها میبندند: اپ با آزمونِ چهارسوالیِ هر جزیره بازی
|
||
میکند، ولی جزیره فقط وقتی «تمام» حساب میشد که تکتکِ درسهایش (۸ تا ۱۳
|
||
درس) جدا تمام شده باشند؛ دانشآموز بعد از جزیرهی اولِ هر فصل گیر میکرد.
|
||
"""
|
||
from subscription.models import Subscription
|
||
from django.core.cache import cache
|
||
from django.test import TestCase
|
||
|
||
from accounts.authentication import issue_access_token
|
||
from accounts.models import User
|
||
from content.models import (
|
||
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserLessonCompletion, UserNodeCompletion,
|
||
)
|
||
from content.testing import record_answers
|
||
from content.views import NodeQuizView
|
||
from notifications.models import Notification
|
||
from progress.models import UserStats
|
||
|
||
|
||
class NodeQuizCompleteTests(TestCase):
|
||
def setUp(self):
|
||
cache.clear()
|
||
self.grade = Grade.objects.create(title='پایه نهم', icon='', color='')
|
||
self.unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
|
||
self.next_unit = Unit.objects.create(grade=self.grade, title='فصل ۲', order=2)
|
||
self.first = self._node(self.unit, 'اول', 1, lessons=5)
|
||
self.second = self._node(self.unit, 'دوم', 2, lessons=3)
|
||
self.other_unit_first = self._node(self.next_unit, 'فصلِ بعد', 1, lessons=2)
|
||
|
||
self.user = User.objects.create(phone='09120000009', grade_id=self.grade.id)
|
||
# فصلِ دوم اشتراک میخواهد (content.views.unit_is_free)؛ این تست ترتیبِ باز شدن را میسنجد، نه پرداخت.
|
||
Subscription.objects.create(user_id=self.user.id, status='active', expires_at=None)
|
||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||
|
||
def _node(self, unit, title, order, lessons):
|
||
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}')
|
||
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)
|
||
return node
|
||
|
||
def _answer(self, node, correct, wrong=0):
|
||
"""مثلِ اپ: اول جوابها ثبت میشوند (`correct` تای اول درست، `wrong` تای بعدی غلط)، بعد پایانِ آزمون."""
|
||
questions = list(Question.objects.filter(lesson__node=node).order_by('id')[:correct + wrong])
|
||
for index, question in enumerate(questions):
|
||
choice = question.choices.get(is_correct=index < correct)
|
||
self.client.post(f'/api/questions/{question.pk}/answer', {'selectedOptionId': choice.pk},
|
||
content_type='application/json', **self.auth)
|
||
|
||
def _complete(self, node, correct=3, total=4, answer=True, **headers):
|
||
if answer:
|
||
# سرور همهی سوالهای آزمون را جوابداده میخواهد (دستِکم آزمونِ استانداردِ ۴ تایی).
|
||
pool = Question.objects.filter(lesson__node=node).count()
|
||
needed = max(total, min(NodeQuizView.DEFAULT_SIZE, pool))
|
||
self._answer(node, correct, wrong=max(needed - correct, 0))
|
||
return self.client.post(
|
||
f'/api/nodes/{node.pk}/quiz/complete',
|
||
{'correctCount': correct, 'totalQuestions': total},
|
||
content_type='application/json', **self.auth, **headers,
|
||
)
|
||
|
||
def _statuses(self):
|
||
units = self.client.get('/api/home', **self.auth).json()['units']
|
||
return {n['title']: n['status'] for u in units for n in u['nodes']}
|
||
|
||
def test_finishing_the_quiz_opens_the_next_island(self):
|
||
self.assertEqual(self._statuses()['دوم'], 'locked')
|
||
|
||
response = self._complete(self.first)
|
||
|
||
self.assertEqual(response.status_code, 200)
|
||
statuses = self._statuses()
|
||
self.assertEqual(statuses['اول'], 'completed')
|
||
self.assertEqual(statuses['دوم'], 'unlocked')
|
||
|
||
def test_a_low_score_still_moves_on(self):
|
||
"""قاعده عمداً ساده است: آزمون که تمام شد، جزیره تمام است."""
|
||
self._complete(self.first, correct=0)
|
||
|
||
self.assertEqual(self._statuses()['دوم'], 'unlocked')
|
||
|
||
def test_it_names_the_next_island(self):
|
||
body = self._complete(self.first).json()
|
||
self.assertEqual(body['nextNodeId'], str(self.second.pk))
|
||
|
||
# آخرین جزیرهی فصل → اولین جزیرهی فصلِ بعد
|
||
body = self._complete(self.second, total=3).json()
|
||
self.assertEqual(body['nextNodeId'], str(self.other_unit_first.pk))
|
||
|
||
# آخرین جزیرهی پایه
|
||
body = self._complete(self.other_unit_first, correct=2, total=2).json()
|
||
self.assertIsNone(body['nextNodeId'])
|
||
|
||
def test_rewards_only_the_first_time(self):
|
||
first = self._complete(self.first, correct=4).json()
|
||
again = self._complete(self.first, correct=4).json()
|
||
|
||
self.assertEqual(first['xpEarned'], 40)
|
||
self.assertEqual(first['stars'], 3)
|
||
self.assertEqual(first['coinsEarned'], 15)
|
||
self.assertEqual(again['xpEarned'], 0)
|
||
self.assertEqual(again['coinsEarned'], 0)
|
||
self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40)
|
||
|
||
def test_answers_that_were_never_given_earn_nothing(self):
|
||
# پیشتر correctCount همانطور که کلاینت فرستاده بود پذیرفته میشد؛ بعد (۲۰۲۶-۱۰-۰۷) بیجواب
|
||
# اصلاً تمام نمیشود: ۴۰۹، و نه جزیره تمام میشود نه امتیازی ثبت.
|
||
response = self._complete(self.first, correct=4, answer=False)
|
||
self.assertEqual((response.status_code, response.json()['code']), (409, 'answers_missing'))
|
||
self.assertFalse(UserNodeCompletion.objects.filter(user_id=self.user.id).exists())
|
||
self.assertEqual(self._statuses()['دوم'], 'locked')
|
||
|
||
# چهار جواب ثبت شده ولی فقط دو تا درست؛ ادعای ۴ درست فقط برای همان دو امتیاز میدهد.
|
||
self._answer(self.first, correct=2, wrong=2)
|
||
body = self._complete(self.first, correct=4, total=4, answer=False).json()
|
||
self.assertEqual(body['xpEarned'], 20)
|
||
|
||
def test_understating_the_question_count_does_not_buy_stars(self):
|
||
"""با totalQuestions=1 و یک جوابِ درست ۳ ستاره میشد؛ آزمونِ استانداردِ سرور ۴ سوال است."""
|
||
self._answer(self.first, correct=1, wrong=3)
|
||
body = self._complete(self.first, correct=1, total=1, answer=False).json()
|
||
self.assertEqual(body['stars'], 1) # ۱ از ۴
|
||
self.assertEqual(UserNodeCompletion.objects.get(user_id=self.user.id).total_questions, 4)
|
||
|
||
def test_answering_fewer_questions_than_the_quiz_has_is_refused(self):
|
||
self._answer(self.first, correct=3)
|
||
response = self._complete(self.first, correct=3, total=4, answer=False)
|
||
self.assertEqual(response.status_code, 409)
|
||
self.assertEqual((response.json()['answered'], response.json()['required']), (3, 4))
|
||
|
||
def test_a_locked_island_cannot_be_finished(self):
|
||
"""جزیرهی دوم تا اولی تمام نشده قفل است؛ حتی با جوابِ همهی سوالهایش."""
|
||
self._answer(self.second, correct=3)
|
||
response = self._complete(self.second, correct=3, total=3, answer=False)
|
||
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
|
||
self.assertFalse(UserNodeCompletion.objects.filter(user_id=self.user.id).exists())
|
||
self.assertEqual(self._statuses()['دوم'], 'locked')
|
||
|
||
def test_the_first_island_of_every_unit_is_open(self):
|
||
"""همانطور که نقشه نشان میدهد: اولین جزیرهی فصلها بیقید باز است."""
|
||
self.assertEqual(self._statuses()['فصلِ بعد'], 'unlocked')
|
||
self.assertEqual(self._complete(self.other_unit_first, correct=2, total=2).status_code, 200)
|
||
|
||
def test_an_open_island_without_questions_passes_without_answers_and_earns_nothing(self):
|
||
"""چیزی برای جوابدادن نیست؛ رد میشود تا مسیرِ بقیه را نبندد (بیستاره، بیامتیاز)."""
|
||
empty = Node.objects.create(unit=self.next_unit, title='خالی', order=0) # اولین جزیرهی فصل: باز
|
||
Lesson.objects.create(node=empty, title='درسِ بیسوال')
|
||
response = self.client.post(
|
||
f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1},
|
||
content_type='application/json', **self.auth)
|
||
self.assertEqual(response.status_code, 200, response.content)
|
||
self.assertEqual((response.json()['xpEarned'], response.json()['stars']), (0, 0))
|
||
self.assertEqual(response.json()['nextNodeId'], str(self.other_unit_first.pk))
|
||
|
||
def test_a_locked_island_without_questions_is_still_locked(self):
|
||
empty = Node.objects.create(unit=self.unit, title='خالیِ قفل', order=9) # بعد از دو جزیرهی تمامنشده
|
||
Lesson.objects.create(node=empty, title='درسِ بیسوال')
|
||
response = self.client.post(
|
||
f'/api/nodes/{empty.pk}/quiz/complete', {'correctCount': 0, 'totalQuestions': 1},
|
||
content_type='application/json', **self.auth)
|
||
self.assertEqual((response.status_code, response.json()['code']), (403, 'node_locked'))
|
||
|
||
def test_same_idempotency_key_does_not_double_count(self):
|
||
self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1')
|
||
body = self._complete(self.first, correct=4, HTTP_IDEMPOTENCY_KEY='k1').json()
|
||
|
||
self.assertEqual(body['xpEarned'], 40) # همان نتیجهی قبلی
|
||
self.assertEqual(UserStats.objects.get(user_id=self.user.id).xp, 40)
|
||
|
||
def test_cannot_claim_more_questions_than_the_island_has(self):
|
||
response = self._complete(self.first, correct=9, total=9)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_cannot_claim_more_correct_than_asked(self):
|
||
response = self._complete(self.first, correct=5, total=4)
|
||
self.assertEqual(response.status_code, 400)
|
||
|
||
def test_requires_sign_in(self):
|
||
response = self.client.post(
|
||
f'/api/nodes/{self.first.pk}/quiz/complete',
|
||
{'correctCount': 1, 'totalQuestions': 4}, content_type='application/json',
|
||
)
|
||
self.assertEqual(response.status_code, 401)
|
||
|
||
def test_it_sends_a_notification_once(self):
|
||
self._complete(self.first)
|
||
self._complete(self.first)
|
||
|
||
notes = Notification.objects.filter(user_id=self.user.id, type='node_completed')
|
||
self.assertEqual(notes.count(), 1)
|
||
self.assertIn('اول', notes.first().body)
|
||
|
||
def test_node_progress_is_completed_and_stays_completed(self):
|
||
"""درسِ تازه در همان جزیره نباید آن را به in_progress برگرداند."""
|
||
self._complete(self.first)
|
||
lesson = self.first.lessons.first()
|
||
record_answers(self.user.id, lesson=lesson)
|
||
self.client.post(
|
||
f'/api/lessons/{lesson.pk}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1},
|
||
content_type='application/json', **self.auth,
|
||
)
|
||
|
||
progress = NodeProgress.objects.get(user_id=self.user.id, node=self.first)
|
||
self.assertEqual(progress.status, 'completed')
|
||
|
||
def test_progress_percent_counts_the_islands_lessons(self):
|
||
"""جزیرهی ۵ درسی از ۱۰ درسِ پایه → ۵۰٪، و فقط در پایهی خودِ کاربر."""
|
||
Grade.objects.create(title='پایه دهم', icon='', color='') # مخرج را بزرگ نکند
|
||
body = self._complete(self.first).json()
|
||
|
||
self.assertEqual(body['stats']['progressPercent'], 50.0)
|
||
home = self.client.get('/api/home', **self.auth).json()
|
||
self.assertEqual(home['stats']['progressPercent'], 50.0)
|
||
|
||
def test_lesson_completion_progress_is_per_grade_too(self):
|
||
# درسی از جزیرهی باز (جزیرهی دوم تا اولی تمام نشده قفل است و درسش را نمیشود تمام کرد).
|
||
lesson = self.first.lessons.first()
|
||
record_answers(self.user.id, lesson=lesson)
|
||
body = self.client.post(
|
||
f'/api/lessons/{lesson.pk}/complete',
|
||
{'correctCount': 1, 'totalQuestions': 1},
|
||
content_type='application/json', **self.auth,
|
||
).json()
|
||
|
||
self.assertEqual(body['stats']['progressPercent'], 10.0) # ۱ از ۱۰ درس
|
||
self.assertTrue(UserLessonCompletion.objects.filter(user_id=self.user.id).exists())
|
||
|
||
|
||
class NodeQuizRotationTests(TestCase):
|
||
"""دوبارهبازیِ یک جزیره آزمونِ تازه میدهد؛ رفرش تا پایانِ آزمون همان را میدهد."""
|
||
|
||
def setUp(self):
|
||
cache.clear()
|
||
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(4):
|
||
lesson = Lesson.objects.create(node=self.node, title=f'درس {i}')
|
||
for j in range(12):
|
||
question = Question.objects.create(
|
||
lesson=lesson, type='multiple_choice', prompt=f'سوال {i}-{j}')
|
||
Choice.objects.create(question=question, value='الف', is_correct=True)
|
||
Choice.objects.create(question=question, value='ب', is_correct=False)
|
||
self.user = User.objects.create(phone='09120000019', grade_id=grade.id)
|
||
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
|
||
|
||
def _ids(self):
|
||
body = self.client.get(f'/api/nodes/{self.node.pk}/quiz', **self.auth).json()
|
||
return [q['id'] for q in body['questions']]
|
||
|
||
def test_refresh_keeps_the_quiz_until_it_is_completed(self):
|
||
self.assertEqual(self._ids(), self._ids())
|
||
|
||
def test_each_completed_round_draws_a_fresh_quiz(self):
|
||
first = self._ids()
|
||
UserNodeCompletion.objects.create(
|
||
user_id=self.user.id, node=self.node, correct_count=4, total_questions=4, stars=3)
|
||
second = self._ids()
|
||
self.assertNotEqual(first, second)
|
||
self.assertEqual(second, self._ids())
|