Files
MathApp/progress/tests_live_gamification.py
Zareian 9024c6b8d8 /api/user/summary: streak.activeToday and streak.lost for the app's lit / at-risk / frozen flame
activeToday: activity recorded today (Tehran date). lost: length of a streak that broke (last active day
before yesterday), so the app can show it frozen. Same rule as effective_current_streak; the weekly report
keeps its own two-field streak serializer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:27:14 +03:30

215 lines
9.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
"""
استریک و جدولِ رتبه‌بندی از فعالیتِ واقعی زنده می‌شوند.
تا ۲۰۲۶-۰۹-۲۴ هیچ‌جای اپ StreakRecord نمی‌نوشت («روز پیاپی» برای هر کاربرِ
واقعی صفر می‌ماند) و جدولِ رتبه‌بندی فقط با اجرای دستیِ rebuild_leaderboard
عوض می‌شد.
"""
from datetime import date
from unittest import mock
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, Question, Unit
from progress.models import StreakRecord
from progress.services import effective_current_streak, record_activity, streak_state
class StreakTests(TestCase):
def test_consecutive_days_grow_the_streak(self):
record_activity(1, today=date(2026, 9, 20))
record_activity(1, today=date(2026, 9, 21))
streak = record_activity(1, today=date(2026, 9, 22))
self.assertEqual(streak.current_streak, 3)
self.assertEqual(streak.longest_streak, 3)
def test_same_day_counts_once(self):
record_activity(1, today=date(2026, 9, 20))
streak = record_activity(1, today=date(2026, 9, 20))
self.assertEqual(streak.current_streak, 1)
self.assertEqual(streak.active_days.count(), 1)
def test_a_missed_day_restarts_it_but_keeps_the_longest(self):
for day in (20, 21, 22):
record_activity(1, today=date(2026, 9, day))
streak = record_activity(1, today=date(2026, 9, 25))
self.assertEqual(streak.current_streak, 1)
self.assertEqual(streak.longest_streak, 3)
def test_shown_streak_is_zero_once_the_chain_breaks(self):
"""عددِ ذخیره‌شده فقط سرِ فعالیت عوض می‌شود؛ نمایش باید شکستن را ببیند."""
streak = record_activity(1, today=date(2026, 9, 20))
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 21)), 1)
self.assertEqual(effective_current_streak(streak, today=date(2026, 9, 23)), 0)
self.assertEqual(effective_current_streak(None), 0)
def test_state_tells_lit_at_risk_and_frozen_apart(self):
"""اپ سه حالت نشان می‌دهد: امروز روشن، امروز هنوز نه (در خطر)، و شکسته («یخ‌زده» با طولِ قبلی)."""
for day in (20, 21, 22):
streak = record_activity(1, today=date(2026, 9, day))
self.assertEqual(streak_state(streak, today=date(2026, 9, 22)), (3, True, 0))
self.assertEqual(streak_state(streak, today=date(2026, 9, 23)), (3, False, 0))
self.assertEqual(streak_state(streak, today=date(2026, 9, 24)), (0, False, 3))
self.assertEqual(streak_state(None), (0, False, 0))
# فعالیتِ بعد از شکستن زنجیره‌ی تازه می‌سازد و «یخ» می‌رود.
streak = record_activity(1, today=date(2026, 9, 24))
self.assertEqual(streak_state(streak, today=date(2026, 9, 24)), (1, True, 0))
class ActivityFeedsStreakAndLeaderboardTests(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}')
q = Question.objects.create(lesson=lesson, type='multiple_choice', prompt=f'س {i}')
self.correct = Choice.objects.create(question=q, value='الف', is_correct=True)
Choice.objects.create(question=q, value='ب', is_correct=False)
self.question = q
self.user = User.objects.create(phone='09120000031', name='سارا', grade_id=grade.id)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def test_answering_a_question_starts_the_streak(self):
self.client.post(
f'/api/questions/{self.question.pk}/answer',
{'selectedOptionId': self.correct.pk}, content_type='application/json', **self.auth,
)
summary = self.client.get('/api/user/summary', **self.auth).json()
self.assertEqual(summary['streak']['current'], 1)
self.assertIs(summary['streak']['activeToday'], True)
self.assertEqual(summary['streak']['lost'], 0)
calendar = self.client.get('/api/user/streak/calendar', **self.auth).json()
self.assertEqual(len(calendar['activeDays']), 1)
def test_a_finished_island_shows_up_on_the_leaderboard(self):
# آزمونِ چهارسوالی: هر چهار سوال جواب داده می‌شود (سه تا درست، یکی غلط)؛ پایان بی‌جواب پذیرفته نمی‌شود.
for index, question in enumerate(Question.objects.filter(lesson__node=self.node).order_by('id')[:4]):
self.client.post(
f'/api/questions/{question.pk}/answer',
{'selectedOptionId': question.choices.get(is_correct=index < 3).pk},
content_type='application/json', **self.auth,
)
self.client.post(
f'/api/nodes/{self.node.pk}/quiz/complete',
{'correctCount': 3, 'totalQuestions': 4},
content_type='application/json', **self.auth,
)
for period in ('weekly', 'all_time'):
entries = self.client.get(f'/api/leaderboard?period={period}', **self.auth).json()['entries']
self.assertEqual(entries[0]['userId'], str(self.user.id), period)
self.assertEqual(entries[0]['name'], 'سارا')
self.assertEqual(entries[0]['xpInPeriod'], 30, period)
def test_streak_failure_never_breaks_the_answer(self):
with mock.patch('progress.services.record_activity', side_effect=RuntimeError('boom')):
response = self.client.post(
f'/api/questions/{self.question.pk}/answer',
{'selectedOptionId': self.correct.pk}, content_type='application/json', **self.auth,
)
self.assertEqual(response.status_code, 200)
self.assertFalse(StreakRecord.objects.filter(user_id=self.user.id).exists())
class ApiResponsesAreNeverCachedTests(TestCase):
def test_api_responses_say_no_store(self):
response = self.client.get('/api/grades')
self.assertIn('no-store', response['Cache-Control'])
def test_a_view_that_sets_its_own_cache_control_keeps_it(self):
from django.http import HttpResponse
from config.middleware import APINoStoreMiddleware
def view(request):
response = HttpResponse()
response['Cache-Control'] = 'no-cache'
return response
request = mock.Mock(path='/api/ai/ask')
self.assertEqual(APINoStoreMiddleware(view)(request)['Cache-Control'], 'no-cache')
class SummaryShowsTheActivePlanTests(TestCase):
def test_active_subscription_is_named(self):
from subscription.models import Plan, Subscription
user = User.objects.create(phone='09120000032')
auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
self.assertIsNone(self.client.get('/api/user/summary', **auth).json()['subscription'])
plan = Plan.objects.create(title='طلایی', price=1000)
Subscription.objects.create(user_id=user.id, plan=plan, status='active')
self.assertEqual(self.client.get('/api/user/summary', **auth).json()['subscription'], 'طلایی')
class HeartsLikeDuolingoTests(TestCase):
"""هر ۵ ساعت یک قلب؛ ۲۴ ساعت بعد از اولین قلبِ ازدست‌رفته، همه پر."""
def _hearts_after_losing(self, n):
from progress.models import UserHearts
h = UserHearts.objects.create(user_id=1)
for _ in range(n):
h.lose_heart()
h.save()
return h
def _at(self, hours):
from django.utils import timezone
from datetime import timedelta
return mock.patch('django.utils.timezone.now', return_value=timezone.now() + timedelta(hours=hours))
def test_one_heart_comes_back_every_five_hours(self):
h = self._hearts_after_losing(5)
with self._at(4.9):
h.apply_regen()
self.assertEqual(h.current_hearts, 0)
with self._at(5.1):
h.apply_regen()
self.assertEqual(h.current_hearts, 1)
with self._at(15.1):
h.apply_regen()
self.assertEqual(h.current_hearts, 3)
def test_everything_refills_24_hours_after_the_first_loss(self):
h = self._hearts_after_losing(5)
with self._at(24.1):
h.apply_regen()
self.assertEqual(h.current_hearts, 5)
self.assertIsNone(h.next_refill_at)
self.assertIsNone(h.full_refill_at)
def test_an_old_24_hour_timer_is_shortened_to_five(self):
from datetime import timedelta
from django.utils import timezone
from progress.models import UserHearts
h = UserHearts.objects.create(
user_id=2, current_hearts=2, next_refill_at=timezone.now() + timedelta(hours=23),
)
h.apply_regen()
self.assertLessEqual(h.next_refill_at, timezone.now() + timedelta(hours=5, seconds=1))
self.assertIsNotNone(h.full_refill_at)
def test_api_reports_both_timers(self):
user = User.objects.create(phone='09120000033')
from progress.models import UserHearts
h = UserHearts.objects.create(user_id=user.id)
h.lose_heart()
h.save()
body = self.client.get(
'/api/user/hearts', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}',
).json()
self.assertEqual(body['currentHearts'], 4)
self.assertIsNotNone(body['nextRefillAt'])
self.assertIsNotNone(body['fullRefillAt'])