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:
159
content/tests_subjects.py
Normal file
159
content/tests_subjects.py
Normal 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)
|
||||
Reference in New Issue
Block a user