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:
+101
-90
@@ -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("<b>", 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)
|
||||
|
||||
Reference in New Issue
Block a user