Files
MathApp/progress/tests.py
2026-10-10 01:42:44 +03:30

293 lines
12 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
from datetime import timedelta
from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import (
HEART_REFILL_INTERVAL,
Character,
LeaderboardEntry,
UserHearts,
UserStats,
)
class UserHeartsRegenTests(TestCase):
"""رگرسیون: قبلاً next_refill_at هیچ‌وقت واقعاً چک نمی‌شد و هارتِ ازدست‌رفته
برای همیشه صفر می‌موند."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(phone='09000000001', name='')
self.token = issue_access_token(self.user.id)
def _get_hearts(self):
return self.client.get('/api/user/hearts', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def test_no_refill_before_interval_elapses(self):
hearts = UserHearts.objects.create(user_id=self.user.id, current_hearts=3, max_hearts=5)
hearts.lose_heart() # شبیه‌سازیِ از دست دادنِ یک هارتِ دیگه؛ تایمر رو ست می‌کنه
hearts.save()
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 2)
self.assertIsNotNone(response.data['nextRefillAt'])
def test_refill_after_interval_elapses(self):
hearts = UserHearts.objects.create(
user_id=self.user.id, current_hearts=3, max_hearts=5,
next_refill_at=timezone.now() - timedelta(seconds=1),
)
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 4)
hearts.refresh_from_db()
self.assertEqual(hearts.current_hearts, 4)
self.assertIsNotNone(hearts.next_refill_at) # هنوز به max نرسیده، تایمرِ بعدی ست شده
def test_refill_catches_up_multiple_missed_intervals(self):
UserHearts.objects.create(
user_id=self.user.id, current_hearts=1, max_hearts=5,
next_refill_at=timezone.now() - 3 * HEART_REFILL_INTERVAL,
)
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 5)
self.assertIsNone(response.data['nextRefillAt']) # به max رسیده، تایمر خاموش می‌شه
def test_full_hearts_have_no_refill_timer(self):
UserHearts.objects.create(user_id=self.user.id, current_hearts=5, max_hearts=5)
response = self._get_hearts()
self.assertEqual(response.data['currentHearts'], 5)
self.assertIsNone(response.data['nextRefillAt'])
class CharacterGamificationTests(TestCase):
"""کاتالوگِ کاراکترها (مثلِ ماسکات‌های Duolingo): باز/بسته‌بودن تابعی از
XP کاربره، و فقط کاراکترِ باز‌شده قابلِ انتخاب‌شدنه."""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(phone='09000000002', name='')
self.token = issue_access_token(self.user.id)
self.owl = Character.objects.create(slug='owl', name='جغد', icon='owl', unlock_xp=0, order=1)
self.fox = Character.objects.create(slug='fox', name='روباه', icon='fox', unlock_xp=100, order=2)
def _list(self):
return self.client.get('/api/characters', HTTP_AUTHORIZATION=f'Bearer {self.token}')
def _select(self, character_id):
return self.client.post(
'/api/characters/select', {'characterId': str(character_id)},
format='json', HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_list_reports_locked_and_unlocked_by_xp(self):
UserStats.objects.create(user_id=self.user.id, xp=50)
response = self._list()
self.assertEqual(response.status_code, 200)
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['unlocked'])
self.assertFalse(by_slug['fox']['unlocked'])
def test_list_with_no_stats_yet_only_zero_xp_characters_unlocked(self):
response = self._list()
self.assertEqual(response.status_code, 200)
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['unlocked'])
self.assertFalse(by_slug['fox']['unlocked'])
def test_can_select_unlocked_character(self):
UserStats.objects.create(user_id=self.user.id, xp=10)
response = self._select(self.owl.id)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.data['selected'])
stats = UserStats.objects.get(user_id=self.user.id)
self.assertEqual(stats.selected_character_id, self.owl.id)
def test_cannot_select_locked_character(self):
UserStats.objects.create(user_id=self.user.id, xp=10)
response = self._select(self.fox.id)
self.assertEqual(response.status_code, 400)
stats = UserStats.objects.get(user_id=self.user.id)
self.assertIsNone(stats.selected_character_id)
def test_selection_reflected_in_list(self):
UserStats.objects.create(user_id=self.user.id, xp=10)
self._select(self.owl.id)
response = self._list()
by_slug = {c['slug']: c for c in response.data['characters']}
self.assertTrue(by_slug['owl']['selected'])
self.assertFalse(by_slug['fox']['selected'])
class RebuildLeaderboardCommandTests(TestCase):
"""
تا پیش از این هیچ‌جای کد LeaderboardEntry نمی‌ساخت و /api/leaderboard روی
هر سرورِ واقعی برای همیشه خالی بود. این تست‌ها تولیدکننده‌ی تازه را قفل
می‌کنند.
"""
def _complete(self, user_id, lesson, xp, when=None):
from content.models import UserLessonCompletion
row = UserLessonCompletion.objects.create(
user_id=user_id, lesson=lesson, correct_count=1, stars=3, xp_earned=xp,
)
if when is not None:
# auto_now_add را نمی‌شود موقعِ create گذاشت؛ بعدش update می‌کنیم.
UserLessonCompletion.objects.filter(pk=row.pk).update(created_at=when)
return row
def _lesson(self, title='درس'):
from content.models import Grade, Lesson, Node, Unit
grade = Grade.objects.create(title='پایه تست')
unit = Unit.objects.create(grade=grade, title='مبحث', order=1)
node = Node.objects.create(unit=unit, title='نود', order=1)
return Lesson.objects.create(node=node, title=title)
def test_all_time_ranks_come_from_user_stats_xp(self):
UserStats.objects.create(user_id=1, xp=50)
UserStats.objects.create(user_id=2, xp=300)
UserStats.objects.create(user_id=3, xp=120)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
rows = list(
LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('rank', 'user_id', 'xp_in_period')
)
self.assertEqual(rows, [(1, 2, 300), (2, 3, 120), (3, 1, 50)])
def test_weekly_sums_only_completions_inside_the_window(self):
lesson = self._lesson()
now = timezone.now()
self._complete(1, lesson, 30, when=now - timedelta(days=1))
self._complete(1, lesson, 20, when=now - timedelta(days=2))
# خارج از بازه‌ی ۷ روزه — نباید شمرده شود.
self._complete(1, lesson, 999, when=now - timedelta(days=30))
self._complete(2, lesson, 40, when=now - timedelta(days=3))
call_command('rebuild_leaderboard', '--period', 'weekly', verbosity=0)
rows = list(
LeaderboardEntry.objects.filter(period='weekly')
.order_by('rank').values_list('rank', 'user_id', 'xp_in_period')
)
self.assertEqual(rows, [(1, 1, 50), (2, 2, 40)])
def test_days_flag_widens_the_window(self):
lesson = self._lesson()
self._complete(1, lesson, 999, when=timezone.now() - timedelta(days=30))
call_command('rebuild_leaderboard', '--period', 'weekly', '--days', '60', verbosity=0)
self.assertEqual(
list(LeaderboardEntry.objects.filter(period='weekly')
.values_list('user_id', 'xp_in_period')),
[(1, 999)],
)
def test_rerun_is_idempotent_and_does_not_duplicate_or_gap_ranks(self):
UserStats.objects.create(user_id=1, xp=10)
UserStats.objects.create(user_id=2, xp=20)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
ranks = list(
LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('rank', flat=True)
)
self.assertEqual(ranks, [1, 2])
def test_zero_xp_users_are_excluded_unless_asked_for(self):
UserStats.objects.create(user_id=1, xp=0)
UserStats.objects.create(user_id=2, xp=5)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='all_time').count(), 1)
call_command('rebuild_leaderboard', '--period', 'all_time', '--include-zero', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='all_time').count(), 2)
def test_rebuilding_one_period_leaves_the_other_untouched(self):
LeaderboardEntry.objects.create(user_id=9, period='weekly', xp_in_period=7, rank=1)
UserStats.objects.create(user_id=1, xp=10)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.filter(period='weekly').count(), 1)
def test_ties_break_deterministically_so_two_runs_agree(self):
UserStats.objects.create(user_id=7, xp=100)
UserStats.objects.create(user_id=3, xp=100)
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
first = list(LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('user_id', flat=True))
call_command('rebuild_leaderboard', '--period', 'all_time', verbosity=0)
second = list(LeaderboardEntry.objects.filter(period='all_time')
.order_by('rank').values_list('user_id', flat=True))
self.assertEqual(first, [3, 7]) # تساوی → user_id کوچک‌تر جلوتر
self.assertEqual(first, second)
def test_dry_run_writes_nothing(self):
UserStats.objects.create(user_id=1, xp=10)
call_command('rebuild_leaderboard', '--period', 'all_time', '--dry-run', verbosity=0)
self.assertEqual(LeaderboardEntry.objects.count(), 0)
class FourCompanionsCatalogTests(TestCase):
"""migrationهای 0004، 0005 و 0007: سه‌یار و امگا (slugِ tedi) همان چهار slugی‌اند که فرانت می‌شناسد."""
def test_catalog_has_the_four_companions_in_order(self):
rows = list(
Character.objects.filter(slug__in=['sigma', 'delta', 'pi', 'tedi'])
.order_by('order').values_list('slug', 'unlock_xp')
)
self.assertEqual(rows, [('sigma', 0), ('delta', 100), ('pi', 300), ('tedi', 500)])
def test_the_fourth_companion_is_called_omega(self):
self.assertEqual(Character.objects.get(slug='tedi').name, 'امگا')
class LeaderboardPeriodValidationTests(TestCase):
def setUp(self):
from accounts.authentication import issue_access_token
from accounts.models import User
from rest_framework.test import APIClient
self.client = APIClient()
user = User.objects.create(phone='09000000003')
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
def test_unknown_period_is_a_400(self):
self.assertEqual(self.client.get('/api/leaderboard?period=xyz').status_code, 400)
self.assertEqual(self.client.get('/api/leaderboard?period=all_time').status_code, 200)
def test_requires_login(self):
# نامِ کاربران (بیشترشان کودک) نباید بی‌ورود قابلِ جمع‌آوری باشد؛ مهمان هم توکن دارد.
from rest_framework.test import APIClient
self.assertEqual(APIClient().get('/api/leaderboard').status_code, 401)