feat: round 3 navigation and search

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 11:02:28 +02:00
commit b1cbadc335
8 files changed
+352 -18

No files matched your search

+25 -3
View File
@@ -2205,6 +2205,7 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
description=description, structured=structured, terms=terms,
search=authored.get("search") != "exclude",
logo=str(authored.get("logo") or ""),
parent=authored.get("parent"),
)
write_decorated(docs / target, text)
# One file per page: a page that gained or lost its components
@@ -7509,12 +7510,19 @@ def _emulator_nav(profiles: dict) -> list:
def _system_nav(manufacturers: dict) -> list:
"""Manufacturer pages, the machines apart from the game and engine trees."""
machines, games = [], []
"""Manufacturer pages, the machines apart from the arcade boards and the
game and engine trees: an arcade page's crumb read "Consoles and
computers"."""
machines, arcade, games = [], [], []
for mfr in sorted(manufacturers, key=str.casefold):
entry = {mfr: f"systems/{_slug(mfr)}.md"}
(games if mfr in GAME_DATA_TOPS else machines).append(entry)
if mfr in GAME_DATA_TOPS:
games.append(entry)
else:
(arcade if mfr == "Arcade" else machines).append(entry)
nav: list = ["systems/index.md", {"Consoles and computers": machines}]
if arcade:
nav.append({"Arcade": arcade})
if games:
nav.append({"Game engines and PC games": games})
return nav
@@ -7904,6 +7912,20 @@ def _generate(args: argparse.Namespace) -> None:
if stem in coverages:
raise RuntimeError(f"a system page takes the platform {stem}'s address")
PART_PAGES.add(f"platforms/{stem}.md")
# One console of one setup's list, a reference: "playstation
# bios" found five setups' lists before the console's page.
# Weighed down, they still won on their title, which the page
# weight does not reach, and their file list is already kept out
# of the index: the title alone, found again on the setup's page
# and the console's, is all the search held of them.
SEARCH_DEMOTED.add(f"platforms/{stem}.md")
front = _front_matter(page)
if front:
page = page[page.find("\n---\n", 4) + 5:].lstrip("\n")
front.update(level="reference", search="exclude",
parent={"label": cov["platform"], "link": f"{name}.md"})
page = "---\n" + yaml.safe_dump(front, sort_keys=False, allow_unicode=True) \
+ "---\n\n" + page
write_if_changed(str(docs / "platforms" / f"{stem}.md"), page)
PAGE_DATA[f"platforms/{name}.md"] = platform_structured_data(name, cov)
+48 -5
View File
@@ -368,6 +368,17 @@ def split_title(markdown: str) -> tuple[str, str, str]:
_CARD_NAME = re.compile(r'<h3[^>]*class="rb-file-title"(?![^>]*data-pagefind-ignore)[^>]*><code>([^<]+)</code>')
def search_field(key: str, words: list[str]) -> str:
"""A search field of the page, held in an attribute of an empty
element: the text of an element is indexed and shown as the result's
excerpt (a run of 250 file names). Pagefind splits several fields at a
comma, so a value carries none."""
value = " ".join(word.replace(",", " ") for word in words).strip()
if not value:
return ""
return f'\n<span data-pagefind-meta="{html.escape(f"{key}:{value}", quote=True)}" hidden></span>\n'
def file_names_meta(body: str) -> str:
"""The names of the files a system page holds cards for, as a search
field of the page. A page of 250 cards is long, and the search weighs a
@@ -375,9 +386,35 @@ def file_names_meta(body: str) -> str:
after every short page citing the name. The field is weighted in the
site's ranking (astro.config.mjs, metaWeights.files)."""
names = sorted({unescape(name) for name in _CARD_NAME.findall(body)})
if not names:
return ""
return f'\n<span data-pagefind-meta="files" hidden>{html.escape(" ".join(names))}</span>\n'
return search_field("files", names)
# systems/ page -> the platforms' own ids for the consoles it holds, built
# on the first system page, once the generator has installed the sections.
_page_aliases: dict[str, list[str]] = {}
_page_aliases_built = [False]
def page_aliases(rel: str) -> list[str]:
"""The names the platforms file the consoles of a system page under
(psx, dreamcast, gba), from siterender.system_search_aliases(): "psx
bios" finds the PlayStation page though the page never says psx."""
if not _page_aliases_built[0]:
import siterender
by_page: dict[str, set[str]] = {}
for sys_id in list(siterender._system_page_map):
section = siterender.system_section(sys_id)
names = siterender.system_search_aliases(sys_id)
if not section or not names:
continue
target = siterender.console_href(*section).partition("#")[0]
by_page.setdefault(urllib.parse.unquote(target), set()).update(names)
_page_aliases.clear()
_page_aliases.update({page: sorted(names, key=str.casefold)
for page, names in by_page.items()})
_page_aliases_built[0] = bool(siterender._system_page_map)
return list(_page_aliases.get(rel, []))
_NEXT_STEPS = re.compile(r"^## Next steps\b[^\n]*\n+(?P<list>(?:[ \t]*[-*] .*\n?)+)", re.M)
@@ -407,9 +444,12 @@ def finalize_page(
terms: dict[str, str],
search: bool = True,
logo: str = "",
parent: dict | None = None,
) -> tuple[str, str]:
"""The Starlight file for a page body: its path under the content
collection and its text."""
collection and its text. *parent* ({label, link}, the link written
relative to the page) is the page a part page belongs to: the route
middleware makes it the last crumb and marks it in the sidebar."""
title, title_id, body = split_title(markdown)
body = body.lstrip("\n")
body = rewrite_links(body, rel)
@@ -427,7 +467,7 @@ def finalize_page(
body = re.sub(r'<span class="(rb-badge(?![^"]*rb-badge-archive)[^"]*)"',
r'<span data-pagefind-ignore class="\1"', body)
if rel.startswith("systems/"):
body += file_names_meta(body)
body += file_names_meta(body) + search_field("aliases", page_aliases(rel))
components = used_components(body)
stem = rel.rsplit(".", 1)[0]
if components:
@@ -449,6 +489,9 @@ def finalize_page(
front["level"] = level
if logo:
front["logo"] = logo
if parent:
front["parent"] = {"label": str(parent["label"]),
"link": resolve(rel, str(parent["link"]))}
if weight:
front["searchWeight"] = weight
if rel == NOT_FOUND:
+10 -4
View File
@@ -209,10 +209,15 @@ class DeepLinksCarryALabel(unittest.TestCase):
if level not in ("start", "setup"):
continue
text = FENCED.sub("", page.read_text(encoding="utf-8"))
heading = ""
# The headings the line sits under, by depth: a system's h3 sits
# in the "## Reference: every file by system" section.
headings: dict[int, str] = {}
for line in text.splitlines():
if re.match(r"#{1,3} ", line):
heading = line.lstrip("#").strip()
level_match = re.match(r"(#{1,3}) ", line)
if level_match:
depth = len(level_match[1])
headings = {d: h for d, h in headings.items() if d < depth}
headings[depth] = line.lstrip("#").strip()
for match in LINK.finditer(line):
target = by_url.get(match.group(1))
if target not in levels:
@@ -221,7 +226,8 @@ class DeepLinksCarryALabel(unittest.TestCase):
if ORDER.index(deeper) - ORDER.index(level) < 2:
continue
label = f"{generate_site.LEVELS[deeper]}:"
if label not in line[: match.start()] and not heading.startswith(label):
if label not in line[: match.start()] and not any(
heading.startswith(label) for heading in headings.values()):
offenders.append(f"{page.relative_to(DOCS)}: {match.group(0)}")
self.assertEqual(offenders, [], "\n".join(offenders[:30]))
+217
View File
@@ -0,0 +1,217 @@
"""Navigation and search (round 3, package 3).
"playstation bios" found five setups' lists for the console before its own
page: the pages a setup's list moves a console to weighed 1.5, three times a
reference page. The file names a system page holds were the text of a hidden
span, so the search showed them as the excerpt. Start here looped: First
install's "Next steps" pointed back to Get your files, Is it legal? to Common
questions, and both pages had the same page as previous and next. A part
page had no crumb to the page it belongs to and nothing marked in the
sidebar. "psx" or "dc" found nothing, though every platform files the
console under that name. These tests read the generator and, where they
are built, the pages and the site.
"""
from __future__ import annotations
import ast
import re
import sys
import unittest
from html import unescape
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import generate_site # noqa: E402
import sitestarlight # noqa: E402
from common import list_registered_platforms # noqa: E402
SEARCH_GATE = REPO_ROOT / "website" / "tools" / "search_gate.py"
DOCS = REPO_ROOT / "website" / "src" / "content" / "docs"
DIST = REPO_ROOT / "website" / "dist"
ROUTE = REPO_ROOT / "website" / "src" / "routeData.ts"
CONTENT_CONFIG = REPO_ROOT / "website" / "src" / "content.config.ts"
START_HERE = [
"/retrobios/", "/retrobios/wiki/what-are-bios-files/", "/retrobios/which-pack/",
"/retrobios/wiki/first-install/", "/retrobios/wiki/troubleshooting/",
"/retrobios/wiki/faq/", "/retrobios/wiki/legal/", "/retrobios/wiki/glossary/",
"/retrobios/platforms/",
]
def _front(text: str) -> str:
end = text.find("\n---\n", 4)
return text[4:end] if text.startswith("---\n") and end != -1 else ""
def _moved_platform_pages() -> list[Path]:
platforms = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
folder = DOCS / "platforms"
if not folder.is_dir():
return []
return [path for path in sorted(folder.glob("*.md*"))
if path.stem not in platforms and path.stem != "index"]
class MetaFields(unittest.TestCase):
def test_file_names_are_an_attribute_not_text(self) -> None:
body = ('<h3 class="rb-file-title"><code>scph5501.bin</code></h3>'
'<h3 class="rb-file-title"><code>a,b.bin</code></h3>')
meta = sitestarlight.file_names_meta(body)
self.assertIn('data-pagefind-meta="files:a b.bin scph5501.bin"', meta)
self.assertRegex(meta, r"<span [^>]*hidden></span>")
def test_a_field_escapes_its_value(self) -> None:
meta = sitestarlight.search_field("aliases", ['a"b', "c&d"])
self.assertIn('data-pagefind-meta="aliases:a&quot;b c&amp;d"', meta)
def test_an_empty_field_writes_nothing(self) -> None:
self.assertEqual(sitestarlight.search_field("aliases", []), "")
self.assertEqual(sitestarlight.file_names_meta("<p>no card</p>"), "")
def test_system_pages_carry_the_platforms_names(self) -> None:
page = DOCS / "systems" / "sony-playstation.md"
if not page.is_file():
self.skipTest("site pages not generated")
text = page.read_text(encoding="utf-8")
match = re.search(r'data-pagefind-meta="aliases:([^"]*)"', text)
self.assertIsNotNone(match, "no aliases field on the PlayStation page")
self.assertIn("psx", unescape(match[1]).split())
dreamcast = (DOCS / "systems" / "sega-dreamcast.md").read_text(encoding="utf-8")
self.assertRegex(dreamcast, r'data-pagefind-meta="aliases:[^"]*\bdc\b')
class MovedPlatformPages(unittest.TestCase):
def test_a_setups_list_for_one_console_is_a_reference(self) -> None:
source = Path(generate_site.__file__).read_text(encoding="utf-8")
block = source[source.index("for stem, page in moved.items():"):]
block = block[: block.index("PAGE_DATA[f\"platforms/{name}.md\"]")]
self.assertIn('SEARCH_DEMOTED.add(f"platforms/{stem}.md")', block)
self.assertIn('level="reference"', block)
self.assertIn('search="exclude"', block)
self.assertIn("parent=", block)
def test_generated_pages_weigh_as_references_and_name_their_setup(self) -> None:
pages = _moved_platform_pages()
if not pages:
self.skipTest("site pages not generated")
for path in pages:
front = _front(path.read_text(encoding="utf-8"))
with self.subTest(page=path.name):
self.assertIn("\nlevel: reference\n", f"\n{front}\n")
self.assertIn("\nsearchWeight: 0.5\n", f"\n{front}\n")
# The title alone was indexed, and it outranked the console.
self.assertIn("\npagefind: false\n", f"\n{front}\n")
self.assertRegex(front, r"parent:\n label: .+\n link: /retrobios/platforms/[a-z0-9_-]+/\n")
def test_parent_link_resolves_against_the_page(self) -> None:
_target, text = sitestarlight.finalize_page(
"platforms/retroarch-sony-playstation.md", "# RetroArch: Sony PlayStation\n\nBody.\n",
level="reference", weight=0.5, browser_title="x", description="d",
structured={}, terms={}, parent={"label": "RetroArch", "link": "retroarch.md"},
)
self.assertIn("parent:\n label: RetroArch\n link: /retrobios/platforms/retroarch/\n", text)
def test_the_schema_accepts_parent(self) -> None:
self.assertRegex(CONTENT_CONFIG.read_text(encoding="utf-8"),
r"parent: z\.object\(\{ label: z\.string\(\), link: z\.string\(\) \}\)")
class ArcadeGroup(unittest.TestCase):
def test_arcade_is_not_a_console_or_computer(self) -> None:
nav = generate_site._system_nav({"Sony": {}, "Arcade": {}, "Sega": {}})
groups = {next(iter(entry)): entry[next(iter(entry))] for entry in nav if isinstance(entry, dict)}
self.assertEqual(groups["Arcade"], [{"Arcade": "systems/arcade.md"}])
self.assertNotIn({"Arcade": "systems/arcade.md"}, groups["Consoles and computers"])
class RouteMiddleware(unittest.TestCase):
def test_next_steps_link_is_kept_only_forward(self) -> None:
source = ROUTE.read_text(encoding="utf-8")
self.assertIn("target <= position", source)
def test_parent_is_a_crumb_and_marked(self) -> None:
source = ROUTE.read_text(encoding="utf-8")
self.assertIn("route.entry.data.parent", source)
self.assertIn("markCurrent(route.sidebar", source)
def _gate_queries() -> dict:
"""The search gate's QUERIES, read from its source: the tool is run
with node beside it, not imported as a package."""
tree = ast.parse(SEARCH_GATE.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.AnnAssign) and getattr(node.target, "id", "") == "QUERIES":
return ast.literal_eval(node.value)
raise AssertionError("no QUERIES in search_gate.py")
class SearchGateQueries(unittest.TestCase):
def test_the_audits_queries_are_gated(self) -> None:
for query in ("playstation bios", "dreamcast bios", "neo geo bios", "bios not found",
"offline install", "r36s", "wonderswan color", "dc bios missing",
"which mame version", "psx bios"):
self.assertIn(query, _gate_queries())
def test_prefixes_match_a_consoles_own_page(self) -> None:
pages, _rank = _gate_queries()["playstation bios"]
self.assertTrue(any("systems/sony-playstation/".startswith(p) for p in pages))
def _html(url: str) -> str:
rel = url[len("/retrobios/"):]
path = DIST / rel / "index.html"
return path.read_text(encoding="utf-8") if path.is_file() else ""
def _pagination(text: str) -> tuple[str | None, str | None]:
start = text.find('<div class="pagination-links')
if start == -1:
return None, None
links = text[start:text.find("</footer>", start)]
prev = re.search(r'<a href="([^"]+)"[^>]*rel="prev"', links)
nxt = re.search(r'<a href="([^"]+)"[^>]*rel="next"', links)
return (prev[1] if prev else None), (nxt[1] if nxt else None)
@unittest.skipUnless((DIST / "index.html").is_file(), "site not built")
class BuiltSite(unittest.TestCase):
def test_next_from_home_reads_start_here_once(self) -> None:
seen, url = [], "/retrobios/"
while url and url not in seen and len(seen) < 20:
seen.append(url)
url = _pagination(_html(url))[1]
self.assertEqual(seen[: len(START_HERE)], START_HERE)
self.assertEqual(len(seen), len(set(seen)))
def test_no_page_has_the_same_previous_and_next(self) -> None:
same = []
for path in DIST.rglob("index.html"):
prev, nxt = _pagination(path.read_text(encoding="utf-8"))
if prev and prev == nxt:
same.append(str(path.relative_to(DIST)))
self.assertEqual(same, [])
def test_search_fields_hold_no_text(self) -> None:
texts = []
for path in (DIST / "systems").rglob("index.html"):
for match in re.finditer(r"<(\w+)[^>]*data-pagefind-meta=[^>]*>([^<]*)</\1>",
path.read_text(encoding="utf-8")):
if match[2].strip():
texts.append(str(path.relative_to(DIST)))
self.assertEqual(texts, [])
def test_a_setups_console_page_names_its_setup(self) -> None:
text = _html("/retrobios/platforms/retroarch-sony-playstation/")
if not text:
self.skipTest("no RetroArch PlayStation page")
crumbs = re.search(r'<nav[^>]*class="[^"]*rb-breadcrumbs[^"]*"[^>]*>(.*?)</nav>', text, re.S)
self.assertIsNotNone(crumbs)
self.assertIn('href="/retrobios/platforms/retroarch/"', crumbs[1])
self.assertRegex(text, r'<a href="/retrobios/platforms/retroarch/"[^>]*aria-current="page"')
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -19,6 +19,9 @@ export const collections = {
// A mark drawn beside the title (a platform's logo), a path under
// the site root such as assets/logos/batocera.svg.
logo: z.string().optional(),
// The page a part page belongs to (a console of a setup's list),
// read by routeData.ts: the last crumb, marked in the sidebar.
parent: z.object({ label: z.string(), link: z.string() }).optional(),
}),
}),
}),
+30 -2
View File
@@ -8,7 +8,12 @@
// - a pruned sidebar: in the tabs listing every system and emulator, a
// group that does not hold the page becomes one link to its overview, so
// an emulator page does not carry the links of every other emulator. The
// groups of the other tabs stay groups.
// groups of the other tabs stay groups;
// - a part page (one console of a setup's list) names the page it belongs
// to in its `parent:` front matter: that page is its last crumb, and its
// sidebar entry is marked when the part page has none of its own;
// - a page's "Next steps" link is its next only when it reads forward: one
// pointing back to an earlier page of the run made Start here loop.
import { defineRouteMiddleware, type StarlightRouteData } from '@astrojs/starlight/route-data';
import sidebar from './generated/sidebar.json' with { type: 'json' };
@@ -69,6 +74,18 @@ const folderTrail = (entries: Entry[], labels: string[], folder: string): Crumb[
return [];
};
// The parent's entry marked current, for a page the sidebar does not list.
const markCurrent = (entries: Entry[], href: string): Entry[] =>
entries.map((entry) =>
entry.type === 'link'
? entry.href === href
? { ...entry, isCurrent: true }
: entry
: { ...entry, entries: markCurrent(entry.entries, href) },
);
const bare = (link: string) => withBase(link).replace(/[?#].*$/, '');
const asLink = (entry: { label: string; link: string }): Link => ({
type: 'link',
label: entry.label,
@@ -84,6 +101,8 @@ export const onRequest = defineRouteMiddleware(async (context, next) => {
const path = context.url.pathname;
const topic = context.locals.starlightSidebarTopics?.topics.find((t) => t.isCurrent);
const parent = route.entry.data.parent;
if (parent && !route.sidebar.some(holdsCurrent)) route.sidebar = markCurrent(route.sidebar, bare(parent.link));
const crumbs: Crumb[] = [];
if (topic && withBase(topic.link) !== path && route.id !== '404') {
crumbs.push({ label: topic.label, href: withBase(topic.link) });
@@ -98,6 +117,10 @@ export const onRequest = defineRouteMiddleware(async (context, next) => {
if (crumb.href && crumb.href === crumbs.at(-1)?.href) continue;
crumbs.push(crumb);
}
const parentHref = parent ? withBase(parent.link) : undefined;
if (parent && parentHref !== path && parentHref !== crumbs.at(-1)?.href) {
crumbs.push({ label: parent.label, href: parentHref });
}
}
context.locals.rbBreadcrumbs = crumbs;
@@ -105,7 +128,12 @@ export const onRequest = defineRouteMiddleware(async (context, next) => {
const run = RUNS.find((pages) => pages.some((entry) => withBase(entry.link) === path));
const position = run ? run.findIndex((entry) => withBase(entry.link) === path) : -1;
if (prev === undefined) route.pagination.prev = run && position > 0 ? asLink(run[position - 1]) : undefined;
if (following === undefined) route.pagination.next = run && position < run.length - 1 ? asLink(run[position + 1]) : undefined;
const inOrder = run && position < run.length - 1 ? asLink(run[position + 1]) : undefined;
if (following === undefined) route.pagination.next = inOrder;
else if (run && typeof following === 'object' && following.link) {
const target = run.findIndex((entry) => bare(entry.link) === bare(following.link));
if (target !== -1 && target <= position) route.pagination.next = inOrder;
}
route.sidebar = prune(route.sidebar, [topic?.label ?? '']);
});
+18 -3
View File
@@ -26,13 +26,28 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
"neogeo.zip": (("emulators/fbneo", "emulators/mame", "systems/snk/", "systems/arcade"), 1),
"retroarch system folder": (("platforms/retroarch/",), 1),
"installer": (("wiki/installer/", "wiki/first-install/"), 1),
# The file's card on its system page, where it is downloaded.
"scph5501": (("systems/sony/",), 3),
# The file's card on its system page, where it is downloaded. "systems/sony"
# holds the manufacturer page and the console's own (systems/sony-playstation/).
"scph5501": (("systems/sony",), 3),
"md5": (("wiki/verification-modes/",), 1),
"where do bios files go": (("which-pack/", "wiki/first-install/"), 1),
"dolphin": (("emulators/dolphin/",), 1),
"duckstation": (("emulators/duckstation/",), 1),
"playstation bios": (("systems/sony/",), 3),
# A console and "bios": the console's page, before any setup's list for it.
"playstation bios": (("systems/sony",), 3),
"dreamcast bios": (("systems/sega",), 3),
"neo geo bios": (("systems/snk",), 3),
"wonderswan color": (("systems/bandai",), 3),
# The platforms' own names for a console (psx, dc, gba), a search field
# of its page.
"psx bios": (("systems/sony",), 3),
"dc bios missing": (("systems/sega",), 3),
"gba bios": (("systems/nintendo",), 3),
"bios not found": (("wiki/troubleshooting/",), 3),
"offline install": (("wiki/installer/",), 3),
"which mame version": (("wiki/technical-faq/",), 3),
# A device name: the guide of the handhelds that run it.
"r36s": (("platforms/", "which-pack/"), 3),
"steam deck": (("platforms/emudeck/", "platforms/retrodeck/", "which-pack/"), 1),
"knulli": (("platforms/knulli/",), 1),
}
+1 -1
View File
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
| 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_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_emudeck`, `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_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `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_emudeck`, `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_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture