Coverage for tests/test_browser.py: 99%
307 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 01:39 -0700
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 01:39 -0700
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.
14import json
15import posixpath
16import re
17import tempfile
18import unittest
19from pathlib import Path
21from bs4 import BeautifulSoup
22from pydantic import ValidationError
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
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"
35def load_sales() -> Schema:
36 """Load the sales test schema."""
37 return Schema.from_uri(str(SALES_YAML), context={"id_generation": True})
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)
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")
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)])
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"))
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")]
68def parse_diagram(text: str) -> tuple[dict[str, list[str]], list[tuple[str, str, str]]]:
69 """Read the Mermaid text of a relationship diagram.
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]
98class BrowserSiteTestCase(unittest.TestCase):
99 """Tests of a site built from the sales schema with every setting of the
100 site configuration on.
101 """
103 site: Path
104 """The directory of the built site."""
106 _tmpdir: tempfile.TemporaryDirectory
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))
114 @classmethod
115 def tearDownClass(cls) -> None:
116 cls._tmpdir.cleanup()
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)
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 )
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 )
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)
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 ]
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 )
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))
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)
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 )
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"), [])
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 )
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"))
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 )
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")
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 )
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 )
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")
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")
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 )
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 )
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 )
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")
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 )
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 )
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")])
536class BrowserDefaultsTestCase(unittest.TestCase):
537 """Tests of a site built from the sales schema without a site
538 configuration.
539 """
541 site: Path
542 """The directory of the built site."""
544 _tmpdir: tempfile.TemporaryDirectory
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()])
552 @classmethod
553 def tearDownClass(cls) -> None:
554 cls._tmpdir.cleanup()
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")
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"))
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 """
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")]
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 )
597class BrowserNameClashTestCase(unittest.TestCase):
598 """Tests of a site with two schemas of the same name."""
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())
615class UniqueNamesTestCase(unittest.TestCase):
616 """Tests of the unique names given to pages and element IDs."""
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 )
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)))
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])
698class RelationshipDiagramTestCase(unittest.TestCase):
699 """Tests of the Mermaid text of relationship diagrams."""
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'")])
754class BrowserEscapingTestCase(unittest.TestCase):
755 """Tests that text from a schema is escaped on every page."""
757 MARKUP = "<b>bold</b> & <script>alert(1)</script>"
758 """Text containing markup, used in the schema below."""
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())
808class UnresolvedColumnTestCase(unittest.TestCase):
809 """Tests of a column reference that isn't a column of the schema."""
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])
835class SiteConfigTestCase(unittest.TestCase):
836 """Tests of reading a site configuration file."""
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)
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())
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)