From f5eb31f910f37b06b170530218255f1a90b9a98c Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:56:39 +0200 Subject: [PATCH] feat: system sections start with their own files The start line keeps entries read for the section's own system, groups a section holding several, names files the way most setups do, links an Arcade-filed set the system requires and says when no file is needed. Cards of MAME and FBNeo sets name the releases whose recipe matches their members. Console headings lose their icon, Arcade gets its own group on the index, and install boxes count toward the weight that splits a heavy manufacturer page. --- scripts/generate_site.py | 498 ++++++++++++++++++++++++++++++---- tests/test_site_r2_systems.py | 15 +- tests/test_site_r3_systems.py | 310 +++++++++++++++++++++ wiki/testing-guide.md | 2 +- 4 files changed, 757 insertions(+), 68 deletions(-) create mode 100644 tests/test_site_r3_systems.py diff --git a/scripts/generate_site.py b/scripts/generate_site.py index df4b4a93..a512a328 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -31,6 +31,7 @@ import sys import urllib.error import urllib.parse import urllib.request +import zipfile from collections.abc import Callable, Iterator from datetime import datetime, timezone from pathlib import Path @@ -3455,14 +3456,19 @@ def generate_systems_index(manufacturers: dict) -> str: ] # Other holds one folder per emulator: a7800 or altirra in the console - # table named no console. + # table named no console. Arcade boards are neither consoles nor + # computers, and the crumb of their page says Arcade too. def kind(mfr: str) -> str: if mfr in GAME_DATA_TOPS: return "games" + if mfr == "Arcade": + return "arcade" return "programs" if mfr == "Other" else "consoles" for heading, group, first, note in ( ("Consoles and computers", "consoles", "Console", ""), + ("Arcade", "arcade", "Name", + "Arcade boards, and the sets each arcade emulator family reads."), ("Emulator folders", "programs", "Folder", "Files kept under the emulator that reads them, not under a console."), ("Game engines and PC games", "games", "Name", @@ -3931,6 +3937,116 @@ def _icon_symbol() -> list[str]: return [PERMALINK_SYMBOL, ""] if markup.starlight() else [] +RomsetMembers = tuple[tuple[str, str], ...] +# A release a recipe agrees with: its place among the imported releases of +# its source, and its name ("0.220", "1.0.0.03"). +Release = tuple[int, str] + + +def _release_name(source: str, label: str, versions: dict) -> str: + """"MAME mame0220" as "0.220", an FBNeo DAT title as its version.""" + match = re.fullmatch(r"MAME mame(\d)(\d+)", label) if source == "mame" else None + return f"{match[1]}.{match[2]}" if match else str(versions.get(label) or label) + + +@functools.lru_cache(maxsize=1) +def _romset_snapshots() -> list[tuple[str, dict]]: + return [(str(snapshot.get("source", path.stem)), snapshot) + for path in sorted((_REPO_ROOT / "recipes").glob("*.json")) + for snapshot in [json.loads(path.read_text(encoding="utf-8"))]] + + +@functools.lru_cache(maxsize=1) +def _romset_releases() -> dict[str, list[tuple[RomsetMembers, str, list[Release]]]]: + """Archive name -> (members, source, the releases agreeing on them), from + the MAME and FBNeo recipe snapshots under recipes/. + + A snapshot stores a recipe once for every version that agrees on it, + so a match reads "this set's contents in those versions". + """ + releases: dict[str, list[tuple[RomsetMembers, str, list[Release]]]] = {} + for source, snapshot in _romset_snapshots(): + versions = snapshot.get("dats") or {} + order = {label: index for index, label in enumerate(versions)} + for entry in snapshot.get("entries") or []: + members = tuple(sorted( + (str(m["name"]), str(m.get("crc32") or "").lower()) + for m in entry.get("members") or [] if m.get("name") is not None + )) + if not entry.get("name") or not members or not all(crc for _n, crc in members): + continue + agreed = [(order.get(label, -1), _release_name(source, str(label), versions)) + for label in entry.get("dats") or [entry.get("dat")]] + releases.setdefault(str(entry["name"]), []).append((members, source, agreed)) + return releases + + +def _release_runs(releases: list[Release]) -> str: + """Releases as runs of consecutive imports: "0.190 to 0.195, 0.220 to + 0.289". One FBNeo version shared by 15 DATs reads once.""" + runs: list[list[Release]] = [] + for release in sorted(set(releases)): + if runs and release[1] == runs[-1][-1][1]: + continue + if runs and release[0] == runs[-1][-1][0] + 1: + runs[-1].append(release) + else: + runs.append([release]) + return ", ".join(run[0][1] if len(run) == 1 else f"{run[0][1]} to {run[-1][1]}" + for run in runs) + + +def _source_order(source: str) -> tuple[bool, str]: + return source != "mame", source + + +def _romset_line(f: dict) -> str: + """Which MAME and FBNeo releases a collected archive's contents match. + + MAME and FBNeo check the members of an archive, by CRC32, never its + own bytes: the set works with a release whose recipe lists the same + members. Empty when no recipe names the archive or the file is not + on disk. + """ + recipes = _romset_releases().get(str(f.get("name", ""))) + path = _REPO_ROOT / str(f.get("path", "")) + if not recipes or not path.is_file(): + return "" + try: + with zipfile.ZipFile(path) as archive: + held = tuple(sorted((info.filename, f"{info.CRC:08x}") + for info in archive.infolist() if not info.is_dir())) + except (OSError, zipfile.BadZipFile) as exc: + print(f" WARNING: {path}: {exc}", file=sys.stderr) + return "" + matched: dict[str, list[Release]] = {} + for members, source, agreed in recipes: + if members == held: + matched.setdefault(source, []).extend(agreed) + if not matched: + return f"No match in {_romset_coverage()}" + return ", ".join(f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(matched[source])}" + for source in sorted(matched, key=_source_order)) + + +@functools.lru_cache(maxsize=1) +def _romset_coverage() -> str: + """The releases the recipes cover: "MAME 0.190 to 0.289 or FBNeo 1.0.0.03".""" + covered = {} + for source, snapshot in _romset_snapshots(): + versions = snapshot.get("dats") or {} + if versions: + ends = [(index, _release_name(source, label, versions)) + for index, label in enumerate(versions)] + # The ends of the span, read as one run. + span = [ends[0], (1, ends[-1][1])] if len(ends) > 1 else ends + covered[source] = f"{_ROMSET_SOURCES.get(source, source)} {_release_runs(span)}" + return " or ".join(covered[source] for source in sorted(covered, key=_source_order)) + + +ROMSET_FAQ = "../wiki/technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match" + + def _system_file_card( f: dict, consumers: FileConsumers, anchored: set[str] | None = None, searchable: bool = True, @@ -3996,6 +4112,11 @@ def _system_file_card( facts.append(("Packed for its emulators by", [_places_text(installed)], False)) if emus: facts.append(("Emulators", [", ".join(_emulator_links(sha1_full, consumers))], False)) + romset = _romset_line(f) + if romset: + # The version question every arcade player asks, answered from the + # recipes: the cards named emulators and no release. + facts.append(("Romset", [romset], False)) if f.get("path"): # The card named a file, its hashes and provenance, but not where to # get it: a user missing scph5501.bin found the reference and no way @@ -4187,6 +4308,14 @@ def system_install_choices(coverages: dict) -> dict[str, dict[str, list[tuple[st # stays under 600 KB from 300 KB. game-engines was one 10.5 MB page that # took 21 s of main-thread work on a phone. SYSTEM_PAGE_BUDGET = 300_000 +# What the sections kept on a manufacturer's summary may weigh once +# rendered, estimated from their Markdown. Fitted on the Nintendo, Sega and +# Commodore summaries: highlighted install commands grow about nine times, +# cards 1.7 times. The page budget is 600 KB of HTML, and the frame around +# the content (head, navigation, footer) is about 45 KB. +CARD_HTML_RATIO = 1.7 +INSTALL_HTML_RATIO = 9 +SYSTEM_SUMMARY_HTML = 500_000 # A system card: whether it is a variant, its lines, its sha1. Card = tuple[bool, list[str], str] @@ -4209,17 +4338,22 @@ _NO_PRIORITY = 1 << 30 def _card_rank(f: dict, consumers: FileConsumers) -> tuple: """Where a file's card sits in its console: the files an emulator requires first, then by the emulators' search order (smallest first), - then by how many setups list the file, then by name. + then a required file by how many emulators read it, then by how many + setups list the file, then by name. By byte order the PlayStation section opened on LibPS.exe and - PSX_DTLH2000.bin, capitals first, and scph5501.bin was 28th. + PSX_DTLH2000.bin, capitals first, and scph5501.bin was 28th. Without + the reader count the Neo Geo section opened on aes.zip, which geolith + alone reads. """ sha1 = str(f.get("sha1", "")) entries = _reading_entries(sha1, consumers) priorities = [e["priority"] for e in entries if isinstance(e.get("priority"), int)] + required = any(e.get("required", True) for e in entries) return ( - not any(e.get("required", True) for e in entries), + not required, min(priorities, default=_NO_PRIORITY), + -len(entries) if required else 0, -len(consumers.platforms.get(sha1, ())), f["name"].casefold(), f["name"], f.get("path", ""), ) @@ -4271,32 +4405,241 @@ def _region_word(region: str) -> str: return _REGION_WORDS.get(region) or region.replace("-", " ").title() -def _start_with(files: list[dict], consumers: FileConsumers, first_below: bool = True) -> list[str]: - """The line a console section opens on: the files an emulator requires, - by the region the emulator picks each for. +def _reading_pairs(sha1: str, consumers: FileConsumers) -> list[tuple[str, dict]]: + """The profile entries that read a file, each with its emulator.""" + files_of = getattr(consumers, "emulator_files", {}) + pairs = [] + for emulator, index in sorted(getattr(consumers, "emulator_entries", {}).get(sha1, {}).items()): + files = files_of.get(emulator) or [] + if 0 <= index < len(files) and isinstance(files[index], dict): + pairs.append((emulator, files[index])) + return pairs - Read from the profiles' required and region fields, never typed per - system. A section where no emulator requires a file gets no line. + +@functools.lru_cache(maxsize=1) +def _profiles_on_disk() -> dict[str, dict]: + from common import load_emulator_profiles # noqa: PLC0415 + + return load_emulator_profiles(str(_REPO_ROOT / "emulators"), skip_aliases=False) + + +def _entry_systems(emulator: str, entry: dict) -> list[str]: + """The system an entry is read for: its own, or its profile's only one.""" + if entry.get("system"): + return [str(entry["system"])] + systems = (_profiles_on_disk().get(emulator) or {}).get("systems") or [] + return [str(systems[0])] if len(systems) == 1 else [] + + +def _emulator_display(emulator: str) -> str: + return str((_profiles_on_disk().get(emulator) or {}).get("emulator") or emulator) + + +# id(consumers) -> (consumers, section -> the entries whose system leads there). +_SECTION_ENTRIES: dict[int, tuple[object, dict[tuple[str, str], list]]] = {} + + +def _entries_by_section(consumers: FileConsumers) -> dict[tuple[str, str], list]: + """Each console section with the (sha1, emulator, entry, system) whose + system leads to it, read once per build.""" + from siterender import system_section # noqa: PLC0415 + + cached = _SECTION_ENTRIES.get(id(consumers)) + if cached is not None and cached[0] is consumers: + return cached[1] + sections: dict[str, tuple[str, str] | None] = {} + index: dict[tuple[str, str], list] = {} + for sha1 in sorted(getattr(consumers, "emulator_entries", {})): + for emulator, entry in _reading_pairs(sha1, consumers): + for sys_id in _entry_systems(emulator, entry): + if sys_id not in sections: + sections[sys_id] = system_section(sys_id) + place = sections[sys_id] + if place is not None: + index.setdefault(place, []).append((sha1, emulator, entry, sys_id)) + _SECTION_ENTRIES.clear() + _SECTION_ENTRIES[id(consumers)] = (consumers, index) + return index + + +def _setup_name(f: dict, consumers: FileConsumers, pairs: list[tuple[str, dict]]) -> str: + """The name most setups give a file, else most emulators: the collection + keeps BizHawk's GBA_bios.rom where eight setups write gba_bios.bin.""" + counts: dict[str, int] = {} + destinations = getattr(consumers, "platform_destinations", {}).get(str(f.get("sha1", "")), {}) + for dests in destinations.values(): + for base in {d.replace("\\", "/").rsplit("/", 1)[-1] for d in dests if d}: + counts[base] = counts.get(base, 0) + 1 + if not counts: + for _emulator, entry in pairs: + if entry.get("name"): + name = str(entry["name"]) + counts[name] = counts.get(name, 0) + 1 + if not counts: + return f["name"] + most = max(counts.values()) + top = sorted((n for n, c in counts.items() if c == most), key=lambda n: (n.casefold(), n)) + return f["name"] if f["name"] in top else top[0] + + +def _without_files(emulators: list[str]) -> str: + """Who runs without the files: every emulator reading one, up to three + by name, more counted, as their order would rank nothing.""" + names = sorted({_emulator_display(e) for e in emulators}, key=str.casefold) + if len(names) > 3: + return f": none of its {len(names)} emulators requires one" + return " with " + (", ".join(names[:-1]) + f" or {names[-1]}" if len(names) > 1 else names[0]) + + +def _visible(name: str) -> str: + """The file name a start line shows, out of its code span and link.""" + match = re.match(r"\[?`([^`]*)`", name) + return match[1] if match else name + + +def _add_name(names: list[str], name: str) -> None: + """A name once per line: two copies of sfcbox.zip read as one file.""" + if all(_visible(n) != _visible(name) for n in names): + names.append(name) + + +class _StartGroup: + """What one system of a section asks for.""" + + def __init__(self) -> None: + self.by_region: dict[str, list[str]] = {} + self.plain: list[str] = [] + # Files an emulator reads when present and replaces when absent. + self.optional: list[str] = [] + self.emulators: list[str] = [] + + def absorb(self, other: _StartGroup) -> None: + for region, names in other.by_region.items(): + for name in names: + _add_name(self.by_region.setdefault(region, []), name) + for mine, theirs in ((self.plain, other.plain), (self.optional, other.optional), + (self.emulators, other.emulators)): + for name in theirs: + _add_name(mine, name) + + +def _start_groups( + files: list[dict], consumers: FileConsumers, section: tuple[str, str] | None +) -> dict[str | None, _StartGroup]: + """The files of a section an emulator reads, by the system they are + read for. An entry whose system leads to another section is that + section's: the DS section named five DSi files. A file the system + requires but filed under Arcade, where sets serve every machine, is + named too, linked to its card: the Neo Geo BIOS. One filed under + another console is that console's, whatever system an emulator files + it under (BizHawk reads the Mega CD BIOS for its Genesis), and one + under Other is a single emulator's, its shaders and fonts. A file the + emulator ships with is no file to fetch.""" + from siterender import _heading_name, _manufacturer_slug, system_name, system_section # noqa: PLC0415 + + here = (_manufacturer_slug(section[0]), section[1]) if section else None + held = {str(f.get("sha1", "")) for f in files} + primaries = [f for f in files if "/.variants/" not in f["path"]] + kept: dict[str, list[tuple[str, dict, str | None]]] = {} + for f in primaries: + sha1 = str(f.get("sha1", "")) + for emulator, entry in _reading_pairs(sha1, consumers): + if entry.get("bundled"): + continue + system = None + if here is not None: + places = {s: system_section(s) for s in _entry_systems(emulator, entry)} + system = next((s for s, place in places.items() if place == here), None) + if system is None and any(places.values()): + continue + kept.setdefault(sha1, []).append((emulator, entry, system)) + candidates = list(primaries) + if here is not None: + known = (getattr(consumers, "_db", None) or {}).get("files", {}) + for sha1, emulator, entry, system in _entries_by_section(consumers).get(here, []): + record = known.get(sha1) + parts = str((record or {}).get("path", "")).split("/") + if (sha1 in held or not record or entry.get("bundled") or ".variants" in parts + or len(parts) < 3 or parts[1] != "Arcade"): + continue + if sha1 not in kept: + candidates.append({**record, "elsewhere": True}) + kept.setdefault(sha1, []).append((emulator, entry, system)) + + groups: dict[str | None, _StartGroup] = {} + for f in sorted(candidates, key=lambda f: _card_rank(f, consumers)): + sha1 = str(f.get("sha1", "")) + pairs = kept.get(sha1, []) + name = _code(_setup_name(f, consumers, [(e, entry) for e, entry, _s in pairs])) + if f.get("elsewhere"): + name = f"[{name}]({markup.site_href(f'files/?sha1={sha1}')})" + for emulator, entry, system in pairs: + try: + key = system_name(system) if system else None + except KeyError: + key = None + group = groups.setdefault(key, _StartGroup()) + if emulator not in group.emulators: + group.emulators.append(emulator) + if entry.get("hle_fallback"): + _add_name(group.optional, name) + continue + if not entry.get("required", True): + continue + regions = {str(r) for r in entry.get("region") or []} + for names in [group.by_region.setdefault(r, []) for r in regions] or [group.plain]: + _add_name(names, name) + named = [key for key in groups if key is not None] + if None in groups and named: + # An entry that names no system reads the section's own console. + heading = _heading_name(*section) if section else None + groups[heading if heading in groups else named[0]].absorb(groups.pop(None)) + return groups + + +def _start_with( + files: list[dict], consumers: FileConsumers, first_below: bool = True, + section: tuple[str, str] | None = None, +) -> list[str]: + """The line a console section opens on: the files an emulator requires, + by the region the emulator picks each for, and by system when the + section holds several. + + ``section`` is the (manufacturer, console) the files are under: an + entry read for another console's system is left to that console. + Read from the profiles' system, required, hle_fallback and region + fields, never typed per system. A system whose emulators all run + without its files says so. A section where no emulator reads a file + gets no line. """ + groups = _start_groups(files, consumers, section) + blocks = {key: _group_start(group, first_below) for key, group in groups.items()} + blocks = {key: block for key, block in blocks.items() if block} + if not blocks: + return [] + if len(blocks) == 1: + return _start_lines(next(iter(blocks.values()))) + lines = ["**Start with** the files of your machine:", ""] + for key in sorted(blocks, key=lambda k: str(k).casefold()): + for lead, text in blocks[key]: + if lead == "*": + lines.append(f"- **{key}**: no file needed{text}") + continue + lines.append(f"- **{key}, {lead}**: {text}" if lead else f"- **{key}**: {text}") + return [*lines, ""] + + +def _group_start(group: _StartGroup, first_below: bool) -> list[tuple[str, str]]: + """One system's start as (lead, text) pairs. The lead is the regions a + line is for, "" the files every region needs, "*" the line of a system + that runs without any file.""" from region import _PARENT # noqa: PLC0415 order = _region_order() - by_region: dict[str, list[str]] = {} - plain: list[str] = [] - primaries = [f for f in files if "/.variants/" not in f["path"]] - for f in sorted(primaries, key=lambda f: _card_rank(f, consumers)): - required = [e for e in _reading_entries(str(f.get("sha1", "")), consumers) - if e.get("required", True)] - if not required: - continue - regions = {str(r) for e in required for r in e.get("region") or []} - for names in [by_region.setdefault(r, []) for r in regions] or [plain]: - if f["name"] not in names: - names.append(f["name"]) # A territory its region answers the same way adds nothing: Brazil # under Latin America. - by_region = {r: names for r, names in by_region.items() - if by_region.get(_PARENT.get(r, "")) != names} + by_region = {r: names for r, names in group.by_region.items() + if group.by_region.get(_PARENT.get(r, "")) != names} # Regions that need the same files share a line. regional: dict[tuple[str, ...], list[str]] = {} for region in sorted(by_region, key=lambda r: (order.get(r, len(order)), r)): @@ -4306,27 +4649,40 @@ def _start_with(files: list[dict], consumers: FileConsumers, first_below: bool = regional[(region,)] = names else: regional[(*same, region)] = regional.pop(same) - if not regional and not plain: - return [] - def named(names: list[str], lead: str) -> str: + def listed(names: list[str]) -> str: if len(names) <= START_WITH_NAMES: - return f"{lead}: {', '.join(_code(n) for n in names)}" - where = ", first below" if first_below else "" - return f"{lead}: {_count(len(names), 'more file' if regional else 'file')}{where}" + return ", ".join(names) + # The first three by rank, the rest counted: "the 9 files" named + # none, and the Neo Geo BIOS everyone reads is filed under Arcade. + return f"{', '.join(names[:3])} and {_count(len(names) - 3, 'more file')}" + ( + ", first below" if first_below else "") - if not regional: - if len(plain) > START_WITH_NAMES: - where = " They come first below." if first_below else "" - return [f"**Start with** the {_count(len(plain), 'file')} an emulator requires.{where}", - ""] - return [named(plain, "**Start with**") + ".", ""] - lines = ["**Start with** the BIOS for the region of your games:", ""] - for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs)): - lead = ", ".join(_region_word(r) for r in regions) - lines.append(f"- **{lead}**: {', '.join(_code(n) for n in regional[regions])}") + out = [(", ".join(_region_word(r) for r in regions), listed(regional[regions])) + for regions in sorted(regional, key=lambda rs: (order.get(rs[0], len(order)), rs))] + # A name a region line gives already is no "also". + shown = {_visible(n) for names in regional.values() for n in names} + plain = [n for n in group.plain if _visible(n) not in shown] if plain: - lines.append(named(plain, "- **Some emulators also require**")) + out.append(("", listed(plain))) + if not out and group.optional: + out.append(("*", f"{_without_files(group.emulators)}. " + f"For full accuracy: {listed(group.optional)}.")) + return out + + +def _start_lines(block: list[tuple[str, str]]) -> list[str]: + """One system's start, as the lines its section opens on.""" + lead, text = block[0] + if lead == "*": + return [f"**No file needed**{text}", ""] + if len(block) == 1 and lead == "": + named, _sep, where = text.partition(", first below") + below = " They come first below." if _sep else "" + return [f"**Start with**: {named}.{below}", ""] + lines = ["**Start with** the BIOS for the region of your games:", ""] + for lead, text in block: + lines.append(f"- **{lead or 'Some emulators also require'}**: {text}") return [*lines, ""] @@ -4359,15 +4715,20 @@ def _parts(cards: list[Card], budget: int, sizes: list[int] | None = None) -> li def _console_heading(manufacturer: str, console: str) -> str: - """A console's heading: its icon hidden from screen readers, then the - name with no space before it. The space read as "Section titled - “ PlayStation”" on five of seven Sony headings.""" - anchor = f" {{ #{console_anchor(manufacturer, console)} }}" - icon = _icon_name(manufacturer, console) - if not _icon_available.get(icon): - return f"## {console}{anchor}" - image = markup.image("", _icon_url(icon), width="24", **{"aria-hidden": "true"}) - return f"## {image}{console}{anchor}" + """A console's heading, its name alone. An icon on 79 of 544 headings + read as missing art on the rest, set the text at two positions on one + page and faded out on one theme or the other.""" + return f"## {console} {{ #{console_anchor(manufacturer, console)} }}" + + +def _romset_note(consoles: dict[str, list]) -> list[str]: + """The line a page of MAME or FBNeo sets carries: an arcade player + looking for the version found the answer only under Reference.""" + releases = _romset_releases() + if not any(str(f.get("name", "")) in releases for files in consoles.values() for f in files): + return [] + return ["MAME and FBNeo sets must match the emulator's version. " + f"[Which version each core reads]({ROMSET_FAQ}).", ""] _NEXT_FROM_SYSTEMS = [ @@ -4417,14 +4778,14 @@ def system_pages( ) def whole_page(sections: dict[str, list[Card]]) -> str: lines = [ - f"# {manufacturer}", "", intro, "", key, "", + f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles), f"The files below by console, with every hash, are also published as {json_link}.", "", *_icon_symbol(), ] for console, cards in sections.items(): lines.append(_console_heading(manufacturer, console)) lines.append("") - lines.extend(_start_with(consoles[console], consumers)) + lines.extend(_start_with(consoles[console], consumers, section=(manufacturer, console))) lines.extend(_console_install_block(console, (installs or {}).get(console, []))) lines.extend(_cards_markdown(cards)) lines.append("") @@ -4440,26 +4801,40 @@ def system_pages( with markup.using("mkdocs"): measured = all_sections() measured_text = whole_page(measured) - if len(measured_text) <= budget: + weights = { + console: sum(card_weight(card) for _variant, card, _sha1 in cards) + for console, cards in measured.items() + } + # An install box is a few lines of Markdown and about nine times that + # once highlighted: ten of them were 295 KB of the 583 KB Nintendo + # page, which the card weights never counted. A page is also cut when + # its estimate as it renders passes the summary's. + with markup.using("mkdocs"): + boxes = {console: sum(len(line) + 1 for line in _console_install_block( + console, (installs or {}).get(console, []))) for console in consoles} + rendered = {console: CARD_HTML_RATIO * weights[console] + INSTALL_HTML_RATIO * boxes[console] + for console in weights} + if len(measured_text) <= budget and sum(rendered.values()) <= SYSTEM_SUMMARY_HTML: text = whole_page(all_sections()) if markup.starlight() else measured_text return {slug: (text, consoles)} # The heaviest sections move to pages of their own until what stays on # the manufacturer's page is half the budget: a section of one file # keeps its place, and a reader does not open 91 pages to browse Other. - weights = { - console: sum(card_weight(card) for _variant, card, _sha1 in cards) - for console, cards in measured.items() - } moved: set[str] = set() for console in sorted(weights, key=lambda c: (-weights[c], c)): if sum(w for c, w in weights.items() if c not in moved) <= budget // 2: break moved.add(console) + # Then until what stays holds its estimate as it renders. + for console in sorted(rendered, key=lambda c: (-rendered[c], c)): + if sum(w for c, w in rendered.items() if c not in moved) <= SYSTEM_SUMMARY_HTML: + break + moved.add(console) pages: dict[str, tuple[str, dict[str, list]]] = {} summary = [ - f"# {manufacturer}", "", intro, "", key, "", + f"# {manufacturer}", "", intro, "", key, "", *_romset_note(consoles), f"The files below by console, with every hash, are also published as {json_link}. " f"The largest sections have a page of their own, linked from their heading.", "", *_icon_symbol(), @@ -4469,7 +4844,7 @@ def system_pages( summary.append(_console_heading(manufacturer, console)) summary.append("") if console not in moved: - summary.extend(_start_with(consoles[console], consumers)) + summary.extend(_start_with(consoles[console], consumers, section=(manufacturer, console))) summary.extend(_console_install_block(console, (installs or {}).get(console, []))) summary.extend(_cards_markdown( _console_cards(manufacturer, consoles[console], consumers, summary_anchors))) @@ -4496,7 +4871,8 @@ def system_pages( ]) title = f"{manufacturer}: {console}" count = _count(len(consoles[console]), "file") - summary.extend(_start_with(consoles[console], consumers, first_below=False)) + summary.extend(_start_with(consoles[console], consumers, first_below=False, + section=(manufacturer, console))) if len(stems) == 1: summary.append(f"{count}, on [their own page]({stems[0]}.md).") else: @@ -4518,8 +4894,10 @@ def system_pages( f"[part {i + 1}]({other}.md)" for i, other in enumerate(stems) if i != index) lines.extend([f"This part holds {_part_range(part)}. The others: {others}.", ""]) if index == 0: - lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1)) + lines.extend(_start_with(consoles[console], consumers, first_below=len(stems) == 1, + section=(manufacturer, console))) lines.extend([key, ""]) + lines.extend(_romset_note({console: consoles[console]})) lines.extend(_console_install_block(console, (installs or {}).get(console, []))) lines.extend(["## Files", ""]) lines.extend(_cards_markdown(part)) diff --git a/tests/test_site_r2_systems.py b/tests/test_site_r2_systems.py index 7ed8e84e..70b44ba0 100644 --- a/tests/test_site_r2_systems.py +++ b/tests/test_site_r2_systems.py @@ -102,11 +102,12 @@ class TheConsoleBiosComesFirst(unittest.TestCase): # Brazil says nothing Latin America does not. self.assertIn("- **North America, Latin America**: `scph5501.bin`", lines) - def test_a_long_list_is_counted(self): + def test_a_long_list_names_the_first_and_counts_the_rest(self): files = [_file(f"rom{i}.bin", "abcdefgh"[i]) for i in range(8)] consumers = _consumers({f["sha1"]: [{"required": True}] for f in files}) self.assertEqual(gs._start_with(files, consumers)[0], - "**Start with** the 8 files an emulator requires. They come first below.") + "**Start with**: `rom0.bin`, `rom1.bin`, `rom2.bin` and 5 more files. " + "They come first below.") def test_no_required_file_no_line(self): self.assertEqual(gs._start_with([_file("a.bin", "a")], _consumers({})), []) @@ -213,14 +214,14 @@ class FileFactsAreAGrid(unittest.TestCase): class ConsoleHeadingsStartWithTheName(unittest.TestCase): """The icon and a space came before the name: "Section titled - “ PlayStation”" on five of seven Sony headings.""" + “ PlayStation”" on five of seven Sony headings. Round 3 removed the + icon altogether.""" - def test_the_icon_is_hidden_and_touches_the_name(self): + def test_no_icon_even_when_one_exists(self): icon = siterender._icon_name("Sony", "PlayStation") with mock.patch.dict(siterender._icon_available, {icon: True}): - heading = gs._console_heading("Sony", "PlayStation") - self.assertRegex(heading, r'^## ]*aria-hidden="true"[^>]*>PlayStation \{ #playstation \}$') - self.assertIn('alt=""', heading) + self.assertEqual(gs._console_heading("Sony", "PlayStation"), + "## PlayStation { #playstation }") def test_no_icon_no_image(self): icon = siterender._icon_name("Nobody", "Nothing") diff --git a/tests/test_site_r3_systems.py b/tests/test_site_r3_systems.py new file mode 100644 index 00000000..3bd7c105 --- /dev/null +++ b/tests/test_site_r3_systems.py @@ -0,0 +1,310 @@ +"""System pages and file cards (round 3, package 6). + +The DS section opened on five DSi files, the Game Boy Advance line printed +BizHawk's file name where eight setups write another, the Neo Geo section +opened on a file one emulator reads, no arcade card said which release its +set matches, console headings carried an icon on 15 % of them, arcade boards +sat under "Consoles and computers", and every system page shared one card. +""" + +from __future__ import annotations + +import re +import sys +import types +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import generate_site as gs # noqa: E402 +import make_og_cards # noqa: E402 + +DIST = REPO_ROOT / "website" / "dist" +TMP = REPO_ROOT / "tmp" + + +def _file(name: str, char: str, path: str) -> dict: + return {"name": name, "sha1": char * 40, "md5": char * 32, "size": 4096, "path": path} + + +def _consumers( + readers: dict[str, list[tuple[str, dict]]], + db_files: list[dict] = (), + destinations: dict[str, dict[str, set[str]]] | None = None, +) -> types.SimpleNamespace: + """Consumers where each sha1 is read by the given (emulator, entry).""" + files_of: dict[str, list] = {} + entries: dict[str, dict[str, int]] = {} + emulators: dict[str, set[str]] = {} + for sha1, read_by in readers.items(): + for emulator, entry in read_by: + files_of.setdefault(emulator, []).append(entry) + entries.setdefault(sha1, {})[emulator] = len(files_of[emulator]) - 1 + emulators.setdefault(sha1, set()).add(emulator) + return types.SimpleNamespace( + platforms={}, emulators=emulators, emulator_entries=entries, + emulator_files=files_of, installed={}, platform_systems={}, + platform_destinations=destinations or {}, + _db={"files": {f["sha1"]: f for f in db_files}}, + ) + + +DS = ("Nintendo", "Nintendo DS") + + +class TheSectionStartsWithItsOwnConsole(unittest.TestCase): + """A file filed with the DS but read for the DSi is the DSi's.""" + + def setUp(self) -> None: + folder = "bios/Nintendo/Nintendo DS" + self.files = [_file("NDS_Bios7.bin", "7", f"{folder}/NDS_Bios7.bin"), + _file("NDS_Bios9.bin", "9", f"{folder}/NDS_Bios9.bin"), + _file("dsi_bios7.bin", "d", f"{folder}/dsi_bios7.bin")] + hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"} + self.consumers = _consumers({ + "7" * 40: [("emu-a", {**hle, "name": "bios7.bin"}), + ("emu-b", {**hle, "name": "bios7.bin"})], + "9" * 40: [("emu-a", {**hle, "name": "bios9.bin"})], + "d" * 40: [("emu-a", {"name": "dsi_bios7.bin", "required": True, + "system": "nintendo-dsi"})], + }) + + def test_no_dsi_file_on_the_ds_line(self) -> None: + text = "\n".join(gs._start_with(self.files, self.consumers, section=DS)) + self.assertNotIn("dsi", text) + + def test_a_console_its_emulators_run_without_says_so(self) -> None: + self.assertEqual(gs._start_with(self.files, self.consumers, section=DS), [ + "**No file needed** with emu-a or emu-b. For full accuracy: " + "`bios7.bin`, `bios9.bin`.", ""]) + + def test_the_dsi_section_names_its_file(self) -> None: + lines = gs._start_with(self.files, self.consumers, section=("Nintendo", "Nintendo DSi")) + self.assertEqual(lines, ["**Start with**: `dsi_bios7.bin`.", ""]) + + def test_more_than_three_emulators_are_counted(self) -> None: + hle = {"required": False, "hle_fallback": True, "system": "nintendo-ds"} + consumers = _consumers({"7" * 40: [(f"emu-{n}", hle) for n in "abcd"]}) + self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS)[0], + "**No file needed**: none of its 4 emulators requires one. " + "For full accuracy: `NDS_Bios7.bin`.") + + def test_a_shipped_file_is_no_start(self) -> None: + consumers = _consumers({"7" * 40: [("emu-a", {"required": True, "bundled": True, + "system": "nintendo-ds"})]}) + self.assertEqual(gs._start_with(self.files[:1], consumers, section=DS), []) + + +class TheNameSetupsWrite(unittest.TestCase): + def test_most_setups_win_over_the_collection_name(self) -> None: + f = _file("GBA_bios.rom", "a", "bios/Nintendo/Game Boy Advance/GBA_bios.rom") + consumers = _consumers( + {"a" * 40: [("emu-a", {"name": "GBA_bios.rom", "required": True, + "system": "nintendo-gba"})]}, + destinations={"a" * 40: {"p1": {"gba_bios.bin"}, "p2": {"bios/gba_bios.bin"}, + "p3": {"GBA_bios.rom"}}}) + self.assertEqual(gs._start_with([f], consumers, section=("Nintendo", "Game Boy Advance")), + ["**Start with**: `gba_bios.bin`.", ""]) + + def test_no_setup_then_the_emulators_name(self) -> None: + f = _file("x.rom", "b", "bios/Nintendo/Game Boy Advance/x.rom") + consumers = _consumers({"b" * 40: [("emu-a", {"name": "gba_bios.bin", "required": True})]}) + self.assertEqual(gs._setup_name(f, consumers, [("emu-a", {"name": "gba_bios.bin"})]), + "gba_bios.bin") + + +class ArcadeSetsAreNamedWhereverFiled(unittest.TestCase): + """The Neo Geo BIOS is under Arcade: the Neo Geo section named + aes.zip, read by one emulator, and never neogeo.zip.""" + + def setUp(self) -> None: + self.aes = _file("aes.zip", "1", "bios/SNK/Neo Geo/aes.zip") + neogeo = _file("neogeo.zip", "2", "bios/Arcade/Arcade/neogeo.zip") + mega_cd = _file("bios_CD_U.bin", "3", "bios/Sega/Mega CD/bios_CD_U.bin") + shader = _file("tfx.glsl", "4", "bios/Other/emu/tfx.glsl") + mvs = {"required": True, "system": "snk-neogeo-mvs"} + self.consumers = _consumers({ + "1" * 40: [("geolith", {"name": "aes.zip", "required": True, + "system": "snk-neogeo-aes"})], + "2" * 40: [(f"mame{n}", {**mvs, "name": "neogeo.zip"}) for n in range(3)], + "3" * 40: [("bizhawk", {"name": "bios_CD_U.bin", "required": True, + "system": "snk-neogeo-mvs"})], + "4" * 40: [("emu", {"name": "tfx.glsl", "required": True, + "system": "snk-neogeo-mvs"})], + }, db_files=[self.aes, neogeo, mega_cd, shader]) + + def test_one_line_per_system_and_the_arcade_set_linked(self) -> None: + lines = gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo")) + self.assertEqual(lines[0], "**Start with** the files of your machine:") + link = f"[`neogeo.zip`](/retrobios/files/?sha1={'2' * 40})" + self.assertIn(f"- **SNK Neo Geo MVS**: {link}", lines) + self.assertIn("- **SNK Neo Geo AES**: `aes.zip`", lines) + + def test_another_consoles_file_or_an_emulators_folder_is_left_out(self) -> None: + text = "\n".join(gs._start_with([self.aes], self.consumers, section=("SNK", "Neo Geo"))) + self.assertNotIn("bios_CD_U.bin", text) + self.assertNotIn("tfx.glsl", text) + + def test_the_card_read_by_most_emulators_comes_first(self) -> None: + one = _file("aes.zip", "1", "bios/SNK/Neo Geo/aes.zip") + many = _file("sp-s3.sp1", "5", "bios/SNK/Neo Geo/sp-s3.sp1") + consumers = _consumers({ + "1" * 40: [("geolith", {"required": True})], + "5" * 40: [(f"emu{n}", {"required": True}) for n in range(3)], + }) + cards = gs._console_cards("SNK", [one, many], consumers, set()) + self.assertIn("sp-s3.sp1", cards[0][1][2]) + + def test_a_long_list_names_the_first_files(self) -> None: + files = [_file(f"rom{i}.bin", "abcdefgh"[i], f"bios/SNK/Neo Geo/rom{i}.bin") + for i in range(8)] + consumers = _consumers({f["sha1"]: [("emu", {"required": True})] for f in files}) + self.assertEqual(gs._start_with(files, consumers, first_below=False)[0], + "**Start with**: `rom0.bin`, `rom1.bin`, `rom2.bin` and 5 more files.") + + +class CardsSayWhichReleaseTheSetMatches(unittest.TestCase): + def setUp(self) -> None: + TMP.mkdir(exist_ok=True) + self.path = TMP / "r3_systems_set.zip" + with zipfile.ZipFile(self.path, "w") as archive: + archive.writestr("sp-s2.sp1", b"bios") + crc = f"{zipfile.ZipFile(self.path).infolist()[0].CRC:08x}" + self.releases = {"set.zip": [ + ((("sp-s2.sp1", crc),), "mame", [(0, "0.190"), (1, "0.195"), (4, "0.220")]), + ((("sp-s2.sp1", crc),), "fbneo", [(2, "1.0.0.03"), (7, "1.0.0.03")]), + ((("other.bin", "00000000"),), "mame", [(9, "0.289")]), + ]} + + def tearDown(self) -> None: + self.path.unlink(missing_ok=True) + + def card(self) -> str: + f = {"name": "set.zip", "sha1": "e" * 40, "md5": "e" * 32, "size": 4, + "path": str(self.path)} + with mock.patch.object(gs, "_romset_releases", return_value=self.releases): + return "\n".join(gs._system_file_card(f, _consumers({}))) + + def test_the_matching_releases_as_runs(self) -> None: + self.assertIn("
Romset
MAME 0.190 to 0.195, 0.220, FBNeo 1.0.0.03
", + self.card()) + + def test_no_match_names_what_was_compared(self) -> None: + self.releases["set.zip"] = self.releases["set.zip"][2:] + with mock.patch.object(gs, "_romset_coverage", return_value="MAME 0.190 to 0.289"): + self.assertIn("
No match in MAME 0.190 to 0.289
", self.card()) + + def test_no_recipe_no_line(self) -> None: + self.releases = {} + self.assertNotIn("Romset", self.card()) + + def test_the_snapshots_cover_mame_and_fbneo(self) -> None: + coverage = gs._romset_coverage() + self.assertRegex(coverage, r"^MAME 0\.\d+ to 0\.\d+ or FBNeo [\d.]+$") + + def test_a_page_of_sets_links_the_version_answer(self) -> None: + f = {"name": "set.zip", "sha1": "e" * 40, "path": "bios/SNK/Neo Geo/set.zip"} + with mock.patch.object(gs, "_romset_releases", return_value=self.releases): + page = gs.generate_system_page("SNK", {"Neo Geo": [f]}, _consumers({})) + self.assertIn("MAME and FBNeo sets must match the emulator's version. [Which version " + f"each core reads]({gs.ROMSET_FAQ}).", page) + anchor = gs.ROMSET_FAQ.split("#")[1] + faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8") + self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq) + self.assertEqual(anchor, "which-mame-version-do-the-arcade-bios-sets-match") + + +class ArcadeIsItsOwnGroup(unittest.TestCase): + def test_arcade_rows_under_arcade(self) -> None: + f = {"name": "a.bin", "sha1": "a" * 40, "path": "bios/x/a.bin"} + index = gs.generate_systems_index({"Arcade": {"MAME": [f]}, "Sony": {"PlayStation": [f]}}) + arcade = index.index("## Arcade") + self.assertLess(index.index("## Consoles and computers"), arcade) + self.assertGreater(index.index("[MAME]"), arcade) + self.assertLess(index.index("[Sony PlayStation]"), arcade) + + +class EachManufacturerHasItsCard(unittest.TestCase): + def test_a_tagline_holds_three_lines_and_counts_the_rest(self) -> None: + names = [f"Console number {n}" for n in range(40)] + text = make_og_cards.consoles_tagline(names) + self.assertRegex(text, r" and \d+ more$") + lines = make_og_cards.wrap(text, make_og_cards.TAGLINE_WIDTH, make_og_cards.TAGLINE_SIZE) + self.assertLessEqual(len(lines), make_og_cards.TAGLINE_LINES) + self.assertEqual(make_og_cards.consoles_tagline(["Saturn", "Dreamcast"]), + "BIOS files for Dreamcast and Saturn") + + def test_every_manufacturer_page_has_a_committed_card(self) -> None: + cards = make_og_cards.system_cards() + missing = [key for key in cards + if not (REPO_ROOT / "docs_assets" / "og" / f"{key}.png").is_file()] + self.assertEqual(missing, [], "run python scripts/make_og_cards.py --only systems") + + +@unittest.skipUnless((DIST / "systems" / "nintendo" / "index.html").is_file(), "site not built") +class TheBuiltPages(unittest.TestCase): + def test_no_console_heading_carries_an_icon(self) -> None: + with_icon = [path.relative_to(DIST).as_posix() for path in DIST.glob("systems/**/index.html") + if re.search(r"]*>(?:(?!).)* None: + page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8") + start = page.index('id="nintendo-ds"') + section = page[start:page.index('id="nintendo-dsi"')] + opening = section[:section.index('class="rb-sys-file"')] + self.assertRegex(opening, r"No file needed|Start with") + self.assertNotRegex(opening.casefold(), r"dsi_|bios7i|bios9i") + + def test_the_gba_line_prints_the_setups_name(self) -> None: + page = (DIST / "systems" / "nintendo" / "index.html").read_text(encoding="utf-8") + section = page[page.index('id="game-boy-advance"'):page.index('id="game-boy-color"')] + self.assertRegex(section[:section.index("class=\"rb-sys-file\"")], r"]*>gba_bios.bin") + + def test_the_neo_geo_section_opens_on_the_neo_geo_bios(self) -> None: + page = (DIST / "systems" / "snk" / "index.html").read_text(encoding="utf-8") + section = page[page.index('id="neo-geo"'):] + opening = section[:section.index('class="rb-sys-file"')] + self.assertRegex(opening, r"]*>neogeo.zip") + self.assertIn("Which version each core reads", page) + + def test_the_nintendo_summary_holds_its_budget(self) -> None: + size = (DIST / "systems" / "nintendo" / "index.html").stat().st_size + self.assertLess(size, 540_000) + + +class InstallBoxesCountTowardTheSummary(unittest.TestCase): + """Ten install boxes were 295 KB of the 583 KB Nintendo page: the + weights that keep a section on its maker's summary counted cards only, + and the page passed its 600 KB budget once the DS files were refiled.""" + + def setUp(self) -> None: + import siterender + + siterender.reset_console_pages() + self.addCleanup(siterender.reset_console_pages) + self.consoles = { + name: [_file(f"{name}{i}.bin", "0123456789"[index * 3 + i], + f"bios/Maker/{name}/{name}{i}.bin") for i in range(3)] + for index, name in enumerate(("Boxed", "Heavy", "Light")) + } + + def pages(self, box: int) -> dict: + def install(console: str, _declared: list) -> list[str]: + return ["x" * box] if console == "Boxed" else [] + + with mock.patch.object(gs, "_console_install_block", side_effect=install), \ + mock.patch.object(gs, "SYSTEM_SUMMARY_HTML", 50_000): + return gs.system_pages("Maker", self.consoles, _consumers({}), budget=30_000) + + def test_a_section_with_a_heavy_install_box_moves(self) -> None: + pages = self.pages(20_000) + self.assertIn("maker-boxed", pages) + self.assertNotIn("maker-light", pages) + + def test_a_light_install_box_stays(self) -> None: + self.assertEqual(list(self.pages(10)), ["maker"]) diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 2e67ef63..6e9c5f7d 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -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_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_p0`, `test_site_r3_systems`, `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