Files
MathApp/content/tests_subjects.py
Zareian 8cb1ecb85e Subjects per grade (math, calculus, geometry, stats, discrete), 5 new textbooks, 202 new islands, long islands split, hand-written figures and lesson texts for the new books
- content.Subject + Unit.subject, /api/home?subject=, tutor grounded in the chosen book
- 5 new books ingested; ~1150 hand-written questions, islands named by hand
- node_split: islands over 7 lessons split into parts of <=6 (lesson ids and progress kept)
- Lesson.figures hand-authored and visually checked for 653 lessons of the new books;
  gen_lesson_figures got --subject and survives network errors
- seed files re-exported

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 05:35:18 +03:30

160 lines
9.4 KiB
Python
Raw 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/home?subject=` نقشه‌ی همان کتاب را می‌دهد؛ کتابِ ناموجود در پایه ۴۰۰ است؛
فصلِ اولِ هر کتاب رایگان است؛ و دستیار کتابِ درس را از خودِ فصل می‌گیرد.
"""
from django.core.cache import cache
from django.test import TestCase
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Grade, Lesson, Node, Subject, Unit
class SubjectHomeTests(TestCase):
def setUp(self):
cache.clear()
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.calc = Subject.objects.get(key='calculus')
self.geo = Subject.objects.get(key='geometry')
self.units = {}
for subject, titles in ((self.calc, ['فصل ۱: تابع', 'فصل ۲: مثلثات']),
(self.geo, ['فصل ۱: ماتریس', 'فصل ۲: مقاطع مخروطی'])):
for order, title in enumerate(titles, start=1):
unit = Unit.objects.create(grade=self.grade, subject=subject, title=title, order=order)
node = Node.objects.create(unit=unit, title=f'جزیره {title}', order=1)
Lesson.objects.create(node=node, title='درس')
self.units[(subject.key, order)] = unit
self.user = User.objects.create(phone='09120007911', grade_id=self.grade.id)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_default_is_the_first_subject_of_the_grade(self):
body = self.client.get('/api/home').json()
self.assertEqual(body['subject']['key'], 'calculus')
self.assertEqual([s['key'] for s in body['subjects']], ['calculus', 'geometry'])
self.assertEqual([u['title'] for u in body['units']], ['فصل ۱: تابع', 'فصل ۲: مثلثات'])
def test_subject_param_switches_the_map(self):
body = self.client.get('/api/home?subject=geometry').json()
self.assertEqual(body['subject']['key'], 'geometry')
self.assertEqual([u['title'] for u in body['units']], ['فصل ۱: ماتریس', 'فصل ۲: مقاطع مخروطی'])
def test_a_subject_the_grade_does_not_have_is_a_400_not_a_silent_default(self):
self.assertEqual(self.client.get('/api/home?subject=discrete').status_code, 400)
self.assertEqual(self.client.get('/api/home?subject=nope').status_code, 400)
def test_the_first_unit_of_each_subject_is_free(self):
for key in ('calculus', 'geometry'):
body = self.client.get(f'/api/home?subject={key}').json()
self.assertEqual([u['requiresSubscription'] for u in body['units']], [False, True], key)
def test_second_unit_of_other_subject_is_still_locked_by_the_paywall(self):
node = Node.objects.get(unit=self.units[('geometry', 2)])
lesson = node.lessons.first()
self.assertEqual(self.client.get(f'/api/lessons/{lesson.id}/questions').status_code, 402)
def test_ordering_follows_subject_order_not_creation_order(self):
# هندسه زودتر ساخته شود؛ نقشه باز هم حسابان را اول می‌دهد (Subject.order).
cache.clear()
Subject.objects.filter(pk=self.geo.pk).update(order=1)
Subject.objects.filter(pk=self.calc.pk).update(order=9)
cache.clear()
self.assertEqual(self.client.get('/api/home').json()['subject']['key'], 'geometry')
class LessonContextUsesTheUnitSubjectTests(TestCase):
def test_a_geometry_lesson_resolves_to_the_geometry_book(self):
from rag.services.lesson_context import resolve_from_lesson
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
geo = Subject.objects.get(key='geometry')
unit = Unit.objects.create(grade=grade, subject=geo, title='فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن')
lesson = Lesson.objects.create(node=Node.objects.create(unit=unit, title='n'), title='l')
self.assertEqual(
resolve_from_lesson(lesson.id),
{'subject': 'هندسه', 'gradeTitle': 'دهم', 'chapter': 2},
)
def test_same_title_under_the_wrong_subject_does_not_match(self):
from rag.services.lesson_context import resolve_from_lesson
grade = Grade.objects.create(title='پایه دهم', icon='', color='')
math = Subject.objects.get(key='math')
# عنوانِ فصلِ هندسه، ولی زیرِ کتابِ ریاضی: نباید به collectionِ هندسه برود.
unit = Unit.objects.create(grade=grade, subject=math, title='فصل ۲: قضیه‌ی تالس، تشابه و کاربردهای آن')
lesson = Lesson.objects.create(node=Node.objects.create(unit=unit, title='n'), title='l')
self.assertEqual(resolve_from_lesson(lesson.id), {'gradeTitle': 'دهم'})
class CurriculumFileNeedsASubjectTests(TestCase):
def test_a_unit_without_subject_is_rejected(self):
import json
from .curriculum_io import CurriculumError, parse
raw = json.dumps({'grades': [{'title': 'پایه ۱', 'units': [{'title': 'فصل ۱', 'nodes': []}]}]})
with self.assertRaises(CurriculumError):
parse(raw.encode())
def test_an_unknown_subject_is_rejected_on_import(self):
from .curriculum_io import CurriculumError, import_curriculum
grades = [{'title': 'پایه ۱', 'icon': '', 'color': '', 'units': [
{'subject': 'فیزیک', 'title': 'فصل ۱', 'blurb': '', 'order': 1, 'nodes': []}]}]
with self.assertRaises(CurriculumError):
import_curriculum(grades)
class NodeSplitTests(TestCase):
"""`content.node_split`: جزیره‌ی بلند → چند جزیره‌ی حداکثر ۶ درسی، بی‌آن‌که پیشرفتِ کسی گم یا دوبار شمرده شود."""
def setUp(self):
from .models import NodeProgress, UserNodeCompletion
self.grade = Grade.objects.create(title='پایه دوازدهم', icon='', color='')
self.subject = Subject.objects.get(key='calculus')
self.unit = Unit.objects.create(grade=self.grade, subject=self.subject, title='فصل ۱: تابع', order=1)
self.long = Node.objects.create(unit=self.unit, title='تبدیل نمودار', order=1)
self.short = Node.objects.create(unit=self.unit, title='کوتاه', order=2)
self.lesson_ids = [Lesson.objects.create(node=self.long, title=f'درس {i}').id for i in range(25)]
Lesson.objects.create(node=self.short, title='تک')
UserNodeCompletion.objects.create(user_id=7, node=self.long, correct_count=3, total_questions=4,
stars=2, xp_earned=50, coins_earned=10)
NodeProgress.objects.create(user_id=7, node=self.long, status='completed')
def _run(self, **kw):
from .models import NodeProgress, UserNodeCompletion
from .node_split import split_long_nodes
return split_long_nodes(Node=Node, Lesson=Lesson, UserNodeCompletion=UserNodeCompletion,
NodeProgress=NodeProgress, units=Unit.objects.all(), **kw)
def test_splits_into_balanced_ordered_parts_and_keeps_lesson_ids(self):
counts = self._run()
self.assertEqual((counts['nodes_split'], counts['nodes_created']), (1, 4))
nodes = list(Node.objects.filter(unit=self.unit).order_by('order'))
self.assertEqual([n.title for n in nodes], ['تبدیل نمودار (۱)', 'تبدیل نمودار (۲)', 'تبدیل نمودار (۳)', 'تبدیل نمودار (۴)', 'تبدیل نمودار (۵)', 'کوتاه'])
self.assertEqual([n.order for n in nodes], [1, 2, 3, 4, 5, 6])
self.assertEqual(nodes[0].pk, self.long.pk) # بخشِ اول همان شناسه را نگه می‌دارد
sizes = [Lesson.objects.filter(node=n).count() for n in nodes]
self.assertEqual(sizes, [5, 5, 5, 5, 5, 1])
# ترتیبِ کتاب حفظ شده: درس‌های هر بخش پشتِ‌هم‌اند.
first = list(Lesson.objects.filter(node=nodes[0]).order_by('id').values_list('id', flat=True))
self.assertEqual(first, self.lesson_ids[:5])
def test_a_finished_island_stays_finished_without_double_counting_xp(self):
from .models import NodeProgress, UserNodeCompletion
self._run()
rows = UserNodeCompletion.objects.filter(user_id=7).order_by('node__order')
self.assertEqual(rows.count(), 5)
self.assertEqual(sum(r.xp_earned for r in rows), 50)
self.assertEqual(sum(r.coins_earned for r in rows), 10)
self.assertEqual(NodeProgress.objects.filter(user_id=7, status='completed').count(), 5)
def test_second_run_changes_nothing(self):
self._run()
again = self._run()
self.assertEqual(again, {'nodes_split': 0, 'nodes_created': 0, 'lessons_moved': 0, 'completions_cloned': 0})
self.assertEqual(Node.objects.filter(unit=self.unit).count(), 6)
def test_dry_run_writes_nothing(self):
counts = self._run(dry_run=True)
self.assertEqual(counts['nodes_created'], 4)
self.assertEqual(Node.objects.filter(unit=self.unit).count(), 2)