"""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'