"""شکلِ پاسخِ دستیار: جدا کردن از متن، اعتبارسنجی، و رسیدنش تا 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\n' + json.dumps(CIRCLE, ensure_ascii=False) + '\n' 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': ''}]}) 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.assertIsNone(spec) 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{"kind": "plane", ') self.assertEqual((text, spec), ('جواب ۵ است.', None)) def test_unclosed_block_never_leaks_json(self): text, _ = extract('جواب.\n{"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('الف {"kind":"fraction","parts":2,"shaded":1} ب') + 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(device_id='dev-diagram', is_guest=True, 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('', 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 خواسته شده