Move from HTML templates to pure Python
Django CI/CD / test (push) Successful in 46s
Django CI/CD / build-and-push (push) Successful in 1m41s

This commit is contained in:
2026-06-06 07:11:46 +02:00
parent 09db54e940
commit 21af7cddd0
108 changed files with 2819 additions and 2576 deletions
+54 -201
View File
@@ -1,5 +1,4 @@
import unittest
from functools import lru_cache
from unittest.mock import MagicMock, patch
import django
@@ -16,7 +15,7 @@ class RenderCachedImplTest(unittest.TestCase):
def test_basic_render(self):
result = components._render_cached_impl(
"cotton/icon/play.html",
"icons/play.html",
'{"slot": "", "title": "Play"}',
)
self.assertIn("<svg", result)
@@ -24,35 +23,35 @@ class RenderCachedImplTest(unittest.TestCase):
def test_slot_marked_safe(self):
result = components._render_cached_impl(
"cotton/icon/play.html",
"icons/play.html",
'{"slot": "<b>bold</b>", "title": "Play"}',
)
self.assertIsInstance(result, SafeText)
def test_different_templates_different_output(self):
r1 = components._render_cached_impl(
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
"icons/play.html", '{"slot": "", "title": "Play"}',
)
r2 = components._render_cached_impl(
"cotton/icon/delete.html", '{"slot": "", "title": "Delete"}',
"icons/delete.html", '{"slot": "", "title": "Delete"}',
)
self.assertNotEqual(r1, r2)
def test_nonexistent_template_raises(self):
with self.assertRaises(TemplateDoesNotExist):
components._render_cached_impl(
"cotton/nonexistent.html", '{"slot": "", "title": "X"}',
"nonexistent.html", '{"slot": "", "title": "X"}',
)
def test_context_keys_are_sorted(self):
"""Verify sort_keys=True in Component produces consistent JSON."""
from common.components import Component
r1 = Component(
template="cotton/icon/play.html",
template="icons/play.html",
attributes=[("title", "Play"), ("b", "2")],
)
r2 = Component(
template="cotton/icon/play.html",
template="icons/play.html",
attributes=[("b", "2"), ("title", "Play")],
)
self.assertEqual(r1, r2)
@@ -71,14 +70,14 @@ class RenderCachedLRUTest(unittest.TestCase):
def test_cache_hits_and_misses(self):
# Call through _render_cached (the cached wrapper), not _render_cached_impl
components._render_cached(
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
"icons/play.html", '{"slot": "", "title": "Play"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 0)
self.assertEqual(info.misses, 1)
components._render_cached(
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
"icons/play.html", '{"slot": "", "title": "Play"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 1)
@@ -86,7 +85,7 @@ class RenderCachedLRUTest(unittest.TestCase):
def test_cache_clear(self):
components._render_cached_impl(
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
"icons/play.html", '{"slot": "", "title": "Play"}',
)
components._render_cached.cache_clear()
info = components._render_cached.cache_info()
@@ -94,18 +93,17 @@ class RenderCachedLRUTest(unittest.TestCase):
self.assertEqual(info.hits, 0)
def test_cache_parameters(self):
info = components._render_cached.cache_info()
self.assertEqual(components._render_cached.cache_parameters()["maxsize"], 4096)
def test_different_contexts_different_entries(self):
# Call through _render_cached (the cached wrapper), not _render_cached_impl
components._render_cached(
"cotton/button.html",
'{"size": "base", "color": "blue", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
"icons/play.html",
'{"slot": "", "title": "Play"}',
)
components._render_cached(
"cotton/button.html",
'{"size": "base", "color": "red", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
"icons/play.html",
'{"slot": "", "title": "Pause"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.currsize, 2)
@@ -114,7 +112,7 @@ class RenderCachedLRUTest(unittest.TestCase):
"""After exceeding maxsize, oldest entries are evicted."""
for i in range(5000):
components._render_cached_impl(
f"cotton/icon/play.html",
"icons/play.html",
f'{{"slot": "", "title": "{i}"}}',
)
info = components._render_cached.cache_info()
@@ -133,7 +131,7 @@ class ComponentIntegrationTest(unittest.TestCase):
def test_template_component(self):
result = components.Component(
template="cotton/icon/play.html", attributes=[],
template="icons/play.html", attributes=[],
)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
@@ -148,19 +146,19 @@ class ComponentIntegrationTest(unittest.TestCase):
def test_repeated_calls_identical(self):
r1 = components.Component(
template="cotton/icon/play.html", attributes=[],
template="icons/play.html", attributes=[],
)
r2 = components.Component(
template="cotton/icon/play.html", attributes=[],
template="icons/play.html", attributes=[],
)
self.assertEqual(r1, r2)
def test_different_components_different(self):
r1 = components.Component(
template="cotton/button.html", attributes=[("hx_get", "/url1")],
template="icons/play.html", attributes=[],
)
r2 = components.Component(
template="cotton/button.html", attributes=[("hx_get", "/url2")],
template="icons/delete.html", attributes=[],
)
self.assertNotEqual(r1, r2)
@@ -265,52 +263,6 @@ class PopoverDeterministicTest(unittest.TestCase):
self.assertEqual(r1.encode(), r2.encode())
class PopoverCacheIntegrationTest(unittest.TestCase):
"""Test that Popover() output works correctly with LRU caching."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def _get_popover_context(self, popover_content, wrapped_content="", wrapped_classes="", slot=""):
"""Build the context JSON matching the new cotton/popover.html shim."""
import json
content = f"{wrapped_content}:{popover_content}:{wrapped_classes}"
id = components.randomid(content=content)
context = {
"id": id,
"popover_content": popover_content,
"wrapped_content": wrapped_content,
"wrapped_classes": wrapped_classes,
"slot": slot,
}
return json.dumps(context, sort_keys=True)
def test_popover_shim_template_is_cached(self):
ctx_a = self._get_popover_context(popover_content="a", wrapped_content="a")
ctx_b = self._get_popover_context(popover_content="b", wrapped_content="b")
components._render_cached("cotton/popover.html", ctx_a)
components._render_cached("cotton/popover.html", ctx_b)
info = components._render_cached.cache_info()
self.assertEqual(info.currsize, 2)
def test_popover_shim_repeated_call_uses_cache(self):
ctx = self._get_popover_context(popover_content="x", wrapped_content="x")
for _ in range(5):
components._render_cached("cotton/popover.html", ctx)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 4)
def test_popover_shim_no_cache_hit_on_first_call(self):
ctx = self._get_popover_context(popover_content="y", wrapped_content="y")
components._render_cached("cotton/popover.html", ctx)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 0)
class TemplatetagRandomidTest(unittest.TestCase):
"""Test games/templatetags/randomid.py produces deterministic IDs."""
@@ -953,154 +905,60 @@ class ResolveNameWithIconTest(unittest.TestCase):
class SimpleTableRenderingTest(unittest.TestCase):
"""Test that c-simple-table renders rows correctly."""
"""Test that the Python SimpleTable() renders rows correctly."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
@staticmethod
def _tbody(result):
return result.split("<tbody")[1].split("</tbody>")[0]
def test_simple_table_renders_list_rows(self):
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Game", "Started", "Ended"],
"rows": [["Game1", "2025-01-01", "2025-03-01"]],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
result = str(
components.SimpleTable(
columns=["Game", "Started", "Ended"],
rows=[["Game1", "2025-01-01", "2025-03-01"]],
)
)
# body rows (not thead)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
tbody = self._tbody(result)
self.assertIn("<tr", tbody)
self.assertIn("Game1", tbody)
self.assertIn("2025-01-01", tbody)
self.assertIn("2025-03-01", tbody)
# first cell is <th scope="row">
self.assertIn("th scope=\"row\"", tbody)
# subsequent cells are <td>
# first cell is <th scope="row">, subsequent cells are <td>
self.assertIn('th scope="row"', tbody)
self.assertIn("<td", tbody)
def test_simple_table_empty_rows(self):
"""Verify empty rows list renders empty <tbody>."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Game", "Started"],
"rows": [],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
)
result = str(components.SimpleTable(columns=["Game", "Started"], rows=[]))
self.assertIn("<tbody", result)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
tbody = self._tbody(result)
self.assertNotIn("<tr", tbody)
self.assertNotIn("<td", tbody)
def test_simple_table_multiple_rows(self):
"""Verify multiple rows all render."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Game", "Started"],
"rows": [["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
result = str(
components.SimpleTable(
columns=["Game", "Started"],
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
)
)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
tbody = self._tbody(result)
self.assertIn("GameA", tbody)
self.assertIn("GameB", tbody)
self.assertIn("<tr", tbody)
# two separate <tr> elements
self.assertEqual(tbody.count("<tr"), 2)
def test_simple_table_dict_rows_with_cell_data(self):
"""Verify dict-style rows with row_id and cell_data render correctly."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Name", "Date"],
"rows": [
{
"row_id": "session-row-1",
"hx_trigger": "device-changed",
"cell_data": ["Game1", "2025-01-01"],
}
],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
self.assertIn('id="session-row-1"', tbody)
self.assertIn("device-changed", tbody)
self.assertIn("th scope=\"row\"", tbody)
self.assertIn("Game1", tbody)
self.assertIn("2025-01-01", tbody)
self.assertIn("2025-03-01", result)
def test_simple_table_empty_rows(self):
"""Verify empty rows list renders empty <tbody>."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Game", "Started"],
"rows": [],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
)
self.assertIn("<tbody", result)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
self.assertNotIn("<tr", tbody)
self.assertNotIn("<td", tbody)
def test_simple_table_multiple_rows(self):
"""Verify multiple rows all render."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Game", "Started"],
"rows": [["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
self.assertIn("GameA", tbody)
self.assertIn("GameB", tbody)
self.assertIn("<tr", tbody)
self.assertEqual(tbody.count("<tr"), 2)
def test_simple_table_header_action_as_caption(self):
"""Verify header_action renders inside <caption>."""
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
result = render_to_string(
"simple_table.html",
{
"columns": ["Game", "Started"],
"rows": [["Game1", "2025-01-01"]],
"header_action": mark_safe('<a href="/add">Add</a>'),
"page_obj": None,
"elided_page_range": None,
},
result = str(
components.SimpleTable(
columns=["Game", "Started"],
rows=[["Game1", "2025-01-01"]],
header_action=mark_safe('<a href="/add">Add</a>'),
)
)
self.assertIn("<caption", result)
self.assertIn('href="/add"', result)
@@ -1108,27 +966,22 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_dict_rows_with_cell_data(self):
"""Verify dict-style rows with row_id and cell_data render correctly."""
from django.template.loader import render_to_string
result = render_to_string(
"simple_table.html",
{
"columns": ["Name", "Date"],
"rows": [
result = str(
components.SimpleTable(
columns=["Name", "Date"],
rows=[
{
"row_id": "session-row-1",
"hx_trigger": "device-changed",
"cell_data": ["Game1", "2025-01-01"],
}
],
"header_action": None,
"page_obj": None,
"elided_page_range": None,
},
)
)
tbody = result.split("<tbody")[1].split("</tbody>")[0]
tbody = self._tbody(result)
self.assertIn('id="session-row-1"', tbody)
self.assertIn("device-changed", tbody)
self.assertIn("th scope=\"row\"", tbody)
self.assertIn('th scope="row"', tbody)
self.assertIn("Game1", tbody)
self.assertIn("2025-01-01", tbody)