"""The text a page gives to whatever reads it without looking at it. A crawler, a screen reader, an assistant, a text browser and a copy read the words of the HTML, not the layout. The Steam Deck steps of Get the files read three times there, once in the prose and once per drawn layout glued letter to letter ("Open Konsole3Click Copy,the button bythe line"), a filter box read "Filter Options41 rows" and the file picker an empty "Setup Choose a setup". These tests read representative pages the way such a reader does, skipping what is `hidden`, `aria-hidden`, scripts and styles, and hold the page to one reading of each thing it says. """ from __future__ import annotations import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache import json import re import shutil import subprocess import sys import unittest from html.parser import HTMLParser from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) import sitediagrams # noqa: E402 import siteentries # noqa: E402 import sitesteps # noqa: E402 from siteguides import TERMINALS # noqa: E402 WEBSITE = ROOT / "website" DOCS = WEBSITE / "src" / "content" / "docs" DIST = WEBSITE / "dist" PLUGIN = WEBSITE / "src" / "plugins" / "rehype-stack-tables.mjs" SATTERI = WEBSITE / "node_modules" / "satteri" / "dist" / "index.js" # The pages that hold each kind: the drawn steps and the picker, the # figures, a filter box written by the build, one by the generator above # a file list, one above an entry table, and the stats of a setup page. PAGES = ("which-pack", "wiki/first-install", "wiki/architecture", "wiki/what-are-bios-files", "wiki/installer", "platforms/retrodeck", "provenance-no-intro", "explore", "index") BLOCKS = frozenset( "address article aside blockquote caption dd details dialog div dl dt fieldset figcaption " "figure footer form h1 h2 h3 h4 h5 h6 header hr li main nav ol option p pre section select " "summary table tbody td tfoot th thead tr ul".split()) VOID = frozenset("area base br col embed hr img input link meta source track wbr".split()) # What the stylesheet lays out as its own box beside the text before it: # the markup has to space it, a reader without the stylesheet sees no box. # A
breaks a line on screen only: a copy and textContent join across # it, so it separates nothing here. link-title is the page name of # Starlight's previous and next links. OWN_BOX = ("rb-tile-name", "rb-tile-runs", "rb-stat-label", "rb-badge", "rb-table-filter-count", "link-title") class PageText(HTMLParser): """The text of a page, as a reader without the stylesheet gets it: a block starts a line, an inline element adds nothing between words. Skips script, style, template, `hidden` and `aria-hidden`. A tab panel, hidden until its tab is picked, is read: it is content, not chrome.""" def __init__(self, article_only: bool = False) -> None: super().__init__(convert_charrefs=True) self.parts: list[str] = [] self.stack: list[tuple[str, bool]] = [] self.skipping = 0 self.inside = not article_only self.glued: list[str] = [] # Filter boxes read: the build writes each one hidden until wired. self.filters_read = 0 def _text_so_far(self) -> str: return "".join(self.parts[-4:]) def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: attr = {key: value or "" for key, value in attrs} classes = attr.get("class", "").split() if not self.inside: self.inside = "sl-markdown-content" in classes return if self.skipping: if tag not in VOID: self.stack.append((tag, False)) return if tag == "img": self.parts.append(attr.get("alt", "")) return if tag in BLOCKS: self.parts.append("\n") if "rb-table-filter" in classes and "hidden" not in attr: self.filters_read += 1 if any(name in OWN_BOX for name in classes) or (tag == "code" and self.stack and self.stack[-1][0] == "summary"): before = self._text_so_far() if before and not before[-1].isspace(): self.glued.append(f"{before[-20:]!r} <{tag} class={' '.join(classes)!r}>") if tag in VOID: return skip = (tag in ("script", "style", "template") or ("hidden" in attr and attr.get("role") != "tabpanel") or attr.get("aria-hidden") == "true") self.stack.append((tag, skip)) self.skipping += skip def handle_endtag(self, tag: str) -> None: if tag in VOID or not self.inside: return while self.stack: name, skip = self.stack.pop() self.skipping -= skip if name == tag: break if not self.skipping and tag in BLOCKS: self.parts.append("\n") def handle_data(self, data: str) -> None: if self.inside and not self.skipping: self.parts.append(data) @property def text(self) -> str: return re.sub(r"\n\s*", "\n", re.sub(r"[ \t]+", " ", "".join(self.parts))).strip() def read(html: str, article_only: bool = False) -> PageText: reader = PageText(article_only) reader.feed(html) reader.close() return reader def _letters(text: str) -> str: """The text without any space: the letters an SVG wrapped across text nodes still spell, joined or not.""" return re.sub(r"\s+", "", text.replace("**", "")) def _sources() -> dict[str, str]: found = {} for page in PAGES: for suffix in (".mdx", ".md"): path = DOCS / f"{page}{suffix}" if path.is_file(): # MDX keeps a space between two tags only as an expression. found[page] = path.read_text(encoding="utf-8").replace('{" "}', " ") return found def _built() -> dict[str, str]: found = {} for page in PAGES: path = DIST / ("index.html" if page == "index" else f"{page}/index.html") if path.is_file(): found[page] = path.read_text(encoding="utf-8") return found class PageReadsOnce: """The assertions, on generated page sources (the HTML the generators write, Markdown left as text) and on built pages when there are any.""" pages: dict[str, PageText] def test_the_drawn_steps_are_not_read_again(self): checked = 0 for name, page in self.pages.items(): text = _letters(page.text) for tab, (title, steps) in sitesteps.STEPS.items(): prose = _letters(TERMINALS[tab][1]) with self.subTest(page=name, tab=tab): self.assertLessEqual(text.count(prose), 1) self.assertNotIn(_letters(title), text) self.assertNotIn(_letters(sitesteps.description(tab)), text) # A drawn layout repeats every caption: a caption long # enough to be told apart is read at most once, by the # prose when it says it. for step in steps: caption = _letters(step.caption) if len(caption) >= 16: checked += 1 self.assertLessEqual(text.count(caption), 1, step.caption) self.assertGreater(checked, 0) def test_a_figure_says_its_words_once(self): for name, page in self.pages.items(): text = _letters(page.text) for image_id in sitediagrams.IMAGE_IDS: figure = sitediagrams.figures()[image_id] with self.subTest(page=name, image=image_id): self.assertLessEqual(text.count(_letters(figure.desc)), 1) self.assertLessEqual(text.count(_letters(figure.caption)), 1) def test_no_rows_counter_glued_to_a_label(self): for name, page in self.pages.items(): with self.subTest(page=name): self.assertIsNone(re.search(r"[^\s\d,(][\d,]*\d rows\b", page.text)) # A filter box read whole ("Filter Options41 rows"). A cell # may open on the word: installer.md's "Filter a long list". self.assertEqual(page.filters_read, 0) def test_no_empty_picker(self): for name, page in self.pages.items(): with self.subTest(page=name): self.assertNotIn("Choose a setup", page.text) def test_what_sits_in_its_own_box_is_spaced(self): for name, page in self.pages.items(): with self.subTest(page=name): self.assertEqual(page.glued, []) class GeneratedPagesReadOnce(PageReadsOnce, unittest.TestCase): @classmethod def setUpClass(cls): sources = _sources() if "which-pack" not in sources: raise unittest.SkipTest("no generated pages: run scripts/generate_site.py") cls.pages = {name: read(text) for name, text in sources.items()} def test_the_pages_hold_what_is_checked(self): sources = _sources() self.assertIn("rb-steps", sources["which-pack"]) self.assertIn('class="rb-table-filter" hidden', sources["platforms/retrodeck"]) self.assertIn('class="rb-table-filter" hidden', sources["provenance-no-intro"]) self.assertIn('class="rb-figure"', sources["wiki/architecture"]) class BuiltPagesReadOnce(PageReadsOnce, unittest.TestCase): @classmethod def setUpClass(cls): built = _built() if "which-pack" not in built: raise unittest.SkipTest("no built site") cls.pages = {name: read(text, article_only=True) for name, text in built.items()} def test_the_build_writes_the_filter_boxes_hidden(self): boxes = re.findall(r'
]*>', _built()["wiki/installer"]) self.assertTrue(boxes) for box in boxes: self.assertIn(" hidden", box) class TheTwoFilterBoxesAreOne(unittest.TestCase): """rehype-stack-tables.mjs writes the box above a Markdown table, siteentries.filter_box above an HTML one: the same markup, so site.ts, the stylesheet and every reader meet one box.""" def test_the_build_writes_what_the_generator_writes(self): node = shutil.which("node") if not node or not SATTERI.is_file(): self.skipTest("no node or no website/node_modules") rows = "\n".join(f"| a{i} | b | c |" for i in range(22)) script = ( f"const {{ markdownToHtml }} = await import({json.dumps(SATTERI.as_uri())});\n" f"const {{ default: stackTables }} = await import({json.dumps(PLUGIN.as_uri())});\n" f"const md = {json.dumps(f'## Options{chr(10)}{chr(10)}| Flag | What | When |{chr(10)}|---|---|---|{chr(10)}{rows}{chr(10)}')};\n" "console.log(markdownToHtml(md, { hastPlugins: [stackTables()] }).html);\n" ) built = subprocess.run([node, "--input-type=module", "-e", script], capture_output=True, text=True, check=True, timeout=60).stdout box = re.search(r'
', built, re.S) self.assertIsNotNone(box) self.assertEqual(box.group(0), siteentries.filter_box( "rb-table-filter-1", "Options", ["Flag", "What", "When"], 22)) def test_a_box_reads_nothing_until_wired(self): box = siteentries.filter_box("x", "files", ["File", "path"], 41) self.assertEqual(read(box).text, "") self.assertIn('aria-describedby="x-count"', box) self.assertIn(' ('.rb-table-filter[hidden]')) box.hidden = false", source) css = (WEBSITE / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8") self.assertIn(".rb-table-filter[hidden] { visibility: hidden; }", css) class PreviousAndNextAreSpaced(unittest.TestCase): """Starlight's previous and next links read "PreviousWhat the packs do not hold": the word and the title were split by a
only. The override (src/components/Pagination.astro) spaces them and names each link by its direction and its page.""" def test_the_override_is_registered(self): config = (WEBSITE / "astro.config.mjs").read_text(encoding="utf-8") self.assertIn("Pagination: './src/components/Pagination.astro',", config) def test_word_and_title_are_spaced_and_named(self): source = (WEBSITE / "src" / "components" / "Pagination.astro").read_text(encoding="utf-8") for word, rel, page in (("previousWord", "prev", "prev"), ("nextWord", "next", "next")): with self.subTest(rel=rel): self.assertIn(f"{{{word}}}{{' '}}\n\t\t\t\t\t
", source) self.assertIn(f'rel="{rel}" aria-label={{`${{{word}}}: ${{{page}.label}}`}}', source) self.assertRegex(source, r'
') page = read(html, article_only=True) self.assertEqual(page.glued, []) self.assertEqual(page.text, "Previous A Next B") self.assertTrue(read(html.replace("Previous
", "Previous
"), article_only=True).glued) class ThePickerWaitsForItsScript(unittest.TestCase): def test_hidden_until_the_setups_are_listed(self): source = (WEBSITE / "src" / "components" / "FilePicker.astro").read_text(encoding="utf-8") self.assertRegex(source, r'