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