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

1from string.templatelib import Interpolation, Template 

2 

3import pytest 

4 

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) 

20 

21 

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 ) 

47 

48 

49# 

50# Text 

51# 

52def test_parse_empty(): 

53 node = TemplateParser.parse(t"") 

54 assert node == TFragment() 

55 

56 

57def test_parse_text(): 

58 node = TemplateParser.parse(t"Hello, world!") 

59 assert node == TText.literal("Hello, world!") 

60 

61 

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""") 

71 

72 

73def test_parse_text_with_entities(): 

74 node = TemplateParser.parse(t"a &lt; b") 

75 assert node == TText.literal("a < b") 

76 

77 

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,))) 

82 

83 

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,))) 

88 

89 

90# 

91# Elements 

92# 

93def test_parse_void_element(): 

94 node = TemplateParser.parse(t"<br>") 

95 assert node == TElement("br") 

96 

97 

98def test_parse_void_element_self_closed(): 

99 node = TemplateParser.parse(t"<br />") 

100 assert node == TElement("br") 

101 

102 

103def test_parse_uppercase_void_element(): 

104 node = TemplateParser.parse(t"<BR>") 

105 assert node == TElement("br") 

106 

107 

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!"),)) 

111 

112 

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 ) 

122 

123 

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 ) 

131 

132 

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 ) 

139 

140 

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 ) 

154 

155 

156def test_parse_text_entities(): 

157 node = TemplateParser.parse(t"<p>&lt;/p&gt;</p>") 

158 assert node == TElement( 

159 "p", 

160 children=(TText.literal("</p>"),), 

161 ) 

162 

163 

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 ) 

172 

173 

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 &amp; b';</script>") 

177 assert node == TElement( 

178 "script", 

179 children=(TText.literal("var x = 'a &amp; b';"),), 

180 ), "Entities SHOULD NOT be evaluated in scripts." 

181 

182 

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 ) 

191 

192 

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 &amp; b';</textarea>") 

196 assert node == TElement( 

197 "textarea", 

198 children=(TText.literal("var x = 'a & b';"),), 

199 ), "Entities SHOULD be evaluated in textarea/title." 

200 

201 

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 ) 

208 

209 

210def test_parse_mismatched_tags(): 

211 with pytest.raises(ValueError): 

212 _ = TemplateParser.parse(t"<div><span>Mismatched</div></span>") 

213 

214 

215def test_parse_unclosed_tag(): 

216 with pytest.raises(ValueError): 

217 _ = TemplateParser.parse(t"<div>Unclosed") 

218 

219 

220def test_parse_unexpected_closing_tag(): 

221 with pytest.raises(ValueError): 

222 _ = TemplateParser.parse(t"Unopened</div>") 

223 

224 

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 ) 

233 

234 

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"),)) 

242 

243 

244def test_self_closing_tags_unexpected_closing_tag(): 

245 with pytest.raises(ValueError): 

246 _ = TemplateParser.parse(t"<div /></div>") 

247 

248 

249def test_self_closing_void_tags_unexpected_closing_tag(): 

250 with pytest.raises(ValueError): 

251 _ = TemplateParser.parse(t"<input /></input>") 

252 

253 

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 ) 

277 

278 

279def test_literal_attr_entities(): 

280 node = TemplateParser.parse(t'<a title="&lt;">Link</a>') 

281 assert node == TElement( 

282 "a", 

283 attrs=(TLiteralAttribute("title", "<"),), 

284 children=(TText.literal("Link"),), 

285 ) 

286 

287 

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 ) 

296 

297 

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 ) 

310 

311 

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 ) 

328 

329 

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 ) 

338 

339 

340def test_templated_attribute_name_error(): 

341 with pytest.raises(ValueError): 

342 attr_name = "some-attr" 

343 _ = TemplateParser.parse(t'<div {attr_name}="value" />') 

344 

345 

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}" />') 

351 

352 

353def test_adjacent_spread_attrs_error(): 

354 with pytest.raises(ValueError): 

355 attrs1 = {} 

356 attrs2 = {} 

357 _ = TemplateParser.parse(t"<div {attrs1}{attrs2} />") 

358 

359 

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 ") 

366 

367 

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 ) 

374 

375 

376# 

377# Doctypes 

378# 

379def test_parse_doctype(): 

380 node = TemplateParser.parse(t"<!DOCTYPE html>") 

381 assert node == TDocumentType("html") 

382 

383 

384def test_parse_doctype_interpolation_error(): 

385 extra = "SYSTEM" 

386 with pytest.raises(ValueError): 

387 _ = TemplateParser.parse(t"<!DOCTYPE html {extra}>") 

388 

389 

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 

395 

396 

397# 

398# Components. 

399# 

400def test_component_element_with_children(): 

401 def Component(children): 

402 return t"{children}" 

403 

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 ) 

410 

411 

412def test_component_element_self_closing(): 

413 def Component(): 

414 pass 

415 

416 node = TemplateParser.parse(t"<{Component} />") 

417 assert node == TComponent(start_i_index=0) 

418 

419 

420def test_component_element_with_closing_tag(): 

421 def Component(): 

422 pass 

423 

424 node = TemplateParser.parse(t"<{Component}></{Component}>") 

425 assert node == TComponent(start_i_index=0, end_i_index=1) 

426 

427 

428def test_component_element_special_case_mismatched_closing_tag_still_parses(): 

429 def Component1(): 

430 pass 

431 

432 def Component2(): 

433 pass 

434 

435 node = TemplateParser.parse(t"<{Component1}></{Component2}>") 

436 assert node == TComponent(start_i_index=0, end_i_index=1) 

437 

438 

439def test_component_element_invalid_closing_tag(): 

440 def Component(): 

441 pass 

442 

443 with pytest.raises(ValueError): 

444 _ = TemplateParser.parse(t"<{Component}></div>") 

445 

446 

447def test_component_element_invalid_opening_tag(): 

448 def Component(): 

449 pass 

450 

451 with pytest.raises(ValueError): 

452 _ = TemplateParser.parse(t"<div></{Component}>") 

453 

454 

455def test_adjacent_start_component_tag_error(): 

456 def Component(): 

457 pass 

458 

459 with pytest.raises(ValueError): 

460 _ = TemplateParser.parse(t"<{Component}{Component}></{Component}>") 

461 

462 

463def test_adjacent_end_component_tag_error(): 

464 def Component(): 

465 pass 

466 

467 with pytest.raises(ValueError): 

468 _ = TemplateParser.parse(t"<{Component}></{Component}{Component}>") 

469 

470 

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 ) 

486 

487 

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="') 

494 

495 def test_unfinished_attribute(self): 

496 with pytest.raises(ValueError, match="Parser expects more data"): 

497 _ = TemplateParser.parse(t"<div a=") 

498 

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}') 

502 

503 

504class TestComponentExtractChildrenTemplate: 

505 @pytest.fixture 

506 def Component(self): 

507 def Component(children: Template, **attrs: str) -> Template: 

508 return t"" 

509 

510 return Component 

511 

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 ) 

519 

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 ) 

527 

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 ) 

539 

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 

558 

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 ) 

571 

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 ) 

587 

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 )