Render nodes explicitly in component tests; drop the proxy/shims

The component tests rendered lazy nodes to HTML through two competing pieces
of scaffolding: a magic ``_RenderingComponents.__getattr__`` proxy that
auto-str()'d any capitalized builder, plus separate ``str()`` wrapper
functions for Checkbox / Radio (test_components) and SearchSelect /
FilterSelect / Pill (test_search_select).

Replace both with one explicit convention: import the real components and
wrap node-returning calls in ``str(...)`` at the call site. ``Node.__str__``
returns a ``SafeText``, so the ``assertIsInstance(..., SafeText)`` checks stay
meaningful and every string assertion is unchanged. Non-node helpers
(``randomid``, ``_resolve_name_with_icon``, ``_render_element``, the legacy
string ``Component()``) are called directly.

No production code touched; 141 component/search-select tests and the full
444-test suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:17:50 +02:00
parent 022d43a5a5
commit bec7a1074c
2 changed files with 298 additions and 262 deletions
+153 -128
View File
@@ -2,48 +2,30 @@ import unittest
from unittest.mock import MagicMock, patch
import django
from django.test import SimpleTestCase
from django.utils.safestring import SafeText, mark_safe
from common import components as _components
from common.components.core import Node
from common import components
from games.models import Platform, Game, Purchase, Session
class _RenderingComponents:
"""Test accessor that renders lazy component nodes to safe HTML strings.
Component builders now return ``Node`` objects (the lazy tree). These tests
assert on rendered HTML, so we render any node a capitalized builder returns
to a ``SafeText`` string. Internals (``_render_element``) and the legacy
string-returning ``Component()`` are untouched (non-node results pass
through), so cache/escaping tests keep working unchanged.
"""
def __getattr__(self, name):
attr = getattr(_components, name)
if not (callable(attr) and name[:1].isupper()):
return attr
def rendered(*args, **kwargs):
result = attr(*args, **kwargs)
return str(result) if isinstance(result, Node) else result
return rendered
components = _RenderingComponents()
# Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
# ``_resolve_name_with_icon``, the legacy string ``Component()``) are called
# directly.
class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent."""
def test_tag_name_component(self):
result = components.Component(
result = str(
components.Component(
tag_name="div",
attributes=[("class", "test")],
children="hello",
)
)
self.assertEqual(result, '<div class="test">hello</div>')
@@ -54,9 +36,17 @@ class ComponentCacheTest(unittest.TestCase):
components._render_element.cache_clear()
def test_identical_components_hit_cache(self):
components.Component(tag_name="div", attributes=[("class", "x")], children="hi")
str(
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")
str(
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
@@ -67,8 +57,10 @@ class ComponentCacheTest(unittest.TestCase):
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>"])
safe = str(
components.Component(tag_name="span", children=[mark_safe("<b>x</b>")])
)
unsafe = str(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)
@@ -140,33 +132,37 @@ class PopoverDeterministicTest(unittest.TestCase):
"""Test that Popover() produces deterministic HTML output."""
def test_same_popover_same_id(self):
r1 = components.Popover("hello", wrapped_content="hello")
r2 = components.Popover("hello", wrapped_content="hello")
r1 = str(components.Popover("hello", wrapped_content="hello"))
r2 = str(components.Popover("hello", wrapped_content="hello"))
self.assertEqual(r1, r2)
def test_different_content_different_id(self):
r1 = components.Popover("content_a", wrapped_content="content_a")
r2 = components.Popover("content_b", wrapped_content="content_b")
r1 = str(components.Popover("content_a", wrapped_content="content_a"))
r2 = str(components.Popover("content_b", wrapped_content="content_b"))
self.assertNotEqual(r1, r2)
def test_wrapped_classes_affect_id(self):
r1 = components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
r2 = components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
r1 = str(
components.Popover("c", wrapped_content="c", wrapped_classes="class_x")
)
r2 = str(
components.Popover("c", wrapped_content="c", wrapped_classes="class_y")
)
self.assertNotEqual(r1, r2)
def test_wrapped_content_affects_id(self):
r1 = components.Popover("popover", wrapped_content="wrapped_a")
r2 = components.Popover("popover", wrapped_content="wrapped_b")
r1 = str(components.Popover("popover", wrapped_content="wrapped_a"))
r2 = str(components.Popover("popover", wrapped_content="wrapped_b"))
self.assertNotEqual(r1, r2)
def test_popover_content_affects_id(self):
r1 = components.Popover("popover_a", wrapped_content="wrapped")
r2 = components.Popover("popover_b", wrapped_content="wrapped")
r1 = str(components.Popover("popover_a", wrapped_content="wrapped"))
r2 = str(components.Popover("popover_b", wrapped_content="wrapped"))
self.assertNotEqual(r1, r2)
def test_full_html_deterministic(self):
r1 = components.Popover("hello world", wrapped_content="hello world")
r2 = components.Popover("hello world", wrapped_content="hello world")
r1 = str(components.Popover("hello world", wrapped_content="hello world"))
r2 = str(components.Popover("hello world", wrapped_content="hello world"))
self.assertEqual(r1.encode(), r2.encode())
@@ -206,26 +202,26 @@ class ComponentReturnTypeTest(unittest.TestCase):
"""Test that component functions return SafeText and render correctly."""
def test_div_returns_safe_text(self):
result = components.Div([("class", "x")], "hello")
result = str(components.Div([("class", "x")], "hello"))
self.assertIsInstance(result, SafeText)
def test_div_deterministic(self):
r1 = components.Div([("class", "x")], "hello")
r2 = components.Div([("class", "x")], "hello")
r1 = str(components.Div([("class", "x")], "hello"))
r2 = str(components.Div([("class", "x")], "hello"))
self.assertEqual(r1, r2)
self.assertIn('<div class="x">hello</div>', r1)
def test_div_no_args(self):
result = components.Div(children="test")
result = str(components.Div(children="test"))
self.assertIsInstance(result, SafeText)
self.assertIn("<div>test</div>", result)
def test_a_returns_safe_text(self):
result = components.A([], "link")
result = str(components.A([], "link"))
self.assertIsInstance(result, SafeText)
def test_a_literal_href(self):
result = components.A([], "x", href="/literal/path")
result = str(components.A([], "x", href="/literal/path"))
self.assertIn('href="/literal/path"', result)
def test_a_url_name_reversed(self):
@@ -234,35 +230,35 @@ class ComponentReturnTypeTest(unittest.TestCase):
with patch(
"common.components.primitives.reverse", return_value="/resolved/url"
):
result = components.A([], "link", url_name="some_name")
result = str(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")
result = str(components.A([], "link"))
self.assertIn("<a>link</a>", result)
self.assertNotIn("href=", result)
def test_a_both_url_name_and_href_raises(self):
with self.assertRaises(ValueError):
components.A(href="/path", url_name="some_name")
str(components.A(href="/path", url_name="some_name"))
def test_button_returns_safe_text(self):
result = components.Button([], "click")
result = str(components.Button([], "click"))
self.assertIsInstance(result, SafeText)
self.assertIn("<button", result)
def test_button_default_colors(self):
result = components.Button([], "click")
result = str(components.Button([], "click"))
self.assertIn("text-white bg-brand", result)
def test_name_with_icon_no_link(self):
result = components.NameWithIcon(name="Game", linkify=False)
result = str(components.NameWithIcon(name="Game", linkify=False))
self.assertIsInstance(result, SafeText)
self.assertIn("Game", result)
self.assertNotIn("<a ", result)
def test_name_with_icon_no_trailing_comma(self):
result = components.NameWithIcon(name="Test", linkify=False)
result = str(components.NameWithIcon(name="Test", linkify=False))
self.assertIsInstance(result, SafeText)
self.assertNotIsInstance(result, tuple)
@@ -272,21 +268,23 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
def test_component_output_starts_with_tag(self):
for label, html in [
("A", components.A(href="/foo", children=["link"])),
("Button", components.Button([], "click")),
("Div", components.Div([], ["hello"])),
("Input", components.Input()),
("ButtonGroup", components.ButtonGroup([])),
("A", str(components.A(href="/foo", children=["link"]))),
("Button", str(components.Button([], "click"))),
("Div", str(components.Div([], ["hello"]))),
("Input", str(components.Input())),
("ButtonGroup", str(components.ButtonGroup([]))),
(
"ButtonGroup with buttons",
str(
components.ButtonGroup(
[{"href": "/", "slot": components.Icon("edit")}]
)
),
),
("SearchField", components.SearchField()),
("PriceConverted", components.PriceConverted(["27 CZK"])),
("H1", components.H1(["Title"])),
("H1 with badge", components.H1(["Title"], badge="3")),
("SearchField", str(components.SearchField())),
("PriceConverted", str(components.PriceConverted(["27 CZK"]))),
("H1", str(components.H1(["Title"]))),
("H1 with badge", str(components.H1(["Title"], badge="3"))),
]:
with self.subTest(component=label):
self.assertTrue(
@@ -295,15 +293,18 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
)
def test_button_with_icon_children_not_escaped(self):
result = components.Button(
result = str(
components.Button(
icon=True,
size="xs",
children=[components.Icon("play"), "LOG"],
)
)
self.assertTrue(str(result).startswith("<button"))
def test_popover_with_button_children_not_escaped(self):
result = components.Popover(
result = str(
components.Popover(
popover_content="test tooltip",
children=[
components.Button(
@@ -314,10 +315,11 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
),
],
)
)
self.assertTrue(str(result).startswith("<span data-popover-target"))
def test_name_with_icon_output_not_escaped(self):
result = components.NameWithIcon(name="Test", linkify=False)
result = str(components.NameWithIcon(name="Test", linkify=False))
self.assertTrue(str(result).startswith("<div"))
@@ -326,59 +328,67 @@ class ComponentEdgeCasesTest(unittest.TestCase):
def test_no_tag_name_raises(self):
with self.assertRaises(ValueError) as ctx:
components.Component(children="hello")
str(components.Component(children="hello"))
self.assertIn("tag_name", str(ctx.exception))
def test_single_string_children_wrapped(self):
result = components.Component(tag_name="span", children="hello")
result = str(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 = str(components.Component(tag_name="div", children=["hello", "world"]))
self.assertIn("hello\nworld", result)
self.assertIn("<div>", result)
self.assertIn("</div>", result)
def test_raw_html_children_are_escaped(self):
result = components.Component(
result = str(
components.Component(
tag_name="div", children=["<script>alert('xss')</script>"]
)
)
self.assertNotIn("<script>", result)
self.assertIn("&lt;script&gt;", result)
def test_mark_safe_children_pass_through(self):
result = components.Component(
result = str(
components.Component(
tag_name="div", children=[mark_safe("<span>safe</span>")]
)
)
self.assertIn("<span>safe</span>", result)
def test_attribute_values_are_escaped(self):
result = components.Component(
result = str(
components.Component(
tag_name="div",
attributes=[("data-x", 'foo"bar')],
)
)
self.assertIn("&quot;", result)
self.assertNotIn('"foo"bar"', result)
def test_attributes_serialized_correctly(self):
result = components.Component(
result = str(
components.Component(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
)
)
self.assertIn('class="foo"', result)
self.assertIn('id="bar"', result)
def test_empty_attributes_no_extra_space(self):
result = components.Component(tag_name="span", children="x")
result = str(components.Component(tag_name="span", children="x"))
self.assertEqual(result, "<span>x</span>")
self.assertNotIn(" <span", result)
def test_non_string_children_not_supported(self):
"""Component only accepts str for children, not integers."""
result = components.Component(tag_name="span", children=str(42))
result = str(components.Component(tag_name="span", children=str(42)))
self.assertIn("42", result)
def test_returns_safetext(self):
result = components.Component(tag_name="div", children="test")
result = str(components.Component(tag_name="div", children="test"))
self.assertIsInstance(result, SafeText)
@@ -386,22 +396,22 @@ class IconTest(unittest.TestCase):
"""Test Icon() component function."""
def test_valid_icon_renders_svg(self):
result = components.Icon("play")
result = str(components.Icon("play"))
self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result)
self.assertIn("</svg>", result)
def test_unavailable_icon_falls_back(self):
result = components.Icon("zzz_nonexistent_platform")
result = str(components.Icon("zzz_nonexistent_platform"))
self.assertIsInstance(result, SafeText)
self.assertIn("<svg", result)
def test_icon_passes_attributes_to_template(self):
result = components.Icon("play", attributes=[("title", "Play")])
result = str(components.Icon("play", attributes=[("title", "Play")]))
self.assertIsInstance(result, SafeText)
def test_returns_safetext(self):
result = components.Icon("delete")
result = str(components.Icon("delete"))
self.assertIsInstance(result, SafeText)
@@ -409,18 +419,20 @@ class InputTest(unittest.TestCase):
"""Test the Input() component."""
def test_input_default_type_text(self):
result = components.Input()
result = str(components.Input())
self.assertIn("<input", result)
self.assertIn('type="text"', result)
def test_input_custom_type(self):
result = components.Input(type="submit")
result = str(components.Input(type="submit"))
self.assertIn('type="submit"', result)
def test_input_attributes_merged_with_type(self):
result = components.Input(
result = str(
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)
@@ -430,12 +442,12 @@ class PopoverTruncatedTest(unittest.TestCase):
"""Test PopoverTruncated() component function."""
def test_short_string_no_popover(self):
result = components.PopoverTruncated("hi")
result = str(components.PopoverTruncated("hi"))
self.assertEqual(result, "hi")
def test_long_string_wrapped_in_popover(self):
long_text = "a" * 100
result = components.PopoverTruncated(long_text)
result = str(components.PopoverTruncated(long_text))
# Should NOT equal the truncated form directly
truncated = components.truncate(long_text, 30)
self.assertNotEqual(result, truncated)
@@ -444,47 +456,55 @@ class PopoverTruncatedTest(unittest.TestCase):
def test_custom_ellipsis_used(self):
long_text = "a" * 50
result = components.PopoverTruncated(long_text, ellipsis=">>")
result = str(components.PopoverTruncated(long_text, ellipsis=">>"))
# Django template escapes >> to &gt;&gt; in the wrapped_content
self.assertIn("&gt;&gt;", result)
def test_popover_if_not_truncated_flag(self):
short_text = "hi"
result = components.PopoverTruncated(
short_text, popover_content="full content", popover_if_not_truncated=True
result = str(
components.PopoverTruncated(
short_text,
popover_content="full content",
popover_if_not_truncated=True,
)
)
# Should be wrapped in popover even though short
self.assertNotEqual(result, "hi")
self.assertIn("data-popover-target", result)
def test_popover_content_override(self):
result = components.PopoverTruncated("short", popover_content="custom popover")
result = str(
components.PopoverTruncated("short", popover_content="custom popover")
)
# With popover_if_not_truncated=False (default), short text returns as-is
self.assertEqual(result, "short")
def test_popover_content_override_with_flag(self):
result = components.PopoverTruncated(
result = str(
components.PopoverTruncated(
"short", popover_content="custom popover", popover_if_not_truncated=True
)
)
self.assertIn("custom popover", result)
def test_endpart_visible_in_output(self):
long_text = "a" * 50
result = components.PopoverTruncated(long_text, endpart="...")
result = str(components.PopoverTruncated(long_text, endpart="..."))
self.assertIn("...", result)
def test_returns_safetext(self):
result = components.PopoverTruncated("a" * 100)
result = str(components.PopoverTruncated("a" * 100))
self.assertIsInstance(result, SafeText)
def test_default_length(self):
text = "a" * 31
result = components.PopoverTruncated(text)
result = str(components.PopoverTruncated(text))
# 31 chars exceeds default length of 30, so should be truncated
self.assertIn("data-popover-target", result)
def test_length_zero(self):
result = components.PopoverTruncated("hello", length=0)
result = str(components.PopoverTruncated("hello", length=0))
# Even empty length triggers popover for any content
self.assertIn("data-popover-target", result)
@@ -516,7 +536,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_linkify_with_game(self):
platform = self._create_platform(name="Steam", icon="steam")
game = self._create_game(platform)
result = components.NameWithIcon(game=game, linkify=True)
result = str(components.NameWithIcon(game=game, linkify=True))
self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result)
self.assertIn("Test Game", result)
@@ -525,7 +545,9 @@ class ModelDependentComponentsTest(django.test.TestCase):
def test_name_with_icon_no_linkify(self):
platform = self._create_platform(name="GOG", icon="gog")
game = self._create_game(platform)
result = components.NameWithIcon(name="Test Game", game=game, linkify=False)
result = str(
components.NameWithIcon(name="Test Game", game=game, linkify=False)
)
self.assertIsInstance(result, SafeText)
self.assertNotIn("<a ", result)
self.assertIn("Test Game", result)
@@ -538,13 +560,13 @@ class ModelDependentComponentsTest(django.test.TestCase):
timestamp_start="2025-01-01 00:00:00+00:00",
emulated=True,
)
result = components.NameWithIcon(session=session, linkify=True)
result = str(components.NameWithIcon(session=session, linkify=True))
self.assertIsInstance(result, SafeText)
self.assertIn("<a ", result)
self.assertIn("Emulated", result)
def test_name_with_icon_no_platform(self):
result = components.NameWithIcon(name="Standalone", linkify=False)
result = str(components.NameWithIcon(name="Standalone", linkify=False))
self.assertIsInstance(result, SafeText)
self.assertIn("Standalone", result)
@@ -555,7 +577,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game=game,
timestamp_start="2025-01-01 00:00:00+00:00",
)
result = components.NameWithIcon(session=session, linkify=True)
result = str(components.NameWithIcon(session=session, linkify=True))
self.assertIsInstance(result, SafeText)
self.assertIn("Epic Game", result)
@@ -563,7 +585,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform()
game = self._create_game(platform)
purchase = self._create_purchase([game], price=29.99)
result = components.PurchasePrice(purchase)
result = str(components.PurchasePrice(purchase))
self.assertIsInstance(result, SafeText)
# floatformat rounds to 1 decimal: 29.99 -> 30.0
self.assertIn("30.0", result)
@@ -574,7 +596,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
platform = self._create_platform(icon="steam")
game = self._create_game(platform, name="Single Game")
purchase = self._create_purchase([game], price=14.99)
result = components.LinkedPurchase(purchase)
result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText)
self.assertIn("Single Game", result)
self.assertIn("<a ", result)
@@ -585,7 +607,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Game One")
game2 = self._create_game(platform, name="Game Two")
purchase = self._create_purchase([game1, game2], price=24.99)
result = components.LinkedPurchase(purchase)
result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText)
self.assertIn("2 games", result)
self.assertIn("<a ", result)
@@ -601,7 +623,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
)
purchase.name = "Bundle"
purchase.save()
result = components.LinkedPurchase(purchase)
result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText)
self.assertIn("Bundle", result)
@@ -610,7 +632,7 @@ class ModelDependentComponentsTest(django.test.TestCase):
game1 = self._create_game(platform, name="Alpha")
game2 = self._create_game(platform, name="Beta")
purchase = self._create_purchase([game1, game2], price=19.99)
result = components.LinkedPurchase(purchase)
result = str(components.LinkedPurchase(purchase))
self.assertIsInstance(result, SafeText)
self.assertIn("Alpha", result)
self.assertIn("Beta", result)
@@ -621,18 +643,18 @@ class PurchaseTruncatedTest(unittest.TestCase):
def test_endpart_shorter_than_length(self):
text = "a" * 50
result = components.PopoverTruncated(text, length=10, endpart="x")
result = str(components.PopoverTruncated(text, length=10, endpart="x"))
# endpart=x takes 1 char, so content gets truncated at 9 chars
self.assertIn("data-popover-target", result)
self.assertIn("x", result)
def test_no_truncation_no_ellipsis(self):
result = components.PopoverTruncated("short text")
result = str(components.PopoverTruncated("short text"))
self.assertEqual(result, "short text")
def test_custom_length(self):
text = "hello world"
result = components.PopoverTruncated(text, length=6)
result = str(components.PopoverTruncated(text, length=6))
self.assertIn("data-popover-target", result)
@@ -646,12 +668,14 @@ 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 = str(
components.NameWithIcon(name="Zelda", game=self.game, linkify=True)
)
self.assertIsInstance(result, SafeText)
self.assertIn("Zelda", result)
def test_name_with_icon_no_game_id_no_platform(self):
result = components.NameWithIcon(name="Unknown Game", linkify=False)
result = str(components.NameWithIcon(name="Unknown Game", linkify=False))
self.assertIsInstance(result, SafeText)
self.assertIn("Unknown Game", result)
@@ -775,11 +799,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_renders_list_rows(self):
"""Verify list-style rows render as <tr> with <th scope='row'> + <td>."""
result = str(
str(
components.SimpleTable(
columns=["Game", "Started", "Ended"],
rows=[["Game1", "2025-01-01", "2025-03-01"]],
)
)
)
tbody = self._tbody(result)
self.assertIn("<tr", tbody)
self.assertIn("Game1", tbody)
@@ -800,11 +826,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
def test_simple_table_multiple_rows(self):
"""Verify multiple rows all render."""
result = str(
str(
components.SimpleTable(
columns=["Game", "Started"],
rows=[["GameA", "2025-01-01"], ["GameB", "2025-02-01"]],
)
)
)
tbody = self._tbody(result)
self.assertIn("GameA", tbody)
self.assertIn("GameB", tbody)
@@ -815,12 +843,14 @@ class SimpleTableRenderingTest(unittest.TestCase):
from django.utils.safestring import mark_safe
result = str(
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)
self.assertIn(">Add</", result)
@@ -828,6 +858,7 @@ 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."""
result = str(
str(
components.SimpleTable(
columns=["Name", "Date"],
rows=[
@@ -839,6 +870,7 @@ class SimpleTableRenderingTest(unittest.TestCase):
],
)
)
)
tbody = self._tbody(result)
self.assertIn('id="session-row-1"', tbody)
self.assertIn("device-changed", tbody)
@@ -847,24 +879,13 @@ class SimpleTableRenderingTest(unittest.TestCase):
self.assertIn("2025-01-01", tbody)
from django.test import SimpleTestCase
from common.components.primitives import Checkbox as _Checkbox, Radio as _Radio
# Checkbox/Radio are lazy nodes; render to safe HTML for the assertions below.
def Checkbox(*args, **kwargs):
return str(_Checkbox(*args, **kwargs))
def Radio(*args, **kwargs):
return str(_Radio(*args, **kwargs))
class ComponentPrimitivesTest(SimpleTestCase):
def test_checkbox_primitive(self):
html = Checkbox(
html = str(
components.Checkbox(
name="test-check", label="Accept Terms", checked=True, value="yes"
)
)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-check"', html)
self.assertIn('value="yes"', html)
@@ -872,14 +893,18 @@ class ComponentPrimitivesTest(SimpleTestCase):
self.assertIn("Accept Terms", html)
def test_checkbox_headless(self):
html = Checkbox(name="test-headless", label=None, checked=True)
html = str(components.Checkbox(name="test-headless", label=None, checked=True))
self.assertNotIn("<label", html)
self.assertIn("<input", html)
self.assertIn('type="checkbox"', html)
self.assertIn('name="test-headless"', html)
def test_radio_primitive(self):
html = Radio(name="test-radio", label="Option A", checked=False, value="A")
html = str(
components.Radio(
name="test-radio", label="Option A", checked=False, value="A"
)
)
self.assertIn('type="radio"', html)
self.assertIn('name="test-radio"', html)
self.assertIn('value="A"', html)
+59 -48
View File
@@ -9,83 +9,74 @@ from django.utils.safestring import SafeText
from common.components import (
searchselect_selected,
)
from common.components import FilterSelect as _FilterSelect
from common.components import Pill as _Pill
from common.components import SearchSelect as _SearchSelect
from common.components import FilterSelect, Pill, SearchSelect
from games.models import Game, Platform
# These components are now lazy nodes; the tests below assert on rendered HTML.
# Render at the call site so existing string assertions (assertIn / .count /
# .index / .split) keep working, and ``isinstance(..., SafeText)`` confirms the
# rendered output is safe markup.
def SearchSelect(*args, **kwargs):
return str(_SearchSelect(*args, **kwargs))
def FilterSelect(*args, **kwargs):
return str(_FilterSelect(*args, **kwargs))
def Pill(*args, **kwargs):
return str(_Pill(*args, **kwargs))
# These components are lazy nodes; the tests below assert on rendered HTML, so
# each call is wrapped in ``str(...)`` (``Node.__str__`` returns a ``SafeText``,
# which keeps the ``assertIsInstance(..., SafeText)`` checks meaningful and the
# string assertions working).
class PillTest(unittest.TestCase):
def test_returns_safetext(self):
self.assertIsInstance(Pill("hi"), SafeText)
self.assertIsInstance(str(Pill("hi")), SafeText)
def test_plain_pill_has_data_pill_no_remove(self):
html = Pill("hi")
html = str(Pill("hi"))
self.assertIn("data-pill", html)
self.assertNotIn("data-pill-remove", html)
def test_removable_adds_remove_button(self):
html = Pill("hi", removable=True)
html = str(Pill("hi", removable=True))
self.assertIn("data-pill-remove", html)
self.assertIn('aria-label="Remove"', html)
def test_value_becomes_data_value(self):
html = Pill("hi", value="42")
html = str(Pill("hi", value="42"))
self.assertIn('data-value="42"', html)
def test_no_value_omits_data_value(self):
self.assertNotIn("data-value", Pill("hi"))
self.assertNotIn("data-value", str(Pill("hi")))
def test_label_is_escaped(self):
html = Pill("<b>x</b>")
html = str(Pill("<b>x</b>"))
self.assertIn("&lt;b&gt;", html)
self.assertNotIn("<b>x</b>", html)
def test_extra_data_attributes(self):
html = Pill("hi", attributes=[("data-platform", "3")])
html = str(Pill("hi", attributes=[("data-platform", "3")]))
self.assertIn('data-platform="3"', html)
class SearchSelectComponentTest(unittest.TestCase):
def test_returns_safetext(self):
self.assertIsInstance(SearchSelect(name="games"), SafeText)
self.assertIsInstance(str(SearchSelect(name="games")), SafeText)
def test_empty_options_renders_no_results_scaffold(self):
html = SearchSelect(name="games")
html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-no-results", html)
self.assertIn("No results", html)
def test_outer_container_carries_config(self):
html = SearchSelect(
html = str(
SearchSelect(
name="games", search_url="/api/games/search", multi_select=True
)
)
self.assertIn("data-search-select", html)
self.assertIn('data-name="games"', html)
self.assertIn('data-search-url="/api/games/search"', html)
self.assertIn('data-multi="true"', html)
def test_multi_selected_renders_pills_and_hidden_inputs(self):
html = SearchSelect(
html = str(
SearchSelect(
name="games",
multi_select=True,
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
)
self.assertIn("data-pill", html)
self.assertIn('<input name="games" value="7" type="hidden">', html)
self.assertIn('data-platform="2"', html)
@@ -94,10 +85,12 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertEqual(html.count(' name="games"'), 1)
def test_single_selected_has_no_pill_and_value_in_search_box(self):
html = SearchSelect(
html = str(
SearchSelect(
name="games",
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
)
)
# single-select renders no pill — the label lives in the search box
self.assertNotIn("data-pill", html)
self.assertIn('value="Game A"', html)
@@ -106,21 +99,23 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertEqual(html.count(' name="games"'), 1)
def test_search_box_has_no_name(self):
html = SearchSelect(name="games")
html = str(SearchSelect(name="games"))
self.assertIn("data-search-select-search", html)
# container exposes data-name, never a submittable name on the search box
self.assertEqual(html.count(' name="games"'), 0)
def test_tuple_options_are_normalized(self):
html = SearchSelect(name="t", options=[("1", "One")])
html = str(SearchSelect(name="t", options=[("1", "One")]))
self.assertIn('data-search-select-option=""', html)
self.assertIn('data-value="1"', html)
self.assertIn("One", html)
def test_options_omitted_when_search_url_set(self):
html = SearchSelect(
html = str(
SearchSelect(
name="t", options=[("1", "One")], search_url="/api/games/search"
)
)
# No pre-rendered rows in the live panel; the row prototype lives only in
# the cloneable <template>.
panel = html.split("data-search-select-template")[0]
@@ -130,7 +125,9 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_templates_carry_label_slot_for_js_cloning(self):
# The dynamic shapes the JS clones expose a [data-search-select-label] slot so the JS
# only fills text — classes/structure stay server-side.
html = SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
html = str(
SearchSelect(name="t", search_url="/api/games/search", multi_select=True)
)
self.assertIn('data-search-select-template="row"', html)
self.assertIn('data-search-select-template="pill"', html)
self.assertIn("data-search-select-label", html)
@@ -138,7 +135,7 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_shell_region_order_pills_search_options(self):
# The shared shell assembles the three regions in a fixed order; option
# rows precede the trailing no-results node inside the options panel.
html = SearchSelect(name="t", options=[("1", "One")])
html = str(SearchSelect(name="t", options=[("1", "One")]))
pills = html.index("data-search-select-pills")
search = html.index("data-search-select-search")
options = html.index("data-search-select-options")
@@ -151,11 +148,11 @@ class SearchSelectComponentTest(unittest.TestCase):
def test_prefetch_attribute_and_defaults(self):
# Default prefetch is 0 in SearchSelect
html_default = SearchSelect(name="t")
html_default = str(SearchSelect(name="t"))
self.assertIn('data-prefetch="0"', html_default)
# Custom prefetch is rendered
html_custom = SearchSelect(name="t", prefetch=42)
html_custom = str(SearchSelect(name="t", prefetch=42))
self.assertIn('data-prefetch="42"', html_custom)
@@ -163,10 +160,10 @@ class FilterSelectComponentTest(unittest.TestCase):
MODIFIERS = [("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")]
def test_returns_safetext(self):
self.assertIsInstance(FilterSelect(field_name="type"), SafeText)
self.assertIsInstance(str(FilterSelect(field_name="type")), SafeText)
def test_is_filter_mode_on_shared_shell(self):
html = FilterSelect(field_name="type")
html = str(FilterSelect(field_name="type"))
# Reuses the SearchSelect shell (data-search-select) but flags filter mode.
self.assertIn("data-search-select", html)
self.assertIn('data-search-select-mode="filter"', html)
@@ -175,18 +172,20 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertEqual(html.count(' name="type"'), 0)
def test_value_rows_have_include_exclude_buttons(self):
html = FilterSelect(field_name="type", options=[("g", "Game")])
html = str(FilterSelect(field_name="type", options=[("g", "Game")]))
self.assertIn('data-search-select-action="include"', html)
self.assertIn('data-search-select-action="exclude"', html)
self.assertIn('data-value="g"', html)
def test_included_renders_check_pill_excluded_renders_cross_pill(self):
html = FilterSelect(
html = str(
FilterSelect(
field_name="platform",
options=[("1", "Steam"), ("2", "GOG")],
included=[("1", "Steam")],
excluded=[("2", "GOG")],
)
)
# Labels live in a [data-search-select-label] slot (so JS can fill clones); the ✓/✗
# symbol is a sibling text node.
self.assertIn('data-search-select-type="include"', html)
@@ -198,7 +197,7 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn("line-through", html) # excluded pill styling
def test_modifier_options_render_pinned_rows(self):
html = FilterSelect(field_name="platform", modifier_options=self.MODIFIERS)
html = str(FilterSelect(field_name="platform", modifier_options=self.MODIFIERS))
# Pinned pseudo-options carry data-search-select-modifier-option, never data-search-select-option,
# so the text filter leaves them visible.
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
@@ -207,13 +206,15 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_modifier_pill_coexists_with_value_pills(self):
"""Modifier and value pills both render server-side; the JS handles
mutual exclusivity for presence modifiers (PRESENCE_MODIFIERS)."""
html = FilterSelect(
html = str(
FilterSelect(
field_name="platform",
options=[("1", "Steam")],
included=[("1", "Steam")],
modifier="IS_NULL",
modifier_options=self.MODIFIERS,
)
)
# Both the modifier pill and the value pill render.
self.assertIn('data-search-select-modifier="IS_NULL"', html)
self.assertIn("(None)", html)
@@ -221,12 +222,14 @@ class FilterSelectComponentTest(unittest.TestCase):
self.assertIn('data-modifier="IS_NULL"', html) # container carries it too
def test_search_url_omits_value_rows_but_keeps_modifiers(self):
html = FilterSelect(
html = str(
FilterSelect(
field_name="game",
search_url="/api/games/search",
prefetch=20,
modifier_options=self.MODIFIERS,
)
)
# No value rows in the live panel (they're fetched); the row prototype
# lives only in a <template>.
panel = html.split("data-search-select-template")[0]
@@ -239,11 +242,13 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_search_url_pills_use_resolved_labels(self):
# A selected value outside the fetched window still shows its label.
html = FilterSelect(
html = str(
FilterSelect(
field_name="game",
search_url="/api/games/search",
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
)
)
self.assertIn(">Obscure Game</span>", html)
self.assertIn('data-value="4172"', html)
@@ -255,7 +260,8 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_m2m_modifiers_render_as_option_rows(self):
"""M2M modifiers (All)/(Only) render as modifier-option rows in the
dropdown, not as a separate <select>."""
html = FilterSelect(
html = str(
FilterSelect(
field_name="games",
modifier_options=[
("NOT_NULL", "(Any)"),
@@ -264,6 +270,7 @@ class FilterSelectComponentTest(unittest.TestCase):
("INCLUDES_ONLY", "(Only)"),
],
)
)
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
self.assertIn('data-search-select-modifier-option="NOT_NULL"', html)
@@ -273,7 +280,8 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_active_modifier_renders_pill(self):
"""When modifier is INCLUDES_ALL, the modifier pill renders with the
(All) label alongside any value pills."""
html = FilterSelect(
html = str(
FilterSelect(
field_name="games",
modifier="INCLUDES_ALL",
modifier_options=[
@@ -284,6 +292,7 @@ class FilterSelectComponentTest(unittest.TestCase):
],
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
)
)
self.assertIn('data-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html)
self.assertIn("Hollow Knight", html)
@@ -291,11 +300,13 @@ class FilterSelectComponentTest(unittest.TestCase):
def test_presence_only_modifiers_no_m2m_rows(self):
"""When modifier_options only has presence entries, no M2M rows appear."""
html = FilterSelect(
html = str(
FilterSelect(
field_name="status",
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
options=[("f", "Finished")],
)
)
self.assertNotIn("INCLUDES_ALL", html)
self.assertNotIn("INCLUDES_ONLY", html)