diff --git a/tests/test_site_page_text.py b/tests/test_site_page_text.py new file mode 100644 index 000000000..d2c07212a --- /dev/null +++ b/tests/test_site_page_text.py @@ -0,0 +1,333 @@ +"""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 br 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. +OWN_BOX = ("rb-tile-name", "rb-tile-runs", "rb-stat-label", "rb-badge", "rb-table-filter-count") + + +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] = [] + + 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 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(): + found[page] = path.read_text(encoding="utf-8") + 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)) + self.assertIsNone(re.search(r"(?m)^Filter \S", page.text)) + + 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 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'