Files
MathApp/rag/tests_diagram.py

490 lines
28 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.
"""شکلِ پاسخِ دستیار: جدا کردن از متن، اعتبارسنجی، و رسیدنش تا SSE و تاریخچه."""
import json
from types import SimpleNamespace
from unittest.mock import patch
from django.core.cache import cache
from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from .services.clients import reset_clients_cache
from .services.diagram import StreamSplitter, extract, validate
from .tests import RAG_ON, _parse_sse
CIRCLE = {
'kind': 'plane', 'title': 'زاویه‌ی ۳۰ درجه', 'axes': True,
'points': {'O': [0, 0], 'A': [1, 0], 'P': {'polar': [1, 30]}},
'items': [
{'type': 'circle', 'center': 'O', 'r': 1},
{'type': 'segment', 'from': 'O', 'to': 'P'},
{'type': 'angle', 'vertex': 'O', 'from': 'A', 'to': 'P', 'label': '30°'},
],
}
ANSWER = 'در شکلِ پایین نقطه‌ی P را می‌بینی.\n✅ جواب: 1/2\n<diagram>\n' + json.dumps(CIRCLE, ensure_ascii=False) + '\n</diagram>'
class ValidateTests(SimpleTestCase):
def test_real_model_output_passes_unchanged_in_meaning(self):
spec = validate(CIRCLE)
self.assertEqual(spec['points']['P'], {'polar': [1.0, 30.0]})
self.assertEqual(len(spec['items']), 3)
def test_unknown_item_type_is_dropped_not_the_whole_diagram(self):
spec = validate({**CIRCLE, 'items': CIRCLE['items'] + [{'type': 'html', 'html': '<script>x</script>'}]})
self.assertEqual([i['type'] for i in spec['items']], ['circle', 'segment', 'angle'])
def test_function_expression_only_allows_math(self):
ok = validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': 'sin(x)+x^2', 'from': -3, 'to': 3}]})
self.assertEqual(ok['items'][0]['expr'], 'sin(x)+x^2')
for bad in ('alert(1)', 'x; fetch()', 'constructor', '__proto__'):
self.assertIsNone(validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': bad}]}), bad)
def test_numbers_must_be_finite_and_bounded(self):
self.assertIsNone(validate({'kind': 'numberline', 'min': 0, 'max': 1e9}))
spec = validate({'kind': 'plane', 'points': {'A': [float('inf'), 0], 'B': [1, 1]}, 'items': []})
self.assertEqual(list(spec['points']), ['B'])
def test_labels_are_truncated_and_fraction_shaded_is_capped(self):
# بیش از تعدادِ تکه‌ها محدود می‌شود، نه اینکه کلِ شکل حذف شود
spec = validate({'kind': 'fraction', 'parts': 4, 'shaded': 9, 'title': 'x' * 500})
self.assertEqual(spec['shaded'], 4)
spec = validate({'kind': 'fraction', 'parts': 4, 'shaded': 3, 'title': 'x' * 500})
self.assertEqual((spec['parts'], spec['shaded'], len(spec['title'])), (4, 3, 40))
def test_garbage_is_none(self):
for bad in (None, [], 'svg', {'kind': 'svg'}, {'kind': 'plane', 'items': 'x'}):
self.assertIsNone(validate(bad), bad)
class ExtractTests(SimpleTestCase):
def test_block_is_removed_from_text(self):
text, spec = extract(ANSWER)
self.assertNotIn('diagram', text)
self.assertNotIn('{', text)
self.assertTrue(text.endswith('✅ جواب: 1/2'))
self.assertEqual(spec['kind'], 'plane')
def test_broken_json_drops_only_the_diagram(self):
text, spec = extract('جواب ۵ است.\n<diagram>{"kind": "plane", </diagram>')
self.assertEqual((text, spec), ('جواب ۵ است.', None))
def test_unclosed_block_never_leaks_json(self):
text, _ = extract('جواب.\n<diagram>{"kind":"fraction","parts":4,"shaded":3}')
self.assertEqual(text, 'جواب.')
def test_splitter_matches_extract_however_the_stream_is_cut(self):
expected_text, expected_spec = extract(ANSWER)
for step in (1, 2, 3, 5, 9, 40, 1000):
splitter = StreamSplitter()
out = ''.join(splitter.feed(ANSWER[i:i + step]) for i in range(0, len(ANSWER), step))
out += splitter.flush()
self.assertEqual(out.rstrip(), expected_text, f'step={step}')
self.assertEqual(splitter.diagram, expected_spec, f'step={step}')
def test_splitter_passes_text_after_the_block(self):
splitter = StreamSplitter()
out = splitter.feed('الف <diagram>{"kind":"fraction","parts":2,"shaded":1}</diagram> ب') + splitter.flush()
self.assertEqual(out, 'الف ب')
self.assertEqual(splitter.diagram['parts'], 2)
def test_lone_less_than_sign_is_not_swallowed(self):
splitter = StreamSplitter()
out = splitter.feed('x <') + splitter.feed(' 3') + splitter.flush()
self.assertEqual(out, 'x < 3')
def _fake_stream(text: str, step: int = 7):
events = [
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=text[i:i + step]))])
for i in range(0, len(text), step)
]
create = lambda **kwargs: iter(events) # noqa: E731
return SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create)))
@override_settings(**RAG_ON)
class DiagramThroughTheApiTests(TestCase):
def setUp(self):
cache.clear()
reset_clients_cache()
self.client = APIClient()
self.user = User.objects.create(phone='09000000030', name='')
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
def test_stream_sends_text_without_json_and_diagram_in_done_and_history(self):
from ai_tutor.models import Message
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
patch('rag.services.clients.get_openai_client', return_value=_fake_stream(ANSWER)):
resp = self.client.post('/api/ai/ask', {'userQuestion': 'سینوسِ ۳۰؟', 'stream': True}, format='json')
events = _parse_sse(resp.streaming_content) # استریم همین‌جا اجرا می‌شود، داخلِ patch
text = ''.join(json.loads(d)['delta'] for e, d in events if e == 'chunk')
self.assertNotIn('<diagram', text)
self.assertNotIn('"kind"', text)
self.assertEqual(events[-1][0], 'done', events[-1])
done = json.loads(events[-1][1])
self.assertEqual(done['diagram']['points']['P'], {'polar': [1.0, 30.0]})
message = Message.objects.get(role='assistant', conversation__user_id=self.user.id)
self.assertEqual(message.diagram, done['diagram'])
from .services.generation import PROMPT_VERSION
self.assertEqual(message.prompt_version, PROMPT_VERSION)
history = self.client.get(f'/api/ai/conversations/{done["conversationId"]}/messages').json()
self.assertEqual(history['messages'][-1]['diagram']['kind'], 'plane')
self.assertIsNone(history['messages'][0]['diagram'])
def test_answer_without_diagram_has_null(self):
with patch('rag.services.retrieval.retrieve_context', return_value=([], {})), \
patch('rag.services.clients.get_openai_client', return_value=_fake_stream('۲۳ × ۴۵ = ۱۰۳۵')):
resp = self.client.post('/api/ai/ask', {'userQuestion': '۲۳×۴۵', 'stream': True}, format='json')
done = json.loads(_parse_sse(resp.streaming_content)[-1][1])
self.assertIsNone(done['diagram'])
class ShapeHintTests(SimpleTestCase):
def test_hint_goes_next_to_shape_questions_only(self):
from .services.diagram import DIAGRAM_HINT, hint_for
from .services.generation import _user_prompt
for q in ('وترِ مثلث با ضلع‌های ۳ و ۴؟', 'سه‌چهارم پیتزا', 'نمودار y=x²', 'sin 30°'):
self.assertTrue(hint_for(q).startswith(DIAGRAM_HINT), q)
self.assertIn(DIAGRAM_HINT, _user_prompt(q, []))
for q in ('۲۳ × ۴۵ چند می‌شود؟', 'عبارت (x+2)(x-3) را ساده کن.'):
self.assertEqual(hint_for(q), '', q)
self.assertEqual(_user_prompt(q, []), q)
def test_every_system_prompt_carries_the_diagram_rule(self):
from .services.generation import system_prompt
for grade in ('سوم', 'هشتم', 'دوازدهم', ''):
self.assertIn('<diagram>', system_prompt(grade))
class AxesDefaultTests(SimpleTestCase):
def test_geometry_without_axes_key_gets_no_axes_but_graphs_do(self):
tri = validate({'kind': 'plane', 'points': {'A': [0, 0], 'B': [3, 0], 'C': [0, 4]},
'items': [{'type': 'polygon', 'points': ['A', 'B', 'C']}]})
self.assertFalse(tri['axes'])
graph = validate({'kind': 'plane', 'items': [{'type': 'function', 'expr': 'x^2', 'from': -2, 'to': 2}]})
self.assertTrue(graph['axes'])
self.assertTrue(validate({**CIRCLE})['axes']) # صریحاً true خواسته شده
class SectionedSpecTests(SimpleTestCase):
"""کنارِ هر سوال فقط بخشِ مربوط به همان سوال از کاتالوگ می‌رود (هزینه‌ی توکن)."""
def test_only_matching_sections_are_sent(self):
from .services.diagram import _GRAPH, _PLANE, _SOLID, _TREE, hint_for
solid = hint_for('حجمِ استوانه‌ای با شعاعِ ۳ و ارتفاعِ ۵ چقدر است؟')
self.assertIn(_SOLID, solid)
self.assertNotIn(_GRAPH, solid)
self.assertNotIn(_TREE, solid)
graph = hint_for('نمودارِ تابعِ y = x² − 4 را رسم کن')
self.assertIn(_GRAPH, graph)
self.assertIn(_PLANE, graph) # نمودار داخلِ plane است
self.assertNotIn(_SOLID, graph)
self.assertLess(len(solid), len(graph))
def test_bar_chart_does_not_pull_function_graphs(self):
from .services.diagram import _CHARTS, _GRAPH, hint_for
text = hint_for('نمودارِ ستونیِ آب‌میوه‌ها را رسم کن')
self.assertIn(_CHARTS, text)
self.assertNotIn(_GRAPH.split(':')[0], text.replace('رسم', ''))
class ExpandedKindsTests(SimpleTestCase):
"""نوع‌های تازه: هر کدام با یک نمونه‌ی واقعی و یک نمونه‌ی خراب."""
def test_constructed_points_and_new_items(self):
spec = validate({
'kind': 'plane', 'points': {
'A': [0, 0], 'B': [4, 0], 'C': [1, 3], 'M': {'mid': ['A', 'B']}, 'H': {'foot': ['C', 'A', 'B']},
'I': {'intersect': [['A', 'C'], ['B', [0, 3]]]}, 'T': {'along': ['A', 'B', 0.25]}, 'F': {'fx': ['x^2', 3]},
'X': {'mid': ['A']}, 'Y': {'fx': ['alert(1)', 2]},
},
'items': [
{'type': 'segment', 'from': 'A', 'to': 'M', 'ticks': 2, 'color': 'green'},
{'type': 'angle', 'vertex': 'A', 'from': 'B', 'to': 'C', 'arcs': 9},
{'type': 'image', 'of': ['A', 'B', 'C'], 'transform': {'rotate': {'center': 'C', 'deg': -90}}},
{'type': 'piecewise', 'pieces': [{'expr': 'x+1', 'from': -2, 'to': 1, 'closedTo': False}, {'expr': 'bad;', 'from': 1, 'to': 2}]},
{'type': 'segment', 'from': 'A', 'to': 'B', 'color': '#ff0000'},
],
})
self.assertEqual(set(spec['points']), {'A', 'B', 'C', 'M', 'H', 'I', 'T', 'F'})
self.assertEqual(spec['items'][1]['arcs'], 3)
self.assertEqual(spec['items'][2]['transform'], {'rotate': {'center': 'C', 'deg': -90.0}})
self.assertEqual(len(spec['items'][3]['pieces']), 1)
self.assertNotIn('color', spec['items'][4]) # فقط نامِ رنگِ شناخته‌شده
def test_solid_chart_venn_tree_mapping_grid_clock_table(self):
self.assertEqual(validate({'kind': 'solid', 'shape': 'cone', 'dims': {'r': 4, 'h': 12}, 'labels': {'r': '4 cm'}})['dims'], {'r': 4.0, 'h': 12.0})
self.assertIsNone(validate({'kind': 'solid', 'shape': 'teapot'}))
pie = validate({'kind': 'pie', 'labels': ['a', 'b', 'c'], 'values': [1, 2]})
self.assertEqual(pie['labels'], ['a', 'b'])
self.assertIsNone(validate({'kind': 'pie', 'labels': ['a'], 'values': [0]}))
venn = validate({'kind': 'venn', 'sets': ['A', 'B'], 'regions': {'AB': ['3'], 'AC': ['x'], 'out': ['6']}, 'shade': ['AB', 'Z']})
self.assertEqual(set(venn['regions']), {'AB', 'out'})
self.assertEqual(venn['shade'], ['AB'])
tree = validate({'kind': 'tree', 'root': {'label': 'شروع', 'children': [{'label': 'رو', 'p': '1/2', 'children': [{'label': 'a', 'children': [{'label': 'b', 'children': [{'label': 'too deep'}]}]}]}]}})
leaf = tree['root']['children'][0]['children'][0]['children'][0]
self.assertNotIn('children', leaf) # حداکثر سه سطح زیرِ ریشه
m = validate({'kind': 'mapping', 'left': ['1', '2'], 'right': ['3'], 'arrows': [[0, 0], [1, 5]]})
self.assertEqual(m['arrows'], [[0, 0]])
self.assertEqual(validate({'kind': 'grid', 'rows': 5, 'cols': 5, 'shaded': 99})['shaded'], 25)
self.assertEqual(validate({'kind': 'clock', 'hour': 3, 'minute': 25})['minute'], 25)
self.assertEqual(validate({'kind': 'table', 'headers': ['x', '1'], 'rows': [['y', '+'], 'bad']})['rows'], [['y', '+']])
def test_numberline_intervals_and_fraction_step(self):
nl = validate({'kind': 'numberline', 'min': 0, 'max': 2, 'step': 0.25, 'fractions': True,
'intervals': [{'from': -2, 'to': 3, 'closedFrom': True}, {'from': 1, 'to': None}, {'from': 3, 'to': 1}]})
self.assertEqual(nl['step'], 0.25)
self.assertEqual(nl['intervals'], [{'from': -2.0, 'to': 3.0, 'closedFrom': True}, {'from': 1.0, 'to': None}])
self.assertNotIn('step', validate({'kind': 'numberline', 'min': 0, 'max': 100, 'step': 0.01}))
class ModelSlipRepairTests(SimpleTestCase):
"""خطاهای قالبیِ واقعیِ gpt-4.1-mini در سنجشِ ۲۰۲۶-۰۹-۲۸؛ عیناً همان خروجی‌ها."""
def test_persian_digits_in_json_numbers(self):
from .services.diagram import parse
spec = parse('{"kind":"grid","title":"نمایش ۱۸ درصد","rows":۱۰,"cols":۱۰,"shaded":۱۸}')
self.assertEqual((spec['rows'], spec['cols'], spec['shaded']), (10, 10, 18))
def test_pi_expressions_as_numbers(self):
from .services.diagram import parse
spec = parse('{"kind":"plane","title":"t","axes":true,"items":[{"type":"function","expr":"sin(x)^2","from":-2*pi,"to":2*pi},'
'{"type":"vline","x":pi/2},{"type":"vline","x":3pi/4}]}')
self.assertAlmostEqual(spec['items'][0]['from'], -6.283185, places=5)
self.assertAlmostEqual(spec['items'][1]['x'], 1.570796, places=5)
self.assertAlmostEqual(spec['items'][2]['x'], 2.356194, places=5)
def test_bare_item_and_function_kind_become_a_plane(self):
from .services.diagram import parse
bare = parse('{"type":"function","expr":"sqrt(3*x - 5)","from":1.6,"to":5,"label":"f(x) = √(3x - 5)","marks":["yIntercept"]}')
self.assertEqual(bare['kind'], 'plane')
self.assertTrue(bare['axes'])
self.assertEqual(bare['items'][0]['expr'], 'sqrt(3*x - 5)')
kinded = parse('{"kind":"function","title":"نمودار تابع p(x)","axes":true,"xRange":[-3,3],"yRange":[-10,10],'
'"type":"function","expr":"x^3 - x^2 - 4*x + 4","from":-3,"to":3,"marks":["roots"]}')
self.assertEqual(kinded['kind'], 'plane')
self.assertEqual(kinded['xRange'], [-3.0, 3.0])
self.assertEqual(kinded['items'][0]['marks'], ['roots'])
def test_venn_with_persian_set_names(self):
from .services.diagram import parse
spec = parse('{"kind":"venn","title":"ون","sets":["والیبال","فوتبال"],"regions":{"والیبال":["7-x"],'
'"والیبال_فوتبال":["x"],"فوتبال":["4-x"],"out":[]},"shade":["والیبال_فوتبال"]}')
self.assertEqual(spec['sets'], ['A', 'B'])
self.assertEqual(spec['names'], ['والیبال', 'فوتبال'])
self.assertEqual(spec['regions'], {'A': ['7-x'], 'AB': ['x'], 'B': ['4-x'], 'out': []})
self.assertEqual(spec['shade'], ['AB'])
def test_unknown_kind_is_still_dropped(self):
from .services.diagram import parse
self.assertIsNone(parse('{"kind":"line_segment","points":[{"label":"مشهد","position":0}]}'))
def test_comments_after_values_and_wide_numberline(self):
from .services.diagram import parse
spec = parse('{"kind":"venn","sets":["A","B","C"],"regions":{"AC":["5"]},"shade":[\n "AC", // الف\n "B" // ب\n]}')
self.assertEqual(spec['shade'], ['AC', 'B'])
wide = parse('{"kind":"numberline","min":-۱۴۰,"max":۸۷,"step":۱۰,"points":[{"at":-۵۵,"label":"-۵۵"}]}')
self.assertEqual((wide['min'], wide['max'], wide['step']), (-140.0, 87.0, 10.0))
class SecondRoundKindsTests(SimpleTestCase):
"""افزوده‌های دورِ دومِ سنجش: خط از معادله، مقایسه‌ی کسرها، عددِ مخلوط، محرکِ ساعت."""
def test_line_from_equation(self):
spec = validate({'kind': 'plane', 'items': [{'type': 'line', 'eq': [3, 5, 4]}, {'type': 'line', 'eq': [0, 0, 1]}]})
self.assertEqual(spec['items'], [{'type': 'line', 'eq': [3.0, 5.0, 4.0]}])
def test_fraction_compare_and_wholes(self):
cmp = validate({'kind': 'fraction', 'compare': [{'parts': 5, 'shaded': 1, 'label': 'یک‌پنجم'}, {'parts': 10, 'shaded': 12}]})
self.assertEqual(cmp['compare'][1], {'parts': 10, 'shaded': 10})
mixed = validate({'kind': 'fraction', 'parts': 10, 'shaded': 7, 'wholes': 2})
self.assertEqual((mixed['wholes'], mixed['shaded']), (2, 7))
def test_clock_only_for_telling_time(self):
from .services.diagram import _CLOCK, hint_for
self.assertNotIn(_CLOCK, hint_for('فاطمه ۶ ساعت کتاب خواند. چند صفحه ماند؟'))
self.assertIn(_CLOCK, hint_for('عقربه‌ی ساعت‌شمار روی ۳ است. ساعت چند است؟'))
self.assertIn(_CLOCK, hint_for('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده'))
class FollowUpHintTests(SimpleTestCase):
"""«اگه به‌جای ۶ بذاریم ۴ چی؟» بعد از یک سوالِ شکل‌دار هم شکلِ تازه می‌خواهد."""
def test_follow_up_without_shape_words_gets_the_soft_hint(self):
from .services.diagram import FOLLOW_UP_HINT, _GRAPH
from .services.generation import build_messages
history = [{'role': 'user', 'text': 'ریشه‌ها و رأسِ سهمیِ y = x² − 5x + 6 را پیدا کن'},
{'role': 'assistant', 'text': '…'}]
last = build_messages('اگه به‌جای ۶ بذاریم ۴ چی میشه؟', None, 'یازدهم', history=history)[-1]['content']
self.assertIn(FOLLOW_UP_HINT, last)
self.assertIn(_GRAPH, last)
def test_no_hint_without_a_shape_question_before(self):
from .services.diagram import FOLLOW_UP_HINT
from .services.generation import build_messages
history = [{'role': 'user', 'text': 'حاصلِ ۲۳ × ۴۵ چند است؟'}, {'role': 'assistant', 'text': '۱۰۳۵'}]
last = build_messages('اگه ۲۴ باشه چی؟', None, 'هشتم', history=history)[-1]['content']
self.assertNotIn(FOLLOW_UP_HINT, last)
def test_a_shape_question_keeps_the_strong_hint(self):
from .services.diagram import DIAGRAM_HINT, FOLLOW_UP_HINT
from .services.generation import build_messages
history = [{'role': 'user', 'text': 'مثلثی با ضلع‌های ۳ و ۴'}]
last = build_messages('حالا دایره‌ای به شعاعِ ۲', None, 'نهم', history=history)[-1]['content']
self.assertIn(DIAGRAM_HINT, last)
self.assertNotIn(FOLLOW_UP_HINT, last)
def test_explicit_request_without_topic_words_gets_the_whole_catalog(self):
from .services.diagram import ASKED_HINT, DIAGRAM_SPEC, asked_hint_for
from .services.generation import build_messages
last = build_messages('یه جعبه‌ی کادو دارم، شکلش رو برام بکش', None, 'ششم')[-1]['content']
self.assertIn(ASKED_HINT, last)
self.assertIn(DIAGRAM_SPEC, last)
for q in ('مشکل دارم با این سوال', 'می‌خوام یه سوال بپرسم', '۳۴۵ × ۱۲'):
self.assertEqual(asked_hint_for(q), '', q)
class ThirdRoundTests(SimpleTestCase):
"""از چتِ واقعی با gpt-4.1-mini (۲۰۲۶-۰۹-۲۹): خط، چندضلعیِ منتظم، زاویه‌ی خارجی، کسرِ کلمه‌ای."""
def test_line_as_written_equation(self):
spec = validate({'kind': 'plane', 'items': [
{'type': 'line', 'equation': '3x + 5y = 4'}, {'type': 'line', 'equation': 'y = 12x − 16'},
{'type': 'line', 'equation': 'y = sin(x)'}, {'type': 'line', 'equation': 'x + y'}]})
self.assertEqual([i.get('equation') for i in spec['items']], ['3x + 5y = 4', 'y = 12x - 16'])
def test_eq_array_is_general_form(self):
# عینِ خروجیِ مدل برای مماسِ y = 12x − 16
spec = validate({'kind': 'plane', 'items': [{'type': 'line', 'eq': [-12, 1, 16]}]})
self.assertEqual(spec['items'][0]['eq'], [-12.0, 1.0, 16.0])
def test_regular_polygon_and_exterior_angle(self):
spec = validate({'kind': 'plane', 'items': [
{'type': 'regularPolygon', 'n': 50, 'r': 2, 'names': ['A', 'B', 'C', 'D', 'E', 'bad name!']},
{'type': 'exteriorAngle', 'vertex': 'B', 'from': 'A', 'to': 'C', 'label': '72°'}]})
self.assertEqual(spec['items'][0]['n'], 12)
self.assertEqual(spec['items'][0]['names'], ['A', 'B', 'C', 'D', 'E'])
self.assertEqual(spec['items'][1]['type'], 'exteriorAngle')
def test_fraction_words_pull_the_fraction_section(self):
from .services.diagram import _PARTS, hint_for
self.assertIn(_PARTS, hint_for('یک‌پنجم و نُه‌دهم رو با هم مقایسه کن، کدوم بزرگ‌تره؟'))
self.assertNotIn(_PARTS, hint_for('رتبه‌ی پنجمین نفر در مسابقه چند است؟'))
class FourthRoundTests(SimpleTestCase):
"""سنجشِ ۲۰۲۶-۰۹-۲۹ (۶۴ مسئله‌ی تازه): مثلث و چهارضلعی از روی داده، الگوی شکلی، و لغزش‌های JSON."""
def test_triangle_and_quad_from_problem_data(self):
from .services.diagram import parse
tri = parse('{"kind":"plane","items":[{"type":"triangle","names":["A","B","C"],"sides":{"AB":6,"AC":8},'
'"angles":{"A":90},"labels":{"BC":"x","Z":"?"},"altitude":"C"}]}')['items'][0]
self.assertEqual(tri['sides'], {'AB': 6.0, 'AC': 8.0})
self.assertEqual(tri['labels'], {'BC': 'x'})
self.assertEqual(tri['altitude'], 'C')
# زاویه‌هایی که جمعشان ۱۸۰ یا بیشتر است مثلث نمی‌سازند
self.assertIsNone(parse('{"kind":"plane","items":[{"type":"triangle","angles":{"A":100,"B":90}}]}'))
quad = parse('{"kind":"plane","items":[{"type":"quad","shape":"rhombus","d1":8,"d2":6}]}')['items'][0]
self.assertEqual((quad['names'], quad['d1'], quad['d2']), (['A', 'B', 'C', 'D'], 8.0, 6.0))
self.assertIsNone(parse('{"kind":"plane","items":[{"type":"quad","shape":"kite","w":3}]}'))
def test_pattern_kind(self):
from .services.diagram import _PATTERN, hint_for, parse
spec = parse('{"kind":"pattern","layout":"triangle","unit":"dot","stages":[1,2,3,40],"next":true}')
self.assertEqual(spec['stages'], [1, 2, 3, 12])
self.assertIn(_PATTERN, hint_for('در الگوی زیر شکلِ ۵ چند چوب کبریت دارد؟'))
def test_json_slips_seen_in_the_benchmark(self):
from .services.diagram import parse
# رشته‌ی بسته‌نشده پیش از }
self.assertEqual(parse('{"kind":"plane","items":[{"type":"segment","from":"A","to":"B","color":"orange}]}')
['items'][0]['color'], 'orange')
# کلیدِ تکراریِ points: هر دو نگه داشته می‌شوند
spec = parse('{"kind":"plane","points":{"A":[0,0]},"items":[{"type":"point","point":"A"}],"points":{"B":[1,1]}}')
self.assertEqual(set(spec['points']), {'A', 'B'})
# عددِ کسری بی‌گیومه؛ برچسبِ «۱۲/۱۸» دست نمی‌خورد
spec = parse('{"kind":"plane","items":[{"type":"function","expr":"x^2","from":-5/3,"to":5/3,"label":"12/18"}]}')
self.assertAlmostEqual(spec['items'][0]['from'], -5 / 3, places=4)
self.assertEqual(spec['items'][0]['label'], '12/18')
self.assertEqual(parse('{"kind":"compare","compare":[{"parts":5,"shaded":1},{"parts":10,"shaded":9}]}')['kind'],
'fraction')
def test_real_world_geometry_words_get_the_plane_section(self):
from .services.diagram import _PLANE, hint_for
for q in ('نردبانی به طولِ ۵ متر به دیوار تکیه داده شده', 'زمینی به طولِ ۲۰ و عرضِ ۱۲ متر'):
self.assertIn(_PLANE, hint_for(q), q)
class FifthRoundTests(SimpleTestCase):
"""نگهبانِ ساعت، نقطه‌ی انتقال، جای شکل."""
def test_clock_only_for_telling_time(self):
from .services.diagram import _CLOCK, fits, hint_for
clock = {'kind': 'clock', 'title': '', 'hour': 0, 'minute': 12}
self.assertIsNone(fits('۲۰ درصد یک ساعت چند دقیقه است؟', clock))
self.assertEqual(fits('ساعت ۳ و ۲۵ دقیقه را روی ساعت نشان بده', clock), clock)
self.assertNotIn(_CLOCK, hint_for('۲۰ درصد یک ساعت چند دقیقه است؟'))
self.assertEqual(fits('حالا ۱۰ دقیقه بعد؟\n\nساعت ۳ و ۲۵ دقیقه', clock), clock)
self.assertIsNone(fits('x', None))
def test_translate_point_and_shape_anchor(self):
from .services.diagram import parse
spec = parse('{"kind":"plane","points":{"A":[0,0],"B":{"translate":[2,3],"from":"A"}},'
'"items":[{"type":"quad","shape":"square","side":2,"at":[5,0]}]}')
self.assertEqual(spec['points']['B'], {'translate': [2.0, 3.0], 'from': 'A'})
self.assertEqual(spec['items'][0]['at'], [5.0, 0.0])
def test_shape_name_as_kind_with_aliases(self):
from .services.diagram import parse
item = parse('{"kind":"trapezoid","title":"ذوزنقه","bases":[10,6],"height":4,"color":"blue"}')['items'][0]
self.assertEqual((item['type'], item['shape'], item['w'], item['top'], item['h']), ('quad', 'trapezoid', 10.0, 6.0, 4.0))
item = parse('{"kind":"rectangle","length":8,"width":5}')['items'][0]
self.assertEqual((item['w'], item['h']), (8.0, 5.0))
class SpaceDiagramTests(SimpleTestCase):
"""kind=space: فضای سه‌بعدیِ ثابت؛ ورودیِ خراب حذف می‌شود و چیزی از بیرون به SVG نمی‌رسد."""
def test_valid_space_keeps_points_and_items(self):
spec = {'kind': 'space', 'title': 'بردار', 'points': {'A': [1, 2, 3]},
'items': [{'type': 'vector', 'from': 'O', 'to': 'A', 'label': 'a', 'color': 'blue'},
{'type': 'drop', 'point': 'A'}, {'type': 'box', 'to': [1, 2, 3]}]}
out = validate(spec)
self.assertEqual(out['kind'], 'space')
self.assertEqual(out['points']['O'], [0.0, 0.0, 0.0])
self.assertEqual([i['type'] for i in out['items']], ['vector', 'drop', 'box'])
def test_bad_items_are_dropped_not_trusted(self):
out = validate({'kind': 'space', 'points': {'A': [1, 2, 3], 'B': [1, 2], 'C': [1e9, 0, 0]},
'items': [{'type': 'vector', 'from': 'A', 'to': 'B'}, # B نامعتبر
{'type': 'script', 'src': 'x'}, # نوعِ ناشناخته
{'type': 'segment', 'from': 'O', 'to': 'A', 'color': '<b>'},
{'type': 'polygon', 'points': ['A', 'O']}]}) # کمتر از سه نقطه
self.assertEqual(out['items'], [{'type': 'segment', 'from': 'O', 'to': 'A'}])
self.assertNotIn('B', out['points'])
self.assertNotIn('C', out['points'])
def test_empty_space_is_rejected(self):
self.assertIsNone(validate({'kind': 'space', 'points': {}, 'items': []}))