mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-12 04:53:52 -06:00
feat: test the text a page gives a reader
Generated and built pages read without the stylesheet, skipping hidden, aria-hidden, scripts and styles: no step or figure text read twice, no counter glued to a label, no empty picker, every laid out box spaced. The build's filter box is compared with the generator's through node.
This commit is contained in:
1 parent
a4d5c6149b
commit
c2e8338a24
2 files changed
+334
-1
No files matched your search
@@ -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'<div class="rb-table-filter"[^>]*>', _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'<div class="rb-table-filter".*?</div>', 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('<span id="x-count"', box)
|
||||
self.assertIn("</label> <input", box)
|
||||
self.assertIn('"> <span', box)
|
||||
|
||||
def test_the_script_shows_the_boxes(self):
|
||||
source = (WEBSITE / "src" / "scripts" / "site.ts").read_text(encoding="utf-8")
|
||||
self.assertIn("querySelectorAll<HTMLElement>('.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'<div class="rb-picker" hidden ')
|
||||
listed = source.index("select.add(new Option(entry.name, entry.platform));")
|
||||
self.assertLess(listed, source.index("root.hidden = false;"))
|
||||
self.assertIn(".rb-picker[hidden] { display: block; visibility: hidden; }", source)
|
||||
|
||||
|
||||
class DiagramsAreImages(unittest.TestCase):
|
||||
"""One text source per drawing: the images are alt="", the figure says
|
||||
the words once. Each theme has its files, coloured as the page is."""
|
||||
|
||||
def test_no_inline_drawing_and_no_alt(self):
|
||||
for html in [*(sitediagrams.figure_html(i) for i in sitediagrams.IMAGE_IDS),
|
||||
*("".join(sitesteps.steps_figure(tab)) for tab in sitesteps.STEPS)]:
|
||||
with self.subTest(html=html[:60]):
|
||||
self.assertNotIn("<svg", html)
|
||||
self.assertEqual(set(re.findall(r'<img [^>]*alt="([^"]*)"', html)), {""})
|
||||
self.assertEqual(len(re.findall(r"<picture ", html)), len(sitediagrams.THEMES))
|
||||
|
||||
def test_every_image_has_its_file(self):
|
||||
files = {**sitediagrams.figure_files(), **sitesteps.steps_files()}
|
||||
pages = [*(sitediagrams.figure_html(i) for i in sitediagrams.IMAGE_IDS),
|
||||
*("".join(sitesteps.steps_figure(tab)) for tab in sitesteps.STEPS)]
|
||||
named = {url.rsplit("/", 1)[1] for html in pages
|
||||
for url in re.findall(r'(?:src|srcset)="([^"]+)"', html)}
|
||||
self.assertEqual(named, set(files))
|
||||
|
||||
def test_the_figure_says_its_desc_once(self):
|
||||
for image_id in sitediagrams.IMAGE_IDS:
|
||||
figure = sitediagrams.figures()[image_id]
|
||||
with self.subTest(image=image_id):
|
||||
text = read(sitediagrams.figure_html(image_id)).text
|
||||
self.assertEqual(text.count(figure.desc), 1)
|
||||
self.assertIn(figure.caption, text)
|
||||
|
||||
def test_the_theme_colours_are_the_stylesheet_tokens(self):
|
||||
css = (WEBSITE / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8")
|
||||
light_at = css.index(":root[data-theme='light'] {")
|
||||
blocks = {"dark": css[css.index(":root {"):light_at], "light": css[light_at:]}
|
||||
tokens = {"fill": "--rb-diagram-fill", "stroke": "--rb-diagram-stroke",
|
||||
"accent": "--rb-diagram-accent", "muted": "--rb-text-secondary"}
|
||||
for theme, block in blocks.items():
|
||||
for key, token in tokens.items():
|
||||
with self.subTest(theme=theme, token=token):
|
||||
value = re.search(rf"{token}:\s*(#[0-9a-f]+);", block).group(1)
|
||||
self.assertEqual(sitediagrams.THEMES[theme][key], value)
|
||||
|
||||
def test_a_file_carries_its_theme(self):
|
||||
for name, text in sitesteps.steps_files().items():
|
||||
theme = name.rsplit("-", 1)[1].removesuffix(".svg")
|
||||
with self.subTest(file=name):
|
||||
self.assertIn(f".rb-d-box{{fill:{sitediagrams.THEMES[theme]['fill']}", text)
|
||||
self.assertTrue(text.startswith("<svg "))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -69,7 +69,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
|
||||
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_amberelec`, `test_launchers_emudeck`, `test_launchers_emuelec`, `test_launchers_knulli`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retired_files`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_freshness`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_pronouns`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_steps`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_amberelec`, `test_launchers_emudeck`, `test_launchers_emuelec`, `test_launchers_knulli`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retired_files`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_freshness`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_page_text`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_pronouns`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_steps`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
|
||||
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
Reference in new issue
Block a user