Coverage for tests/test_browser.py: 99%

307 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-04 01:32 -0800

1# This file is part of felis. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13 

14import json 

15import posixpath 

16import re 

17import tempfile 

18import unittest 

19from pathlib import Path 

20 

21from bs4 import BeautifulSoup 

22from pydantic import ValidationError 

23 

24from felis.browser import SiteConfig, render_static_site 

25from felis.browser.diagram import _RelationshipDiagram 

26from felis.browser.sitemap import _UniqueNames 

27from felis.datamodel import Schema 

28 

29TEST_DIR = Path(__file__).parent 

30SALES_YAML = TEST_DIR / "data" / "sales.yaml" 

31# The site configuration with every setting, and the files it names. 

32SITE_CONFIG = TEST_DIR / "data" / "browser" / "site.yaml" 

33 

34 

35def load_sales() -> Schema: 

36 """Load the sales test schema.""" 

37 return Schema.from_uri(str(SALES_YAML), context={"id_generation": True}) 

38 

39 

40def build_site(site_dir: Path, schemas: list[Schema], config: SiteConfig | None = None) -> None: 

41 """Render a browser site into ``site_dir``.""" 

42 render_static_site(schemas=schemas, output_dir=site_dir, config=config) 

43 

44 

45def read_page(site_dir: Path, path: str) -> BeautifulSoup: 

46 """Parse a page of a site, given its path from the site root.""" 

47 return BeautifulSoup((site_dir / path).read_text(encoding="utf-8"), "html.parser") 

48 

49 

50def read_nav(site_dir: Path) -> dict: 

51 """Read the navigation data that the sidebar and the search use.""" 

52 script = (site_dir / "assets" / "nav-data.js").read_text(encoding="utf-8") 

53 prefix, suffix = "window.FELIS_NAV = ", ";\n" 

54 assert script.startswith(prefix) and script.endswith(suffix), "Unexpected nav-data.js format" 

55 return json.loads(script[len(prefix) : -len(suffix)]) 

56 

57 

58def html_pages(site_dir: Path) -> list[str]: 

59 """Return the paths of all the pages of a site, from the site root.""" 

60 return sorted(path.relative_to(site_dir).as_posix() for path in site_dir.rglob("*.html")) 

61 

62 

63def cell_texts(row: BeautifulSoup) -> list[str]: 

64 """Return the text of each cell of a table row.""" 

65 return [cell.get_text(" ", strip=True) for cell in row.find_all("td")] 

66 

67 

68def parse_diagram(text: str) -> tuple[dict[str, list[str]], list[tuple[str, str, str]]]: 

69 """Read the Mermaid text of a relationship diagram. 

70 

71 Returns 

72 ------- 

73 tuple 

74 The tables, by label, each with its key column lines ("name type 

75 marks"), and the relationships, as (child, parent, label) with the 

76 tables' labels. 

77 """ 

78 tables: dict[str, list[str]] = {} 

79 labels: dict[str, str] = {} 

80 relationships = [] 

81 current = None 

82 for line in text.strip().splitlines()[1:]: 

83 line = line.strip() 

84 if entity := re.fullmatch(r'(\w+)\["(.*)"\] \{', line): 

85 key, label = entity.groups() 

86 labels[key] = label 

87 current = tables.setdefault(label, []) 

88 elif line == "}": 

89 current = None 

90 elif relationship := re.fullmatch(r'(\w+) \}o--\|\| (\w+) : "(.*)"', line): 

91 child, parent, label = relationship.groups() 

92 relationships.append((child, parent, label)) 

93 elif current is not None: 93 ↛ 82line 93 didn't jump to line 82 because the condition on line 93 was always true

94 current.append(line) 

95 return tables, [(labels[child], labels[parent], label) for child, parent, label in relationships] 

96 

97 

98class BrowserSiteTestCase(unittest.TestCase): 

99 """Tests of a site built from the sales schema with every setting of the 

100 site configuration on. 

101 """ 

102 

103 site: Path 

104 """The directory of the built site.""" 

105 

106 _tmpdir: tempfile.TemporaryDirectory 

107 

108 @classmethod 

109 def setUpClass(cls) -> None: 

110 cls._tmpdir = tempfile.TemporaryDirectory() 

111 cls.site = Path(cls._tmpdir.name) / "site" 

112 build_site(cls.site, [load_sales()], SiteConfig.from_file(SITE_CONFIG)) 

113 

114 @classmethod 

115 def tearDownClass(cls) -> None: 

116 cls._tmpdir.cleanup() 

117 

118 def page(self, path: str) -> BeautifulSoup: 

119 """Parse a page of the site, given its path from the site root.""" 

120 return read_page(self.site, path) 

121 

122 def test_pages(self) -> None: 

123 """There is a home page, a page for the schema, and a page for each 

124 table, named after it in lowercase with hyphens. 

125 """ 

126 self.assertEqual( 

127 html_pages(self.site), 

128 [ 

129 "index.html", 

130 "schemas/sales/customers.html", 

131 "schemas/sales/index.html", 

132 "schemas/sales/items.html", 

133 "schemas/sales/order-items.html", 

134 "schemas/sales/orders.html", 

135 ], 

136 ) 

137 

138 def test_links(self) -> None: 

139 """Every link, stylesheet, script and image on every page that isn't 

140 on another website points to a file of the site, and every ``#`` 

141 fragment points to an element on its page. 

142 """ 

143 for page_path in html_pages(self.site): 

144 soup = self.page(page_path) 

145 for element in soup.find_all(["a", "link", "script", "img"]): 

146 url = element.get("href") or element.get("src") 

147 # Leave out links to other websites. 

148 if not url or url.startswith(("https:", "http:")): 

149 continue 

150 with self.subTest(page=page_path, url=url): 

151 path, _, fragment = url.partition("#") 

152 target = posixpath.normpath(posixpath.join(posixpath.dirname(page_path), path or ".")) 

153 if not path: 

154 target = page_path 

155 self.assertTrue((self.site / target).is_file(), f"{target} doesn't exist") 

156 if fragment: 

157 self.assertIsNotNone( 

158 read_page(self.site, target).find(id=fragment), f"#{fragment} isn't on {target}" 

159 ) 

160 

161 def test_navigation_data(self) -> None: 

162 """The navigation data, from which the sidebar tree and the search 

163 are built, lists the schema's tables and each table's columns, 

164 indexes and constraints, all sorted by name. 

165 """ 

166 nav = read_nav(self.site) 

167 

168 def entries(*names_and_descriptions: tuple[str, str], kind: str) -> list[dict]: 

169 # The anchor of a column, index or constraint is its kind 

170 # followed by its name in lowercase with hyphens. 

171 return [ 

172 {"name": name, "anchor": f"{kind}-{name.lower().replace('_', '-')}", "description": text} 

173 for name, text in names_and_descriptions 

174 ] 

175 

176 self.assertEqual( 

177 nav, 

178 { 

179 "schemas": [ 

180 { 

181 "name": "sales", 

182 "path": "schemas/sales/index.html", 

183 "description": "Simple sales schema", 

184 "tables": [ 

185 { 

186 "name": "customers", 

187 "path": "schemas/sales/customers.html", 

188 "anchor": "table-customers", 

189 "description": "Customer information", 

190 "columns": entries( 

191 ("address", "Customer address"), 

192 ("customer_id", "Unique customer identifier"), 

193 ("name", "Customer name"), 

194 kind="column", 

195 ), 

196 "indexes": entries( 

197 ("idx_customers_name", "Index on customer name"), kind="index" 

198 ), 

199 "constraints": entries( 

200 ("idx_customers_uniq_name", "Unique customer name"), kind="constraint" 

201 ), 

202 }, 

203 { 

204 "name": "items", 

205 "path": "schemas/sales/items.html", 

206 "anchor": "table-items", 

207 "description": "Items", 

208 "columns": entries( 

209 ("item_id", "Unique item identifier"), 

210 ("note", "Item note"), 

211 ("order_id", "Order identifier"), 

212 ("product_id", "Product identifier"), 

213 ("quantity", "Quantity ordered"), 

214 kind="column", 

215 ), 

216 "indexes": [], 

217 "constraints": entries( 

218 ("non_negative_quantity", "Quantity must be non-negative"), 

219 kind="constraint", 

220 ), 

221 }, 

222 { 

223 "name": "order_items", 

224 "path": "schemas/sales/order-items.html", 

225 "anchor": "table-order-items", 

226 "description": "Order items", 

227 "columns": entries( 

228 ("item_id", "Item identifier"), 

229 ("order_id", "Order identifier"), 

230 kind="column", 

231 ), 

232 "indexes": [], 

233 "constraints": [], 

234 }, 

235 { 

236 "name": "orders", 

237 "path": "schemas/sales/orders.html", 

238 "anchor": "table-orders", 

239 "description": "Customer orders", 

240 "columns": entries( 

241 ("customer_id", "Customer identifier"), 

242 ("note", "Order note"), 

243 ("order_date", "Order date"), 

244 ("order_id", "Unique order identifier"), 

245 kind="column", 

246 ), 

247 "indexes": [], 

248 "constraints": entries( 

249 ("fk_customer_id", "Foreign key to customers"), kind="constraint" 

250 ), 

251 }, 

252 ], 

253 } 

254 ] 

255 }, 

256 ) 

257 

258 def test_navigation_data_targets(self) -> None: 

259 """Every page and anchor in the navigation data exists, so the links 

260 in the sidebar tree and the search results work. 

261 """ 

262 for schema in read_nav(self.site)["schemas"]: 

263 self.assertTrue((self.site / schema["path"]).is_file()) 

264 for table in schema["tables"]: 

265 soup = self.page(table["path"]) 

266 anchors = [table["anchor"]] + [ 

267 entry["anchor"] for kind in ("columns", "indexes", "constraints") for entry in table[kind] 

268 ] 

269 for anchor in anchors: 

270 with self.subTest(page=table["path"], anchor=anchor): 

271 self.assertIsNotNone(soup.find(id=anchor)) 

272 

273 def test_page_titles(self) -> None: 

274 """Each page's title is the site title, followed by the schema and 

275 table names on the schema and table pages. 

276 """ 

277 for path, title in ( 

278 ("index.html", "Test Schemas"), 

279 ("schemas/sales/index.html", "Test Schemas - sales"), 

280 ("schemas/sales/orders.html", "Test Schemas - sales - orders"), 

281 ): 

282 with self.subTest(page=path): 

283 self.assertEqual(self.page(path).title.get_text(), title) 

284 

285 def test_breadcrumbs(self) -> None: 

286 """The header of a table page links to the home page (named after the 

287 site title), the schema page, and the page itself. 

288 """ 

289 breadcrumb = self.page("schemas/sales/orders.html").find("nav", class_="breadcrumb") 

290 self.assertEqual( 

291 [(link.get_text(strip=True), link["href"]) for link in breadcrumb.find_all("a")], 

292 [("Test Schemas", "../../index.html"), ("sales", "index.html"), ("orders", "orders.html")], 

293 ) 

294 

295 def test_headings(self) -> None: 

296 """Each page has one h1, with the site title, schema name or table 

297 name, and its sections have h2 headings, with no h3. 

298 """ 

299 for path, title, sections in ( 

300 ("index.html", "Test Schemas", ["Schemas"]), 

301 ("schemas/sales/index.html", "sales", ["Tables", "Relationships"]), 

302 ("schemas/sales/orders.html", "orders", ["Columns", "Constraints"]), 

303 ): 

304 with self.subTest(page=path): 

305 soup = self.page(path) 

306 self.assertEqual([h1.get_text() for h1 in soup.find_all("h1")], [title]) 

307 self.assertEqual([h2.get_text() for h2 in soup.find_all("h2")], sections) 

308 self.assertEqual(soup.find_all("h3"), []) 

309 

310 def test_home_page_schemas(self) -> None: 

311 """The home page lists each schema with its version (blank if it has 

312 none), description and number of tables. 

313 """ 

314 table = self.page("index.html").find("table", class_="summary-table") 

315 self.assertEqual( 

316 [cell_texts(row) for row in table.select("tbody tr")], 

317 [["sales", "", "Simple sales schema", "4"]], 

318 ) 

319 

320 def test_site_description(self) -> None: 

321 """The site description is shown on the home page as plain text: HTML 

322 in it is shown as written, not used as markup. 

323 """ 

324 description = self.page("index.html").find("p", class_="site-description") 

325 self.assertEqual(description.get_text(), "Schemas for <b>testing</b>: tables & columns.") 

326 self.assertIsNone(description.find("b")) 

327 

328 def test_schema_page(self) -> None: 

329 """The schema page shows the schema's description, and lists each table 

330 by name with its description and number of columns. 

331 """ 

332 soup = self.page("schemas/sales/index.html") 

333 self.assertEqual(soup.find(class_="schema-description").get_text(), "Simple sales schema") 

334 # The sales schema has no version, so none is shown. 

335 self.assertIsNone(soup.find(class_="schema-version")) 

336 table = soup.find("table", class_="summary-table") 

337 self.assertEqual( 

338 [cell_texts(row) for row in table.select("tbody tr")], 

339 [ 

340 ["customers", "Customer information", "3"], 

341 ["items", "Items", "5"], 

342 ["order_items", "Order items", "2"], 

343 ["orders", "Customer orders", "4"], 

344 ], 

345 ) 

346 

347 def test_columns_table(self) -> None: 

348 """A table page's columns table has a row for each column, by name, 

349 with the value of each column field. 

350 """ 

351 table = self.page("schemas/sales/customers.html").find("table", class_="columns-table") 

352 headers = [th.get_text() for th in table.find_all("th")] 

353 rows = { 

354 row["id"]: dict(zip(headers, cell_texts(row), strict=True)) for row in table.select("tbody tr") 

355 } 

356 self.assertEqual(list(rows), ["column-address", "column-customer-id", "column-name"]) 

357 name = rows["column-name"] 

358 # The name cell also holds the column's tags. 

359 self.assertEqual(name["name"], "name indexed") 

360 self.assertEqual(name["datatype"], "string") 

361 self.assertEqual(name["description"], "Customer name") 

362 self.assertEqual(name["nullable"], "false") 

363 self.assertEqual(name["length"], "30") 

364 # The CSV download is named after the schema and table. 

365 self.assertEqual(table["data-export-name"], "sales.customers") 

366 

367 def test_show_empty_columns(self) -> None: 

368 """With ``show_empty_columns`` on, the columns table has a column for 

369 every column field, even ones that no column of the table sets. 

370 """ 

371 table = self.page("schemas/sales/order-items.html").find("table", class_="columns-table") 

372 self.assertEqual( 

373 [th.get_text() for th in table.find_all("th")], 

374 [ 

375 "name", 

376 "datatype", 

377 "description", 

378 "unit", 

379 "nullable", 

380 "ivoa:ucd", 

381 "tap:column_index", 

382 "tap:principal", 

383 "tap:std", 

384 "votable:arraysize", 

385 "votable:datatype", 

386 "votable:xtype", 

387 "votable:utype", 

388 "value", 

389 "length", 

390 "precision", 

391 "autoincrement", 

392 ], 

393 ) 

394 

395 def test_column_tags(self) -> None: 

396 """Columns in the primary key, a foreign key or an index are tagged, 

397 with hover text giving details. 

398 """ 

399 for path, column, tags in ( 

400 ("customers.html", "column-customer-id", [("primary key", "Part of the table's primary key")]), 

401 ("customers.html", "column-name", [("indexed", "In index idx_customers_name")]), 

402 ("customers.html", "column-address", []), 

403 ("orders.html", "column-customer-id", [("foreign key", "References customers.customer_id")]), 

404 ): 

405 with self.subTest(page=path, column=column): 

406 row = self.page(f"schemas/sales/{path}").find("tr", id=column) 

407 self.assertEqual( 

408 [(tag.get_text(), tag["title"]) for tag in row.find_all(class_="column-tag")], tags 

409 ) 

410 

411 def test_indexes(self) -> None: 

412 """A table page lists the table's indexes, with links to their 

413 columns. 

414 """ 

415 row = self.page("schemas/sales/customers.html").find("tr", id="index-idx-customers-name") 

416 self.assertEqual(cell_texts(row), ["idx_customers_name", "name", "Index on customer name"]) 

417 self.assertEqual(row.find("a")["href"], "#column-name") 

418 

419 def test_unique_constraint(self) -> None: 

420 """A unique constraint is listed with links to its columns.""" 

421 row = self.page("schemas/sales/customers.html").find("tr", id="constraint-idx-customers-uniq-name") 

422 self.assertEqual( 

423 cell_texts(row), ["idx_customers_uniq_name", "Unique", "name", "Unique customer name"] 

424 ) 

425 self.assertEqual(row.find("a")["href"], "#column-name") 

426 

427 def test_foreign_key_constraint(self) -> None: 

428 """A foreign key is listed with its columns, the table and columns it 

429 references, and its delete and update actions. 

430 """ 

431 row = self.page("schemas/sales/orders.html").find("tr", id="constraint-fk-customer-id") 

432 self.assertEqual( 

433 cell_texts(row), 

434 [ 

435 "fk_customer_id", 

436 "ForeignKey", 

437 "Columns: customer_id References: customers . customer_id " 

438 "On Delete: SET NULL On Update: CASCADE", 

439 "Foreign key to customers", 

440 ], 

441 ) 

442 self.assertEqual( 

443 [link["href"] for link in row.find_all("a")], 

444 ["#column-customer-id", "customers.html", "customers.html#column-customer-id"], 

445 ) 

446 

447 def test_check_constraint(self) -> None: 

448 """A check constraint is listed with its expression.""" 

449 row = self.page("schemas/sales/items.html").find("tr", id="constraint-non-negative-quantity") 

450 self.assertEqual( 

451 cell_texts(row), 

452 ["non_negative_quantity", "Check", "quantity >= 0", "Quantity must be non-negative"], 

453 ) 

454 

455 def test_stylesheets(self) -> None: 

456 """The configured stylesheets are copied into the site and linked from 

457 every page after the built-in one, in the order given. 

458 """ 

459 for name in ("theme.css", "local.css"): 

460 index = 1 if name == "theme.css" else 2 

461 self.assertEqual( 

462 (self.site / "assets" / f"custom-{index}-{name}").read_text(encoding="utf-8"), 

463 (SITE_CONFIG.parent / name).read_text(encoding="utf-8"), 

464 ) 

465 for path, root in (("index.html", "."), ("schemas/sales/orders.html", "../..")): 

466 with self.subTest(page=path): 

467 self.assertEqual( 

468 [link["href"] for link in self.page(path).find_all("link", rel="stylesheet")], 

469 [ 

470 f"{root}/assets/style.css", 

471 f"{root}/assets/custom-1-theme.css", 

472 f"{root}/assets/custom-2-local.css", 

473 ], 

474 ) 

475 

476 def test_icon(self) -> None: 

477 """The configured icon is copied into the site and shown at the top of 

478 every page, linking to the home page. 

479 """ 

480 self.assertEqual( 

481 (self.site / "assets" / "site-icon-logo.svg").read_bytes(), 

482 (SITE_CONFIG.parent / "logo.svg").read_bytes(), 

483 ) 

484 for path, root in (("index.html", "."), ("schemas/sales/orders.html", "../..")): 

485 with self.subTest(page=path): 

486 link = self.page(path).find("a", class_="site-icon") 

487 self.assertEqual(link["href"], f"{root}/index.html") 

488 self.assertEqual(link.img["src"], f"{root}/assets/site-icon-logo.svg") 

489 # The icon's alternative text is the site title. 

490 self.assertEqual(link.img["alt"], "Test Schemas") 

491 

492 def test_favicon(self) -> None: 

493 """The configured favicon is copied into the site and linked from every 

494 page. 

495 """ 

496 self.assertEqual( 

497 (self.site / "assets" / "favicon.svg").read_bytes(), 

498 (SITE_CONFIG.parent / "favicon.svg").read_bytes(), 

499 ) 

500 for path, root in (("index.html", "."), ("schemas/sales/orders.html", "../..")): 

501 with self.subTest(page=path): 

502 self.assertEqual( 

503 self.page(path).find("link", rel="icon")["href"], f"{root}/assets/favicon.svg" 

504 ) 

505 

506 def test_footer(self) -> None: 

507 """Every page's footer names the maintainer, linked to its website, and 

508 says the site was built with Felis, linked to its documentation. 

509 """ 

510 for path in ("index.html", "schemas/sales/index.html", "schemas/sales/orders.html"): 

511 with self.subTest(page=path): 

512 footer = self.page(path).find("footer", class_="site-footer") 

513 text = footer.get_text(" ", strip=True) 

514 self.assertIn("Maintained by Test Observatory", text) 

515 self.assertIn("Built with Felis", text) 

516 links = [(a.get_text(), a["href"]) for a in footer.find_all("a")] 

517 self.assertEqual( 

518 links, [("Test Observatory", "https://example.org/"), ("Felis", "https://felis.lsst.io/")] 

519 ) 

520 

521 def test_relationship_diagram(self) -> None: 

522 """With ``diagrams`` on, the schema page has a diagram of the tables 

523 linked by foreign keys, showing their key columns, for Mermaid to 

524 draw. 

525 """ 

526 diagram = self.page("schemas/sales/index.html").find("pre", class_="mermaid") 

527 tables, relationships = parse_diagram(diagram.get_text()) 

528 # orders references customers; the other tables have no foreign 

529 # keys, so aren't shown. 

530 self.assertEqual( 

531 tables, {"customers": ["customer_id int PK"], "orders": ["order_id int PK", "customer_id int FK"]} 

532 ) 

533 self.assertEqual(relationships, [("orders", "customers", "fk_customer_id")]) 

534 

535 

536class BrowserDefaultsTestCase(unittest.TestCase): 

537 """Tests of a site built from the sales schema without a site 

538 configuration. 

539 """ 

540 

541 site: Path 

542 """The directory of the built site.""" 

543 

544 _tmpdir: tempfile.TemporaryDirectory 

545 

546 @classmethod 

547 def setUpClass(cls) -> None: 

548 cls._tmpdir = tempfile.TemporaryDirectory() 

549 cls.site = Path(cls._tmpdir.name) / "site" 

550 build_site(cls.site, [load_sales()]) 

551 

552 @classmethod 

553 def tearDownClass(cls) -> None: 

554 cls._tmpdir.cleanup() 

555 

556 def test_title(self) -> None: 

557 """Without a configured title, the site is titled "Home".""" 

558 self.assertEqual(read_page(self.site, "index.html").title.get_text(), "Home") 

559 self.assertEqual(read_page(self.site, "schemas/sales/index.html").title.get_text(), "Home - sales") 

560 

561 def test_no_optional_parts(self) -> None: 

562 """Without a configuration, pages have no site description, icon, 

563 favicon, extra stylesheets, maintainer or relationship diagram. 

564 """ 

565 for path in ("index.html", "schemas/sales/index.html", "schemas/sales/orders.html"): 

566 with self.subTest(page=path): 

567 soup = read_page(self.site, path) 

568 self.assertIsNone(soup.find(class_="site-description")) 

569 self.assertIsNone(soup.find(class_="site-icon")) 

570 self.assertIsNone(soup.find("link", rel="icon")) 

571 self.assertEqual(len(soup.find_all("link", rel="stylesheet")), 1) 

572 self.assertNotIn("Maintained by", soup.find("footer").get_text()) 

573 self.assertIsNone(soup.find(class_="schema-diagram")) 

574 

575 def test_columns_table_leaves_out_empty_fields(self) -> None: 

576 """By default, the columns table has a column for a field only if some 

577 column of the table sets it to something other than its default; 

578 name, datatype, description, unit and nullable are always shown. 

579 """ 

580 

581 def headings(path: str) -> list[str]: 

582 table = read_page(self.site, path).find("table", class_="columns-table") 

583 return [th.get_text() for th in table.find_all("th")] 

584 

585 # No column of customers sets tap:principal or tap:std from their 

586 # default, 0; one column of order_items is principal. 

587 self.assertEqual( 

588 headings("schemas/sales/customers.html"), 

589 ["name", "datatype", "description", "unit", "nullable", "votable:arraysize", "length"], 

590 ) 

591 self.assertEqual( 

592 headings("schemas/sales/order-items.html"), 

593 ["name", "datatype", "description", "unit", "nullable", "tap:principal"], 

594 ) 

595 

596 

597class BrowserNameClashTestCase(unittest.TestCase): 

598 """Tests of a site with two schemas of the same name.""" 

599 

600 def test_name_clash(self) -> None: 

601 """When two schemas have the same name, the second one's pages are 

602 put under the name with ``-2`` added, so neither overwrites the 

603 other. 

604 """ 

605 with tempfile.TemporaryDirectory() as tmpdir: 

606 site = Path(tmpdir) 

607 build_site(site, [load_sales(), load_sales()]) 

608 self.assertEqual( 

609 [schema["path"] for schema in read_nav(site)["schemas"]], 

610 ["schemas/sales/index.html", "schemas/sales-2/index.html"], 

611 ) 

612 self.assertTrue((site / "schemas" / "sales-2" / "orders.html").is_file()) 

613 

614 

615class UniqueNamesTestCase(unittest.TestCase): 

616 """Tests of the unique names given to pages and element IDs.""" 

617 

618 def test_claim(self) -> None: 

619 """A repeated name gets the next free ``-N`` suffix, skipping names 

620 already handed out, whether as repeats or as themselves. 

621 """ 

622 names = _UniqueNames() 

623 self.assertEqual( 

624 [names.claim(name) for name in ("foo", "foo", "foo-2", "foo", "foo-3")], 

625 ["foo", "foo-2", "foo-2-2", "foo-3", "foo-3-2"], 

626 ) 

627 

628 def test_site(self) -> None: 

629 """Tables and columns whose names give the same slug, including one 

630 that is already a suffixed name, each get their own page or element 

631 ID. 

632 """ 

633 columns = [ 

634 {"name": name, "@id": f"#{{table}}.{name}", "datatype": "int", "description": "Column"} 

635 for name in ("x", "X", "x-2") 

636 ] 

637 schema = Schema.model_validate( 

638 { 

639 "name": "clash", 

640 "@id": "#clash", 

641 "tables": [ 

642 { 

643 "name": name, 

644 "@id": f"#{name}", 

645 "columns": [ 

646 {**column, "@id": column["@id"].format(table=name)} for column in columns 

647 ], 

648 } 

649 for name in ("foo", "Foo", "foo-2") 

650 ], 

651 } 

652 ) 

653 with tempfile.TemporaryDirectory() as tmpdir: 

654 site = Path(tmpdir) 

655 build_site(site, [schema]) 

656 tables = read_nav(site)["schemas"][0]["tables"] 

657 self.assertEqual( 

658 sorted(table["path"] for table in tables), 

659 [ 

660 "schemas/clash/foo-2-2.html", 

661 "schemas/clash/foo-2.html", 

662 "schemas/clash/foo.html", 

663 ], 

664 ) 

665 for table in tables: 

666 with self.subTest(table=table["name"]): 

667 anchors = [column["anchor"] for column in table["columns"]] 

668 self.assertEqual(len(set(anchors)), 3) 

669 ids = [element["id"] for element in read_page(site, table["path"]).find_all(id=True)] 

670 self.assertEqual(len(ids), len(set(ids))) 

671 

672 def test_table_named_index(self) -> None: 

673 """A table named ``index``, whose page would be its schema's page, 

674 ``index.html``, is an error. 

675 """ 

676 schema = Schema.model_validate( 

677 { 

678 "name": "named", 

679 "@id": "#named", 

680 "tables": [ 

681 { 

682 "name": "Index", 

683 "@id": "#Index", 

684 "columns": [ 

685 {"name": "id", "@id": "#Index.id", "datatype": "int", "description": "Key"} 

686 ], 

687 } 

688 ], 

689 } 

690 ) 

691 with ( 

692 tempfile.TemporaryDirectory() as tmpdir, 

693 self.assertRaisesRegex(ValueError, "Invalid table name 'Index'"), 

694 ): 

695 build_site(Path(tmpdir), [schema]) 

696 

697 

698class RelationshipDiagramTestCase(unittest.TestCase): 

699 """Tests of the Mermaid text of relationship diagrams.""" 

700 

701 def test_names_mermaid_rejects(self) -> None: 

702 """A column name starting with a digit gets an underscore in front, 

703 and a table or constraint name loses any ``%`` and backslash and has 

704 its double quotes changed to single ones, as Mermaid's grammar 

705 requires. 

706 """ 

707 schema = Schema.model_validate( 

708 { 

709 "name": "awkward", 

710 "@id": "#awkward", 

711 "tables": [ 

712 { 

713 "name": "a%b\\c", 

714 "@id": "#parent", 

715 "primaryKey": "#parent.2mass_id", 

716 "columns": [ 

717 { 

718 "name": "2mass_id", 

719 "@id": "#parent.2mass_id", 

720 "datatype": "long", 

721 "description": "Key", 

722 } 

723 ], 

724 }, 

725 { 

726 "name": "child", 

727 "@id": "#child", 

728 "columns": [ 

729 { 

730 "name": "parent_id", 

731 "@id": "#child.parent_id", 

732 "datatype": "long", 

733 "description": "Ref", 

734 } 

735 ], 

736 "constraints": [ 

737 { 

738 "name": 'fk_"x"%', 

739 "@id": "#fk_x", 

740 "@type": "ForeignKey", 

741 "columns": ["#child.parent_id"], 

742 "referencedColumns": ["#parent.2mass_id"], 

743 } 

744 ], 

745 }, 

746 ], 

747 } 

748 ) 

749 tables, relationships = parse_diagram(_RelationshipDiagram(schema).text() or "") 

750 self.assertEqual(tables, {"abc": ["_2mass_id long PK"], "child": ["parent_id long FK"]}) 

751 self.assertEqual(relationships, [("child", "abc", "fk_'x'")]) 

752 

753 

754class BrowserEscapingTestCase(unittest.TestCase): 

755 """Tests that text from a schema is escaped on every page.""" 

756 

757 MARKUP = "<b>bold</b> & <script>alert(1)</script>" 

758 """Text containing markup, used in the schema below.""" 

759 

760 def test_markup_escaped(self) -> None: 

761 """Markup in a schema's description, a table's name and description, 

762 and a column's name, shown in an index, is shown as text on every 

763 page, never used as markup. 

764 """ 

765 schema = Schema.model_validate( 

766 { 

767 "name": "markup", 

768 "@id": "#markup", 

769 "description": f"Schema {self.MARKUP}", 

770 "tables": [ 

771 { 

772 "name": "tab<u>le</u>", 

773 "@id": "#table", 

774 "description": f"Table {self.MARKUP}", 

775 "columns": [ 

776 { 

777 "name": "co<u>l</u>", 

778 "@id": "#table.col", 

779 "datatype": "int", 

780 "description": "Key", 

781 } 

782 ], 

783 "indexes": [{"name": "idx_col", "@id": "#idx_col", "columns": ["#table.col"]}], 

784 } 

785 ], 

786 } 

787 ) 

788 with tempfile.TemporaryDirectory() as tmpdir: 

789 site = Path(tmpdir) 

790 build_site(site, [schema]) 

791 pages = html_pages(site) 

792 self.assertEqual(len(pages), 3) 

793 for path in pages: 

794 with self.subTest(page=path): 

795 soup = read_page(site, path) 

796 self.assertEqual(soup.find_all(["b", "u"]), []) 

797 # The site's own scripts only load files. 

798 self.assertFalse(any("alert" in script.get_text() for script in soup.find_all("script"))) 

799 # The text itself is shown as written. 

800 self.assertIn(f"Schema {self.MARKUP}", read_page(site, "index.html").get_text()) 

801 schema_page = read_page(site, "schemas/markup/index.html") 

802 self.assertIn("tab<u>le</u>", schema_page.get_text()) 

803 self.assertIn(f"Table {self.MARKUP}", schema_page.get_text()) 

804 (table_page,) = (path for path in pages if path.startswith("schemas/markup/tab")) 

805 self.assertIn("co<u>l</u>", read_page(site, table_page).find("tr", id="index-idx-col").get_text()) 

806 

807 

808class UnresolvedColumnTestCase(unittest.TestCase): 

809 """Tests of a column reference that isn't a column of the schema.""" 

810 

811 def test_error(self) -> None: 

812 """Rendering a site fails with an error naming the reference.""" 

813 schema = Schema.model_validate( 

814 { 

815 "name": "refs", 

816 "@id": "#refs", 

817 "tables": [ 

818 { 

819 "name": "data", 

820 "@id": "#data", 

821 "columns": [ 

822 {"name": "id", "@id": "#data.id", "datatype": "int", "description": "Key"} 

823 ], 

824 "indexes": [ 

825 {"name": "idx_missing", "@id": "#idx_missing", "columns": ["#data.missing"]} 

826 ], 

827 } 

828 ], 

829 } 

830 ) 

831 with tempfile.TemporaryDirectory() as tmpdir, self.assertRaisesRegex(ValueError, "#data.missing"): 

832 build_site(Path(tmpdir), [schema]) 

833 

834 

835class SiteConfigTestCase(unittest.TestCase): 

836 """Tests of reading a site configuration file.""" 

837 

838 def test_full_configuration(self) -> None: 

839 """Every setting is read from the file, with the stylesheet, icon and 

840 favicon paths taken as relative to the file's directory. 

841 """ 

842 config = SiteConfig.from_file(SITE_CONFIG) 

843 directory = SITE_CONFIG.parent 

844 self.assertEqual(config.title, "Test Schemas") 

845 self.assertEqual(config.stylesheets, [directory / "theme.css", directory / "local.css"]) 

846 self.assertEqual(config.icon, directory / "logo.svg") 

847 self.assertEqual(config.favicon, directory / "favicon.svg") 

848 assert config.maintainer is not None 

849 self.assertEqual(config.maintainer.name, "Test Observatory") 

850 self.assertTrue(config.diagrams) 

851 self.assertTrue(config.show_empty_columns) 

852 

853 def test_empty_file(self) -> None: 

854 """An empty configuration file gives the default settings.""" 

855 with tempfile.TemporaryDirectory() as tmpdir: 

856 path = Path(tmpdir) / "site.yaml" 

857 path.write_text("", encoding="utf-8") 

858 self.assertEqual(SiteConfig.from_file(path), SiteConfig()) 

859 

860 def test_invalid(self) -> None: 

861 """A configuration file with an unknown setting, a missing file or a 

862 maintainer without a name is rejected. 

863 """ 

864 with tempfile.TemporaryDirectory() as tmpdir: 

865 path = Path(tmpdir) / "site.yaml" 

866 for text in ( 

867 "titel: Misspelled\n", 

868 "stylesheets: [missing.css]\n", 

869 "icon: missing.png\n", 

870 "favicon: missing.png\n", 

871 "maintainer:\n url: https://example.org/\n", 

872 ): 

873 with self.subTest(config=text): 

874 path.write_text(text, encoding="utf-8") 

875 with self.assertRaises(ValidationError): 

876 SiteConfig.from_file(path)