Files
MathApp/content/tests_frontend_contract.py
Zareian 1c7c4b8eca API output without Persian diacritics or written ezafe ی (company decision); tutor answers too
plain_persian() strips kasra/fatha/damma/shadda/sukun/hamza-above and the written «ه‌ی» ezafe, keeping tanwin. Applied
at output, not in data: PlainPersianJSONRenderer runs one byte-level pass over every JSON response (~5 ms per MB
locally), and to_plain() cleans tutor answers including the stream. Rewriting titles in the DB/seed would have broken
question binding by title path and SeedRecord keys. Tests updated to expect the cleaned text; config/tests_plain_persian.py.

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

235 lines
12 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.
"""
تست‌های قرارداد API با صفحه‌های فرانت.
این‌ها از روی خود کد فرانت نوشته شده‌اند، نه از روی حدس: هر صفحه داده
مشخصی هاردکد داشت و اینجا بررسی می‌شود که API همان شکل را می‌دهد. بدون
این‌ها، هر بار بعدی که سریالایزری عوض شود، شکستن فقط در مرورگر معلوم می‌شد.
"""
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse
from accounts.models import User
from content.models import (
Choice, Grade, Lesson, Node, Question, Unit, UserLessonCompletion,
)
class GradePickerTests(TestCase):
"""صفحه انتخاب پایه نباید پایه خالی را مثل پایه پر نشان دهد."""
def setUp(self):
cache.clear()
def test_grades_report_whether_they_have_any_content(self):
full = Grade.objects.create(title='پایه نهم', icon='g9', color='#FF9800')
Grade.objects.create(title='پایه یازدهم', icon='g11', color='#111111')
unit = Unit.objects.create(grade=full, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
Lesson.objects.create(node=node, title='نوع ۱', intro_text='x')
rows = {g['title']: g['lessonCount'] for g in self.client.get('/api/grades').json()['grades']}
self.assertEqual(rows['پایه نهم'], 1)
self.assertEqual(rows['پایه یازدهم'], 0)
class SkillMapTests(TestCase):
"""نقشه مهارت برای هر قدم عنوان، نوع، زیرنویس و «انجام شد» می‌خواهد."""
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۴', blurb='توان و ریشه', order=4)
self.node = Node.objects.create(unit=unit, title='توان صحیح',
icon='power', color='#ED6956', order=1)
self.quiz = Lesson.objects.create(node=self.node, title='آزمون توان',
intro_text='x', kind='quiz')
self.video = Lesson.objects.create(node=self.node, title='ویدیوی توان',
intro_text='y', kind='video',
video_url='https://example.com/v.mp4',
duration_minutes=12)
q = Question.objects.create(lesson=self.quiz, type='multiple_choice', prompt='؟')
Choice.objects.create(question=q, value='۱', is_correct=True)
self.user = User.objects.create(phone='09120000001')
from accounts.authentication import issue_access_token
token = issue_access_token(self.user.id)
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {token}'}
def _lessons(self, **extra):
url = reverse('node-lessons', args=[self.node.pk])
return self.client.get(url, **extra).json()
def test_each_step_carries_what_the_card_shows(self):
rows = {r['title']: r for r in self._lessons()['lessons']}
self.assertEqual(rows['آزمون توان']['kind'], 'quiz')
self.assertEqual(rows['آزمون توان']['meta'], '1 سوال')
self.assertEqual(rows['ویدیوی توان']['kind'], 'video')
self.assertEqual(rows['ویدیوی توان']['meta'], '12 دقیقه')
def test_done_is_false_when_signed_out_and_true_after_completion(self):
self.assertFalse(any(r['done'] for r in self._lessons()['lessons']))
UserLessonCompletion.objects.create(user_id=self.user.id, lesson=self.quiz,
correct_count=1, stars=3)
rows = {r['title']: r for r in self._lessons(**self.auth)['lessons']}
self.assertTrue(rows['آزمون توان']['done'])
self.assertFalse(rows['ویدیوی توان']['done'])
def test_home_carries_the_visual_fields_the_topic_cards_need(self):
response = self.client.get(f'/api/home?gradeId={self.node.unit.grade_id}',
**self.auth)
unit = response.json()['units'][0]
self.assertEqual(unit['blurb'], 'توان و ریشه')
self.assertEqual(unit['nodes'][0]['icon'], 'power')
self.assertEqual(unit['nodes'][0]['color'], '#ED6956')
class LessonContentTests(TestCase):
"""صفحه درس سه تکه می‌خواهد: توضیح، مثال، حل گام‌به‌گام."""
def setUp(self):
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='درس ۱', order=1)
self.lesson = Lesson.objects.create(
node=node, title='بردار', intro_text='یک بردار پیکان است.',
example='a = (۳, ۴) → |a| = ۵',
steps=['مؤلفه‌ها را بنویس', 'اندازه را حساب کن'],
)
def test_intro_returns_all_three_parts(self):
data = self.client.get(f'/api/lessons/{self.lesson.pk}/intro').json()
self.assertEqual(data['introText'], 'یک بردار پیکان است.')
self.assertEqual(data['example'], 'a = (۳, ۴) → |a| = ۵')
self.assertEqual(len(data['steps']), 2)
self.assertEqual(data['kind'], 'quiz')
def test_existing_content_without_the_new_fields_still_works(self):
"""محتوای ساخته‌شده example و steps ندارد؛ نباید بشکند."""
bare = Lesson.objects.create(node=self.lesson.node, title='قدیمی', intro_text='z')
data = self.client.get(f'/api/lessons/{bare.pk}/intro').json()
self.assertEqual(data['example'], '')
self.assertEqual(data['steps'], [])
self.assertIsNone(data['durationMinutes'])
class HomeGradeParamTests(TestCase):
"""
کلاینت فرانت `?gradeId=` می‌فرستد ولی نسخه اول این اندپوینت `?grade=`
می‌خواند — و پارامتر ناشناخته بی‌صدا نادیده گرفته می‌شد، یعنی کاربر نقشه
پایه دیگری را می‌دید بدون هیچ خطایی.
"""
def setUp(self):
cache.clear()
self.a = Grade.objects.create(title='پایه هفتم', icon='', color='')
self.b = Grade.objects.create(title='پایه نهم', icon='', color='')
for grade, unit_title in ((self.a, 'فصل هفتم'), (self.b, 'فصل نهم')):
unit = Unit.objects.create(grade=grade, title=unit_title, order=1)
node = Node.objects.create(unit=unit, title='درس', order=1)
Lesson.objects.create(node=node, title='نوع', intro_text='x')
user = User.objects.create(phone='09120000002', grade_id=self.a.id)
from accounts.authentication import issue_access_token
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
def _titles(self, query=''):
data = self.client.get(f'/api/home{query}', **self.auth).json()
return [u['title'] for u in data['units']]
def test_grade_id_selects_that_grade(self):
self.assertEqual(self._titles(f'?gradeId={self.b.id}'), ['فصل نهم'])
def test_the_removed_grade_name_is_a_400_not_a_silent_fallback(self):
self.assertEqual(self.client.get(f'/api/home?grade={self.b.id}', **self.auth).status_code, 400)
def test_without_a_parameter_the_users_own_grade_is_used(self):
self.assertEqual(self._titles(), ['فصل هفتم'])
def test_an_unknown_grade_is_a_clean_404_not_a_silent_fallback(self):
self.assertEqual(self.client.get('/api/home?gradeId=999999', **self.auth).status_code, 404)
class NodeVisualDefaultsTests(TestCase):
"""
تا وقتی کسی از پنل ادمین رنگ نگذاشته، کارت مبحث نباید بی‌رنگ بماند.
پیش‌فرض باید *پایدار* باشد نه تصادفی: رنگی که بین دو بارگذاری عوض شود،
بدتر از بی‌رنگ بودن است.
"""
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه نهم', icon='', color='')
unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1)
self.nodes = [
Node.objects.create(unit=unit, title=f'مبحث {i}', order=i)
for i in range(1, 4)
]
for node in self.nodes:
Lesson.objects.create(node=node, title='نوع', intro_text='x')
user = User.objects.create(phone='09120000003', grade_id=self.grade.id)
from accounts.authentication import issue_access_token
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'}
def _nodes(self):
return self.client.get('/api/home', **self.auth).json()['units'][0]['nodes']
def test_a_node_without_a_colour_still_gets_one(self):
for node in self._nodes():
self.assertTrue(node['color'].startswith('#'), node)
self.assertTrue(node['icon'])
def test_neighbouring_nodes_do_not_share_a_colour(self):
colors = [n['color'] for n in self._nodes()]
self.assertEqual(len(set(colors)), len(colors))
def test_the_same_node_keeps_the_same_colour_between_requests(self):
first = [n['color'] for n in self._nodes()]
cache.clear()
self.assertEqual(first, [n['color'] for n in self._nodes()])
def test_an_explicit_colour_from_the_admin_wins(self):
self.nodes[0].color = '#123456'
self.nodes[0].icon = 'Trophy'
self.nodes[0].save()
cache.clear()
first = self._nodes()[0]
self.assertEqual(first['color'], '#123456')
self.assertEqual(first['icon'], 'Trophy')
class HomeMatchesItsSerializerTests(TestCase):
"""
/api/home برای سرعت dict را مستقیم برمی‌گرداند (content/views.py، HomeView)، ولی HomeResponseSerializer
هنوز قرارداد Swagger است. اگر کسی کلیدی به پاسخ اضافه کند یا نوعی را عوض کند و سریال‌ساز را نه،
این تست می‌افتد: خروجی سریال‌ساز روی همان پاسخ باید عین خودش باشد.
"""
def test_view_output_is_exactly_the_documented_shape(self):
from accounts.authentication import issue_access_token
from content.serializers import HomeResponseSerializer
cache.clear()
grade = Grade.objects.create(title='پایه نهم', icon='', color='')
for order in (1, 2):
unit = Unit.objects.create(grade=grade, title=f'فصل {order}', order=order, blurb='b')
for n in (1, 2):
node = Node.objects.create(unit=unit, title=f'جزیره {n}', order=n)
lesson = Lesson.objects.create(node=node, title='درس')
question = Question.objects.create(lesson=lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=question, value='۱', is_correct=True)
user = User.objects.create(phone='09120000088', grade_id=grade.id)
UserLessonCompletion.objects.create(
user_id=user.id, lesson=Lesson.objects.first(), correct_count=1, stars=3)
body = self.client.get('/api/home', HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}').json()
self.assertEqual(len(body['units']), 2)
self.assertEqual(HomeResponseSerializer(body).data, body)
self.assertIsInstance(body['stats']['progressPercent'], float)
self.assertIsInstance(body['units'][0]['nodes'][0]['id'], str)