Coverage for tdom/processor_test.py: 100%
417 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-31 17:14 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-31 17:14 +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 Element, Fragment, Node, Text
10from .processor import _PLACEHOLDER_PREFIX, _PLACEHOLDER_SUFFIX, html
12# --------------------------------------------------------------------------
13# Basic HTML parsing tests
14# --------------------------------------------------------------------------
17def test_parse_empty():
18 node = html(t"")
19 assert node == Text("")
20 assert str(node) == ""
23def test_parse_text():
24 node = html(t"Hello, world!")
25 assert node == Text("Hello, world!")
26 assert str(node) == "Hello, world!"
29def test_parse_void_element():
30 node = html(t"<br>")
31 assert node == Element("br")
32 assert str(node) == "<br />"
35def test_parse_void_element_self_closed():
36 node = html(t"<br />")
37 assert node == Element("br")
38 assert str(node) == "<br />"
41def test_parse_chain_of_void_elements():
42 # Make sure our handling of CPython issue #69445 is reasonable.
43 node = html(t"<br><hr><img src='image.png' /><br /><hr>")
44 assert node == Fragment(
45 children=[
46 Element("br"),
47 Element("hr"),
48 Element("img", attrs={"src": "image.png"}),
49 Element("br"),
50 Element("hr"),
51 ],
52 )
53 assert str(node) == '<br /><hr /><img src="image.png" /><br /><hr />'
56def test_parse_element_with_text():
57 node = html(t"<p>Hello, world!</p>")
58 assert node == Element(
59 "p",
60 children=[
61 Text("Hello, world!"),
62 ],
63 )
64 assert str(node) == "<p>Hello, world!</p>"
67def test_parse_element_with_attributes():
68 node = html(t'<a href="https://example.com" target="_blank">Link</a>')
69 assert node == Element(
70 "a",
71 attrs={"href": "https://example.com", "target": "_blank"},
72 children=[
73 Text("Link"),
74 ],
75 )
76 assert str(node) == '<a href="https://example.com" target="_blank">Link</a>'
79def test_parse_nested_elements():
80 node = html(t"<div><p>Hello</p><p>World</p></div>")
81 assert node == Element(
82 "div",
83 children=[
84 Element("p", children=[Text("Hello")]),
85 Element("p", children=[Text("World")]),
86 ],
87 )
88 assert str(node) == "<div><p>Hello</p><p>World</p></div>"
91def test_parse_entities_are_escaped():
92 node = html(t"<p></p></p>")
93 assert node == Element(
94 "p",
95 children=[Text("</p>")],
96 )
97 assert str(node) == "<p></p></p>"
100# --------------------------------------------------------------------------
101# Interpolated text content
102# --------------------------------------------------------------------------
105def test_interpolated_text_content():
106 name = "Alice"
107 node = html(t"<p>Hello, {name}!</p>")
108 assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")])
109 assert str(node) == "<p>Hello, Alice!</p>"
112def test_escaping_of_interpolated_text_content():
113 name = "<Alice & Bob>"
114 node = html(t"<p>Hello, {name}!</p>")
115 assert node == Element(
116 "p", children=[Text("Hello, "), Text("<Alice & Bob>"), Text("!")]
117 )
118 assert str(node) == "<p>Hello, <Alice & Bob>!</p>"
121class Convertible:
122 def __str__(self):
123 return "string"
125 def __repr__(self):
126 return "repr"
129def test_conversions():
130 c = Convertible()
131 assert f"{c!s}" == "string"
132 assert f"{c!r}" == "repr"
133 node = html(t"<li>{c!s}</li><li>{c!r}</li><li>{'😊'!a}</li>")
134 assert node == Fragment(
135 children=[
136 Element("li", children=[Text("string")]),
137 Element("li", children=[Text("repr")]),
138 Element("li", children=[Text("'\\U0001f60a'")]),
139 ],
140 )
143def test_interpolated_in_content_node():
144 # https://github.com/t-strings/tdom/issues/68
145 evil = "</style><script>alert('whoops');</script><style>"
146 node = html(t"<style>{evil}</style>")
147 assert node == Element(
148 "style",
149 children=[Text("</style><script>alert('whoops');</script><style>")],
150 )
151 assert (
152 str(node)
153 == "<style></style><script>alert('whoops');</script><style></style>"
154 )
157def test_interpolated_trusted_in_content_node():
158 # https://github.com/t-strings/tdom/issues/68
159 node = html(t"<script>if (a < b && c > d) { alert('wow'); } </script>")
160 assert node == Element(
161 "script",
162 children=[Text(Markup("if (a < b && c > d) { alert('wow'); }"))],
163 )
164 assert str(node) == ("<script>if (a < b && c > d) { alert('wow'); }</script>")
167# --------------------------------------------------------------------------
168# Interpolated non-text content
169# --------------------------------------------------------------------------
172def test_interpolated_false_content():
173 node = html(t"<div>{False}</div>")
174 assert node == Element("div", children=[])
175 assert str(node) == "<div></div>"
178def test_interpolated_none_content():
179 node = html(t"<div>{None}</div>")
180 assert node == Element("div", children=[])
181 assert str(node) == "<div></div>"
184def test_interpolated_zero_arg_function():
185 def get_value():
186 return "dynamic"
188 node = html(t"<p>The value is {get_value}.</p>")
189 assert node == Element(
190 "p", children=[Text("The value is "), Text("dynamic"), Text(".")]
191 )
194def test_interpolated_multi_arg_function_fails():
195 def add(a, b): # pragma: no cover
196 return a + b
198 with pytest.raises(TypeError):
199 _ = html(t"<p>The sum is {add}.</p>")
202# --------------------------------------------------------------------------
203# Raw HTML injection tests
204# --------------------------------------------------------------------------
207def test_raw_html_injection_with_markupsafe():
208 raw_content = Markup("<strong>I am bold</strong>")
209 node = html(t"<div>{raw_content}</div>")
210 assert node == Element("div", children=[Text(text=raw_content)])
211 assert str(node) == "<div><strong>I am bold</strong></div>"
214def test_raw_html_injection_with_dunder_html_protocol():
215 class SafeContent:
216 def __init__(self, text):
217 self._text = text
219 def __html__(self):
220 # In a real app, this would come from a sanitizer or trusted source
221 return f"<em>{self._text}</em>"
223 content = SafeContent("emphasized")
224 node = html(t"<p>Here is some {content}.</p>")
225 assert node == Element(
226 "p",
227 children=[
228 Text("Here is some "),
229 Text(Markup("<em>emphasized</em>")),
230 Text("."),
231 ],
232 )
233 assert str(node) == "<p>Here is some <em>emphasized</em>.</p>"
236def test_raw_html_injection_with_format_spec():
237 raw_content = "<u>underlined</u>"
238 node = html(t"<p>This is {raw_content:safe} text.</p>")
239 assert node == Element(
240 "p",
241 children=[
242 Text("This is "),
243 Text(Markup(raw_content)),
244 Text(" text."),
245 ],
246 )
247 assert str(node) == "<p>This is <u>underlined</u> text.</p>"
250def test_raw_html_injection_with_markupsafe_unsafe_format_spec():
251 supposedly_safe = Markup("<i>italic</i>")
252 node = html(t"<p>This is {supposedly_safe:unsafe} text.</p>")
253 assert node == Element(
254 "p",
255 children=[
256 Text("This is "),
257 Text(str(supposedly_safe)),
258 Text(" text."),
259 ],
260 )
261 assert str(node) == "<p>This is <i>italic</i> text.</p>"
264# --------------------------------------------------------------------------
265# Conditional rendering and control flow
266# --------------------------------------------------------------------------
269def test_conditional_rendering_with_if_else():
270 is_logged_in = True
271 user_profile = t"<span>Welcome, User!</span>"
272 login_prompt = t"<a href='/login'>Please log in</a>"
273 node = html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
275 assert node == Element(
276 "div", children=[Element("span", children=[Text("Welcome, User!")])]
277 )
278 assert str(node) == "<div><span>Welcome, User!</span></div>"
280 is_logged_in = False
281 node = html(t"<div>{user_profile if is_logged_in else login_prompt}</div>")
282 assert str(node) == '<div><a href="/login">Please log in</a></div>'
285def test_conditional_rendering_with_and():
286 show_warning = True
287 warning_message = t'<div class="warning">Warning!</div>'
288 node = html(t"<main>{show_warning and warning_message}</main>")
290 assert node == Element(
291 "main",
292 children=[
293 Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]),
294 ],
295 )
296 assert str(node) == '<main><div class="warning">Warning!</div></main>'
298 show_warning = False
299 node = html(t"<main>{show_warning and warning_message}</main>")
300 # Assuming False renders nothing
301 assert str(node) == "<main></main>"
304# --------------------------------------------------------------------------
305# Interpolated nesting of templates and elements
306# --------------------------------------------------------------------------
309def test_interpolated_template_content():
310 child = t"<span>Child</span>"
311 node = html(t"<div>{child}</div>")
312 assert node == Element("div", children=[html(child)])
313 assert str(node) == "<div><span>Child</span></div>"
316def test_interpolated_element_content():
317 child = html(t"<span>Child</span>")
318 node = html(t"<div>{child}</div>")
319 assert node == Element("div", children=[child])
320 assert str(node) == "<div><span>Child</span></div>"
323def test_interpolated_nonstring_content():
324 number = 42
325 node = html(t"<p>The answer is {number}.</p>")
326 assert node == Element(
327 "p", children=[Text("The answer is "), Text("42"), Text(".")]
328 )
329 assert str(node) == "<p>The answer is 42.</p>"
332def test_list_items():
333 items = ["Apple", "Banana", "Cherry"]
334 node = html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>")
335 assert node == Element(
336 "ul",
337 children=[
338 Element("li", children=[Text("Apple")]),
339 Element("li", children=[Text("Banana")]),
340 Element("li", children=[Text("Cherry")]),
341 ],
342 )
343 assert str(node) == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>"
346def test_nested_list_items():
347 # TODO XXX this is a pretty abusrd test case; clean it up when refactoring
348 outer = ["fruit", "more fruit"]
349 inner = ["apple", "banana", "cherry"]
350 inner_items = [t"<li>{item}</li>" for item in inner]
351 outer_items = [t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer]
352 node = html(t"<ul>{outer_items}</ul>")
353 assert node == Element(
354 "ul",
355 children=[
356 Element(
357 "li",
358 children=[
359 Text("fruit"),
360 Element(
361 "ul",
362 children=[
363 Element("li", children=[Text("apple")]),
364 Element("li", children=[Text("banana")]),
365 Element("li", children=[Text("cherry")]),
366 ],
367 ),
368 ],
369 ),
370 Element(
371 "li",
372 children=[
373 Text("more fruit"),
374 Element(
375 "ul",
376 children=[
377 Element("li", children=[Text("apple")]),
378 Element("li", children=[Text("banana")]),
379 Element("li", children=[Text("cherry")]),
380 ],
381 ),
382 ],
383 ),
384 ],
385 )
386 assert (
387 str(node)
388 == "<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>"
389 )
392# --------------------------------------------------------------------------
393# Interpolated attribute content
394# --------------------------------------------------------------------------
397def test_interpolated_attribute_value():
398 url = "https://example.com/"
399 node = html(t'<a href="{url}">Link</a>')
400 assert node == Element(
401 "a", attrs={"href": "https://example.com/"}, children=[Text("Link")]
402 )
403 assert str(node) == '<a href="https://example.com/">Link</a>'
406def test_escaping_of_interpolated_attribute_value():
407 url = 'https://example.com/?q="test"&lang=en'
408 node = html(t'<a href="{url}">Link</a>')
409 assert node == Element(
410 "a",
411 attrs={"href": Markup('https://example.com/?q="test"&lang=en')},
412 children=[Text("Link")],
413 )
414 assert (
415 str(node)
416 == '<a href="https://example.com/?q="test"&lang=en">Link</a>'
417 )
420def test_interpolated_unquoted_attribute_value():
421 id = "roquefort"
422 node = html(t"<div id={id}>Cheese</div>")
423 assert node == Element("div", attrs={"id": "roquefort"}, children=[Text("Cheese")])
424 assert str(node) == '<div id="roquefort">Cheese</div>'
427def test_interpolated_attribute_value_true():
428 disabled = True
429 node = html(t"<button disabled={disabled}>Click me</button>")
430 assert node == Element(
431 "button", attrs={"disabled": None}, children=[Text("Click me")]
432 )
433 assert str(node) == "<button disabled>Click me</button>"
436def test_interpolated_attribute_value_falsy():
437 disabled = False
438 crumpled = None
439 node = html(t"<button disabled={disabled} crumpled={crumpled}>Click me</button>")
440 assert node == Element("button", attrs={}, children=[Text("Click me")])
441 assert str(node) == "<button>Click me</button>"
444def test_interpolated_attribute_spread_dict():
445 attrs = {"href": "https://example.com/", "target": "_blank"}
446 node = html(t"<a {attrs}>Link</a>")
447 assert node == Element(
448 "a",
449 attrs={"href": "https://example.com/", "target": "_blank"},
450 children=[Text("Link")],
451 )
452 assert str(node) == '<a href="https://example.com/" target="_blank">Link</a>'
455def test_interpolated_mixed_attribute_values_and_spread_dict():
456 attrs = {"href": "https://example.com/", "id": "link1"}
457 target = "_blank"
458 node = html(t'<a {attrs} target="{target}">Link</a>')
459 assert node == Element(
460 "a",
461 attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
462 children=[Text("Link")],
463 )
464 assert (
465 str(node)
466 == '<a href="https://example.com/" id="link1" target="_blank">Link</a>'
467 )
470def test_multiple_attribute_spread_dicts():
471 attrs1 = {"href": "https://example.com/", "id": "overwrtten"}
472 attrs2 = {"target": "_blank", "id": "link1"}
473 node = html(t"<a {attrs1} {attrs2}>Link</a>")
474 assert node == Element(
475 "a",
476 attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"},
477 children=[Text("Link")],
478 )
479 assert (
480 str(node)
481 == '<a href="https://example.com/" id="link1" target="_blank">Link</a>'
482 )
485def test_interpolated_class_attribute():
486 classes = ["btn", "btn-primary", False and "disabled", None, {"active": True}]
487 node = html(t'<button class="{classes}">Click me</button>')
488 assert node == Element(
489 "button",
490 attrs={"class": "btn btn-primary active"},
491 children=[Text("Click me")],
492 )
493 assert str(node) == '<button class="btn btn-primary active">Click me</button>'
496def test_interpolated_class_attribute_with_multiple_placeholders():
497 classes1 = ["btn", "btn-primary"]
498 classes2 = [False and "disabled", None, {"active": True}]
499 node = html(t'<button class="{classes1} {classes2}">Click me</button>')
500 # CONSIDER: Is this what we want? Currently, when we have multiple
501 # placeholders in a single attribute, we treat it as a string attribute.
502 assert node == Element(
503 "button",
504 attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"},
505 children=[Text("Click me")],
506 )
509def test_interpolated_attribute_spread_with_class_attribute():
510 attrs = {"id": "button1", "class": ["btn", "btn-primary"]}
511 node = html(t"<button {attrs}>Click me</button>")
512 assert node == Element(
513 "button",
514 attrs={"id": "button1", "class": "btn btn-primary"},
515 children=[Text("Click me")],
516 )
517 assert str(node) == '<button id="button1" class="btn btn-primary">Click me</button>'
520def test_interpolated_attribute_value_embedded_placeholder():
521 slug = "item42"
522 node = html(t"<div data-id='prefix-{slug}'></div>")
523 assert node == Element(
524 "div",
525 attrs={"data-id": "prefix-item42"},
526 children=[],
527 )
528 assert str(node) == '<div data-id="prefix-item42"></div>'
531def test_interpolated_attribute_value_with_static_prefix_and_suffix():
532 counter = 3
533 node = html(t'<div data-id="item-{counter}-suffix"></div>')
534 assert node == Element(
535 "div",
536 attrs={"data-id": "item-3-suffix"},
537 children=[],
538 )
539 assert str(node) == '<div data-id="item-3-suffix"></div>'
542def test_attribute_value_empty_string():
543 node = html(t'<div data-id=""></div>')
544 assert node == Element(
545 "div",
546 attrs={"data-id": ""},
547 children=[],
548 )
551def test_interpolated_attribute_value_multiple_placeholders():
552 start = 1
553 end = 5
554 node = html(t'<div data-range="{start}-{end}"></div>')
555 assert node == Element(
556 "div",
557 attrs={"data-range": "1-5"},
558 children=[],
559 )
560 assert str(node) == '<div data-range="1-5"></div>'
563def test_interpolated_attribute_value_tricky_multiple_placeholders():
564 start = "start"
565 end = "end"
566 node = html(t'<div data-range="{start}5-and-{end}12"></div>')
567 assert node == Element(
568 "div",
569 attrs={"data-range": "start5-and-end12"},
570 children=[],
571 )
572 assert str(node) == '<div data-range="start5-and-end12"></div>'
575def test_placeholder_collision_avoidance():
576 # This test is to ensure that our placeholder detection avoids collisions
577 # even with content that might look like a placeholder.
578 tricky = "123"
579 template = Template(
580 '<div data-tricky="',
581 _PLACEHOLDER_PREFIX,
582 Interpolation(tricky, "tricky"),
583 _PLACEHOLDER_SUFFIX,
584 '"></div>',
585 )
586 node = html(template)
587 assert node == Element(
588 "div",
589 attrs={"data-tricky": _PLACEHOLDER_PREFIX + tricky + _PLACEHOLDER_SUFFIX},
590 children=[],
591 )
592 assert (
593 str(node)
594 == f'<div data-tricky="{_PLACEHOLDER_PREFIX}{tricky}{_PLACEHOLDER_SUFFIX}"></div>'
595 )
598def test_interpolated_attribute_value_multiple_placeholders_no_quotes():
599 start = 1
600 end = 5
601 node = html(t"<div data-range={start}-{end}></div>")
602 assert node == Element(
603 "div",
604 attrs={"data-range": "1-5"},
605 children=[],
606 )
607 assert str(node) == '<div data-range="1-5"></div>'
610def test_interpolated_data_attributes():
611 data = {"user-id": 123, "role": "admin", "wild": True}
612 node = html(t"<div data={data}>User Info</div>")
613 assert node == Element(
614 "div",
615 attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None},
616 children=[Text("User Info")],
617 )
618 assert (
619 str(node)
620 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>'
621 )
624def test_interpolated_data_attribute_multiple_placeholders():
625 confusing = {"user-id": "user-123"}
626 placeholders = {"role": "admin"}
627 with pytest.raises(TypeError):
628 _ = html(t'<div data="{confusing} {placeholders}">User Info</div>')
631def test_interpolated_aria_attributes():
632 aria = {"label": "Close", "hidden": True, "another": False, "more": None}
633 node = html(t"<button aria={aria}>X</button>")
634 assert node == Element(
635 "button",
636 attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"},
637 children=[Text("X")],
638 )
639 assert (
640 str(node)
641 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>'
642 )
645def test_interpolated_aria_attribute_multiple_placeholders():
646 confusing = {"label": "Close"}
647 placeholders = {"hidden": True}
648 with pytest.raises(TypeError):
649 _ = html(t'<button aria="{confusing} {placeholders}">X</button>')
652def test_interpolated_style_attribute():
653 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"}
654 node = html(t"<p style={styles}>Warning!</p>")
655 assert node == Element(
656 "p",
657 attrs={"style": "color: red; font-weight: bold; font-size: 16px"},
658 children=[Text("Warning!")],
659 )
660 assert (
661 str(node)
662 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>'
663 )
666def test_interpolated_style_attribute_multiple_placeholders():
667 styles1 = {"color": "red"}
668 styles2 = {"font-weight": "bold"}
669 node = html(t"<p style='{styles1} {styles2}'>Warning!</p>")
670 # CONSIDER: Is this what we want? Currently, when we have multiple
671 # placeholders in a single attribute, we treat it as a string attribute.
672 assert node == Element(
673 "p",
674 attrs={"style": "{'color': 'red'} {'font-weight': 'bold'}"},
675 children=[Text("Warning!")],
676 )
679def test_style_attribute_str():
680 styles = "color: red; font-weight: bold;"
681 node = html(t"<p style={styles}>Warning!</p>")
682 assert node == Element(
683 "p",
684 attrs={"style": "color: red; font-weight: bold;"},
685 children=[Text("Warning!")],
686 )
687 assert str(node) == '<p style="color: red; font-weight: bold;">Warning!</p>'
690def test_style_attribute_non_str_non_dict():
691 with pytest.raises(TypeError):
692 styles = [1, 2]
693 _ = html(t"<p style={styles}>Warning!</p>")
696# --------------------------------------------------------------------------
697# Function component interpolation tests
698# --------------------------------------------------------------------------
701def FunctionComponent(
702 children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any
703) -> Template:
704 # Ensure type correctness of props at runtime for testing purposes
705 assert isinstance(first, str)
706 assert isinstance(second, int)
707 assert isinstance(third_arg, str)
708 new_attrs = {
709 "id": third_arg,
710 "data": {"first": first, "second": second},
711 **attrs,
712 }
713 return t"<div {new_attrs}>Component: {children}</div>"
716def test_interpolated_template_component():
717 node = html(
718 t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{FunctionComponent}>'
719 )
720 assert node == Element(
721 "div",
722 attrs={
723 "id": "comp1",
724 "data-first": "1",
725 "data-second": "99",
726 "class": "my-comp",
727 },
728 children=[Text("Component: "), Text("Hello, Component!")],
729 )
730 assert (
731 str(node)
732 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>'
733 )
736def test_interpolated_template_component_no_children_provided():
737 """Same test, but the caller didn't provide any children."""
738 node = html(
739 t'<{FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />'
740 )
741 assert node == Element(
742 "div",
743 attrs={
744 "id": "comp1",
745 "data-first": "1",
746 "data-second": "99",
747 "class": "my-comp",
748 },
749 children=[
750 Text("Component: "),
751 ],
752 )
753 assert (
754 str(node)
755 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>'
756 )
759def test_invalid_component_invocation():
760 with pytest.raises(TypeError):
761 _ = html(t"<{FunctionComponent}>Missing props</{FunctionComponent}>")
764def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template:
765 # Ensure type correctness of props at runtime for testing purposes
766 assert isinstance(first, str)
767 assert isinstance(second, int)
768 assert isinstance(third_arg, str)
769 new_attrs = {
770 "id": third_arg,
771 "data": {"first": first, "second": second},
772 }
773 return t"<div {new_attrs}>Component: ignore children</div>"
776def test_interpolated_template_component_ignore_children():
777 node = html(
778 t'<{FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{FunctionComponentNoChildren}>'
779 )
780 assert node == Element(
781 "div",
782 attrs={
783 "id": "comp1",
784 "data-first": "1",
785 "data-second": "99",
786 },
787 children=[Text(text="Component: ignore children")],
788 )
789 assert (
790 str(node)
791 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>'
792 )
795def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template:
796 # Ensure type correctness of props at runtime for testing purposes
797 assert isinstance(first, str)
798 assert "children" in attrs
799 _ = attrs.pop("children")
800 new_attrs = {"data-first": first, **attrs}
801 return t"<div {new_attrs}>Component with kwargs</div>"
804def test_children_always_passed_via_kwargs():
805 node = html(
806 t'<{FunctionComponentKeywordArgs} first="value" extra="info">Child content</{FunctionComponentKeywordArgs}>'
807 )
808 assert node == Element(
809 "div",
810 attrs={
811 "data-first": "value",
812 "extra": "info",
813 },
814 children=[Text("Component with kwargs")],
815 )
816 assert (
817 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>'
818 )
821def test_children_always_passed_via_kwargs_even_when_empty():
822 node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />')
823 assert node == Element(
824 "div",
825 attrs={
826 "data-first": "value",
827 "extra": "info",
828 },
829 children=[Text("Component with kwargs")],
830 )
831 assert (
832 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>'
833 )
836def ColumnsComponent() -> Template:
837 return t"""<td>Column 1</td><td>Column 2</td>"""
840def test_fragment_from_component():
841 # This test assumes that if a component returns a template that parses
842 # into multiple root elements, they are treated as a fragment.
843 node = html(t"<table><tr><{ColumnsComponent} /></tr></table>")
844 assert node == Element(
845 "table",
846 children=[
847 Element(
848 "tr",
849 children=[
850 Element("td", children=[Text("Column 1")]),
851 Element("td", children=[Text("Column 2")]),
852 ],
853 ),
854 ],
855 )
856 assert str(node) == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>"
859def test_component_passed_as_attr_value():
860 def Wrapper(
861 children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any
862 ) -> Template:
863 return t"<{sub_component} {attrs}>{children}</{sub_component}>"
865 node = html(
866 t'<{Wrapper} sub-component={FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>'
867 )
868 assert node == Element(
869 "div",
870 attrs={
871 "id": "comp1",
872 "data-first": "1",
873 "data-second": "99",
874 "class": "wrapped",
875 },
876 children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])],
877 )
878 assert (
879 str(node)
880 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>'
881 )
884def test_nested_component_gh23():
885 # See https://github.com/t-strings/tdom/issues/23 for context
886 def Header():
887 return html(t"{'Hello World'}")
889 node = html(t"<{Header} />")
890 assert node == Text("Hello World")
891 assert str(node) == "Hello World"
894def test_component_returning_iterable():
895 def Items() -> t.Iterable:
896 for i in range(2):
897 yield t"<li>Item {i + 1}</li>"
898 yield html(t"<li>Item {3}</li>")
900 node = html(t"<ul><{Items} /></ul>")
901 assert node == Element(
902 "ul",
903 children=[
904 Element("li", children=[Text("Item "), Text("1")]),
905 Element("li", children=[Text("Item "), Text("2")]),
906 Element("li", children=[Text("Item "), Text("3")]),
907 ],
908 )
909 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>"
912def test_component_returning_explicit_fragment():
913 def Items() -> Node:
914 return html(t"<><li>Item {1}</li><li>Item {2}</li><li>Item {3}</li></>")
916 node = html(t"<ul><{Items} /></ul>")
917 assert node == Element(
918 "ul",
919 children=[
920 Element("li", children=[Text("Item "), Text("1")]),
921 Element("li", children=[Text("Item "), Text("2")]),
922 Element("li", children=[Text("Item "), Text("3")]),
923 ],
924 )
925 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>"
928@dataclass
929class ClassComponent:
930 """Example class-based component."""
932 user_name: str
933 image_url: str
934 homepage: str = "#"
935 children: t.Iterable[Node] = field(default_factory=list)
937 def __call__(self) -> Node:
938 return html(
939 t"<div class='avatar'>"
940 t"<a href={self.homepage}>"
941 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
942 t"</a>"
943 t"<span>{self.user_name}</span>"
944 t"{self.children}"
945 t"</div>",
946 )
949def test_class_component_implicit_invocation():
950 node = html(
951 t"<{ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{ClassComponent}>"
952 )
953 assert node == Element(
954 "div",
955 attrs={"class": "avatar"},
956 children=[
957 Element(
958 "a",
959 attrs={"href": "#"},
960 children=[
961 Element(
962 "img",
963 attrs={
964 "src": "https://example.com/alice.png",
965 "alt": "Avatar of Alice",
966 },
967 )
968 ],
969 ),
970 Element("span", children=[Text("Alice")]),
971 Text("Fun times!"),
972 ],
973 )
974 assert (
975 str(node)
976 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>'
977 )
980def test_class_component_direct_invocation():
981 avatar = ClassComponent(
982 user_name="Alice",
983 image_url="https://example.com/alice.png",
984 homepage="https://example.com/users/alice",
985 )
986 node = html(t"<{avatar} />")
987 assert node == Element(
988 "div",
989 attrs={"class": "avatar"},
990 children=[
991 Element(
992 "a",
993 attrs={"href": "https://example.com/users/alice"},
994 children=[
995 Element(
996 "img",
997 attrs={
998 "src": "https://example.com/alice.png",
999 "alt": "Avatar of Alice",
1000 },
1001 )
1002 ],
1003 ),
1004 Element("span", children=[Text("Alice")]),
1005 ],
1006 )
1007 assert (
1008 str(node)
1009 == '<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>'
1010 )
1013@dataclass
1014class ClassComponentNoChildren:
1015 """Example class-based component that does not ask for children."""
1017 user_name: str
1018 image_url: str
1019 homepage: str = "#"
1021 def __call__(self) -> Node:
1022 return html(
1023 t"<div class='avatar'>"
1024 t"<a href={self.homepage}>"
1025 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />"
1026 t"</a>"
1027 t"<span>{self.user_name}</span>"
1028 t"ignore children"
1029 t"</div>",
1030 )
1033def test_class_component_implicit_invocation_ignore_children():
1034 node = html(
1035 t"<{ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{ClassComponentNoChildren}>"
1036 )
1037 assert node == Element(
1038 "div",
1039 attrs={"class": "avatar"},
1040 children=[
1041 Element(
1042 "a",
1043 attrs={"href": "#"},
1044 children=[
1045 Element(
1046 "img",
1047 attrs={
1048 "src": "https://example.com/alice.png",
1049 "alt": "Avatar of Alice",
1050 },
1051 )
1052 ],
1053 ),
1054 Element("span", children=[Text("Alice")]),
1055 Text("ignore children"),
1056 ],
1057 )
1058 assert (
1059 str(node)
1060 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>'
1061 )
1064def AttributeTypeComponent(
1065 data_int: int,
1066 data_true: bool,
1067 data_false: bool,
1068 data_none: None,
1069 data_float: float,
1070 data_dt: datetime.datetime,
1071) -> Template:
1072 """Component to test that we don't incorrectly convert attribute types."""
1073 assert isinstance(data_int, int)
1074 assert data_true is True
1075 assert data_false is False
1076 assert data_none is None
1077 assert isinstance(data_float, float)
1078 assert isinstance(data_dt, datetime.datetime)
1079 return t"Looks good!"
1082def test_attribute_type_component():
1083 an_int: int = 42
1084 a_true: bool = True
1085 a_false: bool = False
1086 a_none: None = None
1087 a_float: float = 3.14
1088 a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0)
1089 node = html(
1090 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} "
1091 t"data-false={a_false} data-none={a_none} data-float={a_float} "
1092 t"data-dt={a_dt} />"
1093 )
1094 assert node == Text("Looks good!")
1095 assert str(node) == "Looks good!"
1098def test_component_non_callable_fails():
1099 with pytest.raises(TypeError):
1100 _ = html(t"<{'not a function'} />")
1103def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover
1104 return t"<p>Positional arg: {whoops}</p>"
1107def test_component_requiring_positional_arg_fails():
1108 with pytest.raises(TypeError):
1109 _ = html(t"<{RequiresPositional} />")