Move from HTML templates to pure Python

Remove cruft
This commit is contained in:
2026-06-06 07:11:46 +02:00
parent 09db54e940
commit d101aecd70
109 changed files with 2903 additions and 2949 deletions
+121 -433
View File
@@ -1,143 +1,17 @@
import unittest
from functools import lru_cache
from unittest.mock import MagicMock, patch
import django
from django.template import TemplateDoesNotExist
from django.utils.safestring import SafeText, mark_safe
from common import components
from games.models import Platform, Game, Purchase, Session
class RenderCachedImplTest(unittest.TestCase):
"""Test _render_cached_impl renders templates correctly."""
def test_basic_render(self):
result = components._render_cached_impl(
"cotton/icon/play.html",
'{"slot": "", "title": "Play"}',
)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
def test_slot_marked_safe(self):
result = components._render_cached_impl(
"cotton/icon/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"}',
)
r2 = components._render_cached_impl(
"cotton/icon/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"}',
)
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",
attributes=[("title", "Play"), ("b", "2")],
)
r2 = Component(
template="cotton/icon/play.html",
attributes=[("b", "2"), ("title", "Play")],
)
self.assertEqual(r1, r2)
class RenderCachedLRUTest(unittest.TestCase):
"""Test LRU cache behavior of _render_cached when enabled."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
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"}',
)
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"}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.hits, 1)
self.assertEqual(info.misses, 1)
def test_cache_clear(self):
components._render_cached_impl(
"cotton/icon/play.html", '{"slot": "", "title": "Play"}',
)
components._render_cached.cache_clear()
info = components._render_cached.cache_info()
self.assertEqual(info.currsize, 0)
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": ""}',
)
components._render_cached(
"cotton/button.html",
'{"size": "base", "color": "red", "icon": false, "class": "hover:cursor-pointer", "slot": ""}',
)
info = components._render_cached.cache_info()
self.assertEqual(info.currsize, 2)
def test_cache_size_limited(self):
"""After exceeding maxsize, oldest entries are evicted."""
for i in range(5000):
components._render_cached_impl(
f"cotton/icon/play.html",
f'{{"slot": "", "title": "{i}"}}',
)
info = components._render_cached.cache_info()
self.assertLessEqual(info.currsize, 4096)
class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_template_component(self):
result = components.Component(
template="cotton/icon/play.html", attributes=[],
)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
def test_tag_name_component(self):
result = components.Component(
tag_name="div",
@@ -146,23 +20,34 @@ class ComponentIntegrationTest(unittest.TestCase):
)
self.assertEqual(result, '<div class="test">hello</div>')
def test_repeated_calls_identical(self):
r1 = components.Component(
template="cotton/icon/play.html", attributes=[],
)
r2 = components.Component(
template="cotton/icon/play.html", attributes=[],
)
self.assertEqual(r1, r2)
def test_different_components_different(self):
r1 = components.Component(
template="cotton/button.html", attributes=[("hx_get", "/url1")],
class ComponentCacheTest(unittest.TestCase):
"""Component rendering is memoized via _render_element."""
def setUp(self):
components._render_element.cache_clear()
def test_identical_components_hit_cache(self):
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
misses = components._render_element.cache_info().misses
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
info = components._render_element.cache_info()
self.assertEqual(info.misses, misses) # no new miss
self.assertGreaterEqual(info.hits, 1) # served from cache
def test_cache_is_bounded(self):
self.assertEqual(
components._render_element.cache_parameters()["maxsize"], 4096
)
r2 = components.Component(
template="cotton/button.html", attributes=[("hx_get", "/url2")],
)
self.assertNotEqual(r1, r2)
def test_safe_and_unsafe_children_do_not_collide(self):
"""A SafeText "<b>" and a plain "<b>" are equal as strings but must
render differently — the cache key must keep them distinct."""
safe = components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
unsafe = components.Component(tag_name="span", children=["<b>x</b>"])
self.assertIn("<b>x</b>", safe)
self.assertIn("&lt;b&gt;x&lt;/b&gt;", unsafe)
self.assertNotEqual(safe, unsafe)
class RandomidDeterministicTest(unittest.TestCase):
@@ -191,7 +76,9 @@ class RandomidDeterministicTest(unittest.TestCase):
def test_output_is_lowercase_alphanum(self):
result = components.randomid(content="test")
self.assertTrue(all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result))
self.assertTrue(
all(c in "abcdefghijklmnopqrstuvwxyz0123456789" for c in result)
)
def test_output_length_is_correct(self):
for length in [5, 10, 15, 20]:
@@ -209,6 +96,7 @@ class RandomidVsOldBehaviorTest(unittest.TestCase):
def _old_random_id(self, seed="", length=10):
from random import choices
from string import ascii_lowercase
return seed + "".join(choices(ascii_lowercase, k=length))
def test_old_random_produces_different_ids(self):
@@ -227,13 +115,6 @@ class RandomidVsOldBehaviorTest(unittest.TestCase):
class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_same_popover_same_id(self):
r1 = components.Popover("hello", wrapped_content="hello")
r2 = components.Popover("hello", wrapped_content="hello")
@@ -265,75 +146,33 @@ 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."""
def test_same_seed_same_id(self):
from games.templatetags import randomid
r1 = randomid.randomid(seed="foo")
r2 = randomid.randomid(seed="foo")
self.assertEqual(r1, r2)
def test_different_seed_different_id(self):
from games.templatetags import randomid
r1 = randomid.randomid(seed="foo")
r2 = randomid.randomid(seed="bar")
self.assertNotEqual(r1, r2)
def test_output_length_ten(self):
from games.templatetags import randomid
for seed in ["a", "hello", "test1234"]:
result = randomid.randomid(seed=seed)
self.assertEqual(len(result), 10)
def test_empty_seed_returns_hash(self):
from games.templatetags import randomid
result = randomid.randomid()
self.assertEqual(len(result), 10)
self.assertTrue(all(c in "abcdef0123456789" for c in result))
@@ -342,13 +181,6 @@ class TemplatetagRandomidTest(unittest.TestCase):
class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello")
self.assertIsInstance(result, SafeText)
@@ -362,7 +194,7 @@ class ComponentReturnTypeTest(unittest.TestCase):
def test_div_no_args(self):
result = components.Div(children="test")
self.assertIsInstance(result, SafeText)
self.assertIn('<div>test</div>', result)
self.assertIn("<div>test</div>", result)
def test_a_returns_safe_text(self):
result = components.A([], "link")
@@ -374,14 +206,15 @@ class ComponentReturnTypeTest(unittest.TestCase):
def test_a_url_name_reversed(self):
from unittest.mock import patch
with patch("common.components.reverse", return_value="/resolved/url"):
result = components.A([], "link", url_name="some_name")
self.assertIn('href="/resolved/url"', result)
def test_a_no_url_or_href(self):
result = components.A([], "link")
self.assertIn('<a>link</a>', result)
self.assertNotIn('href=', result)
self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
@@ -416,12 +249,14 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
("A", components.A(href="/foo", children=["link"])),
("Button", components.Button([], "click")),
("Div", components.Div([], ["hello"])),
("Form", components.Form(children=["x"])),
("Input", components.Input()),
("ButtonGroup", components.ButtonGroup([])),
("ButtonGroup with buttons", components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}]
)),
(
"ButtonGroup with buttons",
components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}]
),
),
("SearchField", components.SearchField()),
("PriceConverted", components.PriceConverted(["27 CZK"])),
("H1", components.H1(["Title"])),
@@ -435,7 +270,8 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_button_with_icon_children_not_escaped(self):
result = components.Button(
icon=True, size="xs",
icon=True,
size="xs",
children=[components.Icon("play"), "LOG"],
)
self.assertTrue(str(result).startswith("<button"))
@@ -445,7 +281,9 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
popover_content="test tooltip",
children=[
components.Button(
icon=True, color="gray", size="xs",
icon=True,
color="gray",
size="xs",
children=[components.Icon("play"), "test"],
),
],
@@ -460,19 +298,17 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
class ComponentEdgeCasesTest(unittest.TestCase):
"""Test Component() edge cases and error handling."""
def test_no_template_or_tag_name_raises(self):
def test_no_tag_name_raises(self):
with self.assertRaises(ValueError) as ctx:
components.Component(children="hello")
self.assertIn("template or tag_name", str(ctx.exception))
self.assertIn("tag_name", str(ctx.exception))
def test_single_string_children_wrapped(self):
result = components.Component(tag_name="span", children="hello")
self.assertIn("hello", result)
def test_multiple_children_joined_with_newlines(self):
result = components.Component(
tag_name="div", children=["hello", "world"]
)
result = components.Component(tag_name="div", children=["hello", "world"])
self.assertIn("hello\nworld", result)
self.assertIn("<div>", result)
self.assertIn("</div>", result)
@@ -523,13 +359,6 @@ class ComponentEdgeCasesTest(unittest.TestCase):
class IconTest(unittest.TestCase):
"""Test Icon() component function."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_valid_icon_renders_svg(self):
result = components.Icon("play")
self.assertIsInstance(result, SafeText)
@@ -550,33 +379,12 @@ class IconTest(unittest.TestCase):
self.assertIsInstance(result, SafeText)
class FormInputTest(unittest.TestCase):
"""Test Form(), Input(), and Div() functions."""
def test_form_default_method_get(self):
result = components.Form()
self.assertIn('method="get"', result)
self.assertIn('<form', result)
def test_form_post_method(self):
result = components.Form(method="post")
self.assertIn('method="post"', result)
self.assertIn('<form', result)
def test_form_action(self):
result = components.Form(action="/submit/")
self.assertIn('action="/submit/"', result)
def test_form_children_rendered(self):
child = components.Input(type="text", attributes=[("name", "email")])
result = components.Form(children=[child])
self.assertIn('<input', result)
self.assertIn('type="text"', result)
self.assertIn('name="email"', result)
class InputTest(unittest.TestCase):
"""Test the Input() component."""
def test_input_default_type_text(self):
result = components.Input()
self.assertIn('<input', result)
self.assertIn("<input", result)
self.assertIn('type="text"', result)
def test_input_custom_type(self):
@@ -584,7 +392,9 @@ class FormInputTest(unittest.TestCase):
self.assertIn('type="submit"', result)
def test_input_attributes_merged_with_type(self):
result = components.Input(type="email", attributes=[("id", "email"), ("class", "form-input")])
result = components.Input(
type="email", attributes=[("id", "email"), ("class", "form-input")]
)
self.assertIn('type="email"', result)
self.assertIn('id="email"', result)
self.assertIn('class="form-input"', result)
@@ -593,13 +403,6 @@ class FormInputTest(unittest.TestCase):
class PopoverTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated() component function."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_short_string_no_popover(self):
result = components.PopoverTruncated("hi")
self.assertEqual(result, "hi")
@@ -660,20 +463,6 @@ class PopoverTruncatedTest(unittest.TestCase):
self.assertIn("data-popover-target", result)
class EnableCacheTest(unittest.TestCase):
"""Test enable_cache() function."""
def test_wraps_with_lru_cache(self):
components.enable_cache()
# Should have cache_info method
self.assertTrue(hasattr(components._render_cached, "cache_info"))
def test_cache_has_correct_maxsize(self):
components.enable_cache()
params = components._render_cached.cache_parameters()
self.assertEqual(params["maxsize"], 4096)
class ModelDependentComponentsTest(django.test.TestCase):
"""Test components that depend on Django models."""
@@ -781,7 +570,8 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Game A")
game2 = self._create_game(platform, name="Game B")
purchase = self._create_purchase(
[game1, game2], price=24.99,
[game1, game2],
price=24.99,
)
purchase.name = "Bundle"
purchase.save()
@@ -803,13 +593,6 @@ class ModelDependentComponentsTest(django.test.TestCase):
class PurchaseTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated with endpart edge cases."""
def setUp(self):
components.enable_cache()
components._render_cached.cache_clear()
def tearDown(self):
components._render_cached = components._render_cached_impl
def test_endpart_shorter_than_length(self):
text = "a" * 50
result = components.PopoverTruncated(text, length=10, endpart="x")
@@ -837,9 +620,7 @@ class NameWithIconPlatformTest(django.test.TestCase):
cls.game = Game.objects.create(name="Zelda", platform=cls.platform)
def test_name_with_icon_shows_platform_icon(self):
result = components.NameWithIcon(
name="Zelda", game=self.game, linkify=True
)
result = components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
self.assertIsInstance(result, SafeText)
self.assertIn("Zelda", result)
@@ -871,8 +652,10 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.mock_session.pk = 1
def test_session_provides_game_and_emulated(self):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, self.mock_session, True
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon(
"", self.mock_game, self.mock_session, True
)
)
self.assertEqual(name, "Test Game")
self.assertIs(platform, self.mock_platform)
@@ -884,16 +667,18 @@ class ResolveNameWithIconTest(unittest.TestCase):
override_game.platform = self.mock_platform
override_game.pk = 99
with patch("common.components.reverse", return_value="/game/99"):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", override_game, self.mock_session, True
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon(
"", override_game, self.mock_session, True
)
)
self.assertEqual(name, "Test Game")
self.assertIsNot(name, "Override")
def test_game_only_provides_platform(self):
with patch("common.components.reverse", return_value="/game/1"):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, None, True
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, True)
)
self.assertEqual(name, "Test Game")
self.assertIs(platform, self.mock_platform)
@@ -901,36 +686,36 @@ class ResolveNameWithIconTest(unittest.TestCase):
self.assertEqual(link, "/game/1")
def test_custom_name_overrides_game_name(self):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"Custom", self.mock_game, None, False
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("Custom", self.mock_game, None, False)
)
self.assertEqual(name, "Custom")
def test_empty_name_falls_back_to_game_name(self):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, None, False
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, False)
)
self.assertEqual(name, "Test Game")
def test_no_game_no_session_returns_empty_name(self):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", None, None, False
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", None, None, False)
)
self.assertEqual(name, "")
self.assertIsNone(platform)
self.assertFalse(create_link)
def test_linkify_false_no_link_created(self):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, None, False
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, False)
)
self.assertFalse(create_link)
self.assertEqual(link, "")
def test_linkify_true_creates_link(self):
with patch("common.components.reverse", return_value="/game/42"):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, None, True
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, True)
)
self.assertTrue(create_link)
self.assertEqual(link, "/game/42")
@@ -940,167 +725,75 @@ class ResolveNameWithIconTest(unittest.TestCase):
emulated_session.game = self.mock_game
emulated_session.emulated = True
emulated_session.pk = 1
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, emulated_session, False
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon(
"", self.mock_game, emulated_session, False
)
)
self.assertTrue(emulated)
def test_game_emulated_default_false(self):
name, platform, emulated, create_link, link = components._resolve_name_with_icon(
"", self.mock_game, None, False
name, platform, emulated, create_link, link = (
components._resolve_name_with_icon("", self.mock_game, None, False)
)
self.assertFalse(emulated)
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 +801,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>"))