Coverage for tdom/processor_test.py: 99%

1072 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 00:48 +0000

1import datetime 

2import typing as t 

3from collections import UserDict 

4from collections.abc import Callable 

5from dataclasses import dataclass 

6from itertools import chain, product 

7from string.templatelib import Template 

8 

9import pytest 

10from markupsafe import Markup 

11from markupsafe import escape as markupsafe_escape 

12 

13from .callables import get_callable_info 

14from .escaping import escape_html_text 

15from .processor import ( 

16 CachedTemplateParserProxy, 

17 ProcessContext, 

18 TemplateParserProxy, 

19 TemplateProcessor, 

20 _make_default_template_processor, 

21) 

22from .processor import ( 

23 _prep_component_kwargs as prep_component_kwargs, 

24) 

25from .protocols import HasHTMLDunder 

26 

27processor_api = _make_default_template_processor( 

28 parser_api=TemplateParserProxy(), # do not use cache 

29) 

30 

31 

32def make_ctx(**kwargs): 

33 return ProcessContext(**kwargs) 

34 

35 

36def html(template: Template, assume_ctx: ProcessContext | None = None): 

37 if assume_ctx is None: 

38 assume_ctx = ProcessContext() 

39 return processor_api.process(template, assume_ctx=assume_ctx) 

40 

41 

42# -------------------------------------------------------------------------- 

43# Basic HTML parsing tests 

44# -------------------------------------------------------------------------- 

45 

46 

47# 

48# Text 

49# 

50class TestBareTemplate: 

51 def test_empty(self): 

52 assert html(t"") == "" 

53 

54 def test_text_literal(self): 

55 assert html(t"Hello, world!") == "Hello, world!" 

56 

57 def test_text_singleton(self): 

58 greeting = "Hello, Alice!" 

59 assert html(t"{greeting}", make_ctx(parent_tag="div")) == "Hello, Alice!" 

60 assert html(t"{greeting}", make_ctx(parent_tag="script")) == "Hello, Alice!" 

61 assert html(t"{greeting}", make_ctx(parent_tag="style")) == "Hello, Alice!" 

62 assert html(t"{greeting}", make_ctx(parent_tag="textarea")) == "Hello, Alice!" 

63 assert html(t"{greeting}", make_ctx(parent_tag="title")) == "Hello, Alice!" 

64 

65 def test_text_singleton_without_parent(self): 

66 greeting = "</script>" 

67 res = html(t"{greeting}") 

68 assert res == "&lt;/script&gt;" 

69 assert res != greeting 

70 

71 def test_text_singleton_explicit_parent_script(self): 

72 greeting = "</script>" 

73 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="script")) 

74 assert res == "\\x3c/script>" 

75 assert res != "</script>" 

76 

77 def test_text_singleton_explicit_parent_div(self): 

78 greeting = "</div>" 

79 res = html(t"{greeting}", assume_ctx=make_ctx(parent_tag="div")) 

80 assert res == "&lt;/div&gt;" 

81 assert res != "</div>" 

82 

83 def test_text_template(self): 

84 name = "Alice" 

85 assert ( 

86 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) 

87 == "Hello, Alice!" 

88 ) 

89 

90 def test_text_template_escaping(self): 

91 name = "Alice & Bob" 

92 assert ( 

93 html(t"Hello, {name}!", assume_ctx=make_ctx(parent_tag="div")) 

94 == "Hello, Alice &amp; Bob!" 

95 ) 

96 

97 def test_parse_entities_are_escaped_no_parent_tag(self): 

98 res = html(t"&lt;/p&gt;") 

99 assert res == "&lt;/p&gt;", "Default to standard escaping." 

100 

101 

102class LiteralHTML: 

103 """Text is returned as is by __html__.""" 

104 

105 def __init__(self, text): 

106 self.text = text 

107 

108 def __html__(self): 

109 # In a real app, this would come from a sanitizer or trusted source 

110 return self.text 

111 

112 

113def test_literal_html_has_html_dunder(): 

114 assert isinstance(LiteralHTML, HasHTMLDunder) 

115 

116 

117def test_markup_has_html_dunder(): 

118 assert isinstance(Markup, HasHTMLDunder) 

119 

120 

121class TestComment: 

122 def test_literal(self): 

123 assert html(t"<!--This is a comment-->") == "<!--This is a comment-->" 

124 

125 # 

126 # Singleton / Exact Match 

127 # 

128 def test_singleton_str(self): 

129 text = "This is a comment" 

130 assert html(t"<!--{text}-->") == "<!--This is a comment-->" 

131 

132 def test_singleton_object(self): 

133 assert html(t"<!--{0}-->") == "<!--0-->" 

134 

135 def test_singleton_none(self): 

136 assert html(t"<!--{None}-->") == "<!---->" 

137 

138 @pytest.mark.parametrize("bool_value", (True, False)) 

139 def test_singleton_bool(self, bool_value): 

140 assert html(t"<!--{bool_value}-->") == "<!---->" 

141 

142 @pytest.mark.parametrize( 

143 "html_dunder_cls", 

144 ( 

145 LiteralHTML, 

146 Markup, 

147 ), 

148 ) 

149 def test_singleton_has_html_dunder(self, html_dunder_cls): 

150 content = html_dunder_cls("-->") 

151 assert html(t"<!--{content}-->") == "<!---->-->", ( 

152 "DO NOT DO THIS! This is just an advanced escape hatch." 

153 ) 

154 

155 def test_singleton_escaping(self): 

156 text = "-->comment" 

157 assert html(t"<!--{text}-->") == "<!----&gt;comment-->" 

158 

159 # 

160 # Templated -- literal text mixed with interpolation(s) 

161 # 

162 def test_templated_str(self): 

163 text = "comment" 

164 assert html(t"<!--This is a {text}-->") == "<!--This is a comment-->" 

165 

166 def test_templated_object(self): 

167 assert html(t"<!--This is a {0}-->") == "<!--This is a 0-->" 

168 

169 def test_templated_none(self): 

170 assert html(t"<!--This is a {None}-->") == "<!--This is a -->" 

171 

172 @pytest.mark.parametrize("bool_value", (True, False)) 

173 def test_templated_bool(self, bool_value): 

174 assert html(t"<!--This is a {bool_value}-->") == "<!--This is a -->" 

175 

176 @pytest.mark.parametrize( 

177 "html_dunder_cls", 

178 ( 

179 LiteralHTML, 

180 Markup, 

181 ), 

182 ) 

183 def test_templated_has_html_dunder_error(self, html_dunder_cls): 

184 """Objects with __html__ are not processed with literal text or other interpolations.""" 

185 text = html_dunder_cls("in a comment") 

186 with pytest.raises(ValueError, match="not supported"): 

187 _ = html(t"<!--This is a {text}-->") 

188 with pytest.raises(ValueError, match="not supported"): 

189 _ = html(t"<!--{None}{text}-->") 

190 with pytest.raises(ValueError, match="not supported"): 

191 _ = html(t"<!--This is a {Markup('Also check specialized cls.')}-->") 

192 

193 def test_templated_multiple_interpolations(self): 

194 text = "comment" 

195 assert ( 

196 html(t"<!--This is a {text} with {0} and {None}-->") 

197 == "<!--This is a comment with 0 and -->" 

198 ) 

199 

200 def test_templated_escaping(self): 

201 # @TODO: There doesn't seem to be a way to properly escape this 

202 # so we just use an entity to break the special closing string 

203 # even though it won't be actually unescaped by anything. There 

204 # might be something better for this. 

205 text = "-->comment" 

206 assert html(t"<!--This is a {text}-->") == "<!--This is a --&gt;comment-->" 

207 

208 def test_not_supported__recursive_template_error(self): 

209 text_t = t"comment" 

210 with pytest.raises(ValueError, match="not supported"): 

211 _ = html(t"<!--{text_t}-->") 

212 

213 def test_not_supported_recursive_iterable_error(self): 

214 texts = ["This", "is", "a", "comment"] 

215 with pytest.raises(ValueError, match="not supported"): 

216 _ = html(t"<!--{texts}-->") 

217 

218 

219class TestDocumentType: 

220 def test_literal(self): 

221 assert html(t"<!doctype html>") == "<!DOCTYPE html>" 

222 

223 def test_literal_lowercase(self): 

224 tp = TemplateProcessor(uppercase_doctype=False) 

225 assert ( 

226 tp.process(t"<!doctype html>", assume_ctx=ProcessContext()) 

227 == "<!doctype html>" 

228 ) 

229 

230 

231class TestVoidElementLiteral: 

232 def test_void(self): 

233 assert html(t"<br>") == "<br />" 

234 

235 def test_void_self_closed(self): 

236 assert html(t"<br />") == "<br />" 

237 

238 def test_void_mixed_closing(self): 

239 assert html(t"<br>Is this content?<br />") == "<br />Is this content?<br />" 

240 

241 def test_chain_of_void_elements(self): 

242 # Make sure our handling of CPython issue #69445 is reasonable. 

243 assert ( 

244 html(t"<br><hr><img src='image.png' /><br /><hr>") 

245 == '<br /><hr /><img src="image.png" /><br /><hr />' 

246 ) 

247 

248 

249class TestNormalTextElementLiteral: 

250 def test_empty(self): 

251 assert html(t"<div></div>") == "<div></div>" 

252 

253 def test_with_text(self): 

254 assert html(t"<p>Hello, world!</p>") == "<p>Hello, world!</p>" 

255 

256 def test_nested_elements(self): 

257 assert ( 

258 html(t"<div><p>Hello</p><p>World</p></div>") 

259 == "<div><p>Hello</p><p>World</p></div>" 

260 ) 

261 

262 def test_entities_are_escaped(self): 

263 """Literal entities interpreted by parser but escaped in output.""" 

264 res = html(t"<p>&lt;/p&gt;</p>") 

265 assert res == "<p>&lt;/p&gt;</p>", res 

266 

267 

268class TestNormalTextElementDynamic: 

269 def test_singleton_None(self): 

270 assert html(t"<p>{None}</p>") == "<p></p>" 

271 

272 def test_singleton_str(self): 

273 name = "Alice" 

274 assert html(t"<p>{name}</p>") == "<p>Alice</p>" 

275 

276 @pytest.mark.parametrize("bool_value", (True, False)) 

277 def test_singleton_bool(self, bool_value): 

278 assert html(t"<p>{bool_value}</p>") == "<p></p>" 

279 

280 def test_singleton_object(self): 

281 assert html(t"<p>{0}</p>") == "<p>0</p>" 

282 

283 @pytest.mark.parametrize( 

284 "html_dunder_cls", 

285 ( 

286 LiteralHTML, 

287 Markup, 

288 ), 

289 ) 

290 def test_singleton_has_html_dunder(self, html_dunder_cls): 

291 content = html_dunder_cls("<em>Alright!</em>") 

292 assert html(t"<p>{content}</p>") == "<p><em>Alright!</em></p>" 

293 

294 def test_singleton_simple_template(self): 

295 name = "Alice" 

296 text_t = t"Hi {name}" 

297 assert html(t"<p>{text_t}</p>") == "<p>Hi Alice</p>" 

298 

299 def test_singleton_simple_iterable(self): 

300 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] 

301 assert html(t"<p>{strs}</p>") == "<p>Strings...Yeah!Rock...Yeah!</p>" 

302 

303 def test_singleton_escaping(self): 

304 text = '''<>&'"''' 

305 assert html(t"<p>{text}</p>") == "<p>&lt;&gt;&amp;&#39;&#34;</p>" 

306 

307 def test_templated_None(self): 

308 assert html(t"<p>Response: {None}.</p>") == "<p>Response: .</p>" 

309 

310 def test_templated_str(self): 

311 name = "Alice" 

312 assert html(t"<p>Response: {name}.</p>") == "<p>Response: Alice.</p>" 

313 

314 @pytest.mark.parametrize("bool_value", (True, False)) 

315 def test_templated_bool(self, bool_value): 

316 assert html(t"<p>Response: {bool_value}</p>") == "<p>Response: </p>" 

317 

318 def test_templated_object(self): 

319 assert html(t"<p>Response: {0}.</p>") == "<p>Response: 0.</p>" 

320 

321 @pytest.mark.parametrize( 

322 "html_dunder_cls", 

323 ( 

324 LiteralHTML, 

325 Markup, 

326 ), 

327 ) 

328 def test_templated_has_html_dunder(self, html_dunder_cls): 

329 text = html_dunder_cls("<em>Alright!</em>") 

330 assert ( 

331 html(t"<p>Response: {text}.</p>") == "<p>Response: <em>Alright!</em>.</p>" 

332 ) 

333 

334 def test_templated_simple_template(self): 

335 name = "Alice" 

336 text_t = t"Hi {name}" 

337 assert html(t"<p>Response: {text_t}.</p>") == "<p>Response: Hi Alice.</p>" 

338 

339 def test_templated_simple_iterable(self): 

340 strs = ["Strings", "...", "Yeah!", "Rock", "...", "Yeah!"] 

341 assert ( 

342 html(t"<p>Response: {strs}.</p>") 

343 == "<p>Response: Strings...Yeah!Rock...Yeah!.</p>" 

344 ) 

345 

346 def test_templated_escaping(self): 

347 text = '''<>&'"''' 

348 assert ( 

349 html(t"<p>Response: {text}.</p>") 

350 == "<p>Response: &lt;&gt;&amp;&#39;&#34;.</p>" 

351 ) 

352 

353 def test_templated_escaping_in_literals(self): 

354 text = "This text is fine" 

355 assert ( 

356 html(t"<p>The literal has &lt; in it: {text}.</p>") 

357 == "<p>The literal has &lt; in it: This text is fine.</p>" 

358 ) 

359 

360 def test_iterable_of_templates(self): 

361 items = ["Apple", "Banana", "Cherry"] 

362 assert ( 

363 html(t"<ul>{[t'<li>{item}</li>' for item in items]}</ul>") 

364 == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>" 

365 ) 

366 

367 def test_iterable_of_templates_of_iterable_of_templates(self): 

368 outer = ["fruit", "more fruit"] 

369 inner = ["apple", "banana", "cherry"] 

370 inner_items = [t"<li>{item}</li>" for item in inner] 

371 outer_items = [ 

372 t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer 

373 ] 

374 assert ( 

375 html(t"<ul>{outer_items}</ul>") 

376 == "<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>" 

377 ) 

378 

379 

380class TestRawTextElementLiteral: 

381 def test_script_empty(self): 

382 assert html(t"<script></script>") == "<script></script>" 

383 

384 def test_style_empty(self): 

385 assert html(t"<style></style>") == "<style></style>" 

386 

387 def test_script_with_content(self): 

388 assert html(t"<script>var x = 1;</script>") == "<script>var x = 1;</script>" 

389 

390 def test_style_with_content(self): 

391 # @NOTE: Double {{ and }} to avoid t-string interpolation. 

392 assert ( 

393 html(t"<style>.red { color: red; } </style>") 

394 == "<style>.red { color: red; }</style>" 

395 ) 

396 

397 def test_script_with_content_escaped_in_normal_text(self): 

398 # @NOTE: Double {{ and }} to avoid t-string interpolation. 

399 assert ( 

400 html(t"<script>function CompareNumbers(a, b) { return a < b; } </script>") 

401 == "<script>function CompareNumbers(a, b) { return a < b; }</script>" 

402 ), "The < should not be escaped." 

403 

404 def test_style_with_content_escaped_in_normal_text(self): 

405 # @NOTE: Double {{ and }} to avoid t-string interpolation. 

406 assert ( 

407 html(t"<style>section > h4 { background-color: red; } </style>") 

408 == "<style>section > h4 { background-color: red; }</style>" 

409 ), "The > should not be escaped." 

410 

411 def test_not_supported_recursive_template_error(self): 

412 text_t = t"comment" 

413 with pytest.raises(ValueError, match="not supported"): 

414 _ = html(t"<!--{text_t}-->") 

415 

416 def test_not_supported_recursive_iterable_error(self): 

417 texts = ["This", "is", "a", "comment"] 

418 with pytest.raises(ValueError, match="not supported"): 

419 _ = html(t"<!--{texts}-->") 

420 

421 

422class TestEscapableRawTextElementLiteral: 

423 def test_title_empty(self): 

424 assert html(t"<title></title>") == "<title></title>" 

425 

426 def test_textarea_empty(self): 

427 assert html(t"<textarea></textarea>") == "<textarea></textarea>" 

428 

429 def test_title_with_content(self): 

430 assert html(t"<title>Content</title>") == "<title>Content</title>" 

431 

432 def test_textarea_with_content(self): 

433 assert html(t"<textarea>Content</textarea>") == "<textarea>Content</textarea>" 

434 

435 def test_title_with_escapable_content(self): 

436 assert ( 

437 html(t"<title>Are t-strings > everything?</title>") 

438 == "<title>Are t-strings &gt; everything?</title>" 

439 ), "The > can be escaped in this content type." 

440 

441 def test_textarea_with_escapable_content(self): 

442 assert ( 

443 html(t"<textarea><p>Welcome To TDOM</p></textarea>") 

444 == "<textarea>&lt;p&gt;Welcome To TDOM&lt;/p&gt;</textarea>" 

445 ), "The p tags can be escaped in this content type." 

446 

447 

448class TestRawTextScriptDynamic: 

449 def test_singleton_none(self): 

450 assert html(t"<script>{None}</script>") == "<script></script>" 

451 

452 def test_singleton_str(self): 

453 content = "var x = 1;" 

454 assert html(t"<script>{content}</script>") == "<script>var x = 1;</script>" 

455 

456 @pytest.mark.parametrize("bool_value", (True, False)) 

457 def test_singleton_bool(self, bool_value): 

458 assert html(t"<script>{bool_value}</script>") == "<script></script>" 

459 

460 def test_singleton_object(self): 

461 content = 0 

462 assert html(t"<script>{content}</script>") == "<script>0</script>" 

463 

464 @pytest.mark.parametrize( 

465 "html_dunder_cls", 

466 ( 

467 LiteralHTML, 

468 Markup, 

469 ), 

470 ) 

471 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

472 # @TODO: We should probably put some double override to prevent this by accident. 

473 # Or just disable this and if people want to do this then put the 

474 # content in a SCRIPT and inject the whole thing with a __html__? 

475 content = html_dunder_cls("</script>") 

476 assert html(t"<script>{content}</script>") == "<script></script></script>", ( 

477 "DO NOT DO THIS! This is just an advanced escape hatch! Use a data attribute and parseJSON!" 

478 ) 

479 

480 def test_singleton_escaping(self): 

481 content = "</script>" 

482 script_t = t"<script>{content}</script>" 

483 bad_output = script_t.strings[0] + content + script_t.strings[1] 

484 assert html(script_t) == "<script>\\x3c/script></script>" 

485 assert html(script_t) != bad_output, "Sanity check." 

486 

487 def test_templated_none(self): 

488 assert ( 

489 html(t"<script>var x = 1;{None};</script>") 

490 == "<script>var x = 1;;</script>" 

491 ) 

492 

493 def test_templated_str(self): 

494 content = "var x = 1" 

495 assert ( 

496 html(t"<script>var x = 0;{content};</script>") 

497 == "<script>var x = 0;var x = 1;</script>" 

498 ) 

499 

500 @pytest.mark.parametrize("bool_value", (True, False)) 

501 def test_templated_bool(self, bool_value): 

502 assert ( 

503 html(t"<script>var x = 15; {bool_value}</script>") 

504 == "<script>var x = 15; </script>" 

505 ) 

506 

507 def test_templated_object(self): 

508 content = 0 

509 assert ( 

510 html(t"<script>var x = {content};</script>") 

511 == "<script>var x = 0;</script>" 

512 ) 

513 

514 @pytest.mark.parametrize( 

515 "html_dunder_cls", 

516 ( 

517 LiteralHTML, 

518 Markup, 

519 ), 

520 ) 

521 def test_templated_has_html_dunder(self, html_dunder_cls): 

522 content = html_dunder_cls("anything") 

523 with pytest.raises(ValueError, match="not supported"): 

524 _ = html(t"<script>var x = 1;{content}</script>") 

525 

526 def test_templated_escaping(self): 

527 content = "</script>" 

528 script_t = t"<script>var x = '{content}';</script>" 

529 bad_output = script_t.strings[0] + content + script_t.strings[1] 

530 assert html(script_t) == "<script>var x = '\\x3c/script>';</script>" 

531 assert html(script_t) != bad_output, "Sanity check." 

532 

533 def test_templated_multiple_interpolations(self): 

534 assert ( 

535 html(t"<script>var x = {1}; var y = {2};</script>") 

536 == "<script>var x = 1; var y = 2;</script>" 

537 ) 

538 

539 def test_not_supported_recursive_template_error(self): 

540 text_t = t"script" 

541 with pytest.raises(ValueError, match="not supported"): 

542 _ = html(t"<script>{text_t}</script>") 

543 

544 def test_not_supported_recursive_iterable_error(self): 

545 texts = ["This", "is", "a", "script"] 

546 with pytest.raises(ValueError, match="not supported"): 

547 _ = html(t"<script>{texts}</script>") 

548 

549 

550class TestRawTextStyleDynamic: 

551 def test_singleton_none(self): 

552 assert html(t"<style>{None}</style>") == "<style></style>" 

553 

554 def test_singleton_str(self): 

555 content = "div { background-color: red; }" 

556 assert ( 

557 html(t"<style>{content}</style>") 

558 == "<style>div { background-color: red; }</style>" 

559 ) 

560 

561 @pytest.mark.parametrize("bool_value", (True, False)) 

562 def test_singleton_bool(self, bool_value): 

563 assert html(t"<style>{bool_value}</style>") == "<style></style>" 

564 

565 def test_singleton_object(self): 

566 content = 0 

567 assert html(t"<style>{content}</style>") == "<style>0</style>" 

568 

569 @pytest.mark.parametrize( 

570 "html_dunder_cls", 

571 ( 

572 LiteralHTML, 

573 Markup, 

574 ), 

575 ) 

576 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

577 # @TODO: We should probably put some double override to prevent this by accident. 

578 # Or just disable this and if people want to do this then put the 

579 # content in a STYLE and inject the whole thing with a __html__? 

580 content = html_dunder_cls("</style>") 

581 assert html(t"<style>{content}</style>") == "<style></style></style>", ( 

582 "DO NOT DO THIS! This is just an advanced escape hatch!" 

583 ) 

584 

585 def test_singleton_escaping(self): 

586 content = "</style>" 

587 style_t = t"<style>{content}</style>" 

588 bad_output = style_t.strings[0] + content + style_t.strings[1] 

589 assert html(style_t) == "<style>&lt;/style></style>" 

590 assert html(style_t) != bad_output, "Sanity check." 

591 

592 def test_templated_none(self): 

593 assert ( 

594 html(t"<style>h1 { background-color: red; } {None}</style>") 

595 == "<style>h1 { background-color: red; }</style>" 

596 ) 

597 

598 def test_templated_str(self): 

599 content = " h2 { background-color: blue; }" 

600 assert ( 

601 html(t"<style>h1 { background-color: red; } {content}</style>") 

602 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>" 

603 ) 

604 

605 @pytest.mark.parametrize("bool_value", (True, False)) 

606 def test_templated_bool(self, bool_value): 

607 assert ( 

608 html(t"<style>h1 { background-color: red; } ;{bool_value}</style>") 

609 == "<style>h1 { background-color: red; };</style>" 

610 ) 

611 

612 def test_templated_object(self): 

613 padding_right = 0 

614 assert ( 

615 html(t"<style>h1 { padding-right: {padding_right}px; } </style>") 

616 == "<style>h1 { padding-right: 0px; }</style>" 

617 ) 

618 

619 @pytest.mark.parametrize( 

620 "html_dunder_cls", 

621 ( 

622 LiteralHTML, 

623 Markup, 

624 ), 

625 ) 

626 def test_templated_has_html_dunder(self, html_dunder_cls): 

627 content = html_dunder_cls("anything") 

628 with pytest.raises(ValueError, match="not supported"): 

629 _ = html(t"<style>h1 { color: red; } ;{content}</style>") 

630 

631 def test_templated_escaping(self): 

632 content = "</style>" 

633 style_t = t"<style>div { background-color: red; } {content}</style>" 

634 bad_output = style_t.strings[0] + content + style_t.strings[1] 

635 assert ( 

636 html(style_t) == "<style>div { background-color: red; } &lt;/style></style>" 

637 ) 

638 assert html(style_t) != bad_output, "Sanity check." 

639 

640 def test_templated_multiple_interpolations(self): 

641 assert ( 

642 html( 

643 t"<style>h1 { background-color: {'red'}; } h2 { background-color: {'blue'}; } </style>" 

644 ) 

645 == "<style>h1 { background-color: red; } h2 { background-color: blue; }</style>" 

646 ) 

647 

648 def test_exact_not_supported_recursive_template_error(self): 

649 text_t = t"style" 

650 with pytest.raises(ValueError, match="not supported"): 

651 _ = html(t"<style>{text_t}</style>") 

652 

653 def test_inexact_not_supported_recursive_template_error(self): 

654 text_t = t"style" 

655 with pytest.raises(ValueError, match="not supported"): 

656 _ = html(t"<style>{text_t} and more</style>") 

657 

658 def test_exact_not_supported_recursive_iterable_error(self): 

659 texts = ["This", "is", "a", "style"] 

660 with pytest.raises(ValueError, match="not supported"): 

661 _ = html(t"<style>{texts}</style>") 

662 

663 def test_inexact_not_supported_recursive_iterable_error(self): 

664 texts = ["This", "is", "a", "style"] 

665 with pytest.raises(ValueError, match="not supported"): 

666 _ = html(t"<style>{texts} and more</style>") 

667 

668 

669class TestEscapableRawTextTitleDynamic: 

670 def test_singleton_none(self): 

671 assert html(t"<title>{None}</title>") == "<title></title>" 

672 

673 def test_singleton_str(self): 

674 content = "Welcome To TDOM" 

675 assert html(t"<title>{content}</title>") == "<title>Welcome To TDOM</title>" 

676 

677 @pytest.mark.parametrize("bool_value", (True, False)) 

678 def test_singleton_bool(self, bool_value): 

679 assert html(t"<title>{bool_value}</title>") == "<title></title>" 

680 

681 def test_singleton_object(self): 

682 content = 0 

683 assert html(t"<title>{content}</title>") == "<title>0</title>" 

684 

685 @pytest.mark.parametrize( 

686 "html_dunder_cls", 

687 ( 

688 LiteralHTML, 

689 Markup, 

690 ), 

691 ) 

692 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

693 # @TODO: We should probably put some double override to prevent this by accident. 

694 content = html_dunder_cls("</title>") 

695 assert html(t"<title>{content}</title>") == "<title></title></title>", ( 

696 "DO NOT DO THIS! This is just an advanced escape hatch!" 

697 ) 

698 

699 def test_singleton_escaping(self): 

700 content = "</title>" 

701 assert html(t"<title>{content}</title>") == "<title>&lt;/title&gt;</title>" 

702 

703 def test_templated_none(self): 

704 assert ( 

705 html(t"<title>A great story about: {None}</title>") 

706 == "<title>A great story about: </title>" 

707 ) 

708 

709 def test_templated_str(self): 

710 content = "TDOM" 

711 assert ( 

712 html(t"<title>A great story about: {content}</title>") 

713 == "<title>A great story about: TDOM</title>" 

714 ) 

715 

716 @pytest.mark.parametrize("bool_value", (True, False)) 

717 def test_templated_bool(self, bool_value): 

718 assert ( 

719 html(t"<title>A great story; {bool_value}</title>") 

720 == "<title>A great story; </title>" 

721 ) 

722 

723 def test_templated_object(self): 

724 content = 0 

725 assert ( 

726 html(t"<title>A great number: {content}</title>") 

727 == "<title>A great number: 0</title>" 

728 ) 

729 

730 @pytest.mark.parametrize( 

731 "html_dunder_cls", 

732 ( 

733 LiteralHTML, 

734 Markup, 

735 ), 

736 ) 

737 def test_templated_has_html_dunder(self, html_dunder_cls): 

738 content = html_dunder_cls("No") 

739 with pytest.raises(ValueError, match="not supported"): 

740 _ = html(t"<title>Literal html?: {content}</title>") 

741 

742 def test_templated_escaping(self): 

743 content = "</title>" 

744 assert ( 

745 html(t"<title>The end tag: {content}.</title>") 

746 == "<title>The end tag: &lt;/title&gt;.</title>" 

747 ) 

748 

749 def test_templated_multiple_interpolations(self): 

750 assert ( 

751 html(t"<title>The number {0} is less than {1}.</title>") 

752 == "<title>The number 0 is less than 1.</title>" 

753 ) 

754 

755 def test_exact_not_supported_recursive_template_error(self): 

756 text_t = t"title" 

757 with pytest.raises(ValueError, match="not supported"): 

758 _ = html(t"<title>{text_t}</title>") 

759 

760 def test_exact_not_supported_recursive_iterable_error(self): 

761 texts = ["This", "is", "a", "title"] 

762 with pytest.raises(ValueError, match="not supported"): 

763 _ = html(t"<title>{texts}</title>") 

764 

765 def test_inexact_not_supported_recursive_template_error(self): 

766 text_t = t"title" 

767 with pytest.raises(ValueError, match="not supported"): 

768 _ = html(t"<title>{text_t} and more</title>") 

769 

770 def test_inexact_not_supported_recursive_iterable_error(self): 

771 texts = ["This", "is", "a", "title"] 

772 with pytest.raises(ValueError, match="not supported"): 

773 _ = html(t"<title>{texts} and more</title>") 

774 

775 

776class TestEscapableRawTextTextareaDynamic: 

777 def test_singleton_none(self): 

778 assert html(t"<textarea>{None}</textarea>") == "<textarea></textarea>" 

779 

780 def test_singleton_str(self): 

781 content = "Welcome To TDOM" 

782 assert ( 

783 html(t"<textarea>{content}</textarea>") 

784 == "<textarea>Welcome To TDOM</textarea>" 

785 ) 

786 

787 @pytest.mark.parametrize("bool_value", (True, False)) 

788 def test_singleton_bool(self, bool_value): 

789 assert html(t"<textarea>{bool_value}</textarea>") == "<textarea></textarea>" 

790 

791 def test_singleton_object(self): 

792 content = 0 

793 assert html(t"<textarea>{content}</textarea>") == "<textarea>0</textarea>" 

794 

795 @pytest.mark.parametrize( 

796 "html_dunder_cls", 

797 ( 

798 LiteralHTML, 

799 Markup, 

800 ), 

801 ) 

802 def test_singleton_has_html_dunder_pitfall(self, html_dunder_cls): 

803 # @TODO: We should probably put some double override to prevent this by accident. 

804 content = html_dunder_cls("</textarea>") 

805 assert ( 

806 html(t"<textarea>{content}</textarea>") 

807 == "<textarea></textarea></textarea>" 

808 ), "DO NOT DO THIS! This is just an advanced escape hatch!" 

809 

810 def test_singleton_escaping(self): 

811 content = "</textarea>" 

812 assert ( 

813 html(t"<textarea>{content}</textarea>") 

814 == "<textarea>&lt;/textarea&gt;</textarea>" 

815 ) 

816 

817 def test_templated_none(self): 

818 assert ( 

819 html(t"<textarea>A great story about: {None}</textarea>") 

820 == "<textarea>A great story about: </textarea>" 

821 ) 

822 

823 def test_templated_str(self): 

824 content = "TDOM" 

825 assert ( 

826 html(t"<textarea>A great story about: {content}</textarea>") 

827 == "<textarea>A great story about: TDOM</textarea>" 

828 ) 

829 

830 @pytest.mark.parametrize("bool_value", (True, False)) 

831 def test_templated_bool(self, bool_value): 

832 assert ( 

833 html(t"<textarea>This is great.{bool_value}</textarea>") 

834 == "<textarea>This is great.</textarea>" 

835 ) 

836 

837 def test_templated_object(self): 

838 content = 0 

839 assert ( 

840 html(t"<textarea>A great number: {content}</textarea>") 

841 == "<textarea>A great number: 0</textarea>" 

842 ) 

843 

844 @pytest.mark.parametrize( 

845 "html_dunder_cls", 

846 ( 

847 LiteralHTML, 

848 Markup, 

849 ), 

850 ) 

851 def test_templated_has_html_dunder(self, html_dunder_cls): 

852 content = html_dunder_cls("No") 

853 with pytest.raises(ValueError, match="not supported"): 

854 _ = html(t"<textarea>Literal html?: {content}</textarea>") 

855 

856 def test_templated_multiple_interpolations(self): 

857 assert ( 

858 html(t"<textarea>The number {0} is less than {1}.</textarea>") 

859 == "<textarea>The number 0 is less than 1.</textarea>" 

860 ) 

861 

862 def test_templated_escaping(self): 

863 content = "</textarea>" 

864 assert ( 

865 html(t"<textarea>The end tag: {content}.</textarea>") 

866 == "<textarea>The end tag: &lt;/textarea&gt;.</textarea>" 

867 ) 

868 

869 def test_not_supported_recursive_template_error(self): 

870 text_t = t"textarea" 

871 with pytest.raises(ValueError, match="not supported"): 

872 _ = html(t"<textarea>{text_t}</textarea>") 

873 

874 def test_not_supported_recursive_iterable_error(self): 

875 texts = ["This", "is", "a", "textarea"] 

876 with pytest.raises(ValueError, match="not supported"): 

877 _ = html(t"<textarea>{texts}</textarea>") 

878 

879 

880class Convertible: 

881 def __str__(self): 

882 return "string" 

883 

884 def __repr__(self): 

885 return "repr" 

886 

887 

888def test_convertible_fixture(): 

889 """Make sure test fixture is working correctly.""" 

890 c = Convertible() 

891 assert f"{c!s}" == "string" 

892 assert f"{c!r}" == "repr" 

893 

894 

895def wrap_template_in_tags( 

896 start_tag: str, template: Template, end_tag: str | None = None 

897): 

898 """Utility for testing templated text but with different containing tags.""" 

899 if end_tag is None: 

900 end_tag = start_tag 

901 return Template(f"<{start_tag}>") + template + Template(f"</{end_tag}>") 

902 

903 

904def wrap_text_in_tags(start_tag: str, content: str, end_tag: str | None = None): 

905 """Utility for testing expected text but with different containing tags.""" 

906 if end_tag is None: 

907 end_tag = start_tag 

908 # Stringify to flatten `Markup()` 

909 content = str(content) 

910 return f"<{start_tag}>" + content + f"</{end_tag}>" 

911 

912 

913class TestInterpolationConversion: 

914 def test_str(self): 

915 c = Convertible() 

916 for tag in ("p", "script", "title"): 

917 assert html(wrap_template_in_tags(tag, t"{c!s}")) == wrap_text_in_tags( 

918 tag, "string" 

919 ) 

920 

921 def test_repr(self): 

922 c = Convertible() 

923 for tag in ("p", "script", "title"): 

924 assert html(wrap_template_in_tags(tag, t"{c!r}")) == wrap_text_in_tags( 

925 tag, "repr" 

926 ) 

927 

928 def test_ascii_raw_text(self): 

929 # single quotes are not escaped in raw text 

930 assert html(wrap_template_in_tags("script", t"{'😊'!a}")) == wrap_text_in_tags( 

931 "script", ascii("😊") 

932 ) 

933 

934 def test_ascii_escapable_normal_and_raw(self): 

935 # single quotes are escaped 

936 for tag in ("p", "title"): 

937 assert html(wrap_template_in_tags(tag, t"{'😊'!a}")) == wrap_text_in_tags( 

938 tag, escape_html_text(ascii("😊")) 

939 ) 

940 

941 

942class TestInterpolationFormatSpec: 

943 def test_normal_text_safe(self): 

944 raw_content = "<u>underlined</u>" 

945 assert ( 

946 html(t"<p>This is {raw_content:safe} text.</p>") 

947 == "<p>This is <u>underlined</u> text.</p>" 

948 ) 

949 

950 def test_raw_text_safe(self): 

951 # @TODO: What should even happen here? 

952 raw_content = "</script>" 

953 assert ( 

954 html(t"<script>{raw_content:safe}</script>") == "<script></script></script>" 

955 ), "DO NOT DO THIS! This is an advanced escape hatch." 

956 

957 def test_escapable_raw_text_safe(self): 

958 raw_content = "<u>underlined</u>" 

959 assert ( 

960 html(t"<textarea>{raw_content:safe}</textarea>") 

961 == "<textarea><u>underlined</u></textarea>" 

962 ) 

963 

964 def test_normal_text_unsafe(self): 

965 supposedly_safe = Markup("<i>italic</i>") 

966 assert ( 

967 html(t"<p>This is {supposedly_safe:unsafe} text.</p>") 

968 == "<p>This is &lt;i&gt;italic&lt;/i&gt; text.</p>" 

969 ) 

970 

971 def test_raw_text_unsafe(self): 

972 # @TODO: What should even happen here? 

973 supposedly_safe = "</script>" 

974 assert ( 

975 html(t"<script>{supposedly_safe:unsafe}</script>") 

976 == "<script>\\x3c/script></script>" 

977 ) 

978 assert ( 

979 html(t"<script>{supposedly_safe:unsafe}</script>") 

980 != "<script></script></script>" 

981 ) # Sanity check 

982 

983 def test_escapable_raw_text_unsafe(self): 

984 supposedly_safe = Markup("<i>italic</i>") 

985 assert ( 

986 html(t"<textarea>{supposedly_safe:unsafe}</textarea>") 

987 == "<textarea>&lt;i&gt;italic&lt;/i&gt;</textarea>" 

988 ) 

989 

990 def test_all_text_callback(self): 

991 def get_value(): 

992 return "dynamic" 

993 

994 for tag in ("p", "script", "style"): 

995 assert ( 

996 html( 

997 Template(f"<{tag}>") 

998 + t"The value is {get_value:callback}." 

999 + Template(f"</{tag}>") 

1000 ) 

1001 == f"<{tag}>The value is dynamic.</{tag}>" 

1002 ) 

1003 

1004 def test_callback_nonzero_callable_error(self): 

1005 def add(a, b): 

1006 return a + b 

1007 

1008 assert add(1, 2) == 3, "Make sure fixture could work..." 

1009 

1010 with pytest.raises(TypeError): 

1011 for tag in ("p", "script", "style"): 

1012 _ = html( 

1013 Template(f"<{tag}>") 

1014 + t"The sum is {add:callback}." 

1015 + Template(f"</{tag}>") 

1016 ) 

1017 

1018 

1019# -------------------------------------------------------------------------- 

1020# Conditional rendering and control flow 

1021# -------------------------------------------------------------------------- 

1022 

1023 

1024class TestUsagePatterns: 

1025 def test_conditional_rendering_with_if_else(self): 

1026 is_logged_in = True 

1027 user_profile = t"<span>Welcome, User!</span>" 

1028 login_prompt = t"<a href='/login'>Please log in</a>" 

1029 assert ( 

1030 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>") 

1031 == "<div><span>Welcome, User!</span></div>" 

1032 ) 

1033 

1034 is_logged_in = False 

1035 assert ( 

1036 html(t"<div>{user_profile if is_logged_in else login_prompt}</div>") 

1037 == '<div><a href="/login">Please log in</a></div>' 

1038 ) 

1039 

1040 

1041# -------------------------------------------------------------------------- 

1042# Attributes 

1043# -------------------------------------------------------------------------- 

1044class TestLiteralAttribute: 

1045 """Test literal (non-dynamic) attributes.""" 

1046 

1047 def test_literal_attrs(self): 

1048 assert ( 

1049 html( 

1050 t"<a " 

1051 t" id=example_link" # no quotes required if value has no surrounding whitespace 

1052 t" autofocus" # bare / boolean 

1053 t' title=""' # empty attribute 

1054 t' href="https://example.com" target="_blank"' 

1055 t"></a>" 

1056 ) 

1057 == '<a id="example_link" autofocus title="" href="https://example.com" target="_blank"></a>' 

1058 ) 

1059 

1060 def test_literal_attr_escaped(self): 

1061 assert ( 

1062 html(t'<a title="&lt;&gt;&amp;&#39;&#34;"></a>') 

1063 == '<a title="&lt;&gt;&amp;&#39;&#34;"></a>' 

1064 ) 

1065 

1066 

1067class TestInterpolatedAttribute: 

1068 """Test interpolated attributes, entire value is an exact interpolation.""" 

1069 

1070 def test_interpolated_attr(self): 

1071 url = "https://example.com/" 

1072 assert html(t'<a href="{url}"></a>') == '<a href="https://example.com/"></a>' 

1073 

1074 def test_interpolated_attr_escaped(self): 

1075 url = 'https://example.com/?q="test"&lang=en' 

1076 assert ( 

1077 html(t'<a href="{url}"></a>') 

1078 == '<a href="https://example.com/?q=&#34;test&#34;&amp;lang=en"></a>' 

1079 ) 

1080 

1081 def test_interpolated_attr_unquoted(self): 

1082 id = "roquefort" 

1083 assert html(t"<div id={id}></div>") == '<div id="roquefort"></div>' 

1084 

1085 def test_interpolated_attr_true(self): 

1086 disabled = True 

1087 assert ( 

1088 html(t"<button disabled={disabled}></button>") 

1089 == "<button disabled></button>" 

1090 ) 

1091 

1092 def test_interpolated_attr_false(self): 

1093 disabled = False 

1094 assert html(t"<button disabled={disabled}></button>") == "<button></button>" 

1095 

1096 def test_interpolated_attr_none(self): 

1097 disabled = None 

1098 assert html(t"<button disabled={disabled}></button>") == "<button></button>" 

1099 

1100 def test_interpolate_attr_empty_string(self): 

1101 assert html(t'<div title=""></div>') == '<div title=""></div>' 

1102 

1103 

1104class TestSpreadAttribute: 

1105 """Test spread attributes.""" 

1106 

1107 def test_spread_attr(self): 

1108 attrs = {"href": "https://example.com/", "target": "_blank"} 

1109 assert ( 

1110 html(t"<a {attrs}></a>") 

1111 == '<a href="https://example.com/" target="_blank"></a>' 

1112 ) 

1113 

1114 def test_spread_attr_none(self): 

1115 attrs = None 

1116 assert html(t"<a {attrs}></a>") == "<a></a>" 

1117 

1118 def test_spread_attr_type_errors(self): 

1119 for attrs in (0, [], (), False, True): 

1120 with pytest.raises(TypeError): 

1121 _ = html(t"<a {attrs}></a>") 

1122 

1123 

1124class TestTemplatedAttribute: 

1125 def test_templated_attr_mixed_interpolations_start_end_and_nest(self): 

1126 left, middle, right = 1, 3, 5 

1127 prefix, suffix = t'<div data-range="', t'"></div>' 

1128 # Check interpolations at start, middle and/or end of templated attr 

1129 # or a combination of those to make sure text is not getting dropped. 

1130 for left_part, middle_part, right_part in product( 

1131 (t"{left}", Template(str(left))), 

1132 (t"{middle}", Template(str(middle))), 

1133 (t"{right}", Template(str(right))), 

1134 ): 

1135 test_t = ( 

1136 prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix 

1137 ) 

1138 assert html(test_t) == '<div data-range="1-3-5"></div>' 

1139 

1140 def test_templated_attr_no_quotes(self): 

1141 start = 1 

1142 end = 5 

1143 assert ( 

1144 html(t"<div data-range={start}-{end}></div>") 

1145 == '<div data-range="1-5"></div>' 

1146 ) 

1147 

1148 

1149class TestAttributeMerging: 

1150 def test_attr_merge_disjoint_interpolated_attr_spread_attr(self): 

1151 attrs = {"href": "https://example.com/", "id": "link1"} 

1152 target = "_blank" 

1153 assert ( 

1154 html(t"<a {attrs} target={target}></a>") 

1155 == '<a href="https://example.com/" id="link1" target="_blank"></a>' 

1156 ) 

1157 

1158 def test_attr_merge_overlapping_spread_attrs(self): 

1159 attrs1 = {"href": "https://example.com/", "id": "overwrtten"} 

1160 attrs2 = {"target": "_blank", "id": "link1"} 

1161 assert ( 

1162 html(t"<a {attrs1} {attrs2}></a>") 

1163 == '<a href="https://example.com/" target="_blank" id="link1"></a>' 

1164 ) 

1165 

1166 def test_attr_merge_replace_literal_attr_str_str(self): 

1167 assert ( 

1168 html(t'<div title="default" { {"title": "fresh"} }></div>') 

1169 == '<div title="fresh"></div>' 

1170 ) 

1171 

1172 def test_attr_merge_replace_literal_attr_str_true(self): 

1173 assert ( 

1174 html(t'<div title="default" { {"title": True} }></div>') 

1175 == "<div title></div>" 

1176 ) 

1177 

1178 def test_attr_merge_replace_literal_attr_true_str(self): 

1179 assert ( 

1180 html(t"<div title { {'title': 'fresh'} }></div>") 

1181 == '<div title="fresh"></div>' 

1182 ) 

1183 

1184 def test_attr_merge_remove_literal_attr_str_none(self): 

1185 assert html(t'<div title="default" { {"title": None} }></div>') == "<div></div>" 

1186 

1187 def test_attr_merge_remove_literal_attr_true_none(self): 

1188 assert html(t"<div title { {'title': None} }></div>") == "<div></div>" 

1189 

1190 def test_attr_merge_other_literal_attr_intact(self): 

1191 assert ( 

1192 html(t'<img title="default" { {"alt": "fresh"} }>') 

1193 == '<img title="default" alt="fresh" />' 

1194 ) 

1195 

1196 

1197class TestSpecialDataAttribute: 

1198 """Special data attribute handling.""" 

1199 

1200 def test_interpolated_data_attributes(self): 

1201 data = { 

1202 "user-id": 123, 

1203 "role": "admin", 

1204 "wild": True, 

1205 "false": False, 

1206 "none": None, 

1207 } 

1208 assert ( 

1209 html(t"<div data={data}>User Info</div>") 

1210 == '<div data-user-id="123" data-role="admin" data-wild>User Info</div>' 

1211 ) 

1212 

1213 def test_data_attr_toggle_to_str(self): 

1214 for res in [ 

1215 html(t"<div data-selected data={ {'selected': 'yes'} }></div>"), 

1216 html(t'<div data-selected="no" data={ {"selected": "yes"} }></div>'), 

1217 ]: 

1218 assert res == '<div data-selected="yes"></div>' 

1219 

1220 def test_data_attr_toggle_to_true(self): 

1221 res = html(t'<div data-selected="yes" data={ {"selected": True} }></div>') 

1222 assert res == "<div data-selected></div>" 

1223 

1224 def test_data_attr_unrelated_unaffected(self): 

1225 res = html(t"<div data-selected data={ {'active': True} }></div>") 

1226 assert res == "<div data-selected data-active></div>" 

1227 

1228 def test_data_attr_templated_error(self): 

1229 data1 = {"user-id": "user-123"} 

1230 data2 = {"role": "admin"} 

1231 with pytest.raises(TypeError): 

1232 _ = html(t'<div data="{data1} {data2}"></div>') 

1233 

1234 def test_data_attr_none(self): 

1235 button_data = None 

1236 res = html(t"<button data={button_data}>X</button>") 

1237 assert res == "<button>X</button>" 

1238 

1239 def test_data_attr_errors(self): 

1240 for v in [False, [], (), 0, "data?"]: 

1241 with pytest.raises(TypeError): 

1242 _ = html(t"<button data={v}>X</button>") 

1243 

1244 def test_data_literal_attr_bypass(self): 

1245 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1246 res = html(t'<p data="passthru" id={"resolved"}></p>') 

1247 assert res == '<p data="passthru" id="resolved"></p>', ( 

1248 "A single literal attribute should not trigger data expansion." 

1249 ) 

1250 

1251 

1252class TestSpecialAriaAttribute: 

1253 """Special aria attribute handling.""" 

1254 

1255 def test_aria_templated_attr_error(self): 

1256 aria1 = {"label": "close"} 

1257 aria2 = {"hidden": "true"} 

1258 with pytest.raises(TypeError): 

1259 _ = html(t'<div aria="{aria1} {aria2}"></div>') 

1260 

1261 def test_interpolated_mapping(self): 

1262 aria_dict = {"label": "Close", "hidden": True, "another": False, "more": None} 

1263 for aria_mapping in ( 

1264 aria_dict, 

1265 UserDict(aria_dict.items()), 

1266 ): # dict and non-dict Mapping 

1267 res = html(t"<button aria={aria_mapping}>X</button>") 

1268 assert ( 

1269 res 

1270 == '<button aria-label="Close" aria-hidden="true" aria-another="false">X</button>' 

1271 ) 

1272 

1273 def test_aria_interpolate_attr_none(self): 

1274 button_aria = None 

1275 res = html(t"<button aria={button_aria}>X</button>") 

1276 assert res == "<button>X</button>" 

1277 

1278 def test_aria_attr_errors(self): 

1279 for v in [False, [], (), 0, "aria?"]: 

1280 with pytest.raises(TypeError): 

1281 _ = html(t"<button aria={v}>X</button>") 

1282 

1283 def test_aria_literal_attr_bypass(self): 

1284 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1285 res = html(t'<p aria="passthru" id={"resolved"}></p>') 

1286 assert res == '<p aria="passthru" id="resolved"></p>', ( 

1287 "A single literal attribute should not trigger aria expansion." 

1288 ) 

1289 

1290 

1291class TestSpecialClassAttribute: 

1292 """Special class attribute handling.""" 

1293 

1294 def test_interpolated_class_attribute(self): 

1295 class_list = ["btn", "btn-primary", "one two", None] 

1296 class_dict = {"active": True, "btn-secondary": False} 

1297 class_str = "blue" 

1298 class_space_sep_str = "green yellow" 

1299 class_none = None 

1300 class_empty_list = [] 

1301 class_empty_dict = {} 

1302 button_t = ( 

1303 t"<button " 

1304 t' class="red" class={class_list} class={class_dict}' 

1305 t" class={class_empty_list} class={class_empty_dict}" # ignored 

1306 t" class={class_none}" # ignored 

1307 t" class={class_str} class={class_space_sep_str}" 

1308 t" >Click me</button>" 

1309 ) 

1310 res = html(button_t) 

1311 assert ( 

1312 res 

1313 == '<button class="red btn btn-primary one two active blue green yellow">Click me</button>' 

1314 ) 

1315 

1316 def test_interpolated_mapping(self): 

1317 class_dict = {"active": True, "btn-secondary": False} 

1318 for class_mapping in ( 

1319 class_dict, 

1320 UserDict(class_dict.items()), 

1321 ): # dict and non-dict Mapping 

1322 res = html( 

1323 t"<button class='btn-secondary' class={class_mapping}>X</button>" 

1324 ) 

1325 assert res == '<button class="active">X</button>' 

1326 

1327 def test_interpolated_class_attribute_with_multiple_placeholders(self): 

1328 classes1 = ["btn", "btn-primary"] 

1329 classes2 = [None, {"active": True}] 

1330 res = html(t'<button class="{classes1} {classes2}">Click me</button>') 

1331 # CONSIDER: Is this what we want? Currently, when we have multiple 

1332 # placeholders in a single attribute, we treat it as a string attribute. 

1333 assert ( 

1334 res 

1335 == f'<button class="{escape_html_text(str(classes1))} {escape_html_text(str(classes2))}">Click me</button>' 

1336 ), ( 

1337 "Interpolations that are not exact, or singletons, are instead interpreted as templates and therefore these dictionaries are strified." 

1338 ) 

1339 

1340 def test_interpolated_attribute_spread_with_class_attribute(self): 

1341 attrs = {"id": "button1", "class": ["btn", "btn-primary"]} 

1342 res = html(t"<button {attrs}>Click me</button>") 

1343 assert res == '<button id="button1" class="btn btn-primary">Click me</button>' 

1344 

1345 def test_class_literal_attr_bypass(self): 

1346 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1347 res = html(t'<p class="red red" id={"veryred"}></p>') 

1348 assert res == '<p class="red red" id="veryred"></p>', ( 

1349 "A single literal attribute should not trigger class accumulator." 

1350 ) 

1351 

1352 def test_class_none_ignored(self): 

1353 class_item = None 

1354 res = html(t"<p class={class_item}></p>") 

1355 assert res == "<p></p>" 

1356 # Also ignored inside a sequence. 

1357 res = html(t"<p class={[class_item]}></p>") 

1358 assert res == "<p></p>" 

1359 

1360 def test_class_type_errors(self): 

1361 for class_item in (False, True, 0): 

1362 with pytest.raises(TypeError): 

1363 _ = html(t"<p class={class_item}></p>") 

1364 with pytest.raises(TypeError): 

1365 _ = html(t"<p class={[class_item]}></p>") 

1366 

1367 def test_class_merge_literals(self): 

1368 res = html(t'<p class="red" class="blue"></p>') 

1369 assert res == '<p class="red blue"></p>' 

1370 

1371 def test_class_merge_literal_then_interpolation(self): 

1372 class_item = "blue" 

1373 res = html(t'<p class="red" class="{[class_item]}"></p>') 

1374 assert res == '<p class="red blue"></p>' 

1375 

1376 

1377class TestSpecialStyleAttribute: 

1378 """Special style attribute handling.""" 

1379 

1380 def test_style_literal_attr_passthru(self): 

1381 p_id = "para1" # non-literal attribute to cause attr resolution 

1382 res = html(t'<p style="color: red" id={p_id}>Warning!</p>') 

1383 assert res == '<p style="color: red" id="para1">Warning!</p>' 

1384 

1385 def test_style_in_interpolated_attr(self): 

1386 styles = {"color": "red", "font-weight": "bold", "font-size": "16px"} 

1387 res = html(t"<p style={styles}>Warning!</p>") 

1388 assert ( 

1389 res 

1390 == '<p style="color: red; font-weight: bold; font-size: 16px">Warning!</p>' 

1391 ) 

1392 

1393 def test_style_in_templated_attr(self): 

1394 color = "red" 

1395 res = html(t'<p style="color: {color}">Warning!</p>') 

1396 assert res == '<p style="color: red">Warning!</p>' 

1397 

1398 def test_style_in_spread_attr(self): 

1399 attrs = {"style": {"color": "red"}} 

1400 res = html(t"<p {attrs}>Warning!</p>") 

1401 assert res == '<p style="color: red">Warning!</p>' 

1402 

1403 def test_style_merged_from_all_attrs(self): 

1404 attrs = {"style": "font-size: 15px"} 

1405 style = {"font-weight": "bold"} 

1406 color = "red" 

1407 res = html( 

1408 t'<p style="font-family: serif" style="color: {color}" style={style} {attrs}></p>' 

1409 ) 

1410 assert ( 

1411 res 

1412 == '<p style="font-family: serif; color: red; font-weight: bold; font-size: 15px"></p>' 

1413 ) 

1414 

1415 def test_style_override_left_to_right(self): 

1416 suffix = t"></p>" 

1417 parts = [ 

1418 (t'<p style="color: red"', "color: red"), 

1419 (t" style={ {'color': 'blue'} }", "color: blue"), 

1420 (t' style="color: {"green"}"', "color: green"), 

1421 (t""" { {"style": {"color": "yellow"}} }""", "color: yellow"), 

1422 ] 

1423 for index in range(len(parts)): 

1424 expected_style = parts[index][1] 

1425 t = sum((part[0] for part in parts[: index + 1]), t"") + suffix 

1426 res = html(t) 

1427 assert res == f'<p style="{expected_style}"></p>' 

1428 

1429 def test_interpolated_style_attribute_multiple_placeholders(self): 

1430 styles1 = {"color": "red"} 

1431 styles2 = {"font-weight": "bold"} 

1432 # CONSIDER: Is this what we want? Currently, when we have multiple 

1433 # placeholders in a single attribute, we treat it as a string attribute 

1434 # which produces an invalid style attribute. 

1435 with pytest.raises(ValueError): 

1436 _ = html(t"<p style='{styles1} {styles2}'>Warning!</p>") 

1437 

1438 def test_interpolated_style_attribute_merged(self): 

1439 styles1 = {"color": "red"} 

1440 styles2 = {"font-weight": "bold"} 

1441 res = html(t"<p style={styles1} style={styles2}>Warning!</p>") 

1442 assert res == '<p style="color: red; font-weight: bold">Warning!</p>' 

1443 

1444 def test_interpolated_style_attribute_merged_override(self): 

1445 styles1 = {"color": "red", "font-weight": "normal"} 

1446 styles2 = {"font-weight": "bold"} 

1447 res = html(t"<p style={styles1} style={styles2}>Warning!</p>") 

1448 assert res == '<p style="color: red; font-weight: bold">Warning!</p>' 

1449 

1450 def test_style_attribute_str(self): 

1451 styles = "color: red; font-weight: bold;" 

1452 res = html(t"<p style={styles}>Warning!</p>") 

1453 assert res == '<p style="color: red; font-weight: bold">Warning!</p>' 

1454 

1455 def test_style_attribute_non_str_non_dict(self): 

1456 with pytest.raises(TypeError): 

1457 styles = [1, 2] 

1458 _ = html(t"<p style={styles}>Warning!</p>") 

1459 

1460 def test_style_literal_attr_bypass(self): 

1461 # Trigger overall attribute resolution with an unrelated interpolated attr. 

1462 res = html(t'<p style="invalid;invalid:" id={"resolved"}></p>') 

1463 assert res == '<p style="invalid;invalid:" id="resolved"></p>', ( 

1464 "A single literal attribute should bypass style accumulator." 

1465 ) 

1466 

1467 def test_style_none(self): 

1468 styles = None 

1469 res = html(t"<p style={styles}></p>") 

1470 assert res == "<p></p>" 

1471 

1472 

1473class TestSpecialAttrMerging: 

1474 """ 

1475 Attributes should be merged left to right and displayed at the last 

1476 location they were updated. 

1477 """ 

1478 

1479 def test_accumulator_order(self): 

1480 # Accumlated attrs are flattened to a value at the end of the attribute 

1481 # resolution process which caused them to jump but this asserts that fix. 

1482 attrs = { 

1483 "class": {"btn": True, "active": True}, # Accumulated 

1484 "id": "act_now", # static 

1485 "data": {"wow": "such-attr"}, # Expanded 

1486 "title": "mega", # static 

1487 } 

1488 button = html(t"<button {attrs}>Click me</button>") 

1489 assert ( 

1490 button 

1491 == '<button class="btn active" id="act_now" data-wow="such-attr" title="mega">Click me</button>' 

1492 ) 

1493 

1494 

1495class TestPrepComponentKwargs: 

1496 def test_named(self): 

1497 def InputElement(size=10, type="text"): 

1498 pass 

1499 

1500 callable_info = get_callable_info(InputElement) 

1501 assert prep_component_kwargs(callable_info, {"size": 20}, children=t"") == { 

1502 "size": 20 

1503 } 

1504 assert prep_component_kwargs( 

1505 callable_info, {"type": "email"}, children=t"" 

1506 ) == {"type": "email"} 

1507 assert prep_component_kwargs(callable_info, {}, children=t"") == {} 

1508 

1509 def test_unused_kwargs(self): 

1510 def InputElement(size=10, type="text"): 

1511 pass 

1512 

1513 callable_info = get_callable_info(InputElement) 

1514 with pytest.raises(ValueError): 

1515 assert ( 

1516 prep_component_kwargs(callable_info, {"type2": 15}, children=t"") == {} 

1517 ) 

1518 

1519 def test_accepts_children(self): 

1520 def DivWrapper( 

1521 children: Template, add_classes: list[str] | None = None 

1522 ) -> Template: 

1523 return t"<div class={add_classes}>{children}</div>" 

1524 

1525 callable_info = get_callable_info(DivWrapper) 

1526 kwargs = prep_component_kwargs(callable_info, {}, children=t"") 

1527 assert tuple(kwargs.keys()) == ("children",) 

1528 assert isinstance(kwargs["children"], Template) and kwargs[ 

1529 "children" 

1530 ].strings == ("",) 

1531 

1532 add_classes = ["red"] 

1533 kwargs = prep_component_kwargs( 

1534 callable_info, {"add_classes": add_classes}, children=t"<span></span>" 

1535 ) 

1536 assert set(kwargs.keys()) == {"children", "add_classes"} 

1537 assert isinstance(kwargs["children"], Template) and kwargs[ 

1538 "children" 

1539 ].strings == ("<span></span>",) 

1540 assert kwargs["add_classes"] == add_classes 

1541 

1542 def test_no_children(self): 

1543 def SpanMaker(content_text: str) -> Template: 

1544 return t"<span>{content_text}</span>" 

1545 

1546 callable_info = get_callable_info(SpanMaker) 

1547 content_text = "inner" 

1548 kwargs = prep_component_kwargs( 

1549 callable_info, {"content_text": content_text}, children=t"<div></div>" 

1550 ) 

1551 assert kwargs == {"content_text": content_text} # no children 

1552 

1553 def test_children_attr_error(self): 

1554 def Comp(children: Template) -> Template: 

1555 return t"<div>{children}</div>" 

1556 

1557 callable_info = get_callable_info(Comp) 

1558 with pytest.raises(ValueError, match="The children attribute is reserved"): 

1559 _ = prep_component_kwargs( 

1560 callable_info, {"children": t""}, children=t"<span></span>" 

1561 ) 

1562 

1563 

1564class TestFunctionComponent: 

1565 @staticmethod 

1566 def FunctionComponent( 

1567 children: Template, first: str, second: int, third_arg: str, **attrs: t.Any 

1568 ) -> Template: 

1569 # Ensure type correctness of props at runtime for testing purposes 

1570 assert isinstance(first, str) 

1571 assert isinstance(second, int) 

1572 assert isinstance(third_arg, str) 

1573 new_attrs = { 

1574 "id": third_arg, 

1575 "data": {"first": first, "second": second}, 

1576 **attrs, 

1577 } 

1578 return t"<div {new_attrs}>Component: {children}</div>" 

1579 

1580 def test_with_children(self): 

1581 res = html( 

1582 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp">Hello, Component!</{self.FunctionComponent}>' 

1583 ) 

1584 assert ( 

1585 res 

1586 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: Hello, Component!</div>' 

1587 ) 

1588 

1589 def test_with_no_children(self): 

1590 """Same test, but the caller didn't provide any children.""" 

1591 res = html( 

1592 t'<{self.FunctionComponent} first=1 second={99} third-arg="comp1" class="my-comp" />' 

1593 ) 

1594 assert ( 

1595 res 

1596 == '<div id="comp1" data-first="1" data-second="99" class="my-comp">Component: </div>' 

1597 ) 

1598 

1599 def test_missing_props_error(self): 

1600 with pytest.raises(TypeError): 

1601 _ = html( 

1602 t"<{self.FunctionComponent}>Missing props</{self.FunctionComponent}>" 

1603 ) 

1604 

1605 

1606class TestFunctionComponentNoChildren: 

1607 @staticmethod 

1608 def FunctionComponentNoChildren( 

1609 first: str, second: int, third_arg: str 

1610 ) -> Template: 

1611 # Ensure type correctness of props at runtime for testing purposes 

1612 assert isinstance(first, str) 

1613 assert isinstance(second, int) 

1614 assert isinstance(third_arg, str) 

1615 new_attrs = { 

1616 "id": third_arg, 

1617 "data": {"first": first, "second": second}, 

1618 } 

1619 return t"<div {new_attrs}>Component: ignore children</div>" 

1620 

1621 def test_interpolated_template_component_ignore_children(self): 

1622 res = html( 

1623 t'<{self.FunctionComponentNoChildren} first=1 second={99} third-arg="comp1">Hello, Component!</{self.FunctionComponentNoChildren}>' 

1624 ) 

1625 assert ( 

1626 res 

1627 == '<div id="comp1" data-first="1" data-second="99">Component: ignore children</div>' 

1628 ) 

1629 

1630 

1631class TestFunctionComponentKeywordArgs: 

1632 @staticmethod 

1633 def FunctionComponentKeywordArgs(first: str, **attrs: t.Any) -> Template: 

1634 # Ensure type correctness of props at runtime for testing purposes 

1635 assert isinstance(first, str) 

1636 if "children" in attrs: 

1637 raise ValueError("Children not expected in attrs.") 

1638 new_attrs = {"data-first": first, **attrs} 

1639 return t"<div {new_attrs}>No children in kwargs</div>" 

1640 

1641 def test_children_not_passed_via_kwargs(self): 

1642 res = html( 

1643 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info">Child content</{self.FunctionComponentKeywordArgs}>' 

1644 ) 

1645 assert res == '<div data-first="value" extra="info">No children in kwargs</div>' 

1646 

1647 def test_children_not_passed_via_kwargs_even_when_empty(self): 

1648 res = html( 

1649 t'<{self.FunctionComponentKeywordArgs} first="value" extra="info" />' 

1650 ) 

1651 assert res == '<div data-first="value" extra="info">No children in kwargs</div>' 

1652 

1653 

1654class TestComponentSpecialUsage: 

1655 @staticmethod 

1656 def ColumnsComponent() -> Template: 

1657 return t"""<td>Column 1</td><td>Column 2</td>""" 

1658 

1659 def test_fragment_from_component(self): 

1660 # This test assumes that if a component returns a template that parses 

1661 # into multiple root elements, they are treated as a fragment. 

1662 res = html(t"<table><tr><{self.ColumnsComponent} /></tr></table>") 

1663 assert res == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>" 

1664 

1665 def test_component_passed_as_attr_value(self): 

1666 def Wrapper( 

1667 children: Template, sub_component: Callable, **attrs: t.Any 

1668 ) -> Template: 

1669 return t"<{sub_component} {attrs}>{children}</{sub_component}>" 

1670 

1671 res = html( 

1672 t'<{Wrapper} sub-component={TestFunctionComponent.FunctionComponent} class="wrapped" first=1 second={99} third-arg="comp1"><p>Inside wrapper</p></{Wrapper}>' 

1673 ) 

1674 assert ( 

1675 res 

1676 == '<div id="comp1" data-first="1" data-second="99" class="wrapped">Component: <p>Inside wrapper</p></div>' 

1677 ) 

1678 

1679 def test_nested_component_gh23(self): 

1680 # @DESIGN: Do we need this? Should we recommend an alternative? 

1681 # See https://github.com/t-strings/tdom/issues/23 for context 

1682 def Header() -> Template: 

1683 return t"{'Hello World'}" 

1684 

1685 res = html(t"<{Header} />", assume_ctx=make_ctx(parent_tag="div")) 

1686 assert res == "Hello World" 

1687 

1688 

1689class TestClassComponent: 

1690 @dataclass 

1691 class ClassComponent: 

1692 """Example class-based component.""" 

1693 

1694 user_name: str 

1695 image_url: str 

1696 children: Template 

1697 homepage: str = "#" 

1698 

1699 def __call__(self) -> Template: 

1700 return ( 

1701 t"<div class='avatar'>" 

1702 t"<a href={self.homepage}>" 

1703 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />" 

1704 t"</a>" 

1705 t"<span>{self.user_name}</span>" 

1706 t"{self.children}" 

1707 t"</div>" 

1708 ) 

1709 

1710 def test_class_component_implicit_invocation_with_children(self): 

1711 res = html( 

1712 t"<{self.ClassComponent} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponent}>" 

1713 ) 

1714 assert ( 

1715 res 

1716 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>Fun times!</div>' 

1717 ) 

1718 

1719 def test_class_component_direct_invocation(self): 

1720 avatar = self.ClassComponent( 

1721 user_name="Alice", 

1722 image_url="https://example.com/alice.png", 

1723 homepage="https://example.com/users/alice", 

1724 children=t"", # Children is required so we set it to an empty template. 

1725 ) 

1726 res = html(t"<{avatar} />") 

1727 assert ( 

1728 res 

1729 == '<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>' 

1730 ) 

1731 

1732 @dataclass 

1733 class ClassComponentNoChildren: 

1734 """Example class-based component that does not ask for children.""" 

1735 

1736 user_name: str 

1737 image_url: str 

1738 homepage: str = "#" 

1739 

1740 def __call__(self) -> Template: 

1741 return ( 

1742 t"<div class='avatar'>" 

1743 t"<a href={self.homepage}>" 

1744 t"<img src='{self.image_url}' alt='{f'Avatar of {self.user_name}'}' />" 

1745 t"</a>" 

1746 t"<span>{self.user_name}</span>" 

1747 t"ignore children" 

1748 t"</div>" 

1749 ) 

1750 

1751 def test_implicit_invocation_ignore_children(self): 

1752 res = html( 

1753 t"<{self.ClassComponentNoChildren} user-name='Alice' image-url='https://example.com/alice.png'>Fun times!</{self.ClassComponentNoChildren}>" 

1754 ) 

1755 assert ( 

1756 res 

1757 == '<div class="avatar"><a href="#"><img src="https://example.com/alice.png" alt="Avatar of Alice" /></a><span>Alice</span>ignore children</div>' 

1758 ) 

1759 

1760 

1761def test_attribute_type_component(): 

1762 def AttributeTypeComponent( 

1763 data_int: int, 

1764 data_true: bool, 

1765 data_false: bool, 

1766 data_none: None, 

1767 data_float: float, 

1768 data_dt: datetime.datetime, 

1769 **kws: dict[str, object | None], 

1770 ) -> Template: 

1771 """Component to test that we don't incorrectly convert attribute types.""" 

1772 assert isinstance(data_int, int) 

1773 assert data_true is True 

1774 assert data_false is False 

1775 assert data_none is None 

1776 assert isinstance(data_float, float) 

1777 assert isinstance(data_dt, datetime.datetime) 

1778 for kw, v_type in [ 

1779 ("spread_true", True), 

1780 ("spread_false", False), 

1781 ("spread_int", int), 

1782 ("spread_none", None), 

1783 ("spread_float", float), 

1784 ("spread_dt", datetime.datetime), 

1785 ("spread_dict", dict), 

1786 ("spread_list", list), 

1787 ]: 

1788 if v_type in (True, False, None): 

1789 assert kw in kws and kws[kw] is v_type, ( 

1790 f"{kw} should be {v_type} but got {kws=}" 

1791 ) 

1792 else: 

1793 assert kw in kws and isinstance(kws[kw], v_type), ( 

1794 f"{kw} should instance of {v_type} but got {kws=}" 

1795 ) 

1796 return t"Looks good!" 

1797 

1798 an_int: int = 42 

1799 a_true: bool = True 

1800 a_false: bool = False 

1801 a_none: None = None 

1802 a_float: float = 3.14 

1803 a_dt: datetime.datetime = datetime.datetime( 

1804 2024, 1, 1, 12, 0, 0, tzinfo=datetime.UTC 

1805 ) 

1806 spread_attrs: dict[str, object | None] = { 

1807 "spread_true": True, 

1808 "spread_false": False, 

1809 "spread_none": None, 

1810 "spread_int": 0, 

1811 "spread_float": 0.0, 

1812 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1, tzinfo=datetime.UTC), 

1813 "spread_dict": {}, 

1814 "spread_list": ["eggs", "milk"], 

1815 } 

1816 res = html( 

1817 t"<{AttributeTypeComponent} data-int={an_int} data-true={a_true} " 

1818 t"data-false={a_false} data-none={a_none} data-float={a_float} " 

1819 t"data-dt={a_dt} {spread_attrs}/>" 

1820 ) 

1821 assert res == "Looks good!" 

1822 

1823 

1824class TestComponentErrors: 

1825 def test_component_non_callable_fails(self): 

1826 with pytest.raises(TypeError): 

1827 _ = html(t"<{'not a function'} />") 

1828 

1829 def test_component_requiring_positional_arg_fails(self): 

1830 def RequiresPositional(whoops: int, /) -> Template: # pragma: no cover 

1831 return t"<p>Positional arg: {whoops}</p>" 

1832 

1833 with pytest.raises(TypeError): 

1834 _ = html(t"<{RequiresPositional} />") 

1835 

1836 def test_mismatched_component_closing_tag_fails(self): 

1837 def OpenTag(children: Template) -> Template: 

1838 return t"<div>open</div>" 

1839 

1840 def CloseTag(children: Template) -> Template: 

1841 return t"<div>close</div>" 

1842 

1843 with pytest.raises(TypeError): 

1844 _ = html(t"<{OpenTag}>Hello</{CloseTag}>") 

1845 

1846 @pytest.mark.parametrize( 

1847 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) 

1848 ) 

1849 def test_function_component_returns_nontemplate_fails(self, bad_value): 

1850 def BadFunctionComp(children: Template): 

1851 return bad_value 

1852 

1853 with pytest.raises( 

1854 TypeError, match="Component callable must return Template or Callable:" 

1855 ): 

1856 _ = html(t"<{BadFunctionComp}>Hello</{BadFunctionComp}>") 

1857 

1858 @pytest.mark.parametrize( 

1859 "bad_value", ("", "text", None, 1, ("tuple", "of", "strs")) 

1860 ) 

1861 def test_component_object_returns_nontemplate_fails(self, bad_value): 

1862 def BadFactoryComp(children: Template): 

1863 def component_object(): 

1864 return bad_value 

1865 

1866 return component_object 

1867 

1868 with pytest.raises( 

1869 TypeError, match="Component object must return Template when called:" 

1870 ): 

1871 _ = html(t"<{BadFactoryComp}>Hello</{BadFactoryComp}>") 

1872 

1873 

1874def test_integration_basic(): 

1875 comment_text = "comment is not literal" 

1876 interpolated_class = "red" 

1877 text_in_element = "text is not literal" 

1878 templated = "not literal" 

1879 spread_attrs = {"data-on": True} 

1880 markup_content = Markup("<div>safe</div>") 

1881 

1882 def WrapperComponent(children): 

1883 return t"<div>{children}</div>" 

1884 

1885 smoke_t = t"""<!doctype html> 

1886<html> 

1887<body> 

1888<!-- literal --> 

1889<span attr="literal">literal</span> 

1890<!-- {comment_text} --> 

1891<span>{text_in_element}</span> 

1892<span attr="literal" class={interpolated_class} title="is {templated}" {spread_attrs}>{text_in_element}</span> 

1893<{WrapperComponent}><span>comp body</span></{WrapperComponent}> 

1894{markup_content} 

1895</body> 

1896</html>""" 

1897 smoke_str = """<!DOCTYPE html> 

1898<html> 

1899<body> 

1900<!-- literal --> 

1901<span attr="literal">literal</span> 

1902<!-- comment is not literal --> 

1903<span>text is not literal</span> 

1904<span attr="literal" class="red" title="is not literal" data-on>text is not literal</span> 

1905<div><span>comp body</span></div> 

1906<div>safe</div> 

1907</body> 

1908</html>""" 

1909 assert html(smoke_t) == smoke_str 

1910 

1911 

1912def struct_repr(st): 

1913 """Breakdown Templates into comparable parts for test verification.""" 

1914 return st.strings, tuple( 

1915 (i.value, i.expression, i.conversion, i.format_spec) for i in st.interpolations 

1916 ) 

1917 

1918 

1919def test_process_template_internal_cache(): 

1920 """Test that cache and non-cache both generally work as expected.""" 

1921 # @NOTE: We use a made-up custom element so that we can be sure to 

1922 # miss the cache. If this element is used elsewhere than the global 

1923 # cache might cache it and it will ruin our counting, specifically 

1924 # the first miss will instead be a hit. 

1925 sample_t = t"<div>{'content'}<tdom-cache-test-element /></div>" 

1926 sample_diff_t = t"<div>{'diffcontent'}<tdom-cache-test-element /></div>" 

1927 alt_t = t"<span>{'content'}</span>" 

1928 process_api = TemplateProcessor(parser_api=TemplateParserProxy()) 

1929 cached_process_api = TemplateProcessor(parser_api=CachedTemplateParserProxy()) 

1930 # Because the cache is stored on the class itself this can be affect by 

1931 # other tests, so save this off and take the difference to determine the result, 

1932 # this is not great and hopefully we can find a better solution. 

1933 assert isinstance(cached_process_api, TemplateProcessor) 

1934 assert isinstance(cached_process_api.parser_api, CachedTemplateParserProxy) 

1935 start_ci = cached_process_api.parser_api._to_tnode.cache_info() 

1936 tnode1 = process_api.parser_api.to_tnode(sample_t) 

1937 tnode2 = process_api.parser_api.to_tnode(sample_t) 

1938 cached_tnode1 = cached_process_api.parser_api.to_tnode(sample_t) 

1939 cached_tnode2 = cached_process_api.parser_api.to_tnode(sample_t) 

1940 cached_tnode3 = cached_process_api.parser_api.to_tnode(sample_diff_t) 

1941 # Check that the uncached and cached services are actually 

1942 # returning non-identical results. 

1943 assert tnode1 is not cached_tnode1 

1944 assert tnode1 is not cached_tnode2 

1945 assert tnode1 is not cached_tnode3 

1946 # Check that the uncached service returns a brand new result everytime. 

1947 assert tnode1 is not tnode2 

1948 # Check that the cached service is returning the exact same, identical, result. 

1949 assert cached_tnode1 is cached_tnode2 

1950 # Even if the input templates are not identical (but are still equivalent). 

1951 assert cached_tnode1 is cached_tnode3 and sample_t is not sample_diff_t 

1952 # Check that the cached service and uncached services return 

1953 # results that are equivalent (even though they are not (id)entical). 

1954 assert tnode1 == cached_tnode1 

1955 assert tnode2 == cached_tnode1 

1956 # Now that we are setup we check that the cache is internally 

1957 # working as we intended. 

1958 ci = cached_process_api.parser_api._to_tnode.cache_info() 

1959 # cached_tnode2 and cached_tnode3 are hits after cached_tnode1 

1960 assert ci.hits - start_ci.hits == 2 

1961 # cached_tf1 was a miss because cache was empty (brand new) 

1962 assert ci.misses - start_ci.misses == 1 

1963 cached_tnode4 = cached_process_api.parser_api.to_tnode(alt_t) 

1964 # A different template produces a brand new tf. 

1965 assert cached_tnode1 is not cached_tnode4 

1966 # The template is new AND has a different structure so it also 

1967 # produces an unequivalent tf. 

1968 assert cached_tnode1 != cached_tnode4 

1969 

1970 

1971def test_repeat_calls(): 

1972 """Crude check for any unintended state being kept between calls.""" 

1973 

1974 def get_sample_t(idx, spread_attrs, button_text): 

1975 return t"""<div><button data-key={idx} {spread_attrs}>{button_text}</button></div>""" 

1976 

1977 for idx in range(3): 

1978 spread_attrs = {"data-enabled": True} 

1979 button_text = "PROCESS" 

1980 sample_t = get_sample_t(idx, spread_attrs, button_text) 

1981 assert ( 

1982 html(sample_t) 

1983 == f'<div><button data-key="{idx}" data-enabled>PROCESS</button></div>' 

1984 ) 

1985 

1986 

1987def get_select_t_with_list(options, selected_values): 

1988 return t"""<select>{ 

1989 [ 

1990 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>" 

1991 for opt in options 

1992 ] 

1993 }</select>""" 

1994 

1995 

1996def get_select_t_with_generator(options, selected_values): 

1997 return t"""<select>{ 

1998 ( 

1999 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>" 

2000 for opt in options 

2001 ) 

2002 }</select>""" 

2003 

2004 

2005def get_select_t_with_concat(options, selected_values): 

2006 parts = [t"<select>"] 

2007 parts.extend( 

2008 [ 

2009 t"<option value={opt[0]} selected={opt[0] in selected_values}>{opt[1]}</option>" 

2010 for opt in options 

2011 ] 

2012 ) 

2013 parts.append(t"</select>") 

2014 return sum(parts, t"") 

2015 

2016 

2017@pytest.mark.parametrize( 

2018 "provider", 

2019 ( 

2020 get_select_t_with_list, 

2021 get_select_t_with_generator, 

2022 get_select_t_with_concat, 

2023 ), 

2024) 

2025def test_process_template_iterables(provider): 

2026 def get_color_select_t(selected_values: set, provider: Callable) -> Template: 

2027 PRIMARY_COLORS = [("R", "Red"), ("Y", "Yellow"), ("B", "Blue")] 

2028 assert set(selected_values).issubset({opt[0] for opt in PRIMARY_COLORS}) 

2029 return provider(PRIMARY_COLORS, selected_values) 

2030 

2031 no_selection_t = get_color_select_t(set(), provider) 

2032 assert ( 

2033 html(no_selection_t) 

2034 == '<select><option value="R">Red</option><option value="Y">Yellow</option><option value="B">Blue</option></select>' 

2035 ) 

2036 selected_yellow_t = get_color_select_t({"Y"}, provider) 

2037 assert ( 

2038 html(selected_yellow_t) 

2039 == '<select><option value="R">Red</option><option value="Y" selected>Yellow</option><option value="B">Blue</option></select>' 

2040 ) 

2041 

2042 

2043def test_component_integration(): 

2044 """Broadly test that common template component usage works.""" 

2045 

2046 def PageComponent(children, root_attrs=None): 

2047 return t"""<div class="content" {root_attrs}>{children}</div>""" 

2048 

2049 def FooterComponent(classes=("footer-default",)): 

2050 return t'<div class="footer" class={classes}><a href="about">About</a></div>' 

2051 

2052 def LayoutComponent(children, body_classes=None): 

2053 return t"""<!doctype html> 

2054<html> 

2055 <head> 

2056 <meta charset="utf-8"> 

2057 <script src="scripts.js"></script> 

2058 <link rel="stylesheet" href="styles.css"> 

2059 </head> 

2060 <body class={body_classes}> 

2061 {children} 

2062 <{FooterComponent} /> 

2063 </body> 

2064</html> 

2065""" 

2066 

2067 content = "HTML never goes out of style." 

2068 content_str = html( 

2069 t"<{LayoutComponent} body_classes={['theme-default']}><{PageComponent}>{content}</{PageComponent}></{LayoutComponent}>" 

2070 ) 

2071 assert ( 

2072 content_str 

2073 == """<!DOCTYPE html> 

2074<html> 

2075 <head> 

2076 <meta charset="utf-8" /> 

2077 <script src="scripts.js"></script> 

2078 <link rel="stylesheet" href="styles.css" /> 

2079 </head> 

2080 <body class="theme-default"> 

2081 <div class="content">HTML never goes out of style.</div> 

2082 <div class="footer footer-default"><a href="about">About</a></div> 

2083 </body> 

2084</html> 

2085""" 

2086 ) 

2087 

2088 

2089class TestInterpolatingHTMLInTemplateWithDynamicParentTag: 

2090 """ 

2091 When a template does not have a parent tag we cannot determine the type 

2092 of text that should be allowed and therefore we cannot determine how to 

2093 escape that text. Once the type is known we should escape any 

2094 interpolations in that text correctly. 

2095 """ 

2096 

2097 def test_dynamic_raw_text(self): 

2098 """Type raw text should fail because template is already not allowed.""" 

2099 content = '<script>console.log("123!");</script>' 

2100 content_t = t"{content}" 

2101 with pytest.raises( 

2102 ValueError, match="Recursive includes are not supported within script" 

2103 ): 

2104 content_t = t'<script>console.log("{123}!");</script>' 

2105 _ = html(t"<script>{content_t}</script>") 

2106 

2107 def test_dynamic_escapable_raw_text(self): 

2108 """Type escapable raw text should fail because template is already not allowed.""" 

2109 content = '<script>console.log("123!");</script>' 

2110 content_t = t"{content}" 

2111 with pytest.raises( 

2112 ValueError, match="Recursive includes are not supported within textarea" 

2113 ): 

2114 _ = html(t"<textarea>{content_t}</textarea>") 

2115 

2116 def test_dynamic_normal_text(self): 

2117 """Escaping should be applied when normal text type is goes into effect.""" 

2118 content = '<script>console.log("123!");</script>' 

2119 content_t = t"{content}" 

2120 LT, GT, DQ = map(markupsafe_escape, ["<", ">", '"']) 

2121 assert ( 

2122 html(t"<div>{content_t}</div>") 

2123 == f"<div>{LT}script{GT}console.log({DQ}123!{DQ});{LT}/script{GT}</div>" 

2124 ) 

2125 

2126 

2127class TestPagerComponentExample: 

2128 @dataclass 

2129 class Pager: 

2130 left_pages: tuple = () 

2131 page: int = 0 

2132 right_pages: tuple = () 

2133 prev_page: int | None = None 

2134 next_page: int | None = None 

2135 

2136 @dataclass 

2137 class PagerDisplay: 

2138 pager: TestPagerComponentExample.Pager 

2139 paginate_url: Callable[[int], str] 

2140 root_classes: tuple[str, ...] = ("cb", "tc", "w-100") 

2141 part_classes: tuple[str, ...] = ("dib", "pa1") 

2142 

2143 def __call__(self) -> Template: 

2144 parts = [t"<div class={self.root_classes}>"] 

2145 if self.pager.prev_page: 

2146 parts.append( 

2147 t"<a class={self.part_classes} href={self.paginate_url(self.pager.prev_page)}>Prev</a>" 

2148 ) 

2149 for left_page in self.pager.left_pages: 

2150 parts.append( 

2151 t'<a class={self.part_classes} href="{self.paginate_url(left_page)}">{left_page}</a>' 

2152 ) 

2153 parts.append(t"<span class={self.part_classes}>{self.pager.page}</span>") 

2154 for right_page in self.pager.right_pages: 

2155 parts.append( 

2156 t'<a class={self.part_classes} href="{self.paginate_url(right_page)}">{right_page}</a>' 

2157 ) 

2158 if self.pager.next_page: 

2159 parts.append( 

2160 t"<a class={self.part_classes} href={self.paginate_url(self.pager.next_page)}>Next</a>" 

2161 ) 

2162 parts.append(t"</div>") 

2163 return Template(*chain.from_iterable(parts)) 

2164 

2165 def test_example(self): 

2166 def paginate_url(page: int) -> str: 

2167 return f"/pages?page={page}" 

2168 

2169 def Footer(pager, paginate_url, footer_classes=("footer",)) -> Template: 

2170 return t"<div class={footer_classes}><{self.PagerDisplay} pager={pager} paginate_url={paginate_url} /></div>" 

2171 

2172 pager = self.Pager( 

2173 left_pages=(1, 2), page=3, right_pages=(4, 5), next_page=6, prev_page=None 

2174 ) 

2175 content_t = t"<{Footer} pager={pager} paginate_url={paginate_url} />" 

2176 res = html(content_t) 

2177 print(res) 

2178 assert ( 

2179 res 

2180 == '<div class="footer"><div class="cb tc w-100"><a class="dib pa1" href="/pages?page=1">1</a><a class="dib pa1" href="/pages?page=2">2</a><span class="dib pa1">3</span><a class="dib pa1" href="/pages?page=4">4</a><a class="dib pa1" href="/pages?page=5">5</a><a class="dib pa1" href="/pages?page=6">Next</a></div></div>' 

2181 ) 

2182 

2183 

2184def test_mathml(): 

2185 num = 1 

2186 denom = 3 

2187 mathml_t = t"""<p> 

2188 The fraction 

2189 <math> 

2190 <mfrac> 

2191 <mn>{num}</mn> 

2192 <mn>{denom}</mn> 

2193 </mfrac> 

2194 </math> 

2195 is not a decimal number. 

2196</p>""" 

2197 res = html(mathml_t) 

2198 assert ( 

2199 str(res) 

2200 == """<p> 

2201 The fraction 

2202 <math> 

2203 <mfrac> 

2204 <mn>1</mn> 

2205 <mn>3</mn> 

2206 </mfrac> 

2207 </math> 

2208 is not a decimal number. 

2209</p>""" 

2210 )