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>
235 lines
12 KiB
Python
235 lines
12 KiB
Python
"""
|
||
تستهای قرارداد 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)
|