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)
+4 -4
View File
@@ -26,9 +26,9 @@ class MiddlewareIntegrationTest(TestCase):
self.client = Client()
self.user = self._create_user()
self.client.force_login(self.user)
pl = Platform(name="Test Platform")
pl.save()
self.game = Game(name="Test Game", platform=pl)
self.platform = Platform(name="Test Platform")
self.platform.save()
self.game = Game(name="Test Game", platform=self.platform)
self.game.save()
def test_non_htmx_request_with_message_gets_hx_trigger(self):
@@ -82,7 +82,7 @@ class MiddlewareIntegrationTest(TestCase):
"""
purchase = Purchase.objects.create(
date_purchased=datetime(2023, 1, 1),
platform=Platform.objects.first() or pl,
platform=self.platform,
)
purchase.games.set([self.game])
response = self.client.post(
+1 -1
View File
@@ -6,7 +6,7 @@ from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from games.models import Game, Platform, Purchase, Session
from games.models import Game, Platform, Purchase
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
+291
View File
@@ -0,0 +1,291 @@
"""Rendered-HTML assertions for pages converted to the Python layout/components.
These go beyond `test_paths_return_200`: they assert that the `Page()` document
wrapper and the Python component bodies emit the right structure, and — most
importantly — that nothing is double-escaped (the recurring failure mode when a
`SafeText` loses its safe marker and renders as `&lt;tag&gt;`).
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from games.models import Game, GameStatusChange, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
# If any of these appear in output, a SafeText lost its safe marker somewhere.
_ESCAPED_TAG_MARKERS = [
"&lt;a",
"&lt;div",
"&lt;span",
"&lt;button",
"&lt;input",
"&lt;li",
]
class RenderedPagesTest(TestCase):
def setUp(self) -> None:
self.user = User.objects.create_superuser(
username="testuser", email="test@example.com", password="testpass"
)
self.client.force_login(self.user)
self.platform = Platform.objects.create(name="Test Platform", icon="test")
self.game = Game.objects.create(name="Test Game", platform=self.platform)
self.purchase = Purchase.objects.create(
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
platform=self.platform,
)
self.purchase.games.add(self.game)
self.session = Session.objects.create(
game=self.game,
timestamp_start=datetime(2022, 9, 26, 15, 0, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 16, 0, tzinfo=ZONEINFO),
)
def get(self, url_name, *args):
return self.client.get(reverse(url_name, args=args), follow=True)
def assertNoEscapedTags(self, html):
for marker in _ESCAPED_TAG_MARKERS:
self.assertNotIn(
marker, html, f"Found double-escaped markup ({marker!r}) in output"
)
# --- layout wrapper ------------------------------------------------------
def test_page_layout_wrapper(self):
"""A converted page is wrapped in the full Page() document."""
html = self.get("games:list_playevents").content.decode()
for marker in [
"<!DOCTYPE html>",
"<nav",
'id="main-container"',
'id="global-modal-container"',
"toastStore()",
"</html>",
]:
self.assertIn(marker, html)
self.assertIn("Timetracker - Manage play events", html)
# --- list pages ----------------------------------------------------------
def test_list_pages_render_table_unescaped(self):
for url_name in [
"games:list_games",
"games:list_purchases",
"games:list_sessions",
"games:list_platforms",
"games:list_devices",
"games:list_playevents",
]:
with self.subTest(url_name=url_name):
html = self.get(url_name).content.decode()
self.assertIn("<table", html)
self.assertNoEscapedTags(html)
def test_session_list_keeps_inline_edit_attributes(self):
html = self.get("games:list_sessions").content.decode()
self.assertIn(f"session-row-{self.session.pk}", html)
self.assertIn("device-changed from:body", html)
# --- generic forms -------------------------------------------------------
def test_generic_form_pages(self):
for url_name in ["games:add_device", "games:add_platform"]:
with self.subTest(url_name=url_name):
html = self.get(url_name).content.decode()
self.assertIn("csrfmiddlewaretoken", html)
self.assertIn("<form", html)
self.assertIn('type="submit"', html)
self.assertNoEscapedTags(html)
# --- specialized forms ---------------------------------------------------
def test_add_game_form(self):
html = self.get("games:add_game").content.decode()
self.assertIn("add_game.js", html)
self.assertIn("submit_and_redirect", html)
self.assertIn("Submit &amp; Create Purchase", html) # & correctly escaped
self.assertNoEscapedTags(html)
def test_add_purchase_form(self):
html = self.get("games:add_purchase").content.decode()
self.assertIn("add_purchase.js", html)
self.assertIn("Submit &amp; Create Session", html)
self.assertIn("<tr>", html)
self.assertNoEscapedTags(html)
def test_add_session_form_has_timestamp_helpers(self):
html = self.get("games:add_session").content.decode()
self.assertIn("add_session.js", html)
for marker in [
"Set to now",
"Toggle text",
"Copy start value to end",
"Copy end value to start",
'data-target="timestamp_start"',
'data-type="now"',
'hx-boost="false"',
]:
self.assertIn(marker, html)
self.assertNoEscapedTags(html)
# --- detail pages --------------------------------------------------------
def test_view_game(self):
html = self.get("games:view_game", self.game.id).content.decode()
for marker in [
'id="game-info"',
"font-bold font-serif",
self.game.name,
"Total hours played", # stat popover tooltip
'id="popover-hours"',
"Original year",
"Status",
"Played",
"Platform",
'id="history-container"',
"status-changed from:body",
"createPlayEvent", # the played-row Alpine dropdown script
'hx-target="#global-modal-container"', # delete trigger
"Purchases",
"Sessions",
"Play Events",
"History",
]:
self.assertIn(marker, html)
self.assertNoEscapedTags(html)
self.assertEqual(html.count("<div"), html.count("</div>"))
def test_view_game_empty_sections(self):
"""A game with no sessions/purchases/etc shows the empty messages."""
lonely = Game.objects.create(name="Lonely Game", platform=self.platform)
html = self.get("games:view_game", lonely.id).content.decode()
for marker in [
"No purchases yet.",
"No sessions yet.",
"No play events yet.",
]:
self.assertIn(marker, html)
self.assertNoEscapedTags(html)
# --- HTMX fragments ------------------------------------------------------
def test_delete_game_confirmation_modal(self):
html = self.get("games:delete_game_confirmation", self.game.id).content.decode()
# A fragment (no full-page layout).
self.assertNotIn("<!DOCTYPE html>", html)
self.assertIn('id="delete-game-confirmation-modal"', html)
self.assertIn("hx-post", html)
self.assertIn(self.game.name, html)
self.assertIn("session(s)", html) # seeded session
self.assertIn("purchase(s)", html) # seeded purchase
self.assertNoEscapedTags(html)
def test_refund_confirmation_modal(self):
html = self.get(
"games:refund_purchase_confirmation", self.purchase.id
).content.decode()
self.assertIn('id="refund-confirmation-modal"', html)
self.assertIn(f"#purchase-row-{self.purchase.id}", html)
self.assertIn("Refund", html)
self.assertNoEscapedTags(html)
def test_session_row_fragment_via_htmx(self):
# The inline "finish session" endpoint returns a <tr> fragment.
resp = self.client.get(
reverse("games:list_sessions_end_session", args=[self.session.id]),
HTTP_HX_REQUEST="true",
)
html = resp.content.decode()
self.assertTrue(html.lstrip().startswith("<tr"))
self.assertIn(self.game.name, html)
self.assertNoEscapedTags(html)
# --- statuschange --------------------------------------------------------
def test_statuschange_list_and_delete(self):
change = GameStatusChange.objects.create(
game=self.game,
new_status="f",
timestamp=self.session.timestamp_start,
)
list_html = self.get("games:list_statuschanges").content.decode()
self.assertIn("<table", list_html)
self.assertIn(self.game.name, list_html)
self.assertNoEscapedTags(list_html)
confirm_html = self.get("games:delete_statuschange", change.id).content.decode()
self.assertIn(
"Are you sure you want to delete this status change?", confirm_html
)
self.assertIn("Delete", confirm_html)
self.assertIn("Cancel", confirm_html)
self.assertNoEscapedTags(confirm_html)
# --- login ---------------------------------------------------------------
def test_login_page(self):
from django.test import Client
anon = Client() # unauthenticated
html = anon.get(reverse("login")).content.decode()
for marker in [
"<!DOCTYPE html>", # full Page() layout
"Please log in to continue",
"csrfmiddlewaretoken",
'type="submit"',
'value="Login"',
"</html>",
]:
self.assertIn(marker, html)
self.assertIn("Timetracker - Login", html)
self.assertNoEscapedTags(html)
# --- stats ---------------------------------------------------------------
def test_stats_alltime(self):
html = self.get("games:stats_alltime").content.decode()
for marker in [
'id="yearSelect"',
"responsive-table",
"Playtime",
"Purchases",
"Games by playtime",
"Platforms by playtime",
]:
self.assertIn(marker, html)
self.assertNoEscapedTags(html)
self.assertEqual(html.count("<table"), html.count("</table>"))
def test_stats_by_year(self):
year = self.session.timestamp_start.year
html = self.get("games:stats_by_year", year).content.decode()
# The seeded game/session/purchase should surface in the year view.
self.assertIn("Playtime per month", html)
self.assertIn(self.game.name, html)
self.assertNoEscapedTags(html)
self.assertEqual(html.count("<table"), html.count("</table>"))
def test_view_purchase(self):
html = self.get("games:view_purchase", self.purchase.id).content.decode()
for marker in [
"dark:text-white max-w-sm",
"font-bold font-serif",
"Owned on",
"Price per game:",
"decoration-dotted underline",
"Games included in this purchase:",
"<ul>",
"<li>",
]:
self.assertIn(marker, html)
self.assertNoEscapedTags(html)
# The Python builder emits well-formed, balanced markup.
self.assertEqual(html.count("<div"), html.count("</div>"))