- 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>
160 lines
9.4 KiB
Python
160 lines
9.4 KiB
Python
"""
|
||
یک پایه چند کتاب دارد (دوازدهم: حسابان، هندسه، گسسته) و دانشآموز بینشان جابهجا میشود
|
||
(۲۰۲۶-۱۰-۱۱). `/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)
|