diff --git a/content/tests_frontend_contract.py b/content/tests_frontend_contract.py index f5a72da..1999e72 100644 --- a/content/tests_frontend_contract.py +++ b/content/tests_frontend_contract.py @@ -150,3 +150,53 @@ class HomeGradeParamTests(TestCase): def test_an_unknown_grade_is_a_clean_404_not_a_silent_fallback(self): self.assertEqual(self.client.get('/api/home?gradeId=999999', **self.auth).status_code, 404) + + +class NodeVisualDefaultsTests(TestCase): + """ + تا وقتی کسی از پنلِ ادمین رنگ نگذاشته، کارتِ مبحث نباید بی‌رنگ بماند. + + پیش‌فرض باید *پایدار* باشد نه تصادفی: رنگی که بینِ دو بارگذاری عوض شود، + بدتر از بی‌رنگ بودن است. + """ + + def setUp(self): + cache.clear() + self.grade = Grade.objects.create(title='پایه نهم', icon='', color='') + unit = Unit.objects.create(grade=self.grade, title='فصل ۱', order=1) + self.nodes = [ + Node.objects.create(unit=unit, title=f'مبحث {i}', order=i) + for i in range(1, 4) + ] + for node in self.nodes: + Lesson.objects.create(node=node, title='نوع', intro_text='x') + + user = User.objects.create(phone='09120000003', grade_id=self.grade.id) + from accounts.authentication import issue_access_token + self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(user.id)}'} + + def _nodes(self): + return self.client.get('/api/home', **self.auth).json()['units'][0]['nodes'] + + def test_a_node_without_a_colour_still_gets_one(self): + for node in self._nodes(): + self.assertTrue(node['color'].startswith('#'), node) + self.assertTrue(node['icon']) + + def test_neighbouring_nodes_do_not_share_a_colour(self): + colors = [n['color'] for n in self._nodes()] + self.assertEqual(len(set(colors)), len(colors)) + + def test_the_same_node_keeps_the_same_colour_between_requests(self): + first = [n['color'] for n in self._nodes()] + cache.clear() + self.assertEqual(first, [n['color'] for n in self._nodes()]) + + def test_an_explicit_colour_from_the_admin_wins(self): + self.nodes[0].color = '#123456' + self.nodes[0].icon = 'Trophy' + self.nodes[0].save() + cache.clear() + first = self._nodes()[0] + self.assertEqual(first['color'], '#123456') + self.assertEqual(first['icon'], 'Trophy') diff --git a/content/views.py b/content/views.py index c316f1d..305d055 100644 --- a/content/views.py +++ b/content/views.py @@ -94,6 +94,16 @@ def _grades_payload() -> dict: return cache.get_or_set(key, build, _CACHE_TTL) +# پالت و آیکونِ پیش‌فرضِ کارتِ مبحث — هر دو از خودِ اپ گرفته شده‌اند +# (رنگ‌های موجود در طرح، و آیکون‌هایی که از lucide وارد می‌شوند)، تا تا وقتی +# کسی از پنلِ ادمین مقدارِ دلخواه نگذاشته، کارت‌ها بی‌رنگ نمانند. +# +# چرخشی و بر اساسِ جایگاهِ مبحث است، نه تصادفی: رنگ نباید بینِ دو درخواست +# عوض شود، و دو مبحثِ کنارِ هم نباید یک‌رنگ دربیایند. +_NODE_COLORS = ['#ED6956', '#91ADC6', '#97C0C0', '#9B7BFF', '#FF7A3D'] +_NODE_ICONS = ['Grid3x3', 'TrendingUp', 'LineChart', 'Sigma', 'Compass', 'Repeat'] + + def _grade_tree(grade_id: int): """اسکلتِ گریدِ [{id,title,nodes:[{id,title,lesson_ids}]}] — بدونِ status/دیتای کاربر. None یعنی گریدِ ناموجود؛ [] یعنی گریدِ موجودِ بدونِ واحد.""" @@ -110,24 +120,25 @@ def _grade_tree(grade_id: int): Grade.objects.get(pk=grade_id) .units.prefetch_related('nodes__lessons').all() ) - tree = [ - { + tree = [] + position = 0 # شمارنده‌ی سراسریِ مبحث در این پایه، برای چرخشِ رنگ + for unit in units: + nodes = [] + for node in unit.nodes.all(): + nodes.append({ + 'id': str(node.id), + 'title': node.title, + 'icon': node.icon or _NODE_ICONS[position % len(_NODE_ICONS)], + 'color': node.color or _NODE_COLORS[position % len(_NODE_COLORS)], + 'lesson_ids': [lsn.id for lsn in node.lessons.all()], + }) + position += 1 + tree.append({ 'id': str(unit.id), 'title': unit.title, 'blurb': unit.blurb, - 'nodes': [ - { - 'id': str(node.id), - 'title': node.title, - 'icon': node.icon, - 'color': node.color, - 'lesson_ids': [lsn.id for lsn in node.lessons.all()], - } - for node in unit.nodes.all() - ], - } - for unit in units - ] + 'nodes': nodes, + }) cache.set(key, tree, _CACHE_TTL) return tree