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
+101 -90
View File
@@ -9,71 +9,60 @@ 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(
name="games", search_url="/api/games/search", multi_select=True
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)
@@ -81,10 +70,12 @@ class SearchSelectComponentTest(unittest.TestCase):
self.assertIn('data-multi="true"', html)
def test_multi_selected_renders_pills_and_hidden_inputs(self):
html = SearchSelect(
name="games",
multi_select=True,
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
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)
@@ -94,9 +85,11 @@ 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(
name="games",
selected=[{"value": 7, "label": "Game A", "data": {"platform": "2"}}],
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)
@@ -106,20 +99,22 @@ 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(
name="t", options=[("1", "One")], search_url="/api/games/search"
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>.
@@ -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,17 +172,19 @@ 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(
field_name="platform",
options=[("1", "Steam"), ("2", "GOG")],
included=[("1", "Steam")],
excluded=[("2", "GOG")],
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.
@@ -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,12 +206,14 @@ 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(
field_name="platform",
options=[("1", "Steam")],
included=[("1", "Steam")],
modifier="IS_NULL",
modifier_options=self.MODIFIERS,
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)
@@ -221,11 +222,13 @@ 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(
field_name="game",
search_url="/api/games/search",
prefetch=20,
modifier_options=self.MODIFIERS,
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>.
@@ -239,10 +242,12 @@ 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(
field_name="game",
search_url="/api/games/search",
excluded=[{"value": 4172, "label": "Obscure Game", "data": {}}],
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,14 +260,16 @@ 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(
field_name="games",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
html = str(
FilterSelect(
field_name="games",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
)
)
self.assertIn('data-search-select-modifier-option="INCLUDES_ALL"', html)
self.assertIn('data-search-select-modifier-option="INCLUDES_ONLY"', html)
@@ -273,16 +280,18 @@ 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(
field_name="games",
modifier="INCLUDES_ALL",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
html = str(
FilterSelect(
field_name="games",
modifier="INCLUDES_ALL",
modifier_options=[
("NOT_NULL", "(Any)"),
("IS_NULL", "(None)"),
("INCLUDES_ALL", "(All)"),
("INCLUDES_ONLY", "(Only)"),
],
included=[{"value": 5, "label": "Hollow Knight", "data": {}}],
)
)
self.assertIn('data-modifier="INCLUDES_ALL"', html)
self.assertIn("(All)", html)
@@ -291,10 +300,12 @@ 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(
field_name="status",
modifier_options=[("NOT_NULL", "(Any)"), ("IS_NULL", "(None)")],
options=[("f", "Finished")],
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)