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