Coverage for tdom/parser_test.py: 96%
245 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-23 04:35 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-23 04:35 +0000
1from string.templatelib import Interpolation, Template
3import pytest
5from .parser import TemplateParser
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 )
488class TestIncompleteParsing:
489 def test_dangling_quotes(self):
490 with pytest.raises(ValueError, match="Parser expects more data"):
491 _ = TemplateParser.parse(t"<div a='")
492 with pytest.raises(ValueError, match="Parser expects more data"):
493 _ = TemplateParser.parse(t'<div a="')
495 def test_unfinished_attribute(self):
496 with pytest.raises(ValueError, match="Parser expects more data"):
497 _ = TemplateParser.parse(t"<div a=")
499 def test_placeholder_missing_from_dangling_quote(self):
500 with pytest.raises(ValueError, match="Parser expects more data"):
501 _ = TemplateParser.parse(t'<div a="{None}')
504class TestComponentExtractChildrenTemplate:
505 @pytest.fixture
506 def Component(self):
507 def Component(children: Template, **attrs: str) -> Template:
508 return t""
510 return Component
512 def test_extract_no_content(self, Component):
513 node = TemplateParser.parse(t"<{Component}></{Component}>")
514 assert node == TComponent(
515 start_i_index=0,
516 end_i_index=1,
517 children_ref=TemplateRef(strings=("",), i_indexes=()),
518 )
520 def test_extract_startend(self, Component):
521 node = TemplateParser.parse(t"<{Component} />")
522 assert node == TComponent(
523 start_i_index=0,
524 end_i_index=None,
525 children_ref=TemplateRef(strings=("",), i_indexes=()),
526 )
528 def test_extract(self, Component):
529 node = TemplateParser.parse(
530 t"<{Component}><div>Hello, World!</div></{Component}>"
531 )
532 assert node == TComponent(
533 start_i_index=0,
534 end_i_index=1,
535 children_ref=TemplateRef(
536 strings=("<div>Hello, World!</div>",), i_indexes=()
537 ),
538 )
540 def test_extract_with_attr_interpolation(self, Component):
541 # Unquoted ...
542 node = TemplateParser.parse(
543 t"<{Component} title={'Skip over this.'}><div>Hello, World!</div></{Component}>"
544 )
545 assert node == TComponent(
546 start_i_index=0,
547 end_i_index=2,
548 attrs=(TInterpolatedAttribute(name="title", value_i_index=1),),
549 children_ref=TemplateRef(
550 strings=("<div>Hello, World!</div>",), i_indexes=()
551 ),
552 )
553 # Quoted...
554 node2 = TemplateParser.parse(
555 t'<{Component} title="{"Skip over this."}"><div>Hello, World!</div></{Component}>'
556 )
557 assert node2 == node
559 def test_extract_with_literal_attr_gt_char(self, Component):
560 node = TemplateParser.parse(
561 t'<{Component} title="1 > 0"><div>Hello, World!</div></{Component}>'
562 )
563 assert node == TComponent(
564 start_i_index=0,
565 end_i_index=1,
566 attrs=(TLiteralAttribute("title", "1 > 0"),),
567 children_ref=TemplateRef(
568 strings=("<div>Hello, World!</div>",), i_indexes=()
569 ),
570 )
572 def test_extract_with_interpolated_attr_literal_attr_gt_char(self, Component):
573 node = TemplateParser.parse(
574 t'<{Component} id={"simple"} title="1 > 0"><div>Hello, World!</div></{Component}>'
575 )
576 assert node == TComponent(
577 start_i_index=0,
578 end_i_index=2,
579 attrs=(
580 TInterpolatedAttribute(name="id", value_i_index=1),
581 TLiteralAttribute("title", "1 > 0"),
582 ),
583 children_ref=TemplateRef(
584 strings=("<div>Hello, World!</div>",), i_indexes=()
585 ),
586 )
588 def test_extract_with_templated_attr_gt_char(self, Component):
589 node = TemplateParser.parse(
590 t'<{Component} id="{"header"}_{"container"}" title="1 > 0"><div>Hello, World!</div></{Component}>'
591 )
592 assert node == TComponent(
593 start_i_index=0,
594 end_i_index=3,
595 attrs=(
596 TTemplatedAttribute(
597 "id", TemplateRef(strings=("", "_", ""), i_indexes=(1, 2))
598 ),
599 TLiteralAttribute("title", "1 > 0"),
600 ),
601 children_ref=TemplateRef(
602 strings=("<div>Hello, World!</div>",), i_indexes=()
603 ),
604 )