Coverage for tdom / processor_test.py: 99%

612 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-12 16:43 +0000

1import datetime 

2import typing as t 

3from dataclasses import dataclass, field 

4from string.templatelib import Interpolation, Template 

5from itertools import product 

6 

7import pytest 

8from markupsafe import Markup 

9 

10from .nodes import Comment, DocumentType, Element, Fragment, Node, Text 

11from .placeholders import make_placeholder_config 

12from .processor import html 

13 

14# -------------------------------------------------------------------------- 

15# Basic HTML parsing tests 

16# -------------------------------------------------------------------------- 

17 

18 

19# 

20# Text 

21# 

22def test_empty(): 

23 node = html(t"") 

24 assert node == Fragment(children=[]) 

25 assert str(node) == "" 

26 

27 

28def test_text_literal(): 

29 node = html(t"Hello, world!") 

30 assert node == Text("Hello, world!") 

31 assert str(node) == "Hello, world!" 

32 

33 

34def test_text_singleton(): 

35 greeting = "Hello, Alice!" 

36 node = html(t"{greeting}") 

37 assert node == Text("Hello, Alice!") 

38 assert str(node) == "Hello, Alice!" 

39 

40 

41def test_text_template(): 

42 name = "Alice" 

43 node = html(t"Hello, {name}!") 

44 assert node == Fragment(children=[Text("Hello, "), Text("Alice"), Text("!")]) 

45 assert str(node) == "Hello, Alice!" 

46 

47 

48def test_text_template_escaping(): 

49 name = "Alice & Bob" 

50 node = html(t"Hello, {name}!") 

51 assert node == Fragment(children=[Text("Hello, "), Text("Alice & Bob"), Text("!")]) 

52 assert str(node) == "Hello, Alice & Bob!" 

53 

54 

55# 

56# Comments. 

57# 

58def test_comment(): 

59 node = html(t"<!--This is a comment-->") 

60 assert node == Comment("This is a comment") 

61 assert str(node) == "<!--This is a comment-->" 

62 

63 

64def test_comment_template(): 

65 text = "comment" 

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

67 assert node == Comment("This is a comment") 

68 assert str(node) == "<!--This is a comment-->" 

69 

70 

71def test_comment_template_escaping(): 

72 text = "-->comment" 

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

74 assert node == Comment("This is a -->comment") 

75 assert str(node) == "<!--This is a --&gt;comment-->" 

76 

77 

78# 

79# Document types. 

80# 

81def test_parse_document_type(): 

82 node = html(t"<!doctype html>") 

83 assert node == DocumentType("html") 

84 assert str(node) == "<!DOCTYPE html>" 

85 

86 

87# 

88# Elements 

89# 

90def test_parse_void_element(): 

91 node = html(t"<br>") 

92 assert node == Element("br") 

93 assert str(node) == "<br />" 

94 

95 

96def test_parse_void_element_self_closed(): 

97 node = html(t"<br />") 

98 assert node == Element("br") 

99 assert str(node) == "<br />" 

100 

101 

102def test_parse_chain_of_void_elements(): 

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

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

105 assert node == Fragment( 

106 children=[ 

107 Element("br"), 

108 Element("hr"), 

109 Element("img", attrs={"src": "image.png"}), 

110 Element("br"), 

111 Element("hr"), 

112 ], 

113 ) 

114 assert str(node) == '<br /><hr /><img src="image.png" /><br /><hr />' 

115 

116 

117def test_parse_element_with_text(): 

118 node = html(t"<p>Hello, world!</p>") 

119 assert node == Element( 

120 "p", 

121 children=[ 

122 Text("Hello, world!"), 

123 ], 

124 ) 

125 assert str(node) == "<p>Hello, world!</p>" 

126 

127 

128def test_parse_nested_elements(): 

129 node = html(t"<div><p>Hello</p><p>World</p></div>") 

130 assert node == Element( 

131 "div", 

132 children=[ 

133 Element("p", children=[Text("Hello")]), 

134 Element("p", children=[Text("World")]), 

135 ], 

136 ) 

137 assert str(node) == "<div><p>Hello</p><p>World</p></div>" 

138 

139 

140def test_parse_entities_are_escaped(): 

141 node = html(t"<p>&lt;/p&gt;</p>") 

142 assert node == Element( 

143 "p", 

144 children=[Text("</p>")], 

145 ) 

146 assert str(node) == "<p>&lt;/p&gt;</p>" 

147 

148 

149# -------------------------------------------------------------------------- 

150# Interpolated text content 

151# -------------------------------------------------------------------------- 

152 

153 

154def test_interpolated_text_content(): 

155 name = "Alice" 

156 node = html(t"<p>Hello, {name}!</p>") 

157 assert node == Element("p", children=[Text("Hello, "), Text("Alice"), Text("!")]) 

158 assert str(node) == "<p>Hello, Alice!</p>" 

159 

160 

161def test_escaping_of_interpolated_text_content(): 

162 name = "<Alice & Bob>" 

163 node = html(t"<p>Hello, {name}!</p>") 

164 assert node == Element( 

165 "p", children=[Text("Hello, "), Text("<Alice & Bob>"), Text("!")] 

166 ) 

167 assert str(node) == "<p>Hello, &lt;Alice &amp; Bob&gt;!</p>" 

168 

169 

170class Convertible: 

171 def __str__(self): 

172 return "string" 

173 

174 def __repr__(self): 

175 return "repr" 

176 

177 

178def test_conversions(): 

179 c = Convertible() 

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

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

182 node = html(t"<li>{c!s}</li><li>{c!r}</li><li>{'😊'!a}</li>") 

183 assert node == Fragment( 

184 children=[ 

185 Element("li", children=[Text("string")]), 

186 Element("li", children=[Text("repr")]), 

187 Element("li", children=[Text("'\\U0001f60a'")]), 

188 ], 

189 ) 

190 

191 

192def test_interpolated_in_content_node(): 

193 # https://github.com/t-strings/tdom/issues/68 

194 evil = "</style><script>alert('whoops');</script><style>" 

195 node = html(t"<style>{evil}{evil}</style>") 

196 assert node == Element( 

197 "style", 

198 children=[ 

199 Text("</style><script>alert('whoops');</script><style>"), 

200 Text("</style><script>alert('whoops');</script><style>"), 

201 ], 

202 ) 

203 LT = "&lt;" 

204 assert ( 

205 str(node) 

206 == f"<style>{LT}/style><script>alert('whoops');</script><style>{LT}/style><script>alert('whoops');</script><style></style>" 

207 ) 

208 

209 

210def test_interpolated_trusted_in_content_node(): 

211 # https://github.com/t-strings/tdom/issues/68 

212 node = html(t"<script>if (a < b && c > d) { alert('wow'); } </script>") 

213 assert node == Element( 

214 "script", 

215 children=[Text("if (a < b && c > d) { alert('wow'); }")], 

216 ) 

217 assert str(node) == ("<script>if (a < b && c > d) { alert('wow'); }</script>") 

218 

219 

220def test_script_elements_error(): 

221 nested_template = t"<div></div>" 

222 # Putting non-text content inside a script is not allowed. 

223 with pytest.raises(ValueError): 

224 node = html(t"<script>{nested_template}</script>") 

225 _ = str(node) 

226 

227 

228# -------------------------------------------------------------------------- 

229# Interpolated non-text content 

230# -------------------------------------------------------------------------- 

231 

232 

233def test_interpolated_false_content(): 

234 node = html(t"<div>{False}</div>") 

235 assert node == Element("div") 

236 assert str(node) == "<div></div>" 

237 

238 

239def test_interpolated_none_content(): 

240 node = html(t"<div>{None}</div>") 

241 assert node == Element("div", children=[]) 

242 assert str(node) == "<div></div>" 

243 

244 

245def test_interpolated_zero_arg_function(): 

246 def get_value(): 

247 return "dynamic" 

248 

249 node = html(t"<p>The value is {get_value}.</p>") 

250 assert node == Element( 

251 "p", children=[Text("The value is "), Text("dynamic"), Text(".")] 

252 ) 

253 

254 

255def test_interpolated_multi_arg_function_fails(): 

256 def add(a, b): # pragma: no cover 

257 return a + b 

258 

259 with pytest.raises(TypeError): 

260 _ = html(t"<p>The sum is {add}.</p>") 

261 

262 

263# -------------------------------------------------------------------------- 

264# Raw HTML injection tests 

265# -------------------------------------------------------------------------- 

266 

267 

268def test_raw_html_injection_with_markupsafe(): 

269 raw_content = Markup("<strong>I am bold</strong>") 

270 node = html(t"<div>{raw_content}</div>") 

271 assert node == Element("div", children=[Text(text=raw_content)]) 

272 assert str(node) == "<div><strong>I am bold</strong></div>" 

273 

274 

275def test_raw_html_injection_with_dunder_html_protocol(): 

276 class SafeContent: 

277 def __init__(self, text): 

278 self._text = text 

279 

280 def __html__(self): 

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

282 return f"<em>{self._text}</em>" 

283 

284 content = SafeContent("emphasized") 

285 node = html(t"<p>Here is some {content}.</p>") 

286 assert node == Element( 

287 "p", 

288 children=[ 

289 Text("Here is some "), 

290 Text(Markup("<em>emphasized</em>")), 

291 Text("."), 

292 ], 

293 ) 

294 assert str(node) == "<p>Here is some <em>emphasized</em>.</p>" 

295 

296 

297def test_raw_html_injection_with_format_spec(): 

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

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

300 assert node == Element( 

301 "p", 

302 children=[ 

303 Text("This is "), 

304 Text(Markup(raw_content)), 

305 Text(" text."), 

306 ], 

307 ) 

308 assert str(node) == "<p>This is <u>underlined</u> text.</p>" 

309 

310 

311def test_raw_html_injection_with_markupsafe_unsafe_format_spec(): 

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

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

314 assert node == Element( 

315 "p", 

316 children=[ 

317 Text("This is "), 

318 Text(str(supposedly_safe)), 

319 Text(" text."), 

320 ], 

321 ) 

322 assert str(node) == "<p>This is &lt;i&gt;italic&lt;/i&gt; text.</p>" 

323 

324 

325# -------------------------------------------------------------------------- 

326# Conditional rendering and control flow 

327# -------------------------------------------------------------------------- 

328 

329 

330def test_conditional_rendering_with_if_else(): 

331 is_logged_in = True 

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

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

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

335 

336 assert node == Element( 

337 "div", children=[Element("span", children=[Text("Welcome, User!")])] 

338 ) 

339 assert str(node) == "<div><span>Welcome, User!</span></div>" 

340 

341 is_logged_in = False 

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

343 assert str(node) == '<div><a href="/login">Please log in</a></div>' 

344 

345 

346def test_conditional_rendering_with_and(): 

347 show_warning = True 

348 warning_message = t'<div class="warning">Warning!</div>' 

349 node = html(t"<main>{show_warning and warning_message}</main>") 

350 

351 assert node == Element( 

352 "main", 

353 children=[ 

354 Element("div", attrs={"class": "warning"}, children=[Text("Warning!")]), 

355 ], 

356 ) 

357 assert str(node) == '<main><div class="warning">Warning!</div></main>' 

358 

359 show_warning = False 

360 node = html(t"<main>{show_warning and warning_message}</main>") 

361 # Assuming False renders nothing 

362 assert str(node) == "<main></main>" 

363 

364 

365# -------------------------------------------------------------------------- 

366# Interpolated nesting of templates and elements 

367# -------------------------------------------------------------------------- 

368 

369 

370def test_interpolated_template_content(): 

371 child = t"<span>Child</span>" 

372 node = html(t"<div>{child}</div>") 

373 assert node == Element("div", children=[html(child)]) 

374 assert str(node) == "<div><span>Child</span></div>" 

375 

376 

377def test_interpolated_element_content(): 

378 child = html(t"<span>Child</span>") 

379 node = html(t"<div>{child}</div>") 

380 assert node == Element("div", children=[child]) 

381 assert str(node) == "<div><span>Child</span></div>" 

382 

383 

384def test_interpolated_nonstring_content(): 

385 number = 42 

386 node = html(t"<p>The answer is {number}.</p>") 

387 assert node == Element( 

388 "p", children=[Text("The answer is "), Text("42"), Text(".")] 

389 ) 

390 assert str(node) == "<p>The answer is 42.</p>" 

391 

392 

393def test_list_items(): 

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

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

396 assert node == Element( 

397 "ul", 

398 children=[ 

399 Element("li", children=[Text("Apple")]), 

400 Element("li", children=[Text("Banana")]), 

401 Element("li", children=[Text("Cherry")]), 

402 ], 

403 ) 

404 assert str(node) == "<ul><li>Apple</li><li>Banana</li><li>Cherry</li></ul>" 

405 

406 

407def test_nested_list_items(): 

408 # TODO XXX this is a pretty abusrd test case; clean it up when refactoring 

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

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

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

412 outer_items = [t"<li>{category}<ul>{inner_items}</ul></li>" for category in outer] 

413 node = html(t"<ul>{outer_items}</ul>") 

414 assert node == Element( 

415 "ul", 

416 children=[ 

417 Element( 

418 "li", 

419 children=[ 

420 Text("fruit"), 

421 Element( 

422 "ul", 

423 children=[ 

424 Element("li", children=[Text("apple")]), 

425 Element("li", children=[Text("banana")]), 

426 Element("li", children=[Text("cherry")]), 

427 ], 

428 ), 

429 ], 

430 ), 

431 Element( 

432 "li", 

433 children=[ 

434 Text("more fruit"), 

435 Element( 

436 "ul", 

437 children=[ 

438 Element("li", children=[Text("apple")]), 

439 Element("li", children=[Text("banana")]), 

440 Element("li", children=[Text("cherry")]), 

441 ], 

442 ), 

443 ], 

444 ), 

445 ], 

446 ) 

447 assert ( 

448 str(node) 

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

450 ) 

451 

452 

453# -------------------------------------------------------------------------- 

454# Attributes 

455# -------------------------------------------------------------------------- 

456 

457 

458def test_literal_attrs(): 

459 node = html( 

460 ( 

461 t"<a " 

462 t" id=example_link" # no quotes allowed without spaces 

463 t" autofocus" # bare / boolean 

464 t' title=""' # empty attribute 

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

466 t"></a>" 

467 ) 

468 ) 

469 assert node == Element( 

470 "a", 

471 attrs={ 

472 "id": "example_link", 

473 "autofocus": None, 

474 "title": "", 

475 "href": "https://example.com", 

476 "target": "_blank", 

477 }, 

478 ) 

479 assert ( 

480 str(node) 

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

482 ) 

483 

484 

485def test_literal_attr_escaped(): 

486 node = html(t'<a title="&lt;"></a>') 

487 assert node == Element( 

488 "a", 

489 attrs={"title": "<"}, 

490 ) 

491 assert str(node) == '<a title="&lt;"></a>' 

492 

493 

494def test_interpolated_attr(): 

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

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

497 assert node == Element("a", attrs={"href": "https://example.com/"}) 

498 assert str(node) == '<a href="https://example.com/"></a>' 

499 

500 

501def test_interpolated_attr_escaped(): 

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

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

504 assert node == Element( 

505 "a", 

506 attrs={"href": 'https://example.com/?q="test"&lang=en'}, 

507 ) 

508 assert ( 

509 str(node) == '<a href="https://example.com/?q=&#34;test&#34;&amp;lang=en"></a>' 

510 ) 

511 

512 

513def test_interpolated_attr_unquoted(): 

514 id = "roquefort" 

515 node = html(t"<div id={id}></div>") 

516 assert node == Element("div", attrs={"id": "roquefort"}) 

517 assert str(node) == '<div id="roquefort"></div>' 

518 

519 

520def test_interpolated_attr_true(): 

521 disabled = True 

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

523 assert node == Element("button", attrs={"disabled": None}) 

524 assert str(node) == "<button disabled></button>" 

525 

526 

527def test_interpolated_attr_false(): 

528 disabled = False 

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

530 assert node == Element("button") 

531 assert str(node) == "<button></button>" 

532 

533 

534def test_interpolated_attr_none(): 

535 disabled = None 

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

537 assert node == Element("button") 

538 assert str(node) == "<button></button>" 

539 

540 

541def test_interpolate_attr_empty_string(): 

542 node = html(t'<div title=""></div>') 

543 assert node == Element( 

544 "div", 

545 attrs={"title": ""}, 

546 ) 

547 assert str(node) == '<div title=""></div>' 

548 

549 

550def test_spread_attr(): 

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

552 node = html(t"<a {attrs}></a>") 

553 assert node == Element( 

554 "a", 

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

556 ) 

557 assert str(node) == '<a href="https://example.com/" target="_blank"></a>' 

558 

559 

560def test_spread_attr_none(): 

561 attrs = None 

562 node = html(t"<a {attrs}></a>") 

563 assert node == Element("a") 

564 assert str(node) == "<a></a>" 

565 

566 

567def test_spread_attr_type_errors(): 

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

569 with pytest.raises(TypeError): 

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

571 

572 

573def test_templated_attr_mixed_interpolations_start_end_and_nest(): 

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

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

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

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

578 for left_part, middle_part, right_part in product( 

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

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

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

582 ): 

583 test_t = prefix + left_part + t"-" + middle_part + t"-" + right_part + suffix 

584 node = html(test_t) 

585 assert node == Element( 

586 "div", 

587 attrs={"data-range": "1-3-5"}, 

588 ) 

589 assert str(node) == '<div data-range="1-3-5"></div>' 

590 

591 

592def test_templated_attr_no_quotes(): 

593 start = 1 

594 end = 5 

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

596 assert node == Element( 

597 "div", 

598 attrs={"data-range": "1-5"}, 

599 ) 

600 assert str(node) == '<div data-range="1-5"></div>' 

601 

602 

603def test_attr_merge_disjoint_interpolated_attr_spread_attr(): 

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

605 target = "_blank" 

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

607 assert node == Element( 

608 "a", 

609 attrs={"href": "https://example.com/", "id": "link1", "target": "_blank"}, 

610 ) 

611 assert str(node) == '<a href="https://example.com/" id="link1" target="_blank"></a>' 

612 

613 

614def test_attr_merge_overlapping_spread_attrs(): 

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

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

617 node = html(t"<a {attrs1} {attrs2}></a>") 

618 assert node == Element( 

619 "a", 

620 attrs={"href": "https://example.com/", "target": "_blank", "id": "link1"}, 

621 ) 

622 assert str(node) == '<a href="https://example.com/" target="_blank" id="link1"></a>' 

623 

624 

625def test_attr_merge_replace_literal_attr_str_str(): 

626 node = html(t'<div title="default" {dict(title="fresh")}></div>') 

627 assert node == Element("div", {"title": "fresh"}) 

628 assert str(node) == '<div title="fresh"></div>' 

629 

630 

631def test_attr_merge_replace_literal_attr_str_true(): 

632 node = html(t'<div title="default" {dict(title=True)}></div>') 

633 assert node == Element("div", {"title": None}) 

634 assert str(node) == "<div title></div>" 

635 

636 

637def test_attr_merge_replace_literal_attr_true_str(): 

638 node = html(t"<div title {dict(title='fresh')}></div>") 

639 assert node == Element("div", {"title": "fresh"}) 

640 assert str(node) == '<div title="fresh"></div>' 

641 

642 

643def test_attr_merge_remove_literal_attr_str_none(): 

644 node = html(t'<div title="default" {dict(title=None)}></div>') 

645 assert node == Element("div") 

646 assert str(node) == "<div></div>" 

647 

648 

649def test_attr_merge_remove_literal_attr_true_none(): 

650 node = html(t"<div title {dict(title=None)}></div>") 

651 assert node == Element("div") 

652 assert str(node) == "<div></div>" 

653 

654 

655def test_attr_merge_other_literal_attr_intact(): 

656 node = html(t'<img title="default" {dict(alt="fresh")}>') 

657 assert node == Element("img", {"title": "default", "alt": "fresh"}) 

658 assert str(node) == '<img title="default" alt="fresh" />' 

659 

660 

661def test_placeholder_collision_avoidance(): 

662 config = make_placeholder_config() 

663 # This test is to ensure that our placeholder detection avoids collisions 

664 # even with content that might look like a placeholder. 

665 tricky = "0" 

666 template = Template( 

667 f'<div data-tricky="{config.prefix}', 

668 Interpolation(tricky, "tricky", None, ""), 

669 f'{config.suffix}"></div>', 

670 ) 

671 node = html(template) 

672 assert node == Element( 

673 "div", 

674 attrs={"data-tricky": config.prefix + tricky + config.suffix}, 

675 children=[], 

676 ) 

677 assert ( 

678 str(node) == f'<div data-tricky="{config.prefix}{tricky}{config.suffix}"></div>' 

679 ) 

680 

681 

682# 

683# Special data attribute handling. 

684# 

685def test_interpolated_data_attributes(): 

686 data = {"user-id": 123, "role": "admin", "wild": True, "false": False, "none": None} 

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

688 assert node == Element( 

689 "div", 

690 attrs={"data-user-id": "123", "data-role": "admin", "data-wild": None}, 

691 children=[Text("User Info")], 

692 ) 

693 assert ( 

694 str(node) 

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

696 ) 

697 

698 

699def test_data_attr_toggle_to_str(): 

700 for node in [ 

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

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

703 ]: 

704 assert node == Element("div", {"data-selected": "yes"}) 

705 assert str(node) == '<div data-selected="yes"></div>' 

706 

707 

708def test_data_attr_toggle_to_true(): 

709 node = html(t'<div data-selected="yes" data={dict(selected=True)}></div>') 

710 assert node == Element("div", {"data-selected": None}) 

711 assert str(node) == "<div data-selected></div>" 

712 

713 

714def test_data_attr_unrelated_unaffected(): 

715 node = html(t"<div data-selected data={dict(active=True)}></div>") 

716 assert node == Element("div", {"data-selected": None, "data-active": None}) 

717 assert str(node) == "<div data-selected data-active></div>" 

718 

719 

720def test_data_attr_templated_error(): 

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

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

723 with pytest.raises(TypeError): 

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

725 print(str(node)) 

726 

727 

728def test_data_attr_none(): 

729 button_data = None 

730 node = html(t"<button data={button_data}>X</button>") 

731 assert node == Element("button", children=[Text("X")]) 

732 assert str(node) == "<button>X</button>" 

733 

734 

735def test_data_attr_errors(): 

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

737 with pytest.raises(TypeError): 

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

739 

740 

741def test_data_literal_attr_bypass(): 

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

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

744 assert node == Element( 

745 "p", 

746 attrs={"data": "passthru", "id": "resolved"}, 

747 ), "A single literal attribute should not trigger data expansion." 

748 

749 

750# 

751# Special aria attribute handling. 

752# 

753def test_aria_templated_attr_error(): 

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

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

756 with pytest.raises(TypeError): 

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

758 print(str(node)) 

759 

760 

761def test_aria_interpolated_attr_dict(): 

762 aria = {"label": "Close", "hidden": True, "another": False, "more": None} 

763 node = html(t"<button aria={aria}>X</button>") 

764 assert node == Element( 

765 "button", 

766 attrs={"aria-label": "Close", "aria-hidden": "true", "aria-another": "false"}, 

767 children=[Text("X")], 

768 ) 

769 assert ( 

770 str(node) 

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

772 ) 

773 

774 

775def test_aria_interpolate_attr_none(): 

776 button_aria = None 

777 node = html(t"<button aria={button_aria}>X</button>") 

778 assert node == Element("button", children=[Text("X")]) 

779 assert str(node) == "<button>X</button>" 

780 

781 

782def test_aria_attr_errors(): 

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

784 with pytest.raises(TypeError): 

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

786 

787 

788def test_aria_literal_attr_bypass(): 

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

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

791 assert node == Element( 

792 "p", 

793 attrs={"aria": "passthru", "id": "resolved"}, 

794 ), "A single literal attribute should not trigger aria expansion." 

795 

796 

797# 

798# Special class attribute handling. 

799# 

800def test_interpolated_class_attribute(): 

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

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

803 class_str = "blue" 

804 class_space_sep_str = "green yellow" 

805 class_none = None 

806 class_empty_list = [] 

807 class_empty_dict = {} 

808 button_t = ( 

809 t"<button " 

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

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

812 t" class={class_none}" # ignored 

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

814 t" >Click me</button>" 

815 ) 

816 node = html(button_t) 

817 assert node == Element( 

818 "button", 

819 attrs={"class": "red btn btn-primary one two active blue green yellow"}, 

820 children=[Text("Click me")], 

821 ) 

822 assert ( 

823 str(node) 

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

825 ) 

826 

827 

828def test_interpolated_class_attribute_with_multiple_placeholders(): 

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

830 classes2 = [False and "disabled", None, {"active": True}] 

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

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

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

834 assert node == Element( 

835 "button", 

836 attrs={"class": "['btn', 'btn-primary'] [False, None, {'active': True}]"}, 

837 children=[Text("Click me")], 

838 ) 

839 

840 

841def test_interpolated_attribute_spread_with_class_attribute(): 

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

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

844 assert node == Element( 

845 "button", 

846 attrs={"id": "button1", "class": "btn btn-primary"}, 

847 children=[Text("Click me")], 

848 ) 

849 assert str(node) == '<button id="button1" class="btn btn-primary">Click me</button>' 

850 

851 

852def test_class_literal_attr_bypass(): 

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

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

855 assert node == Element( 

856 "p", 

857 attrs={"class": "red red", "id": "veryred"}, 

858 ), "A single literal attribute should not trigger class accumulator." 

859 

860 

861def test_class_none_ignored(): 

862 class_item = None 

863 node = html(t"<p class={class_item}></p>") 

864 assert node == Element("p") 

865 # Also ignored inside a sequence. 

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

867 assert node == Element("p") 

868 

869 

870def test_class_type_errors(): 

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

872 with pytest.raises(TypeError): 

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

874 with pytest.raises(TypeError): 

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

876 

877 

878def test_class_merge_literals(): 

879 node = html(t'<p class="red" class="blue"></p>') 

880 assert node == Element("p", {"class": "red blue"}) 

881 

882 

883def test_class_merge_literal_then_interpolation(): 

884 class_item = "blue" 

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

886 assert node == Element("p", {"class": "red blue"}) 

887 

888 

889# 

890# Special style attribute handling. 

891# 

892def test_style_literal_attr_passthru(): 

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

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

895 assert node == Element( 

896 "p", 

897 attrs={"style": "color: red", "id": "para1"}, 

898 children=[Text("Warning!")], 

899 ) 

900 assert str(node) == '<p style="color: red" id="para1">Warning!</p>' 

901 

902 

903def test_style_in_interpolated_attr(): 

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

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

906 assert node == Element( 

907 "p", 

908 attrs={"style": "color: red; font-weight: bold; font-size: 16px"}, 

909 children=[Text("Warning!")], 

910 ) 

911 assert ( 

912 str(node) 

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

914 ) 

915 

916 

917def test_style_in_templated_attr(): 

918 color = "red" 

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

920 assert node == Element( 

921 "p", 

922 attrs={"style": "color: red"}, 

923 children=[Text("Warning!")], 

924 ) 

925 assert str(node) == '<p style="color: red">Warning!</p>' 

926 

927 

928def test_style_in_spread_attr(): 

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

930 node = html(t"<p {attrs}>Warning!</p>") 

931 assert node == Element( 

932 "p", 

933 attrs={"style": "color: red"}, 

934 children=[Text("Warning!")], 

935 ) 

936 assert str(node) == '<p style="color: red">Warning!</p>' 

937 

938 

939def test_style_merged_from_all_attrs(): 

940 attrs = dict(style="font-size: 15px") 

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

942 color = "red" 

943 node = html( 

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

945 ) 

946 assert node == Element( 

947 "p", 

948 {"style": "font-family: serif; color: red; font-weight: bold; font-size: 15px"}, 

949 ) 

950 assert ( 

951 str(node) 

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

953 ) 

954 

955 

956def test_style_override_left_to_right(): 

957 suffix = t"></p>" 

958 parts = [ 

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

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

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

962 (t""" {dict(style=dict(color="yellow"))}""", "color: yellow"), 

963 ] 

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

965 expected_style = parts[index][1] 

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

967 node = html(t) 

968 assert node == Element("p", {"style": expected_style}) 

969 assert str(node) == f'<p style="{expected_style}"></p>' 

970 

971 

972def test_interpolated_style_attribute_multiple_placeholders(): 

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

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

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

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

977 # which produces an invalid style attribute. 

978 with pytest.raises(ValueError): 

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

980 

981 

982def test_interpolated_style_attribute_merged(): 

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

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

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

986 assert node == Element( 

987 "p", 

988 attrs={"style": "color: red; font-weight: bold"}, 

989 children=[Text("Warning!")], 

990 ) 

991 assert str(node) == '<p style="color: red; font-weight: bold">Warning!</p>' 

992 

993 

994def test_interpolated_style_attribute_merged_override(): 

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

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

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

998 assert node == Element( 

999 "p", 

1000 attrs={"style": "color: red; font-weight: bold"}, 

1001 children=[Text("Warning!")], 

1002 ) 

1003 assert str(node) == '<p style="color: red; font-weight: bold">Warning!</p>' 

1004 

1005 

1006def test_style_attribute_str(): 

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

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

1009 assert node == Element( 

1010 "p", 

1011 attrs={"style": "color: red; font-weight: bold"}, 

1012 children=[Text("Warning!")], 

1013 ) 

1014 assert str(node) == '<p style="color: red; font-weight: bold">Warning!</p>' 

1015 

1016 

1017def test_style_attribute_non_str_non_dict(): 

1018 with pytest.raises(TypeError): 

1019 styles = [1, 2] 

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

1021 

1022 

1023def test_style_literal_attr_bypass(): 

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

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

1026 assert node == Element( 

1027 "p", 

1028 attrs={"style": "invalid;invalid:", "id": "resolved"}, 

1029 ), "A single literal attribute should bypass style accumulator." 

1030 

1031 

1032def test_style_none(): 

1033 styles = None 

1034 node = html(t"<p style={styles}></p>") 

1035 assert node == Element("p") 

1036 

1037 

1038# -------------------------------------------------------------------------- 

1039# Function component interpolation tests 

1040# -------------------------------------------------------------------------- 

1041 

1042 

1043def FunctionComponent( 

1044 children: t.Iterable[Node], first: str, second: int, third_arg: str, **attrs: t.Any 

1045) -> Template: 

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

1047 assert isinstance(first, str) 

1048 assert isinstance(second, int) 

1049 assert isinstance(third_arg, str) 

1050 new_attrs = { 

1051 "id": third_arg, 

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

1053 **attrs, 

1054 } 

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

1056 

1057 

1058def test_interpolated_template_component(): 

1059 node = html( 

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

1061 ) 

1062 assert node == Element( 

1063 "div", 

1064 attrs={ 

1065 "id": "comp1", 

1066 "data-first": "1", 

1067 "data-second": "99", 

1068 "class": "my-comp", 

1069 }, 

1070 children=[Text("Component: "), Text("Hello, Component!")], 

1071 ) 

1072 assert ( 

1073 str(node) 

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

1075 ) 

1076 

1077 

1078def test_interpolated_template_component_no_children_provided(): 

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

1080 node = html( 

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

1082 ) 

1083 assert node == Element( 

1084 "div", 

1085 attrs={ 

1086 "id": "comp1", 

1087 "data-first": "1", 

1088 "data-second": "99", 

1089 "class": "my-comp", 

1090 }, 

1091 children=[ 

1092 Text("Component: "), 

1093 ], 

1094 ) 

1095 assert ( 

1096 str(node) 

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

1098 ) 

1099 

1100 

1101def test_invalid_component_invocation(): 

1102 with pytest.raises(TypeError): 

1103 _ = html(t"<{FunctionComponent}>Missing props</{FunctionComponent}>") 

1104 

1105 

1106def FunctionComponentNoChildren(first: str, second: int, third_arg: str) -> Template: 

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

1108 assert isinstance(first, str) 

1109 assert isinstance(second, int) 

1110 assert isinstance(third_arg, str) 

1111 new_attrs = { 

1112 "id": third_arg, 

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

1114 } 

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

1116 

1117 

1118def test_interpolated_template_component_ignore_children(): 

1119 node = html( 

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

1121 ) 

1122 assert node == Element( 

1123 "div", 

1124 attrs={ 

1125 "id": "comp1", 

1126 "data-first": "1", 

1127 "data-second": "99", 

1128 }, 

1129 children=[Text(text="Component: ignore children")], 

1130 ) 

1131 assert ( 

1132 str(node) 

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

1134 ) 

1135 

1136 

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

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

1139 assert isinstance(first, str) 

1140 assert "children" in attrs 

1141 _ = attrs.pop("children") 

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

1143 return t"<div {new_attrs}>Component with kwargs</div>" 

1144 

1145 

1146def test_children_always_passed_via_kwargs(): 

1147 node = html( 

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

1149 ) 

1150 assert node == Element( 

1151 "div", 

1152 attrs={ 

1153 "data-first": "value", 

1154 "extra": "info", 

1155 }, 

1156 children=[Text("Component with kwargs")], 

1157 ) 

1158 assert ( 

1159 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>' 

1160 ) 

1161 

1162 

1163def test_children_always_passed_via_kwargs_even_when_empty(): 

1164 node = html(t'<{FunctionComponentKeywordArgs} first="value" extra="info" />') 

1165 assert node == Element( 

1166 "div", 

1167 attrs={ 

1168 "data-first": "value", 

1169 "extra": "info", 

1170 }, 

1171 children=[Text("Component with kwargs")], 

1172 ) 

1173 assert ( 

1174 str(node) == '<div data-first="value" extra="info">Component with kwargs</div>' 

1175 ) 

1176 

1177 

1178def ColumnsComponent() -> Template: 

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

1180 

1181 

1182def test_fragment_from_component(): 

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

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

1185 node = html(t"<table><tr><{ColumnsComponent} /></tr></table>") 

1186 assert node == Element( 

1187 "table", 

1188 children=[ 

1189 Element( 

1190 "tr", 

1191 children=[ 

1192 Element("td", children=[Text("Column 1")]), 

1193 Element("td", children=[Text("Column 2")]), 

1194 ], 

1195 ), 

1196 ], 

1197 ) 

1198 assert str(node) == "<table><tr><td>Column 1</td><td>Column 2</td></tr></table>" 

1199 

1200 

1201def test_component_passed_as_attr_value(): 

1202 def Wrapper( 

1203 children: t.Iterable[Node], sub_component: t.Callable, **attrs: t.Any 

1204 ) -> Template: 

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

1206 

1207 node = html( 

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

1209 ) 

1210 assert node == Element( 

1211 "div", 

1212 attrs={ 

1213 "id": "comp1", 

1214 "data-first": "1", 

1215 "data-second": "99", 

1216 "class": "wrapped", 

1217 }, 

1218 children=[Text("Component: "), Element("p", children=[Text("Inside wrapper")])], 

1219 ) 

1220 assert ( 

1221 str(node) 

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

1223 ) 

1224 

1225 

1226def test_nested_component_gh23(): 

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

1228 def Header(): 

1229 return html(t"{'Hello World'}") 

1230 

1231 node = html(t"<{Header} />") 

1232 assert node == Text("Hello World") 

1233 assert str(node) == "Hello World" 

1234 

1235 

1236def test_component_returning_iterable(): 

1237 def Items() -> t.Iterable: 

1238 for i in range(2): 

1239 yield t"<li>Item {i + 1}</li>" 

1240 yield html(t"<li>Item {3}</li>") 

1241 

1242 node = html(t"<ul><{Items} /></ul>") 

1243 assert node == Element( 

1244 "ul", 

1245 children=[ 

1246 Element("li", children=[Text("Item "), Text("1")]), 

1247 Element("li", children=[Text("Item "), Text("2")]), 

1248 Element("li", children=[Text("Item "), Text("3")]), 

1249 ], 

1250 ) 

1251 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>" 

1252 

1253 

1254def test_component_returning_fragment(): 

1255 def Items() -> Node: 

1256 return html(t"<li>Item {1}</li><li>Item {2}</li><li>Item {3}</li>") 

1257 

1258 node = html(t"<ul><{Items} /></ul>") 

1259 assert node == Element( 

1260 "ul", 

1261 children=[ 

1262 Element("li", children=[Text("Item "), Text("1")]), 

1263 Element("li", children=[Text("Item "), Text("2")]), 

1264 Element("li", children=[Text("Item "), Text("3")]), 

1265 ], 

1266 ) 

1267 assert str(node) == "<ul><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>" 

1268 

1269 

1270@dataclass 

1271class ClassComponent: 

1272 """Example class-based component.""" 

1273 

1274 user_name: str 

1275 image_url: str 

1276 homepage: str = "#" 

1277 children: t.Iterable[Node] = field(default_factory=list) 

1278 

1279 def __call__(self) -> Node: 

1280 return html( 

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

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

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

1284 t"</a>" 

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

1286 t"{self.children}" 

1287 t"</div>", 

1288 ) 

1289 

1290 

1291def test_class_component_implicit_invocation_with_children(): 

1292 node = html( 

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

1294 ) 

1295 assert node == Element( 

1296 "div", 

1297 attrs={"class": "avatar"}, 

1298 children=[ 

1299 Element( 

1300 "a", 

1301 attrs={"href": "#"}, 

1302 children=[ 

1303 Element( 

1304 "img", 

1305 attrs={ 

1306 "src": "https://example.com/alice.png", 

1307 "alt": "Avatar of Alice", 

1308 }, 

1309 ) 

1310 ], 

1311 ), 

1312 Element("span", children=[Text("Alice")]), 

1313 Text("Fun times!"), 

1314 ], 

1315 ) 

1316 assert ( 

1317 str(node) 

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

1319 ) 

1320 

1321 

1322def test_class_component_direct_invocation(): 

1323 avatar = ClassComponent( 

1324 user_name="Alice", 

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

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

1327 ) 

1328 node = html(t"<{avatar} />") 

1329 assert node == Element( 

1330 "div", 

1331 attrs={"class": "avatar"}, 

1332 children=[ 

1333 Element( 

1334 "a", 

1335 attrs={"href": "https://example.com/users/alice"}, 

1336 children=[ 

1337 Element( 

1338 "img", 

1339 attrs={ 

1340 "src": "https://example.com/alice.png", 

1341 "alt": "Avatar of Alice", 

1342 }, 

1343 ) 

1344 ], 

1345 ), 

1346 Element("span", children=[Text("Alice")]), 

1347 ], 

1348 ) 

1349 assert ( 

1350 str(node) 

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

1352 ) 

1353 

1354 

1355@dataclass 

1356class ClassComponentNoChildren: 

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

1358 

1359 user_name: str 

1360 image_url: str 

1361 homepage: str = "#" 

1362 

1363 def __call__(self) -> Node: 

1364 return html( 

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

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

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

1368 t"</a>" 

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

1370 t"ignore children" 

1371 t"</div>", 

1372 ) 

1373 

1374 

1375def test_class_component_implicit_invocation_ignore_children(): 

1376 node = html( 

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

1378 ) 

1379 assert node == Element( 

1380 "div", 

1381 attrs={"class": "avatar"}, 

1382 children=[ 

1383 Element( 

1384 "a", 

1385 attrs={"href": "#"}, 

1386 children=[ 

1387 Element( 

1388 "img", 

1389 attrs={ 

1390 "src": "https://example.com/alice.png", 

1391 "alt": "Avatar of Alice", 

1392 }, 

1393 ) 

1394 ], 

1395 ), 

1396 Element("span", children=[Text("Alice")]), 

1397 Text("ignore children"), 

1398 ], 

1399 ) 

1400 assert ( 

1401 str(node) 

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

1403 ) 

1404 

1405 

1406def AttributeTypeComponent( 

1407 data_int: int, 

1408 data_true: bool, 

1409 data_false: bool, 

1410 data_none: None, 

1411 data_float: float, 

1412 data_dt: datetime.datetime, 

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

1414) -> Template: 

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

1416 assert isinstance(data_int, int) 

1417 assert data_true is True 

1418 assert data_false is False 

1419 assert data_none is None 

1420 assert isinstance(data_float, float) 

1421 assert isinstance(data_dt, datetime.datetime) 

1422 for kw, v_type in [ 

1423 ("spread_true", True), 

1424 ("spread_false", False), 

1425 ("spread_int", int), 

1426 ("spread_none", None), 

1427 ("spread_float", float), 

1428 ("spread_dt", datetime.datetime), 

1429 ("spread_dict", dict), 

1430 ("spread_list", list), 

1431 ]: 

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

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

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

1435 ) 

1436 else: 

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

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

1439 ) 

1440 return t"Looks good!" 

1441 

1442 

1443def test_attribute_type_component(): 

1444 an_int: int = 42 

1445 a_true: bool = True 

1446 a_false: bool = False 

1447 a_none: None = None 

1448 a_float: float = 3.14 

1449 a_dt: datetime.datetime = datetime.datetime(2024, 1, 1, 12, 0, 0) 

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

1451 "spread_true": True, 

1452 "spread_false": False, 

1453 "spread_none": None, 

1454 "spread_int": 0, 

1455 "spread_float": 0.0, 

1456 "spread_dt": datetime.datetime(2024, 1, 1, 12, 0, 1), 

1457 "spread_dict": dict(), 

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

1459 } 

1460 node = html( 

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

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

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

1464 ) 

1465 assert node == Text("Looks good!") 

1466 assert str(node) == "Looks good!" 

1467 

1468 

1469def test_component_non_callable_fails(): 

1470 with pytest.raises(TypeError): 

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

1472 

1473 

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

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

1476 

1477 

1478def test_component_requiring_positional_arg_fails(): 

1479 with pytest.raises(TypeError): 

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

1481 

1482 

1483def test_mismatched_component_closing_tag_fails(): 

1484 with pytest.raises(TypeError): 

1485 _ = html( 

1486 t"<{FunctionComponent} first=1 second={99} third-arg='comp1'>Hello</{ClassComponent}>" 

1487 )