Coverage for tdom / processor_test.py: 99%
483 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-17 23:32 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-17 23:32 +0000
1import datetime
2import typing as t
3from dataclasses import dataclass, field
4from string.templatelib import Interpolation, Template
6import pytest
7from markupsafe import Markup
9from .nodes import Comment, DocumentType, Element, Fragment, Node, Text
10from .placeholders import _PLACEHOLDER_PREFIX, _PLACEHOLDER_SUFFIX
11from .processor import html
13# --------------------------------------------------------------------------
14# Basic HTML parsing tests
15# --------------------------------------------------------------------------
18def test_parse_empty():
19 node = html(t"")
20 assert node == Fragment(children=[])
21 assert str(node) == ""
24def test_parse_text():
25 node = html(t"Hello, world!")
26 assert node == Text("Hello, world!")
27 assert str(node) == "Hello, world!"
30def test_parse_comment():
31 node = html(t"<!--This is a comment-->")
32 assert node == Comment("This is a comment")
33 assert str(node) == "<!--This is a comment-->"
36def test_parse_document_type():
37 node = html(t"<!doctype html>")
38 assert node == DocumentType("html")
39 assert str(node) == "<!DOCTYPE html>"
42def test_parse_void_element():
43 node = html(t"<br>")
44 assert node == Element("br")
45 assert str(node) == "<br />"
48def test_parse_void_element_self_closed():
49 node = html(t"<br />")
50 assert node == Element("br")
51 assert str(node) == "<br />"
54def test_parse_chain_of_void_elements():
55 # Make sure our handling of CPython issue #69445 is reasonable.
56 node = html(t"<br><hr><img src='image.png' /><br /><hr>")
57 assert node == Fragment(
58 children=[
59 Element("br"),
60 Element("hr"),
61 Element("img", attrs={"src": "image.png"}),
62 Element("br"),
63 Element("hr"),
64 ],
65 )
66 assert str(node) == '<br /><hr /><img src="image.png" /><br /><hr />'
69def test_static_boolean_attr_retained():
70 # Make sure a boolean attribute (bare attribute) is not omitted.
71 node = html(t"<input disabled>")
72 assert node == Element("input", {"disabled": None})
73 assert str(node) == "<input disabled />"
76def test_parse_element_with_text():
77 node = html(t"<p>Hello, world!</p>")
78 assert node == Element(
79 "p",
80 children=[
81 Text("Hello, world!"),
82 ],
83 )
84 assert str(node) == "<p>Hello, world!</p>"
87def test_parse_element_with_attributes():
88 node = html(t'<a href="https://example.com" target="_blank">Link</a>')
89 assert node == Element(
90 "a",
91 attrs={"href": "https://example.com", "target": "_blank"},
92 children=[
93 Text("Link"),
94 ],
95 )
96 assert str(node) == '<a href="https://example.com" target="_blank">Link</a>'
99def test_parse_nested_elements():
100 node = html(t"<div><p>Hello</p><p>World</p></div>")
101 assert node == Element(
102 "div",
103 children=[
104 Element("p", children=[Text("Hello")]),
105 Element("p", children=[Text("World")]),
106 ],
107 )
108 assert str(node) == "<div><p>Hello</p><p>World</p></div>"
111def test_parse_entities_are_escaped():
112 node = html(t"<p></p></p>")
113 assert node == Element(
114 "p",
115 children=[Text("</p>")],
116 )
117 assert str(node) == "<p></p></p>"
120# --------------------------------------------------------------------------
121# Interpolated text content
122# --------------------------------------------------------------------------
125def test_interpolated_text_content():
126 name = "Alice"
127 node = html(t"<p>Hello, {name}!</p>")
128 assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")])
129 assert str(node) == "<p>Hello, Alice!</p>"
132def test_escaping_of_interpolated_text_content():
133 name = "<Alice & Bob>"
134 node = html(t"<p>Hello, {name}!</p>")
135 assert node == Element(
136 "p", children=[Text("Hello, "), Text("<Alice & Bob>"), Text("!")]
137 )
138 assert str(node) == "<p>Hello, <Alice & Bob>!</p>"
141class Convertible:
142 def __str__(self):
143 return "string"
145 def __repr__(self):
146 return "repr"
149def test_conversions():
150 c = Convertible()
151 assert f"{c!s}" == "string"
152 assert f"{c!r}" == "repr"
153 node = html(t"<li>{c!s}</li><li>{c!r}</li><li>{'😊'!a}</li>")
154 assert node == Fragment(
155 children=[
156 Element("li", children=[Text("string")]),
157 Element("li", children=[Text("repr")]),
158 Element("li", children=[Text("'\\U0001f60a'")]),
159 ],
160 )
163def test_interpolated_in_content_node():
164 # https://github.com/t-strings/tdom/issues/68
165 evil = "</style><script>alert('whoops');</script><style>"
166 node = html(t"<style>{evil}{evil}</style>")
167 assert node == Element(
168 "style",
169 children=[
170 Text("</style><script>alert('whoops');</script><style>"),
171 Text("</style><script>alert('whoops');</script><style>"),
172 ],
173 )
174 LT = "<"
175 assert (
176 str(node)
177 == f"<style>{LT}/style><script>alert('whoops');</script><style>{LT}/style><script>alert('whoops');</script><style></style>"
178 )
181def test_interpolated_trusted_in_content_node():
182 # https://github.com/t-strings/tdom/issues/68
183 node = html(t"<script>if (a < b && c > d) { alert('wow'); } </script>")
184 assert node == Element(
185 "script",
186 children=[Text("if (a < b && c > d) { alert('wow'); }")],
187 )
188 assert str(node) == ("<script>if (a < b && c > d) { alert('wow'); }</script>")
191# --------------------------------------------------------------------------
192# Interpolated non-text content
193# --------------------------------------------------------------------------
196def test_interpolated_false_content():
197 node = html(t"<div>{False}</div>")
198 assert node == Element("div")
199 assert str(node) == "<div></div>"
202def test_interpolated_none_content():
203 node = html(t"<div>{None}</div>")
204 assert node == Element("div", children=[])
205 assert str(node) == "<div></div>"
208def test_interpolated_zero_arg_function():
209 def get_value():
210 return "dynamic"
212 node = html(t"<p>The value is {get_value}.</p>")
213 assert node == Element(
214 "p", children=[Text("The value is "), Text("dynamic"), Text(".")]
215 )
218def test_interpolated_multi_arg_function_fails():
219 def add(a, b): # pragma: no cover
220 return a + b
222 with pytest.raises(TypeError):
223 _ = html(t"<p>The sum is {add}.</p>")
226# --------------------------------------------------------------------------
227# Raw HTML injection tests
228# --------------------------------------------------------------------------
231def test_raw_html_injection_with_markupsafe():
232 raw_content = Markup("<strong>I am bold</strong>")
233 node = html(t"<div>{raw_content}</div>")
234 assert node == Element("div", children=[Text(text=raw_content)])
235 assert str(node) == "<div><strong>I am bold</strong></div>"
238def test_raw_html_injection_with_dunder_html_protocol():
239 class SafeContent:
240 def __init__(self, text):
241 self._text = text
243 def __html__(self):
244 # In a real app, this would come from a sanitizer or trusted source
245 return f"<em>{self._text}</em>"
247 content = SafeContent("emphasized")
248 node = html(t"<p>Here is some {content}.</p>")
249 assert node == Element(
250 "p",
251 children=[
252 Text("Here is some "),
253 Text(Markup("<em>emphasized</em>")),
254 Text("."),
255 ],
256 )
257 assert str(node) == "<p>Here is some <em>emphasized</em>.</p>"
260def test_raw_html_injection_with_format_spec():
261 raw_content = "<u>underlined</u>"
262 node = html(t"<p>This is {raw_content:safe} text.</p>")
263 assert node == Element(
264 "p",
265 children=[
266 Text("This is "),
267 Text(Markup(raw_content)),
268 Text(" text."),
269 ],
270 )
271 assert str(node) == "<p>This is <u>underlined</u> text.</p>"
274def test_raw_html_injection_with_markupsafe_unsafe_format_spec():
275 supposedly_safe = Markup("<i>italic</i>")
276 node = html(t"<p>This is {supposedly_safe:unsafe} text.</p>")
277 assert node == Element(
278 "p",
279 children=[
280 Text("This is "),
281 Text(str(supposedly_safe)),
282 Text(" text."),
283 ],
284 )
285 assert str(node) == "<p>This is <i>italic</i> text.</p>"
288# --------------------------------------------------------------------------
289# Conditional rendering and control flow
290# --------------------------------------------------------------------------
293def test_conditional_rendering_with_if_else():
294 is_logged_in = True
295 user_profile = t"<span>Welcome, User!</span>"
296 login_prompt = t"<a href='/login'>Please log in</a>"
297 node = html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
299 assert node == Element(
300 "div", children=[Element("span", children=[Text("Welcome, User!")])]
301 )
302 assert str(node) == "<div><span>Welcome, User!</span></div>"
304 is_logged_in = False
305 node = html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
306 assert str(node) == '<div><a href="/login">Please log in</a></div>'
309def test_conditional_rendering_with_and():
310 show_warning = True
311 warning_message = t'<div class="warning">Warning!</div>'
312 node = html(t"<main>{show_warning and warning_message}</main>")
314 assert node == Element(
315 "main",
316 children=[
317 Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]),
318 ],
319 )
320 assert str(node) == '<main><div class="warning">Warning!</div></main>'
322 show_warning = False
323 node = html(t"<main>{show_warning and warning_message}</main>")
324 # Assuming False renders nothing
325 assert str(node) == "<main></main>"
328# --------------------------------------------------------------------------
329# Interpolated nesting of templates and elements
330# --------------------------------------------------------------------------
333def test_interpolated_template_content():
334 child = t"<span>Child</span>"
335 node = html(t"<div>{child}</div>")
336 assert node == Element("div", children=[html(child)])
337 assert str(node) == "<div><span>Child</span></div>"
340def test_interpolated_element_content():
341 child = html(t"<span>Child</span>")
342 node = html(t"<div>{child}</div>")
343 assert node == Element("div", children=[child])
344 assert str(node) == "<div><span>Child</span></div>"
347def test_interpolated_nonstring_content():
348 number = 42
349 node = html(t"<p>The answer is {number}.</p>")
350 assert node == Element(
351 "p", children=[Text("The answer is "), Text("42"), Text(".")]
352 )
353 assert str(node) == "<p>The answer is 42.</p>"
356def test_list_items():
357 items = ["Apple", "Banana", "Cherry"]
358 node = html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>")
359 assert node == Element(
360 "ul",
361 children=[
362 Element("li", children=[Text("Apple")]),
363 Element("li", children=[Text("Banana")]),
364 Element("li", children=[Text("Cherry")]),
365 ],
366 )
367 assert str(node) == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"
370def test_nested_list_items():
371 # TODO XXX this is a pretty abusrd test case; clean it up when refactoring
372 outer = ["fruit", "more fruit"]
373 inner = ["apple", "banana", "cherry"]
374 inner_items = [t"<li>{item}</li>" for item in inner]
375 outer_items = [t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer]
376 node = html(t"<ul>{outer_items}</ul>")
377 assert node == Element(
378 "ul",
379 children=[
380 Element(
381 "li",
382 children=[
383 Text("fruit"),
384 Element(
385 "ul",
386 children=[
387 Element("li", children=[Text("apple")]),
388 Element("li", children=[Text("banana")]),
389 Element("li", children=[Text("cherry")]),
390 ],
391 ),
392 ],
393 ),
394 Element(
395 "li",
396 children=[
397 Text("more fruit"),
398 Element(
399 "ul",
400 children=[
401 Element("li", children=[Text("apple")]),
402 Element("li", children=[Text("banana")]),
403 Element("li", children=[Text("cherry")]),
404 ],
405 ),
406 ],
407 ),
408 ],
409 )
410 assert (
411 str(node)
412 == "<ul><li>fruit<ul><li>apple</li><li>banana</li><li>cherry</li></ul></li><li>more fruit<ul><li>apple</li><li>banana</li><li>cherry</li></ul></li></ul>"
413 )
416# --------------------------------------------------------------------------
417# Interpolated attribute content
418# --------------------------------------------------------------------------
421def test_interpolated_attribute_value():
422 url = "https://example.com/"
423 node = html(t'<a href="{url}">Link</a>')
424 assert node == Element(
425 "a", attrs={"href": "https://example.com/"}, children=[Text("Link")]
426 )
427 assert str(node) == '<a href="https://example.com/">Link</a>'
430def test_escaping_of_interpolated_attribute_value():
431 url = 'https://example.com/?q="test"&lang=en'
432 node = html(t'<a href="{url}">Link</a>')
433 assert node == Element(
434 "a",
435 attrs={"href": 'https://example.com/?q="test"&lang=en'},
436 children=[Text("Link")],
437 )
438 assert (
439 str(node)
440 == '<a href="https://example.com/?q="test"&lang=en">Link</a>'
441 )
444def test_interpolated_unquoted_attribute_value():
445 id = "roquefort"
446 node = html(t"<div id={id}>Cheese</div>")
447 assert node == Element("div", attrs={"id": "roquefort"}, children=[Text("Cheese")])
448 assert str(node) == '<div id="roquefort">Cheese</div>'
451def test_interpolated_attribute_value_true():
452 disabled = True
453 node = html(t"<button disabled={disabled}>Click me</button>")
454 assert node == Element(
455 "button", attrs={"disabled": None}, children=[Text("Click me")]
456 )
457 assert str(node) == "<button disabled>Click me</button>"
460def test_interpolated_attribute_value_falsy():
461 disabled = False
462 crumpled = None
463 node = html(t"<button disabled={disabled} crumpled={crumpled}>Click me</button>")
464 assert node == Element("button", attrs={}, children=[Text("Click me")])
465 assert str(node) == "<button>Click me</button>"
468def test_interpolated_attribute_spread_dict():
469 attrs = {"href": "https://example.com/", "target": "_blank"}
470 node = html(t"<a {attrs}>Link</a>")
471 assert node == Element(
472 "a",
473 attrs={"href": "https://example.com/", "target": "_blank"},
474 children=[Text("Link")],
475 )
476 assert str(node) == '<a href="https://example.com/" target="_blank">Link</a>'
479def test_interpolated_mixed_attribute_values_and_spread_dict():
480 attrs = {"href": "https://example.com/", "id": "link1"}
481 target = "_blank"
482 node = html(t'<a {attrs} target="{target}">Link</a>')
483 assert node == Element(
484 "a",
485 attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
486 children=[Text("Link")],
487 )
488 assert (
489 str(node)
490 == '<a href="https://example.com/" id="link1" target="_blank">Link</a>'
491 )
494def test_multiple_attribute_spread_dicts():
495 attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
496 attrs2 = {"target": "_blank", "id": "link1"}
497 node = html(t"<a {attrs1} {attrs2}>Link</a>")
498 assert node == Element(
499 "a",
500 attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"},
501 children=[Text("Link")],
502 )
503 assert (
504 str(node)
505 == '<a href="https://example.com/" target="_blank" id="link1">Link</a>'
506 )
509def test_interpolated_class_attribute():
510 classes = ["btn", "btn-primary", False and "disabled", None, {"active": True}]
511 node = html(t'<button class="{classes}">Click me</button>')
512 assert node == Element(
513 "button",
514 attrs={"class": "btn btn-primary active"},
515 children=[Text("Click me")],
516 )
517 assert str(node) == '<button class="btn btn-primary active">Click me</button>'
520def test_interpolated_class_attribute_with_multiple_placeholders():
521 classes1 = ["btn", "btn-primary"]
522 classes2 = [False and "disabled", None, {"active": True}]
523 node = html(t'<button class="{classes1} {classes2}">Click me</button>')
524 # CONSIDER: Is this what we want? Currently, when we have multiple
525 # placeholders in a single attribute, we treat it as a string attribute.
526 assert node == Element(
527 "button",
528 attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
529 children=[Text("Click me")],
530 )
533def test_interpolated_attribute_spread_with_class_attribute():
534 attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
535 node = html(t"<button {attrs}>Click me</button>")
536 assert node == Element(
537 "button",
538 attrs={"id": "button1", "class": "btn btn-primary"},
539 children=[Text("Click me")],
540 )
541 assert str(node) == '<button id="button1" class="btn btn-primary">Click me</button>'
544def test_interpolated_attribute_value_embedded_placeholder():
545 slug = "item42"
546 node = html(t"<div data-id='prefix-{slug}'></div>")
547 assert node == Element(
548 "div",
549 attrs={"data-id": "prefix-item42"},
550 children=[],
551 )
552 assert str(node) == '<div data-id="prefix-item42"></div>'
555def test_interpolated_attribute_value_with_static_prefix_and_suffix():
556 counter = 3
557 node = html(t'<div data-id="item-{counter}-suffix"></div>')
558 assert node == Element(
559 "div",
560 attrs={"data-id": "item-3-suffix"},
561 children=[],
562 )
563 assert str(node) == '<div data-id="item-3-suffix"></div>'
566def test_attribute_value_empty_string():
567 node = html(t'<div data-id=""></div>')
568 assert node == Element(
569 "div",
570 attrs={"data-id": ""},
571 children=[],
572 )
575def test_interpolated_attribute_value_multiple_placeholders():
576 start = 1
577 end = 5
578 node = html(t'<div data-range="{start}-{end}"></div>')
579 assert node == Element(
580 "div",
581 attrs={"data-range": "1-5"},
582 children=[],
583 )
584 assert str(node) == '<div data-range="1-5"></div>'
587def test_interpolated_attribute_value_tricky_multiple_placeholders():
588 start = "start"
589 end = "end"
590 node = html(t'<div data-range="{start}5-and-{end}12"></div>')
591 assert node == Element(
592 "div",
593 attrs={"data-range": "start5-and-end12"},
594 children=[],
595 )
596 assert str(node) == '<div data-range="start5-and-end12"></div>'
599def test_placeholder_collision_avoidance():
600 # This test is to ensure that our placeholder detection avoids collisions
601 # even with content that might look like a placeholder.
602 tricky = "123"
603 template = Template(
604 '<div data-tricky="',
605 _PLACEHOLDER_PREFIX,
606 Interpolation(tricky, "tricky"),
607 _PLACEHOLDER_SUFFIX,
608 '"></div>',
609 )
610 node = html(template)
611 assert node == Element(
612 "div",
613 attrs={"data-tricky": _PLACEHOLDER_PREFIX + tricky + _PLACEHOLDER_SUFFIX},
614 children=[],
615 )
616 assert (
617 str(node)
618 == f'<div data-tricky="{_PLACEHOLDER_PREFIX}{tricky}{_PLACEHOLDER_SUFFIX}"></div>'
619 )
622def test_interpolated_attribute_value_multiple_placeholders_no_quotes():
623 start = 1
624 end = 5
625 node = html(t"<div data-range={start}-{end}></div>")
626 assert node == Element(
627 "div",
628 attrs={"data-range": "1-5"},
629 children=[],
630 )
631 assert str(node) == '<div data-range="1-5"></div>'
634def test_interpolated_data_attributes():
635 data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None}
636 node = html(t"<div data={data}>User Info</div>")
637 assert node == Element(
638 "div",
639 attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None},
640 children=[Text("User Info")],
641 )
642 assert (
643 str(node)
644 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>'
645 )
648def test_data_attr_toggle_to_str():
649 for node in [
650 html(t"<div data-selected data={dict(selected='yes')}></div>"),
651 html(t'<div data-selected="no" data={dict(selected="yes")}></div>'),
652 ]:
653 assert node == Element("div", {"data-selected": "yes"})
654 assert str(node) == '<div data-selected="yes"></div>'
657def test_data_attr_toggle_to_true():
658 node = html(t'<div data-selected="yes" data={dict(selected=True)}></div>')
659 assert node == Element("div", {"data-selected": None})
660 assert str(node) == "<div data-selected></div>"
663def test_data_attr_unrelated_unaffected():
664 node = html(t"<div data-selected data={dict(active=True)}></div>")
665 assert node == Element("div", {"data-selected": None, "data-active": None})
666 assert str(node) == "<div data-selected data-active></div>"
669@pytest.mark.skip(reason="Waiting on attribute resolution ... resolution.")
670def test_interpolated_data_attribute_multiple_placeholders():
671 confusing = {"user-id": "user-123"}
672 placeholders = {"role": "admin"}
673 with pytest.raises(TypeError):
674 node = html(t'<div data="{confusing} {placeholders}">User Info</div>')
675 print(str(node))
678def test_interpolated_aria_attributes():
679 aria = {"label": "Close", "hidden": True, "another": False, "more": None}
680 node = html(t"<button aria={aria}>X</button>")
681 assert node == Element(
682 "button",
683 attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"},
684 children=[Text("X")],
685 )
686 assert (
687 str(node)
688 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>'
689 )
692def test_interpolated_style_attribute():
693 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
694 node = html(t"<p style={styles}>Warning!</p>")
695 assert node == Element(
696 "p",
697 attrs={"style": "color: red; font-weight: bold; font-size: 16px"},
698 children=[Text("Warning!")],
699 )
700 assert (
701 str(node)
702 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>'
703 )
706def test_override_static_style_str_str():
707 node = html(t'<p style="font-color: red" {dict(style="font-size: 15px")}></p>')
708 assert node == Element("p", {"style": "font-size: 15px"})
709 assert str(node) == '<p style="font-size: 15px"></p>'
712def test_override_static_style_str_builder():
713 node = html(t'<p style="font-color: red" {dict(style={"font-size": "15px"})}></p>')
714 assert node == Element("p", {"style": "font-size: 15px"})
715 assert str(node) == '<p style="font-size: 15px"></p>'
718def test_interpolated_style_attribute_multiple_placeholders():
719 styles1 = {"color": "red"}
720 styles2 = {"font-weight": "bold"}
721 node = html(t"<p style='{styles1} {styles2}'>Warning!</p>")
722 # CONSIDER: Is this what we want? Currently, when we have multiple
723 # placeholders in a single attribute, we treat it as a string attribute.
724 assert node == Element(
725 "p",
726 attrs={"style": "{'color': 'red'} {'font-weight': 'bold'}"},
727 children=[Text("Warning!")],
728 )
731def test_style_attribute_str():
732 styles = "color: red; font-weight: bold;"
733 node = html(t"<p style={styles}>Warning!</p>")
734 assert node == Element(
735 "p",
736 attrs={"style": "color: red; font-weight: bold;"},
737 children=[Text("Warning!")],
738 )
739 assert str(node) == '<p style="color: red; font-weight: bold;">Warning!</p>'
742def test_style_attribute_non_str_non_dict():
743 with pytest.raises(TypeError):
744 styles = [1, 2]
745 _ = html(t"<p style={styles}>Warning!</p>")
748def test_special_attrs_as_static():
749 node = html(t'<p aria="aria?" data="data?" class="class?" style="style?"></p>')
750 assert node == Element(
751 "p",
752 attrs={"aria": "aria?", "data": "data?", "class": "class?", "style": "style?"},
753 )
756# --------------------------------------------------------------------------
757# Function component interpolation tests
758# --------------------------------------------------------------------------
761def FunctionComponent(
762 children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any
763) -> Template:
764 # Ensure type correctness of props at runtime for testing purposes
765 assert isinstance(first, str)
766 assert isinstance(second, int)
767 assert isinstance(third_arg, str)
768 new_attrs = {
769 "id": third_arg,
770 "data": {"first": first, "second": second},
771 **attrs,
772 }
773 return t"<div {new_attrs}>Component: {children}</div>"
776def test_interpolated_template_component():
777 node = html(
778 t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{FunctionComponent}>'
779 )
780 assert node == Element(
781 "div",
782 attrs={
783 "id": "comp1",
784 "data-first": "1",
785 "data-second": "99",
786 "class": "my-comp",
787 },
788 children=[Text("Component: "), Text("Hello, Component!")],
789 )
790 assert (
791 str(node)
792 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>'
793 )
796def test_interpolated_template_component_no_children_provided():
797 """Same test, but the caller didn't provide any children."""
798 node = html(
799 t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
800 )
801 assert node == Element(
802 "div",
803 attrs={
804 "id": "comp1",
805 "data-first": "1",
806 "data-second": "99",
807 "class": "my-comp",
808 },
809 children=[
810 Text("Component: "),
811 ],
812 )
813 assert (
814 str(node)
815 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>'
816 )
819def test_invalid_component_invocation():
820 with pytest.raises(TypeError):
821 _ = html(t"<{FunctionComponent}>Missing props</{FunctionComponent}>")
824def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template:
825 # Ensure type correctness of props at runtime for testing purposes
826 assert isinstance(first, str)
827 assert isinstance(second, int)
828 assert isinstance(third_arg, str)
829 new_attrs = {
830 "id": third_arg,
831 "data": {"first": first, "second": second},
832 }
833 return t"<div {new_attrs}>Component: ignore children</div>"
836def test_interpolated_template_component_ignore_children():
837 node = html(
838 t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{FunctionComponentNoChildren}>'
839 )
840 assert node == Element(
841 "div",
842 attrs={
843 "id": "comp1",
844 "data-first": "1",
845 "data-second": "99",
846 },
847 children=[Text(text="Component: ignore children")],
848 )
849 assert (
850 str(node)
851 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>'
852 )
855def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
856 # Ensure type correctness of props at runtime for testing purposes
857 assert isinstance(first, str)
858 assert "children" in attrs
859 _ = attrs.pop("children")
860 new_attrs = {"data-first": first, **attrs}
861 return t"<div {new_attrs}>Component with kwargs</div>"
864def test_children_always_passed_via_kwargs():
865 node = html(
866 t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content</{FunctionComponentKeywordArgs}>'
867 )
868 assert node == Element(
869 "div",
870 attrs={
871 "data-first": "value",
872 "extra": "info",
873 },
874 children=[Text("Component with kwargs")],
875 )
876 assert (
877 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>'
878 )
881def test_children_always_passed_via_kwargs_even_when_empty():
882 node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />')
883 assert node == Element(
884 "div",
885 attrs={
886 "data-first": "value",
887 "extra": "info",
888 },
889 children=[Text("Component with kwargs")],
890 )
891 assert (
892 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>'
893 )
896def ColumnsComponent() -> Template:
897 return t"""<td>Column 1</td><td>Column 2</td>"""
900def test_fragment_from_component():
901 # This test assumes that if a component returns a template that parses
902 # into multiple root elements, they are treated as a fragment.
903 node = html(t"<table><tr><{ColumnsComponent} /></tr></table>")
904 assert node == Element(
905 "table",
906 children=[
907 Element(
908 "tr",
909 children=[
910 Element("td", children=[Text("Column 1")]),
911 Element("td", children=[Text("Column 2")]),
912 ],
913 ),
914 ],
915 )
916 assert str(node) == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
919def test_component_passed_as_attr_value():
920 def Wrapper(
921 children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any
922 ) -> Template:
923 return t"<{sub_component} {attrs}>{children}</{sub_component}>"
925 node = html(
926 t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>'
927 )
928 assert node == Element(
929 "div",
930 attrs={
931 "id": "comp1",
932 "data-first": "1",
933 "data-second": "99",
934 "class": "wrapped",
935 },
936 children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])],
937 )
938 assert (
939 str(node)
940 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
941 )
944def test_nested_component_gh23():
945 # See https://github.com/t-strings/tdom/issues/23 for context
946 def Header():
947 return html(t"{'Hello World'}")
949 node = html(t"<{Header} />")
950 assert node == Text("Hello World")
951 assert str(node) == "Hello World"
954def test_component_returning_iterable():
955 def Items() -> t.Iterable:
956 for i in range(2):
957 yield t"<li>Item {i + 1}</li>"
958 yield html(t"<li>Item {3}</li>")
960 node = html(t"<ul><{Items} /></ul>")
961 assert node == Element(
962 "ul",
963 children=[
964 Element("li", children=[Text("Item "), Text("1")]),
965 Element("li", children=[Text("Item "), Text("2")]),
966 Element("li", children=[Text("Item "), Text("3")]),
967 ],
968 )
969 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>"
972def test_component_returning_fragment():
973 def Items() -> Node:
974 return html(t"<li>Item {1}</li><li>Item {2}</li><li>Item {3}</li>")
976 node = html(t"<ul><{Items} /></ul>")
977 assert node == Element(
978 "ul",
979 children=[
980 Element("li", children=[Text("Item "), Text("1")]),
981 Element("li", children=[Text("Item "), Text("2")]),
982 Element("li", children=[Text("Item "), Text("3")]),
983 ],
984 )
985 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>"
988@dataclass
989class ClassComponent:
990 """Example class-based component."""
992 user_name: str
993 image_url: str
994 homepage: str = "#"
995 children: t.Iterable[Node] = field(default_factory=list)
997 def __call__(self) -> Node:
998 return html(
999 t"<div class='avatar'>"
1000 t"<a href={self.homepage}>"
1001 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1002 t"</a>"
1003 t"<span>{self.user_name}</span>"
1004 t"{self.children}"
1005 t"</div>",
1006 )
1009def test_class_component_implicit_invocation_with_children():
1010 node = html(
1011 t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{ClassComponent}>"
1012 )
1013 assert node == Element(
1014 "div",
1015 attrs={"class": "avatar"},
1016 children=[
1017 Element(
1018 "a",
1019 attrs={"href": "#"},
1020 children=[
1021 Element(
1022 "img",
1023 attrs={
1024 "src": "https://example.com/alice.png",
1025 "alt": "Avatar of Alice",
1026 },
1027 )
1028 ],
1029 ),
1030 Element("span", children=[Text("Alice")]),
1031 Text("Fun times!"),
1032 ],
1033 )
1034 assert (
1035 str(node)
1036 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>'
1037 )
1040def test_class_component_direct_invocation():
1041 avatar = ClassComponent(
1042 user_name="Alice",
1043 image_url="https://example.com/alice.png",
1044 homepage="https://example.com/users/alice",
1045 )
1046 node = html(t"<{avatar} />")
1047 assert node == Element(
1048 "div",
1049 attrs={"class": "avatar"},
1050 children=[
1051 Element(
1052 "a",
1053 attrs={"href": "https://example.com/users/alice"},
1054 children=[
1055 Element(
1056 "img",
1057 attrs={
1058 "src": "https://example.com/alice.png",
1059 "alt": "Avatar of Alice",
1060 },
1061 )
1062 ],
1063 ),
1064 Element("span", children=[Text("Alice")]),
1065 ],
1066 )
1067 assert (
1068 str(node)
1069 == '<div class="avatar"><a href="https://example.com/users/alice"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span></div>'
1070 )
1073@dataclass
1074class ClassComponentNoChildren:
1075 """Example class-based component that does not ask for children."""
1077 user_name: str
1078 image_url: str
1079 homepage: str = "#"
1081 def __call__(self) -> Node:
1082 return html(
1083 t"<div class='avatar'>"
1084 t"<a href={self.homepage}>"
1085 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1086 t"</a>"
1087 t"<span>{self.user_name}</span>"
1088 t"ignore children"
1089 t"</div>",
1090 )
1093def test_class_component_implicit_invocation_ignore_children():
1094 node = html(
1095 t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{ClassComponentNoChildren}>"
1096 )
1097 assert node == Element(
1098 "div",
1099 attrs={"class": "avatar"},
1100 children=[
1101 Element(
1102 "a",
1103 attrs={"href": "#"},
1104 children=[
1105 Element(
1106 "img",
1107 attrs={
1108 "src": "https://example.com/alice.png",
1109 "alt": "Avatar of Alice",
1110 },
1111 )
1112 ],
1113 ),
1114 Element("span", children=[Text("Alice")]),
1115 Text("ignore children"),
1116 ],
1117 )
1118 assert (
1119 str(node)
1120 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>'
1121 )
1124def AttributeTypeComponent(
1125 data_int: int,
1126 data_true: bool,
1127 data_false: bool,
1128 data_none: None,
1129 data_float: float,
1130 data_dt: datetime.datetime,
1131 **kws: dict[str, object | None],
1132) -> Template:
1133 """Component to test that we don't incorrectly convert attribute types."""
1134 assert isinstance(data_int, int)
1135 assert data_true is True
1136 assert data_false is False
1137 assert data_none is None
1138 assert isinstance(data_float, float)
1139 assert isinstance(data_dt, datetime.datetime)
1140 for kw, v_type in [
1141 ("spread_true", True),
1142 ("spread_false", False),
1143 ("spread_int", int),
1144 ("spread_none", None),
1145 ("spread_float", float),
1146 ("spread_dt", datetime.datetime),
1147 ("spread_dict", dict),
1148 ("spread_list", list),
1149 ]:
1150 if v_type in (True, False, None):
1151 assert kw in kws and kws[kw] is v_type, (
1152 f"{kw} should be {v_type} but got {kws=}"
1153 )
1154 else:
1155 assert kw in kws and isinstance(kws[kw], v_type), (
1156 f"{kw} should instance of {v_type} but got {kws=}"
1157 )
1158 return t"Looks good!"
1161def test_attribute_type_component():
1162 an_int: int = 42
1163 a_true: bool = True
1164 a_false: bool = False
1165 a_none: None = None
1166 a_float: float = 3.14
1167 a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0)
1168 spread_attrs: dict[str, object | None] = {
1169 "spread_true": True,
1170 "spread_false": False,
1171 "spread_none": None,
1172 "spread_int": 0,
1173 "spread_float": 0.0,
1174 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1),
1175 "spread_dict": dict(),
1176 "spread_list": ["eggs", "milk"],
1177 }
1178 node = html(
1179 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
1180 t"data-false={a_false} data-none={a_none} data-float={a_float} "
1181 t"data-dt={a_dt} {spread_attrs}/>"
1182 )
1183 assert node == Text("Looks good!")
1184 assert str(node) == "Looks good!"
1187def test_component_non_callable_fails():
1188 with pytest.raises(TypeError):
1189 _ = html(t"<{'not a function'} />")
1192def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
1193 return t"<p>Positional arg: {whoops}</p>"
1196def test_component_requiring_positional_arg_fails():
1197 with pytest.raises(TypeError):
1198 _ = html(t"<{RequiresPositional} />")
1201def test_mismatched_component_closing_tag_fails():
1202 with pytest.raises(TypeError):
1203 _ = html(
1204 t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello</{ClassComponent}>"
1205 )
1208def test_replace_static_attr_str_str():
1209 node = html(t'<div title="default" {dict(title="fresh")}></div>')
1210 assert node == Element("div", {"title": "fresh"})
1211 assert str(node) == '<div title="fresh"></div>'
1214def test_replace_static_attr_str_true():
1215 node = html(t'<div title="default" {dict(title=True)}></div>')
1216 assert node == Element("div", {"title": None})
1217 assert str(node) == "<div title></div>"
1220def test_replace_static_attr_true_str():
1221 node = html(t"<div title {dict(title='fresh')}></div>")
1222 assert node == Element("div", {"title": "fresh"})
1223 assert str(node) == '<div title="fresh"></div>'
1226def test_remove_static_attr_str_none():
1227 node = html(t'<div title="default" {dict(title=None)}></div>')
1228 assert node == Element("div")
1229 assert str(node) == "<div></div>"
1232def test_remove_static_attr_true_none():
1233 node = html(t"<div title {dict(title=None)}></div>")
1234 assert node == Element("div")
1235 assert str(node) == "<div></div>"
1238def test_other_static_attr_intact():
1239 node = html(t'<img title="default" {dict(alt="fresh")}>')
1240 assert node == Element("img", {"title": "default", "alt": "fresh"})
1241 assert str(node) == '<img title="default" alt="fresh" />'