Coverage for tdom/parser_test.py: 96%
284 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 00:48 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-09 00:48 +0000
1from string.templatelib import Interpolation, Template
3import pytest
5from .parser import TemplateParser, configure_source_tracker
6from .placeholders import make_placeholder_config
7from .template_utils import TemplateRef
8from .tnodes import (
9 TComment,
10 TComponent,
11 TDocumentType,
12 TElement,
13 TFragment,
14 TInterpolatedAttribute,
15 TLiteralAttribute,
16 TSpreadAttribute,
17 TTemplatedAttribute,
18 TText,
19)
22def test_parse_mixed_literal_content():
23 node = TemplateParser.parse(
24 t"<!DOCTYPE html>"
25 t"<!-- Comment -->"
26 t'<div class="container">'
27 t"Hello, <br class='funky' />world <!-- neato -->!"
28 t"</div>"
29 )
30 assert node == TFragment(
31 children=(
32 TDocumentType("html"),
33 TComment.literal(" Comment "),
34 TElement(
35 "div",
36 attrs=(TLiteralAttribute("class", "container"),),
37 children=(
38 TText.literal("Hello, "),
39 TElement("br", attrs=(TLiteralAttribute("class", "funky"),)),
40 TText.literal("world "),
41 TComment.literal(" neato "),
42 TText.literal("!"),
43 ),
44 ),
45 )
46 )
49#
50# Text
51#
52def test_parse_empty():
53 node = TemplateParser.parse(t"")
54 assert node == TFragment()
57def test_parse_text():
58 node = TemplateParser.parse(t"Hello, world!")
59 assert node == TText.literal("Hello, world!")
62def test_parse_text_multiline():
63 node = TemplateParser.parse(t"""Hello, world!
64 Hello, moon!
65Hello, sun!
66""")
67 assert node == TText.literal("""Hello, world!
68 Hello, moon!
69Hello, sun!
70""")
73def test_parse_text_with_entities():
74 node = TemplateParser.parse(t"a < b")
75 assert node == TText.literal("a < b")
78def test_parse_text_with_template_singleton():
79 greeting = "Hello, World!"
80 node = TemplateParser.parse(t"{greeting}")
81 assert node == TText(ref=TemplateRef(strings=("", ""), i_indexes=(0,)))
84def test_parse_text_with_template():
85 who = "World"
86 node = TemplateParser.parse(t"Hello, {who}!")
87 assert node == TText(ref=TemplateRef(strings=("Hello, ", "!"), i_indexes=(0,)))
90#
91# Elements
92#
93def test_parse_void_element():
94 node = TemplateParser.parse(t"<br>")
95 assert node == TElement("br")
98def test_parse_void_element_self_closed():
99 node = TemplateParser.parse(t"<br />")
100 assert node == TElement("br")
103def test_parse_uppercase_void_element():
104 node = TemplateParser.parse(t"<BR>")
105 assert node == TElement("br")
108def test_parse_standard_element_with_text():
109 node = TemplateParser.parse(t"<div>Hello, world!</div>")
110 assert node == TElement("div", children=(TText.literal("Hello, world!"),))
113def test_parse_nested_elements():
114 node = TemplateParser.parse(t"<div><span>Nested</span> content</div>")
115 assert node == TElement(
116 "div",
117 children=(
118 TElement("span", children=(TText.literal("Nested"),)),
119 TText.literal(" content"),
120 ),
121 )
124def test_parse_element_with_template():
125 who = "World"
126 node = TemplateParser.parse(t"<div>Hello, {who}!</div>")
127 assert node == TElement(
128 "div",
129 children=(TText(ref=TemplateRef(strings=("Hello, ", "!"), i_indexes=(0,))),),
130 )
133def test_parse_element_with_template_singleton():
134 greeting = "Hello, World!"
135 node = TemplateParser.parse(t"<div>{greeting}</div>")
136 assert node == TElement(
137 "div", children=(TText(ref=TemplateRef(strings=("", ""), i_indexes=(0,))),)
138 )
141def test_parse_multiple_voids():
142 node = TemplateParser.parse(t"<br><hr><hr /><hr /><br /><br><br>")
143 assert node == TFragment(
144 children=(
145 TElement("br"),
146 TElement("hr"),
147 TElement("hr"),
148 TElement("hr"),
149 TElement("br"),
150 TElement("br"),
151 TElement("br"),
152 )
153 )
156def test_parse_text_entities():
157 node = TemplateParser.parse(t"<p></p></p>")
158 assert node == TElement(
159 "p",
160 children=(TText.literal("</p>"),),
161 )
164def test_parse_script_tag_content():
165 node = TemplateParser.parse(
166 t"<script>if (a < b && c > d) { alert('wow'); } </script>"
167 )
168 assert node == TElement(
169 "script",
170 children=(TText.literal("if (a < b && c > d) { alert('wow'); }"),),
171 )
174def test_parse_script_with_entities():
175 # The <script> tag (and <style>) tag uses the CDATA content model.
176 node = TemplateParser.parse(t"<script>var x = 'a & b';</script>")
177 assert node == TElement(
178 "script",
179 children=(TText.literal("var x = 'a & b';"),),
180 ), "Entities SHOULD NOT be evaluated in scripts."
183def test_parse_textarea_tag_content():
184 node = TemplateParser.parse(
185 t"<textarea>if (a < b && c > d) { alert('wow'); } </textarea>"
186 )
187 assert node == TElement(
188 "textarea",
189 children=(TText.literal("if (a < b && c > d) { alert('wow'); }"),),
190 )
193def test_parse_textarea_with_entities():
194 # The <textarea> (and <title>) tag uses the RCDATA content model.
195 node = TemplateParser.parse(t"<textarea>var x = 'a & b';</textarea>")
196 assert node == TElement(
197 "textarea",
198 children=(TText.literal("var x = 'a & b';"),),
199 ), "Entities SHOULD be evaluated in textarea/title."
202def test_parse_title_unusual():
203 node = TemplateParser.parse(t"<title>My & Awesome <Site></title>")
204 assert node == TElement(
205 "title",
206 children=(TText.literal("My & Awesome <Site>"),),
207 )
210def test_parse_mismatched_tags():
211 with pytest.raises(ValueError):
212 _ = TemplateParser.parse(t"<div><span>Mismatched</div></span>")
215def test_parse_unclosed_tag():
216 with pytest.raises(ValueError):
217 _ = TemplateParser.parse(t"<div>Unclosed")
220def test_parse_unexpected_closing_tag():
221 with pytest.raises(ValueError):
222 _ = TemplateParser.parse(t"Unopened</div>")
225def test_self_closing_tags():
226 node = TemplateParser.parse(t"<div/><p></p>")
227 assert node == TFragment(
228 children=(
229 TElement("div"),
230 TElement("p"),
231 )
232 )
235def test_nested_self_closing_tags():
236 node = TemplateParser.parse(t"<div><br><div /><br></div>")
237 assert node == TElement(
238 "div", children=(TElement("br"), TElement("div"), TElement("br"))
239 )
240 node = TemplateParser.parse(t"<div><div /></div>")
241 assert node == TElement("div", children=(TElement("div"),))
244def test_self_closing_tags_unexpected_closing_tag():
245 with pytest.raises(ValueError):
246 _ = TemplateParser.parse(t"<div /></div>")
249def test_self_closing_void_tags_unexpected_closing_tag():
250 with pytest.raises(ValueError):
251 _ = TemplateParser.parse(t"<input /></input>")
254#
255# Attributes
256#
257def test_literal_attrs():
258 node = TemplateParser.parse(
259 t"<a"
260 t" id=example_link" # no quotes allowed without spaces
261 t" autofocus" # bare / boolean
262 t' title=""' # empty attribute
263 t' href="https://example.com" target="_blank"'
264 t">Link</a>"
265 )
266 assert node == TElement(
267 "a",
268 attrs=(
269 TLiteralAttribute("id", "example_link"),
270 TLiteralAttribute("autofocus", None),
271 TLiteralAttribute("title", ""),
272 TLiteralAttribute("href", "https://example.com"),
273 TLiteralAttribute("target", "_blank"),
274 ),
275 children=(TText.literal("Link"),),
276 )
279def test_literal_attr_entities():
280 node = TemplateParser.parse(t'<a title="<">Link</a>')
281 assert node == TElement(
282 "a",
283 attrs=(TLiteralAttribute("title", "<"),),
284 children=(TText.literal("Link"),),
285 )
288def test_literal_attr_order():
289 node = TemplateParser.parse(t'<a title="a" href="b" title="c"></a>')
290 assert isinstance(node, TElement)
291 assert node.attrs == (
292 TLiteralAttribute("title", "a"),
293 TLiteralAttribute("href", "b"),
294 TLiteralAttribute("title", "c"), # dupe IS allowed
295 )
298def test_interpolated_attr():
299 value1 = 42
300 value2 = 99
301 node = TemplateParser.parse(t'<div value1="{value1}" value2={value2} />')
302 assert node == TElement(
303 "div",
304 attrs=(
305 TInterpolatedAttribute("value1", 0),
306 TInterpolatedAttribute("value2", 1),
307 ),
308 children=(),
309 )
312def test_templated_attr():
313 value1 = 42
314 value2 = 99
315 node = TemplateParser.parse(
316 t'<div value1="{value1}-burrito" value2="neato-{value2}-wow" />'
317 )
318 value1_ref = TemplateRef(strings=("", "-burrito"), i_indexes=(0,))
319 value2_ref = TemplateRef(strings=("neato-", "-wow"), i_indexes=(1,))
320 assert node == TElement(
321 "div",
322 attrs=(
323 TTemplatedAttribute("value1", value1_ref),
324 TTemplatedAttribute("value2", value2_ref),
325 ),
326 children=(),
327 )
330def test_spread_attr():
331 spread_attrs = {}
332 node = TemplateParser.parse(t"<div {spread_attrs} />")
333 assert node == TElement(
334 "div",
335 attrs=(TSpreadAttribute(i_index=0),),
336 children=(),
337 )
340def test_templated_attribute_name_error():
341 with pytest.raises(ValueError):
342 attr_name = "some-attr"
343 _ = TemplateParser.parse(t'<div {attr_name}="value" />')
346def test_templated_attribute_name_and_value_error():
347 with pytest.raises(ValueError):
348 attr_name = "some-attr"
349 value = "value"
350 _ = TemplateParser.parse(t'<div {attr_name}="{value}" />')
353def test_adjacent_spread_attrs_error():
354 with pytest.raises(ValueError):
355 attrs1 = {}
356 attrs2 = {}
357 _ = TemplateParser.parse(t"<div {attrs1}{attrs2} />")
360#
361# Comments
362#
363def test_parse_comment():
364 node = TemplateParser.parse(t"<!-- This is a comment -->")
365 assert node == TComment.literal(" This is a comment ")
368def test_parse_comment_interpolation():
369 text = "comment"
370 node = TemplateParser.parse(t"<!-- This is a {text} -->")
371 assert node == TComment(
372 ref=TemplateRef(strings=(" This is a ", " "), i_indexes=(0,))
373 )
376#
377# Doctypes
378#
379def test_parse_doctype():
380 node = TemplateParser.parse(t"<!DOCTYPE html>")
381 assert node == TDocumentType("html")
384def test_parse_doctype_interpolation_error():
385 extra = "SYSTEM"
386 with pytest.raises(ValueError):
387 _ = TemplateParser.parse(t"<!DOCTYPE html {extra}>")
390def test_unsupported_decl_error():
391 with pytest.raises(NotImplementedError):
392 _ = TemplateParser.parse(t"<!doctype-alt html500>") # Unknown declaration
393 with pytest.raises(NotImplementedError):
394 _ = TemplateParser.parse(t"<!doctype>") # missing DTD
397#
398# Components.
399#
400def test_component_element_with_children():
401 def Component(children):
402 return t"{children}"
404 node = TemplateParser.parse(t"<{Component}><div>Hello, World!</div></{Component}>")
405 assert node == TComponent(
406 start_i_index=0,
407 end_i_index=1,
408 children_ref=TemplateRef(strings=("<div>Hello, World!</div>",), i_indexes=()),
409 )
412def test_component_element_self_closing():
413 def Component():
414 pass
416 node = TemplateParser.parse(t"<{Component} />")
417 assert node == TComponent(start_i_index=0)
420def test_component_element_with_closing_tag():
421 def Component():
422 pass
424 node = TemplateParser.parse(t"<{Component}></{Component}>")
425 assert node == TComponent(start_i_index=0, end_i_index=1)
428def test_component_element_special_case_mismatched_closing_tag_still_parses():
429 def Component1():
430 pass
432 def Component2():
433 pass
435 node = TemplateParser.parse(t"<{Component1}></{Component2}>")
436 assert node == TComponent(start_i_index=0, end_i_index=1)
439def test_component_element_invalid_closing_tag():
440 def Component():
441 pass
443 with pytest.raises(ValueError):
444 _ = TemplateParser.parse(t"<{Component}></div>")
447def test_component_element_invalid_opening_tag():
448 def Component():
449 pass
451 with pytest.raises(ValueError):
452 _ = TemplateParser.parse(t"<div></{Component}>")
455def test_adjacent_start_component_tag_error():
456 def Component():
457 pass
459 with pytest.raises(ValueError):
460 _ = TemplateParser.parse(t"<{Component}{Component}></{Component}>")
463def test_adjacent_end_component_tag_error():
464 def Component():
465 pass
467 with pytest.raises(ValueError):
468 _ = TemplateParser.parse(t"<{Component}></{Component}{Component}>")
471def test_placeholder_collision_avoidance():
472 config = make_placeholder_config()
473 # This test is to ensure that our placeholder detection avoids collisions
474 # even with content that might look like a placeholder.
475 tricky = "0"
476 template = Template(
477 f'<div data-tricky="{config.prefix}',
478 Interpolation(tricky, "tricky", None, ""),
479 f'{config.suffix}"></div>',
480 )
481 tnode = TemplateParser.parse(template)
482 value_ref = TemplateRef(strings=(config.prefix, config.suffix), i_indexes=(0,))
483 assert tnode == TElement(
484 "div", attrs=(TTemplatedAttribute(name="data-tricky", value_ref=value_ref),)
485 )
488def test_unresolved_placeholder():
489 t = t"<div>{0}<span>{1}</span>{2}</div>"
490 tp = TemplateParser()
491 tp.feed_template(t)
492 # This would be a bug in the parser so we have to fabricate
493 # this error manually.
494 tp.get_source().placeholders.add_placeholder(3)
495 with pytest.raises(ValueError, match="Some placeholders were never resolved"):
496 tp.close()
499class TestSourceTracker:
500 @pytest.mark.parametrize("t", (t"", t"simple"))
501 def test_only_string(self, t: Template):
502 st = configure_source_tracker(t)
503 itr = iter(st)
504 part = next(itr)
505 assert isinstance(part, str) and part == t.strings[0]
506 assert itr.index == 0
507 with pytest.raises(StopIteration):
508 _ = next(itr)
509 assert itr.index == 0, "Still at 0."
511 def test_iter(self):
512 t = t"<div>{0}</div>"
513 st = configure_source_tracker(t)
514 itr = iter(st)
515 parts = []
516 parts.append(next(itr))
517 assert itr.index == 0
518 assert not itr.has_placeholders()
519 assert parts[0] == t.strings[0], "String parts pass through."
520 parts.append(next(itr))
521 assert itr.index == 1
522 assert itr.has_placeholders(), "Placeholder was addeded."
523 tref_find = st.find_placeholders(parts[1])
524 assert itr.has_placeholders() and tref_find.i_indexes[0] == 0
525 tref_removed = st.remove_placeholders(parts[1])
526 assert not itr.has_placeholders() and tref_removed.i_indexes[0] == 0, (
527 "Placeholder removed."
528 )
529 parts.append(next(itr))
530 assert itr.index == 2
531 with pytest.raises(StopIteration):
532 next(itr)
533 assert itr.index == 2, (
534 "Once the iter is exhausted the index remains at the last element."
535 )
538class TestIncompleteParsing:
539 def test_dangling_quotes(self):
540 with pytest.raises(ValueError, match="Parser expects more data"):
541 _ = TemplateParser.parse(t"<div a='")
542 with pytest.raises(ValueError, match="Parser expects more data"):
543 _ = TemplateParser.parse(t'<div a="')
545 def test_unfinished_attribute(self):
546 with pytest.raises(ValueError, match="Parser expects more data"):
547 _ = TemplateParser.parse(t"<div a=")
549 def test_placeholder_missing_from_dangling_quote(self):
550 with pytest.raises(ValueError, match="Parser expects more data"):
551 _ = TemplateParser.parse(t'<div a="{None}')
554class TestComponentExtractChildrenTemplate:
555 @pytest.fixture
556 def Component(self):
557 def Component(children: Template, **attrs: str) -> Template:
558 return t""
560 return Component
562 def test_extract_no_content(self, Component):
563 node = TemplateParser.parse(t"<{Component}></{Component}>")
564 assert node == TComponent(
565 start_i_index=0,
566 end_i_index=1,
567 children_ref=TemplateRef(strings=("",), i_indexes=()),
568 )
570 def test_extract_startend(self, Component):
571 node = TemplateParser.parse(t"<{Component} />")
572 assert node == TComponent(
573 start_i_index=0,
574 end_i_index=None,
575 children_ref=TemplateRef(strings=("",), i_indexes=()),
576 )
578 def test_extract(self, Component):
579 node = TemplateParser.parse(
580 t"<{Component}><div>Hello, World!</div></{Component}>"
581 )
582 assert node == TComponent(
583 start_i_index=0,
584 end_i_index=1,
585 children_ref=TemplateRef(
586 strings=("<div>Hello, World!</div>",), i_indexes=()
587 ),
588 )
590 def test_extract_with_attr_interpolation(self, Component):
591 # Unquoted ...
592 node = TemplateParser.parse(
593 t"<{Component} title={'Skip over this.'}><div>Hello, World!</div></{Component}>"
594 )
595 assert node == TComponent(
596 start_i_index=0,
597 end_i_index=2,
598 attrs=(TInterpolatedAttribute(name="title", value_i_index=1),),
599 children_ref=TemplateRef(
600 strings=("<div>Hello, World!</div>",), i_indexes=()
601 ),
602 )
603 # Quoted...
604 node2 = TemplateParser.parse(
605 t'<{Component} title="{"Skip over this."}"><div>Hello, World!</div></{Component}>'
606 )
607 assert node2 == node
609 def test_extract_with_literal_attr_gt_char(self, Component):
610 node = TemplateParser.parse(
611 t'<{Component} title="1 > 0"><div>Hello, World!</div></{Component}>'
612 )
613 assert node == TComponent(
614 start_i_index=0,
615 end_i_index=1,
616 attrs=(TLiteralAttribute("title", "1 > 0"),),
617 children_ref=TemplateRef(
618 strings=("<div>Hello, World!</div>",), i_indexes=()
619 ),
620 )
622 def test_extract_with_interpolated_attr_literal_attr_gt_char(self, Component):
623 node = TemplateParser.parse(
624 t'<{Component} id={"simple"} title="1 > 0"><div>Hello, World!</div></{Component}>'
625 )
626 assert node == TComponent(
627 start_i_index=0,
628 end_i_index=2,
629 attrs=(
630 TInterpolatedAttribute(name="id", value_i_index=1),
631 TLiteralAttribute("title", "1 > 0"),
632 ),
633 children_ref=TemplateRef(
634 strings=("<div>Hello, World!</div>",), i_indexes=()
635 ),
636 )
638 def test_extract_with_templated_attr_gt_char(self, Component):
639 node = TemplateParser.parse(
640 t'<{Component} id="{"header"}_{"container"}" title="1 > 0"><div>Hello, World!</div></{Component}>'
641 )
642 assert node == TComponent(
643 start_i_index=0,
644 end_i_index=3,
645 attrs=(
646 TTemplatedAttribute(
647 "id", TemplateRef(strings=("", "_", ""), i_indexes=(1, 2))
648 ),
649 TLiteralAttribute("title", "1 > 0"),
650 ),
651 children_ref=TemplateRef(
652 strings=("<div>Hello, World!</div>",), i_indexes=()
653 ),
654 )