Phase 1: add lazy node tree (Node/Element/Safe/Fragment/Media)
Introduce a FastHTML-style component model alongside the existing function-based one, purely additive: - Node: base renderable; __html__/__str__ render lazily so str()/f-string composition keeps working during migration. - Element: the single class for any HTML element (tag + attrs + children), rendering via the existing memoized _render_element. - Safe: wraps pre-rendered HTML (migration bridge for f-string components). - Fragment: ordered children with no wrapper tag (replaces str(a)+str(b)). - BaseComponent: base for higher-level components; render() returns a subtree, media declared via a Media attribute. - Media: declarative JS deps with order-preserving dedup merge. - collect_media()/render() helpers walk the tree. The legacy Component() function now builds an Element and is Node-aware in its child handling, so a tree mixing string- and node-returning components renders correctly with byte-identical output. No call sites changed yet. https://claude.ai/code/session_01BKurBhE3Qj25p7Bfsg7EeK
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media).
|
||||
|
||||
These cover the new machinery directly and assert byte-for-byte parity between
|
||||
``Element`` and the legacy ``Component()`` shim, so the migration of call sites
|
||||
in later phases can rely on identical output.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from django.utils.safestring import SafeText, mark_safe
|
||||
|
||||
from common.components import (
|
||||
BaseComponent,
|
||||
Component,
|
||||
Element,
|
||||
Fragment,
|
||||
Media,
|
||||
Node,
|
||||
Safe,
|
||||
collect_media,
|
||||
render,
|
||||
)
|
||||
|
||||
|
||||
class ElementRenderTest(unittest.TestCase):
|
||||
def test_matches_legacy_component(self):
|
||||
element = Element("div", [("class", "test")], "hello")
|
||||
legacy = Component(
|
||||
tag_name="div", attributes=[("class", "test")], children="hello"
|
||||
)
|
||||
self.assertEqual(render(element), legacy)
|
||||
self.assertEqual(render(element), '<div class="test">hello</div>')
|
||||
|
||||
def test_plain_string_children_escaped(self):
|
||||
self.assertEqual(
|
||||
render(Element("span", children=["<b>"])), "<span><b></span>"
|
||||
)
|
||||
|
||||
def test_safe_children_pass_through(self):
|
||||
self.assertEqual(
|
||||
render(Element("span", children=[mark_safe("<b>x</b>")])),
|
||||
"<span><b>x</b></span>",
|
||||
)
|
||||
|
||||
def test_node_children_render_safely(self):
|
||||
inner = Element("b", children=["x"])
|
||||
self.assertEqual(
|
||||
render(Element("span", children=[inner])), "<span><b>x</b></span>"
|
||||
)
|
||||
|
||||
def test_legacy_component_renders_node_children(self):
|
||||
# The compatibility bridge: a string-returning legacy Component must
|
||||
# render nested Node children as HTML, not escape them.
|
||||
inner = Element("b", children=["x"])
|
||||
result = Component(tag_name="span", children=[inner])
|
||||
self.assertEqual(result, "<span><b>x</b></span>")
|
||||
self.assertIsInstance(result, SafeText)
|
||||
|
||||
|
||||
class SafeAndFragmentTest(unittest.TestCase):
|
||||
def test_safe_passes_html_through(self):
|
||||
self.assertEqual(render(Safe("<i>raw</i>")), "<i>raw</i>")
|
||||
|
||||
def test_fragment_concatenates(self):
|
||||
frag = Fragment(
|
||||
Element("span", children=["a"]), Element("span", children=["b"])
|
||||
)
|
||||
self.assertEqual(render(frag), "<span>a</span><span>b</span>")
|
||||
|
||||
def test_fragment_skips_empty_children(self):
|
||||
frag = Fragment("", None, Element("span", children=["a"]))
|
||||
self.assertEqual(render(frag), "<span>a</span>")
|
||||
|
||||
def test_fragment_escapes_plain_strings(self):
|
||||
self.assertEqual(render(Fragment("<x>", Safe("<y>"))), "<x><y>")
|
||||
|
||||
|
||||
class MediaTest(unittest.TestCase):
|
||||
def test_merge_dedups_preserving_order(self):
|
||||
merged = Media(js=["a.js", "b.js"]) + Media(js=["b.js", "c.js"])
|
||||
self.assertEqual(merged.js, ("a.js", "b.js", "c.js"))
|
||||
|
||||
def test_external_kept_separate(self):
|
||||
merged = Media(js=["a.js"]) + Media(js_external=["umd.js"])
|
||||
self.assertEqual(merged.js, ("a.js",))
|
||||
self.assertEqual(merged.js_external, ("umd.js",))
|
||||
|
||||
def test_sum_with_radd(self):
|
||||
merged = sum([Media(js=["a.js"]), Media(js=["b.js"])], Media())
|
||||
self.assertEqual(merged.js, ("a.js", "b.js"))
|
||||
|
||||
def test_falsy_when_empty(self):
|
||||
self.assertFalse(Media())
|
||||
self.assertTrue(Media(js=["a.js"]))
|
||||
|
||||
|
||||
class MediaCollectionTest(unittest.TestCase):
|
||||
def test_bubbles_through_element_children(self):
|
||||
class Widget(BaseComponent):
|
||||
media = Media(js=["widget.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("div", children=["x"])
|
||||
|
||||
tree = Element("section", children=[Element("div", children=[Widget()])])
|
||||
self.assertEqual(collect_media(tree).js, ("widget.js",))
|
||||
|
||||
def test_bubbles_through_fragment(self):
|
||||
class Widget(BaseComponent):
|
||||
media = Media(js=["w.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("div")
|
||||
|
||||
self.assertEqual(collect_media(Fragment(Widget(), Element("p"))).js, ("w.js",))
|
||||
|
||||
def test_component_merges_own_and_subtree_media(self):
|
||||
class Inner(BaseComponent):
|
||||
media = Media(js=["inner.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("span")
|
||||
|
||||
class Outer(BaseComponent):
|
||||
media = Media(js=["outer.js"])
|
||||
|
||||
def render(self) -> Node:
|
||||
return Element("div", children=[Inner()])
|
||||
|
||||
self.assertEqual(collect_media(Outer()).js, ("outer.js", "inner.js"))
|
||||
|
||||
def test_bare_string_has_no_media(self):
|
||||
self.assertFalse(collect_media("just a string"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user