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(device_id='dev-hearts-regen', is_guest=True, 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(device_id='device-characters-1', is_guest=True, 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: سه‌یار و تدی همان چهار 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', 600)])