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>
This commit is contained in:
2026-10-12 05:35:18 +03:30
parent 3d51b3b83a
commit 8cb1ecb85e
32 changed files with 81218 additions and 4486 deletions

159
content/tests_subjects.py Normal file
View File

@@ -0,0 +1,159 @@
"""
یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته) و دانش‌آموز بینشان جابه‌جا می‌شود
(۲۰۲۶-۱۰-۱۱). `/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)