Migrate remaining Component() callers to Element; delete the shim

The legacy back-compat ``Component(tag_name=...)`` function (a thin
string-returning wrapper over ``Element``) was the last piece of the
pre-node-tree API. Migrate its ~18 call sites across the views to the node
builders and remove it:

- stats_content.py: the table helpers now use the whitelisted ``Td`` / ``Th``
  / ``Tr`` builders and ``Element`` for table/tbody/thead/h1; helper return
  types are ``Node``.
- auth.py / statuschange.py / game.py / purchase.py: the hand-built
  ``<form>`` / ``<button>`` / ``<h1>`` / ``<h2>`` / ``<table>`` markup now uses
  ``Element("tag", ...)``.
- core.py: drop the ``Component()`` function and its back-compat note;
  ``common/components/__init__`` no longer exports it.
- Tests that exercised the shim now target ``Element`` directly
  (test_components cache/escaping/edge-case classes; test_node_tree drops the
  legacy-parity and legacy-bridge cases, which ``Element`` coverage subsumes).
- CLAUDE.md: drop the "legacy Component retained for back-compat" notes.

Full suite green (443; one obsolete legacy-bridge test removed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:51:27 +02:00
parent bec7a1074c
commit 9c42d85f52
10 changed files with 79 additions and 115 deletions
+2 -2
View File
@@ -62,7 +62,7 @@ docs/ — Additional documentation
**Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`: **Component system** (`common/components/`): a FastHTML-style **lazy node tree**. Components are `Node` objects that render to HTML only when asked (`str(node)` / `Page()`), so `Page()` can walk a finished tree and collect each component's JS. Split into submodules re-exported via `common/components/__init__.py`:
- **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped; children escaped unless `SafeText`/`Node`. The legacy `Component(tag_name=...)` function still returns a `SafeText` string (Node-aware children) for back-compat. `randomid()` generates stable hash-based IDs. - **`core.py`** — the node layer. `Node` (base; `__html__`/`__str__` return a `SafeString`), `Element` (the single class for *any* HTML element), `Safe` (wraps pre-rendered/trusted HTML), `Fragment` (ordered children, no wrapper tag — use instead of `str(a)+str(b)`), `BaseComponent` (base for higher-level components: implement `render()`, declare `media`), and `Media` (declarative JS deps with order-preserving dedup merge; `collect_media()` sums them over a tree, `node.with_media(...)` attaches them). `_render_element()` is `@lru_cache`-memoized (4096). Attribute values are always escaped; children escaped unless `SafeText`/`Node`. `randomid()` generates stable hash-based IDs.
- **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`). - **`primitives.py`** — Generic HTML. Plain leaf builders (`Div`, `Span`, `P`, `Ul`, `Li`, `Strong`, `Label`, `Template`, `Td`, `Tr`, `Th`) are **generated from a whitelist** via the `_html_element(tag)` factory over `Element` — not hand-written per tag. Builders that add classes/behaviour are written out: `A()`, `Button()`, `ButtonGroup()`, `Input()`, `Checkbox()`, `Radio()`, `Pill()`, `Icon()`, `Popover()`, `PopoverTruncated()`, `SearchField()`, `H1()`, `Modal()`, `SimpleTable()`, `TableRow()`, `TableTd()`, `TableHeader()`, `paginated_table_content()`, `AddForm()`, `YearPicker()` (declares datepicker media), `CsrfInput()`/`ModuleScript()`/`StaticScript()` (script-tag string helpers used by `Page()`).
- **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()` - **`domain.py`** — Domain-specific: `GameLink()`, `GameStatus()` (colored dot + label), `GameStatusSelector()` (Alpine.js PATCH dropdown), `SessionDeviceSelector()` (Alpine.js PATCH dropdown), `LinkedPurchase()`, `NameWithIcon()`, `PriceConverted()`, `PurchasePrice()`
- **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets) - **`filters.py`** — Filter UI: `FilterBar()`, `SessionFilterBar()`, `PurchaseFilterBar()` (built from `FilterSelect` widgets)
@@ -166,7 +166,7 @@ Pytest settings are in `pyproject.toml` under `[tool.pytest.ini_options]` (`DJAN
- **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch. - **Name variables with complete words** — readable, unabbreviated identifiers in both Python and JavaScript (e.g. `template` not `tpl`, `event` not `e`, `element` not `el`, `removeButton` not `removeBtn`, `option`/`value` not single letters in loops). This applies to new code and to code you touch.
- **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`. - **Use `render_page()` not `render()`** for all full-page HTTP responses. Import from `common.layout`.
- **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped. - **Build UI with Python components** from `common.components`, not raw HTML strings or Django templates. `SafeText` children pass through unescaped; plain strings are auto-escaped.
- **Components are nodes; use the named builders, not the legacy `Component()`** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue). The legacy `Component(tag_name=…)` function is retained for back-compat only. - **Components are nodes; use the named builders** — build with `Div()`, `Span()`, `Element("tag", ...)`, etc., which return `Node` objects. For a tag with no builder, add it to the whitelist in `primitives.py` (one line) or use `Element("tag", attrs, children)`. Use `Fragment(a, b, ...)` to group siblings (never `str(a)+str(b)`, which flattens the tree and drops media). Wrap trusted pre-rendered HTML in `Safe(html)` (the `mark_safe` analogue).
- **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency. - **JS-bearing components declare `Media`, they don't rely on the view** — give a component `class Media: js = (...)` (a `BaseComponent`) or `return node.with_media(Media(js=...))`. `Page()` collects and emits it. Never re-add `scripts=ModuleScript(...)` threading in a view for a component that can declare its own dependency.
- **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`. - **Filter views** accept `?filter=<JSON>` (structured) and fall back to `?search_string=` (free-text). New filter criteria go in `games/filters.py`; new criterion types go in `common/criteria.py`.
- **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete. - **Signals handle side-effects** — do not manually recalculate `Game.playtime` or `Purchase.num_purchases`; the signals in `games/signals.py` do this on save/delete.
-2
View File
@@ -6,7 +6,6 @@ re-exports the public API so ``from common.components import X`` keeps working.
from common.components.core import ( from common.components.core import (
BaseComponent, BaseComponent,
Component,
Element, Element,
Fragment, Fragment,
HTMLAttribute, HTMLAttribute,
@@ -92,7 +91,6 @@ from common.utils import truncate
__all__ = [ __all__ = [
"truncate", "truncate",
"BaseComponent", "BaseComponent",
"Component",
"Element", "Element",
"Fragment", "Fragment",
"Media", "Media",
-19
View File
@@ -11,11 +11,6 @@ Nodes are *lazy*: they hold structure and render to HTML only when asked
(``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets (``str(node)`` / ``node.__html__()`` / :func:`render`). This is what lets
``Page()`` walk a finished tree and collect every component's declared JS ``Page()`` walk a finished tree and collect every component's declared JS
(:class:`Media`) instead of each view threading ``scripts=`` by hand. (:class:`Media`) instead of each view threading ``scripts=`` by hand.
Backwards compatibility: the legacy ``Component(tag_name=...)`` function still
returns a ``SafeText`` string, so existing string-based call sites keep working
during the migration. Its child handling is Node-aware, so a tree mixing old
(string-returning) and new (node-returning) components renders correctly.
""" """
import hashlib import hashlib
@@ -288,20 +283,6 @@ def collect_media(node: "Node | str") -> Media:
return Media() return Media()
def Component(
attributes: list[HTMLAttribute] | None = None,
children: "list[HTMLTag] | HTMLTag | None" = None,
tag_name: str = "",
) -> SafeText:
"""Legacy element builder: returns a ``SafeText`` string.
Kept for backwards compatibility while call sites migrate to :class:`Element`
and the generated tag builders. Child handling is Node-aware, so a tree that
mixes string-returning and node-returning components still renders correctly.
"""
return render(Element(tag_name, attributes, children))
def randomid(seed: str = "", content: str = "", length: int = 10) -> str: def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
if not seed and not content: if not seed and not content:
return seed return seed
+9 -9
View File
@@ -3,16 +3,16 @@ registration/login.html)."""
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
from django.http import HttpResponse from django.http import HttpResponse
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import mark_safe
from common.components import Component, CsrfInput, Div, Input from common.components import CsrfInput, Div, Element, Input, Node
from common.components.primitives import Td, Tr from common.components.primitives import Td, Tr
from common.layout import render_page from common.layout import render_page
def _login_content(form, request) -> SafeText: def _login_content(form, request) -> Node:
table = Component( table = Element(
tag_name="table", "table",
children=[ children=[
CsrfInput(request), CsrfInput(request),
mark_safe(str(form.as_table())), mark_safe(str(form.as_table())),
@@ -31,13 +31,13 @@ def _login_content(form, request) -> SafeText:
return Div( return Div(
[("class", "flex items-center flex-col")], [("class", "flex items-center flex-col")],
[ [
Component( Element(
tag_name="h2", "h2",
attributes=[("class", "text-3xl text-white mb-8")], attributes=[("class", "text-3xl text-white mb-8")],
children=["Please log in to continue"], children=["Please log in to continue"],
), ),
Component( Element(
tag_name="form", "form",
attributes=[("method", "post")], attributes=[("method", "post")],
children=[table], children=[table],
), ),
+7 -7
View File
@@ -17,9 +17,9 @@ from common.components import (
AddForm, AddForm,
Button, Button,
ButtonGroup, ButtonGroup,
Component,
CsrfInput, CsrfInput,
Div, Div,
Element,
FilterBar, FilterBar,
GameStatus, GameStatus,
GameStatusSelector, GameStatusSelector,
@@ -201,8 +201,8 @@ def _delete_game_confirmation_modal(
if not (session_count or purchase_count or playevent_count): if not (session_count or purchase_count or playevent_count):
data_items.append(Li(children=["No associated data"])) data_items.append(Li(children=["No associated data"]))
form = Component( form = Element(
tag_name="form", "form",
attributes=[ attributes=[
("hx-post", reverse("games:delete_game", args=[game.id])), ("hx-post", reverse("games:delete_game", args=[game.id])),
("hx-replace-url", "true"), ("hx-replace-url", "true"),
@@ -442,8 +442,8 @@ def _game_action_buttons(game: Game) -> SafeText:
edit_link = A( edit_link = A(
href=reverse("games:edit_game", args=[game.id]), href=reverse("games:edit_game", args=[game.id]),
children=[ children=[
Component( Element(
tag_name="button", "button",
attributes=[("type", "button"), ("class", edit_class)], attributes=[("type", "button"), ("class", edit_class)],
children=["Edit"], children=["Edit"],
) )
@@ -456,8 +456,8 @@ def _game_action_buttons(game: Game) -> SafeText:
("hx-target", "#global-modal-container"), ("hx-target", "#global-modal-container"),
], ],
children=[ children=[
Component( Element(
tag_name="button", "button",
attributes=[("type", "button"), ("class", delete_class)], attributes=[("type", "button"), ("class", delete_class)],
children=["Delete"], children=["Delete"],
) )
+7 -6
View File
@@ -19,14 +19,15 @@ from common.components import (
AddForm, AddForm,
Button, Button,
ButtonGroup, ButtonGroup,
Component,
CsrfInput, CsrfInput,
Div, Div,
Element,
GameLink, GameLink,
Icon, Icon,
LinkedPurchase, LinkedPurchase,
Modal, Modal,
ModuleScript, ModuleScript,
Node,
PriceConverted, PriceConverted,
PurchasePrice, PurchasePrice,
TableRow, TableRow,
@@ -301,9 +302,9 @@ def drop_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse:
return redirect("games:list_purchases") return redirect("games:list_purchases")
def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeText: def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> Node:
form = Component( form = Element(
tag_name="form", "form",
attributes=[ attributes=[
("hx-post", reverse("games:refund_purchase", args=[purchase_id])), ("hx-post", reverse("games:refund_purchase", args=[purchase_id])),
("hx-target", f"#purchase-row-{purchase_id}"), ("hx-target", f"#purchase-row-{purchase_id}"),
@@ -339,8 +340,8 @@ def _refund_confirmation_modal(purchase_id: int, request: HttpRequest) -> SafeTe
return Modal( return Modal(
"refund-confirmation-modal", "refund-confirmation-modal",
children=[ children=[
Component( Element(
tag_name="h1", "h1",
attributes=[ attributes=[
( (
"class", "class",
+30 -31
View File
@@ -9,9 +9,9 @@ from django.template.defaultfilters import date as date_filter
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.urls import reverse from django.urls import reverse
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import mark_safe
from common.components import A, Component, Div, GameLink, YearPicker from common.components import A, Div, Element, GameLink, Node, Td, Th, Tr, YearPicker
from common.time import durationformat, format_duration from common.time import durationformat, format_duration
_CELL = "px-2 sm:px-4 md:px-6 md:py-2" _CELL = "px-2 sm:px-4 md:px-6 md:py-2"
@@ -19,41 +19,40 @@ _CELL_MONO = f"{_CELL} font-mono"
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char" _NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
def _td(children, cls: str = _CELL_MONO) -> SafeText: def _td(children, cls: str = _CELL_MONO) -> Node:
if not isinstance(children, list): if not isinstance(children, list):
children = [children] children = [children]
children = [c if isinstance(c, (str, SafeText)) else str(c) for c in children] return Td(attributes=[("class", cls)], children=children)
return Component(tag_name="td", attributes=[("class", cls)], children=children)
def _th(text: str, cls: str = _CELL) -> SafeText: def _th(text: str, cls: str = _CELL) -> Node:
return Component(tag_name="th", attributes=[("class", cls)], children=[text]) return Th(attributes=[("class", cls)], children=[text])
def _tr(cells: list) -> SafeText: def _tr(cells: list) -> Node:
return Component(tag_name="tr", children=cells) return Tr(children=cells)
def _kv(label, value) -> SafeText: def _kv(label, value) -> Node:
"""A label/value row: plain label cell + mono value cell.""" """A label/value row: plain label cell + mono value cell."""
return _tr([_td(label, _CELL), _td(value)]) return _tr([_td(label, _CELL), _td(value)])
def _h1(title: str) -> SafeText: def _h1(title: str) -> Node:
return Component( return Element(
tag_name="h1", "h1",
attributes=[("class", "text-3xl text-heading text-center my-6")], attributes=[("class", "text-3xl text-heading text-center my-6")],
children=[title], children=[title],
) )
def _table(rows: list, thead: SafeText | None = None) -> SafeText: def _table(rows: list, thead: Node | None = None) -> Node:
children = [] children = []
if thead is not None: if thead is not None:
children.append(thead) children.append(thead)
children.append(Component(tag_name="tbody", children=rows)) children.append(Element("tbody", children=rows))
return Component( return Element(
tag_name="table", "table",
attributes=[("class", "responsive-table")], attributes=[("class", "responsive-table")],
children=children, children=children,
) )
@@ -63,7 +62,7 @@ def _dur(value) -> str:
return format_duration(value, durationformat) return format_duration(value, durationformat)
def _purchase_name(purchase) -> SafeText: def _purchase_name(purchase) -> Node:
"""Mirror of the `purchase-name` partial in the old template.""" """Mirror of the `purchase-name` partial in the old template."""
game_name = getattr(purchase, "game_name", None) game_name = getattr(purchase, "game_name", None)
first_game = purchase.first_game first_game = purchase.first_game
@@ -76,7 +75,7 @@ def _purchase_name(purchase) -> SafeText:
return GameLink(first_game.id, name) return GameLink(first_game.id, name)
def _year_nav(year, year_range, url_template) -> SafeText: def _year_nav(year, year_range, url_template) -> Node:
# `year` is an int for a specific year, or "Alltime" (from compute_stats) # `year` is an int for a specific year, or "Alltime" (from compute_stats)
# for the all-time view. Normalize to int-or-None so nothing downstream has # for the all-time view. Normalize to int-or-None so nothing downstream has
# to know about the "Alltime" sentinel. # to know about the "Alltime" sentinel.
@@ -107,7 +106,7 @@ def _year_nav(year, year_range, url_template) -> SafeText:
) )
def _playtime_table(ctx) -> SafeText: def _playtime_table(ctx) -> Node:
year = ctx.get("year") year = ctx.get("year")
rows = [ rows = [
_kv("Hours", ctx.get("total_hours")), _kv("Hours", ctx.get("total_hours")),
@@ -186,7 +185,7 @@ def _playtime_table(ctx) -> SafeText:
return _table(rows) return _table(rows)
def _purchases_table(ctx) -> SafeText: def _purchases_table(ctx) -> Node:
rows = [ rows = [
_kv("Total", ctx.get("all_purchased_this_year_count")), _kv("Total", ctx.get("all_purchased_this_year_count")),
_kv( _kv(
@@ -213,18 +212,18 @@ def _purchases_table(ctx) -> SafeText:
return _table(rows) return _table(rows)
def _two_col_table(header: str, items, name_key, value_fn) -> SafeText: def _two_col_table(header: str, items, name_key, value_fn) -> Node:
thead = Component( thead = Element(
tag_name="thead", "thead",
children=[_tr([_th(header), _th("Playtime")])], children=[_tr([_th(header), _th("Playtime")])],
) )
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items] rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
return _table(rows, thead) return _table(rows, thead)
def _finished_table(purchases) -> SafeText: def _finished_table(purchases) -> Node:
thead = Component( thead = Element(
tag_name="thead", "thead",
children=[_tr([_th("Name", _NAME_TH), _th("Date")])], children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
) )
rows = [ rows = [
@@ -234,9 +233,9 @@ def _finished_table(purchases) -> SafeText:
return _table(rows, thead) return _table(rows, thead)
def _priced_table(purchases, currency) -> SafeText: def _priced_table(purchases, currency) -> Node:
thead = Component( thead = Element(
tag_name="thead", "thead",
children=[ children=[
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")]) _tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
], ],
@@ -254,7 +253,7 @@ def _priced_table(purchases, currency) -> SafeText:
return _table(rows, thead) return _table(rows, thead)
def stats_content(ctx: dict) -> SafeText: def stats_content(ctx: dict) -> Node:
year = ctx.get("year") year = ctx.get("year")
currency = ctx.get("total_spent_currency") currency = ctx.get("total_spent_currency")
# Build a navigation URL with an `__year__` placeholder the picker's JS # Build a navigation URL with an `__year__` placeholder the picker's JS
+3 -3
View File
@@ -8,9 +8,9 @@ from common.components import (
A, A,
AddForm, AddForm,
Button, Button,
Component,
CsrfInput, CsrfInput,
Div, Div,
Element,
paginated_table_content, paginated_table_content,
) )
from common.components.primitives import P from common.components.primitives import P
@@ -89,8 +89,8 @@ def _delete_statuschange_content(statuschange, request: HttpRequest) -> SafeText
), ),
], ],
) )
form = Component( form = Element(
tag_name="form", "form",
attributes=[("method", "post"), ("class", "dark:text-white")], attributes=[("method", "post"), ("class", "dark:text-white")],
children=[CsrfInput(request), inner], children=[CsrfInput(request), inner],
) )
+18 -18
View File
@@ -11,16 +11,16 @@ from games.models import Platform, Game, Purchase, Session
# Component builders return lazy ``Node`` objects; these tests assert on rendered # Component builders return lazy ``Node`` objects; these tests assert on rendered
# HTML, so node-returning calls are wrapped in ``str(...)`` at the call site # HTML, so node-returning calls are wrapped in ``str(...)`` at the call site
# (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``, # (``Node.__str__`` returns a ``SafeText``). Non-node helpers (``randomid``,
# ``_resolve_name_with_icon``, the legacy string ``Component()``) are called # ``_resolve_name_with_icon``, ``_render_element``) are called
# directly. # directly.
class ComponentIntegrationTest(unittest.TestCase): class ComponentIntegrationTest(unittest.TestCase):
"""Test Component() works correctly with caching transparent.""" """Test Element() renders correctly with caching transparent."""
def test_tag_name_component(self): def test_tag_name_component(self):
result = str( result = str(
components.Component( components.Element(
tag_name="div", tag_name="div",
attributes=[("class", "test")], attributes=[("class", "test")],
children="hello", children="hello",
@@ -37,13 +37,13 @@ class ComponentCacheTest(unittest.TestCase):
def test_identical_components_hit_cache(self): def test_identical_components_hit_cache(self):
str( str(
components.Component( components.Element(
tag_name="div", attributes=[("class", "x")], children="hi" tag_name="div", attributes=[("class", "x")], children="hi"
) )
) )
misses = components._render_element.cache_info().misses misses = components._render_element.cache_info().misses
str( str(
components.Component( components.Element(
tag_name="div", attributes=[("class", "x")], children="hi" tag_name="div", attributes=[("class", "x")], children="hi"
) )
) )
@@ -58,9 +58,9 @@ class ComponentCacheTest(unittest.TestCase):
"""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 = str( safe = str(
components.Component(tag_name="span", children=[mark_safe("<b>x</b>")]) components.Element(tag_name="span", children=[mark_safe("<b>x</b>")])
) )
unsafe = str(components.Component(tag_name="span", children=["<b>x</b>"])) unsafe = str(components.Element(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)
@@ -324,26 +324,26 @@ class ComponentOutputIsNotEscapedTest(unittest.TestCase):
class ComponentEdgeCasesTest(unittest.TestCase): class ComponentEdgeCasesTest(unittest.TestCase):
"""Test Component() edge cases and error handling.""" """Test Element() edge cases and error handling."""
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:
str(components.Component(children="hello")) str(components.Element("", 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 = str(components.Component(tag_name="span", children="hello")) result = str(components.Element(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 = str(components.Component(tag_name="div", children=["hello", "world"])) result = str(components.Element(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 = str( result = str(
components.Component( components.Element(
tag_name="div", children=["<script>alert('xss')</script>"] tag_name="div", children=["<script>alert('xss')</script>"]
) )
) )
@@ -352,7 +352,7 @@ class ComponentEdgeCasesTest(unittest.TestCase):
def test_mark_safe_children_pass_through(self): def test_mark_safe_children_pass_through(self):
result = str( result = str(
components.Component( components.Element(
tag_name="div", children=[mark_safe("<span>safe</span>")] tag_name="div", children=[mark_safe("<span>safe</span>")]
) )
) )
@@ -360,7 +360,7 @@ class ComponentEdgeCasesTest(unittest.TestCase):
def test_attribute_values_are_escaped(self): def test_attribute_values_are_escaped(self):
result = str( result = str(
components.Component( components.Element(
tag_name="div", tag_name="div",
attributes=[("data-x", 'foo"bar')], attributes=[("data-x", 'foo"bar')],
) )
@@ -370,7 +370,7 @@ class ComponentEdgeCasesTest(unittest.TestCase):
def test_attributes_serialized_correctly(self): def test_attributes_serialized_correctly(self):
result = str( result = str(
components.Component( components.Element(
tag_name="div", attributes=[("class", "foo"), ("id", "bar")] tag_name="div", attributes=[("class", "foo"), ("id", "bar")]
) )
) )
@@ -378,17 +378,17 @@ class ComponentEdgeCasesTest(unittest.TestCase):
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 = str(components.Component(tag_name="span", children="x")) result = str(components.Element(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 = str(components.Component(tag_name="span", children=str(42))) result = str(components.Element(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 = str(components.Component(tag_name="div", children="test")) result = str(components.Element(tag_name="div", children="test"))
self.assertIsInstance(result, SafeText) self.assertIsInstance(result, SafeText)
+3 -18
View File
@@ -1,17 +1,14 @@
"""Phase 1: the lazy node layer (Node/Element/Safe/Fragment/BaseComponent/Media). """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 These cover the new machinery directly: rendering, escaping, media bubbling.
``Element`` and the legacy ``Component()`` shim, so the migration of call sites
in later phases can rely on identical output.
""" """
import unittest import unittest
from django.utils.safestring import SafeText, mark_safe from django.utils.safestring import mark_safe
from common.components import ( from common.components import (
BaseComponent, BaseComponent,
Component,
Element, Element,
Fragment, Fragment,
Media, Media,
@@ -23,12 +20,8 @@ from common.components import (
class ElementRenderTest(unittest.TestCase): class ElementRenderTest(unittest.TestCase):
def test_matches_legacy_component(self): def test_renders_tag_attrs_children(self):
element = Element("div", [("class", "test")], "hello") 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>') self.assertEqual(render(element), '<div class="test">hello</div>')
def test_plain_string_children_escaped(self): def test_plain_string_children_escaped(self):
@@ -48,14 +41,6 @@ class ElementRenderTest(unittest.TestCase):
render(Element("span", children=[inner])), "<span><b>x</b></span>" 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): class SafeAndFragmentTest(unittest.TestCase):
def test_safe_passes_html_through(self): def test_safe_passes_html_through(self):