diff --git a/scripts/siterender.py b/scripts/siterender.py index bd9498cb..b3f2e082 100644 --- a/scripts/siterender.py +++ b/scripts/siterender.py @@ -223,14 +223,20 @@ def emulator_card_page(emulator: str, anchor: str) -> str: def system_section(sys_id: str) -> tuple[str, str] | None: - """The console section a system id is named after, as (manufacturer - slug, console): its own section, else the one section whose heading - reads system_name(). The folder a system's files sit in is no answer: - Emerson's Arcadia 2001 led to the Saturn and Sega's NAOMI to the - Dreamcast.""" + """The console section a system id leads to, as (manufacturer slug, + console): its own section, else the parent SYSTEM_PARENTS names, else + the one section whose heading reads system_name(). The folder a + system's files sit in is no answer by itself: Emerson's Arcadia 2001 + led to the Saturn and Sega's NAOMI to the Dreamcast. + + A heading under Arcade counts when it reads the name exactly (Namco + System 246). One under Other never does, its headings name programs.""" own = _own_section(sys_id) if own is not None: return _manufacturer_slug(own[0]), own[1] + parent = SYSTEM_PARENTS.get(sys_id) + if parent is not None and parent in _section_pairs: + return _manufacturer_slug(parent[0]), parent[1] try: name = system_name(sys_id) except KeyError: @@ -239,7 +245,9 @@ def system_section(sys_id: str) -> tuple[str, str] | None: if len(places) != 1: return None mfr, console = next(iter(places)) - if mfr in _PROGRAM_TOPS: + if mfr in _NAMELESS_TOPS or ( + mfr in _PROGRAM_TOPS and console.casefold() in _program_names + ): return None return _manufacturer_slug(mfr), console @@ -359,7 +367,12 @@ def _by_mode(value: str | dict, mode: str) -> str: # Folders that file copies by the emulator or board that loads them. Their # headings name a program, not a system. _PROGRAM_TOPS = frozenset({"Other", "Arcade"}) +# Of those, the top whose headings never name a system, even read whole. +# An Arcade heading can (Namco System 246), an Other one cannot. +_NAMELESS_TOPS = frozenset({"Other"}) +# Every (manufacturer, console) section the system pages carry. +_section_pairs: set[tuple[str, str]] = set() # Normalized id -> the console sections spelled that way. _console_sections: dict[str, set[tuple[str, str]]] = {} # A console section by the name its heading reads (Bandai WonderSwan). @@ -402,6 +415,8 @@ def set_console_sections( _console_sections.clear() _sections_by_name.clear() _console_anchors.clear() + _section_pairs.clear() + _section_pairs.update(pairs) by_maker: dict[str, list[str]] = {} for mfr, console in pairs: by_maker.setdefault(mfr, []).append(console) @@ -927,3 +942,172 @@ SYSTEM_NAMES: dict[str, str] = { "zx-spectrum": "Sinclair ZX Spectrum", "zx-spectrum-128": "Sinclair ZX Spectrum 128", } + + +# A system id with no section of its own -> the (manufacturer, console) +# section holding its files: a model, a regional name or an add-on of the +# machine that section covers, or a game whose data that section files. +# Reviewed entry by entry against the files the profiles and platforms +# declare for the id, never decided by a vote of the folders those files +# sit in. A section that is not the machine stays out of this table even +# when it holds a copy: NAOMI's naomi2.zip filed with the Dreamcast, the +# PET's ROMs in VICE's tree under the C128, the Arcadia's under the Saturn. +SYSTEM_PARENTS: dict[str, tuple[str, str]] = { + # Nintendo. The twin folders (DS and Nintendo DS, 64DD and Nintendo + # 64DD) gave each name two headings and no link. + "64dd": ("Nintendo", "Nintendo 64DD"), + "nintendo-64dd": ("Nintendo", "Nintendo 64DD"), + "nintendo-ds": ("Nintendo", "Nintendo DS"), + "nintendo-dsi": ("Nintendo", "Nintendo DS"), + "famicom": ("Nintendo", "NES"), + "super-famicom": ("Nintendo", "SNES"), + # Bandai: the Color boot ROMs, and those of Benesse's WonderSwan-based + # Pocket Challenge V2, sit with the WonderSwan. + "wonderswan-color": ("Bandai", "WonderSwan"), + "bandai-wswanc": ("Bandai", "WonderSwan"), + "pocket-challenge-v2": ("Bandai", "WonderSwan"), + # SNK + "ngpc": ("SNK", "Neo Geo Pocket"), + "snk-ngpc": ("SNK", "Neo Geo Pocket"), + "neo-geo-pocket-color": ("SNK", "Neo Geo Pocket"), + "snk-neogeo-aes": ("SNK", "Neo Geo"), + "snk-neogeo-mvs": ("SNK", "Neo Geo"), + # Sega. The VMU is the Dreamcast's memory card, its images sit there. + # Sega's arcade section holds the OutRun board ROMs Cannonball reads. + "sega-genesis": ("Sega", "Mega Drive"), + "mega-cd-32x": ("Sega", "Mega CD"), + "sega-vmu": ("Sega", "Dreamcast"), + "sega-outrun": ("Sega", "Arcade"), + # NEC and Pioneer: both LaserActive packs (LD-ROM2 and Mega LD) file + # their BIOS under the LaserActive. + "nec-supergrafx": ("NEC", "PC Engine"), + "supergrafx": ("NEC", "PC Engine"), + "supergrafx-cd": ("NEC", "PC Engine CD"), + "pc-engine-ld": ("Pioneer", "LaserActive"), + "mega-ld": ("Pioneer", "LaserActive"), + # Microsoft MSX generations + "microsoft-msx2": ("Microsoft", "MSX"), + "msx2": ("Microsoft", "MSX"), + "msx2plus": ("Microsoft", "MSX"), + "msx2+": ("Microsoft", "MSX"), + "msxturboR": ("Microsoft", "MSX"), + "msxturbor": ("Microsoft", "MSX"), + # Sinclair: 128-0.rom and 128-1.rom, and Timex's tc2048.rom. + "zx-spectrum-128": ("Sinclair", "ZX Spectrum"), + "timex-tc2048": ("Sinclair", "ZX Spectrum"), + # Acorn: os12.rom and basic2.rom for the Model B and B+, and the + # Master Compact's bbcmc.zip. + "bbc-micro-b": ("Acorn", "BBC Micro"), + "bbc-micro-bplus": ("Acorn", "BBC Micro"), + "bbc-master-compact": ("Acorn", "BBC Micro"), + # Atari: falcon.img, ste.img and tt.img sit with the ST TOS images. + "atari-ste": ("Atari", "ST"), + "atari-tt": ("Atari", "ST"), + "atari-falcon": ("Atari", "ST"), + # Home computers filed under their family + "apple-iie": ("Apple", "Apple II"), + "apf-m1000": ("APF", "Imagination Machine"), + "apfm1000": ("APF", "Imagination Machine"), + "bbk-longman-4980": ("BBK", "A4980"), + "bbk-longman-4988": ("BBK", "A4988"), + "coco3": ("Tandy", "CoCo"), + "commodore-cdtv": ("Commodore", "Amiga"), + "dragon32": ("Dragon", "Dragon"), + "dragon64": ("Dragon", "Dragon"), + "enterprise-64": ("Enterprise", "64-128"), + "enterprise-128": ("Enterprise", "64-128"), + "fm77av": ("Fujitsu", "FM-7"), + "gamepock": ("Epoch", "Game Pocket"), + "ibm-pcjr": ("IBM", "PC"), + "ibm-xt": ("IBM", "PC"), + "marty": ("Fujitsu", "FM Towns"), + "mz700": ("Sharp", "MZ"), + "mz80k": ("Sharp", "MZ"), + "oricatmos": ("Oric", "Oric"), + "palm-os": ("Palm", "Palm"), + "philips-vg5k": ("Philips", "VG5000"), + "vg5k": ("Philips", "VG5000"), + "videobrain": ("VideoBrain", "Family Computer"), + # Files kept at the maker's root, so each file is a heading of its own + # on a page that is the machine's: the first one leads there. + "galaksija": ("Galaksija", "CHRGEN.BIN"), + "vircon32": ("Vircon", "Vircon32Bios.v32"), + # Games, filed with the game whose folder holds their data + "dinothawr": ("Dinothawr", "Dinothawr"), + "doom": ("Id Software", "Doom"), + "ctf": ("Id Software", "Quake II"), + "teamarena": ("Id Software", "Quake III Arena"), + "enemyterritory": ("Id Software", "Wolfenstein Enemy Territory"), + "gearbox": ("Valve", "Half-Life"), + "dmc": ("Valve", "Half-Life"), + "ricochet": ("Valve", "Half-Life"), + "tfc": ("Valve", "Half-Life"), + "mohaa": ("Electronic Arts", "Medal of Honor Allied Assault"), + "mohaa-breakthrough": ("Electronic Arts", "Medal of Honor Allied Assault"), + "mohaa-spearhead": ("Electronic Arts", "Medal of Honor Allied Assault"), + "ur-quan-masters": ("Toys for Bob", "The Ur-Quan Masters"), + "star-control-2": ("Toys for Bob", "The Ur-Quan Masters"), + # The collection's general arcade section, where naomi.zip, neogeo.zip + # and the other board sets sit. + "arcade": ("Arcade", "Arcade"), +} + + +def system_ids(profiles: dict[str, dict], platforms: list[dict]) -> set[str]: + """Every system id a page can name: the platforms' and the profiles'.""" + ids = {str(sys_id) for config in platforms for sys_id in config.get("systems", {})} + for profile in profiles.values(): + ids.update(str(sys_id) for sys_id in profile.get("systems") or []) + for entry in profile.get("files") or []: + if not isinstance(entry, dict): + continue + if entry.get("system"): + ids.add(str(entry["system"])) + ids.update(str(sys_id) for sys_id in entry.get("systems") or []) + return ids + + +def link_coverage(ids: set[str]) -> tuple[int, int]: + """How many of ``ids`` lead to a section, and how many there are.""" + return sum(1 for sys_id in ids if system_section(sys_id)), len(ids) + + +# A system id -> the names the platforms' own files give it, built once. +_search_aliases: dict[str, list[str]] = {} +_search_aliases_built = [False] +# A native name as a person types it: no space, no comma. RetroArch's +# native names are No-Intro titles ("Sony - PlayStation") and stay out. +_ALIAS_SHAPE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.+-]*") + + +def system_search_aliases(sys_id: str) -> list[str]: + """The native system ids the platform YAMLs file ``sys_id`` under + (psx, dreamcast, gba), sorted, for search. Read from each platform's + own entries by common.platform_system_names, never typed by hand.""" + if not _search_aliases_built[0]: + from common import ( + list_registered_platforms, + load_platform_config, + platform_system_names, + ) + + platforms_dir = str(_REPO_ROOT / "platforms") + index: dict[str, set[str]] = {} + for platform in list_registered_platforms(platforms_dir, include_archived=True): + config = load_platform_config(platform, platforms_dir) + names = platform_system_names(config, list(config.get("systems", {}))) + for owner, spelled in names.items(): + index.setdefault(owner, set()).update( + name for name in spelled if _ALIAS_SHAPE.fullmatch(name) + ) + # Search ignores case: one spelling per word, the lower-case one + # when a platform writes it (psx, not PSX). + _search_aliases.clear() + for owner, names in index.items(): + spellings: dict[str, str] = {} + for name in sorted(names, key=lambda n: (n != n.lower(), n)): + spellings.setdefault(name.casefold(), name) + if spellings: + _search_aliases[owner] = sorted(spellings.values(), key=str.casefold) + _search_aliases_built[0] = True + return list(_search_aliases.get(str(sys_id), [])) diff --git a/tests/test_site_r3_p0.py b/tests/test_site_r3_p0.py new file mode 100644 index 00000000..7860480c --- /dev/null +++ b/tests/test_site_r3_p0.py @@ -0,0 +1,293 @@ +"""Every system id with a section leads to it (round 3, package P0). + +260 of 636 system ids linked: a variant (WonderSwan Color, Neo Geo MVS) +never matched a heading, twin folders (DS and Nintendo DS) made the exact +name ambiguous, and every Arcade heading was refused. siterender now reads +a reviewed SYSTEM_PARENTS table after the id's own section, and accepts an +Arcade heading that reads the name exactly. An id whose files the +collection holds and that stays plain is named below, with the reason. +Reads the repository: skips when the database is absent. +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +import siterender # noqa: E402 + +# Ids whose files the collection holds and that lead to no section. +UNLINKED: dict[str, str] = { + # Files only under a program's folder (Other/, Arcade/, + # Game Engines/, a top named after the program). The heading + # names the program, not the system. + "atari-system1": "Other > fbneo", + "bbc-master-128": "Other > beebem", + "bbc-master-et": "Other > beebem", + "capcom-cps3": "Arcade > FBNeo", + "cave-story": "Other > NXEngine", + "cps3": "Arcade > Arcade holds one CPS-3 set among every board's", + "commanderx16": "Arcade > Arcade", + "dark-forces-ii": "Game Engines > OpenJKDF2", + "deco-cassette": "Arcade > FBNeo", + "dos": "DOSBox, Roland and Other folders", + "fpinball": "Other > future-pinball-fploader", + "fruitmachine": "Other > mfme", + "igs-pgm": "Arcade > FBNeo", + "konami-bubsys": "Arcade > FBNeo", + "microsoft-xbox-360": "Other > xenia-edge", + "midway-mcr": "Arcade > FBNeo", + "mysteries-of-the-sith": "Game Engines > OpenJKDF2", + "mz2000": "Arcade > Arcade, not the Sharp MZ section", + "mz2500": "Arcade > Arcade, not the Sharp MZ section", + "mz800": "Arcade > Arcade, not the Sharp MZ section", + "namco-na1": "Arcade > FBNeo", + "namco-nb1": "Arcade > FBNeo", + "nmk16": "Arcade > FBNeo", + "omicron": "Other > omicron", + "pegasus": "Arcade > MAME", + "rpgmaker": "RPG Maker > easyrpg", + "scummvm": "Other > ScummVM", + "sdlpal": "Other > sdlpal", + "sega-system16b": "Arcade > FBNeo", + "sinclair-zx-spectrum-next": "Other > cspect", + "smbx": "Game Engines > TheXTech", + "solarus": "Game Engines > Solarus", + "sunplus-spmp8000": "Other > spmp8000emu", + "super-kaneko-nova": "Arcade > FBNeo", + "taito-cchip": "Arcade > FBNeo", + "thomson-mo6": "Other > Clock Signal", + "tomb-raider-1": "Game Engines > TRX", + "vgmplay": "Arcade > Arcade and MAME", + "vm-labs-nuon": "Other > nuance", + "vpinball": "Arcade > PinMAME", + "xrick": "Other > xrick", + "zc210": "ZC > ZC210, the emulator's folder", + # Files filed with another machine: a link would name the wrong one. + "atom": "atom.zip is filed with the BBC Micro", + "commodore-c64-supercpu": "VICE's tree under the C128", + "commodore-cbm-ii": "VICE's tree under the C128", + "commodore-pet": "VICE's tree under the C128", + "emerson-arcadia-2001": "ar_bios.zip is filed with the Saturn", + "naomi2": "naomi2.zip is filed with the Dreamcast", + "neogeo64": "the platform declares the Neo Geo's neogeo.zip", + "sega-dreamcast-arcade": "naomi.zip and awbios.zip, Arcade and Dreamcast", + "spectravideo": "the SVI ROMs are filed with the MSX", + "vvvvvv": "SDL's controller database", + "ym2608": "an FBNeo sound ROM, also filed with the NES", + # Files of distinct machines or games, one section each. + "catacomb-adventure": "three games, three sections", + "sinclair-zx80-81": "ZX 80 and ZX 81 sections", +} + + +def _accepts(fe: dict, local: str | None, status: str) -> bool: + """A resolution that proves the entry's content or its full path.""" + from common import resolution_is_hash_exact + + if not local: + return False + if resolution_is_hash_exact(status) or status == "path_exact": + return True + path = fe.get("path") + return bool(path) and local.endswith("/" + str(path)) + + +@unittest.skipUnless((REPO_ROOT / "database.json").exists(), "database.json is not built") +class SystemLinks(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + previous = os.getcwd() + os.chdir(REPO_ROOT) + try: + from common import ( + list_registered_platforms, + load_emulator_profiles, + load_platform_config, + resolve_local_file, + ) + from generate_site import _copy_places, _group_by_manufacturer + + with open("database.json", encoding="utf-8") as handle: + db = json.load(handle) + cls.manufacturers = _group_by_manufacturer(db) + profiles = load_emulator_profiles("emulators", skip_aliases=False) + siterender.set_console_sections( + cls.manufacturers, siterender.program_names(profiles)) + platforms = [ + load_platform_config(name, "platforms") + for name in list_registered_platforms("platforms", include_archived=True) + ] + cls.ids = siterender.system_ids(profiles, platforms) + + entries: dict[str, list[dict]] = {} + for config in platforms: + for sys_id, system in config.get("systems", {}).items(): + entries.setdefault(sys_id, []).extend(system.get("files") or []) + for profile in profiles.values(): + systems = [str(s) for s in profile.get("systems") or []] + for fe in profile.get("files") or []: + if not isinstance(fe, dict): + continue + owners = {str(fe["system"])} if fe.get("system") else set() + owners.update(str(s) for s in fe.get("systems") or []) + if not owners and len(systems) == 1: + owners = set(systems) + for owner in owners: + entries.setdefault(owner, []).append(fe) + + places = _copy_places(db) + by_path = {entry.get("path"): sha1 for sha1, entry in db["files"].items()} + # Id -> the (manufacturer, console) sections holding its files. + cls.held: dict[str, set[tuple[str, str]]] = {} + for sys_id in cls.ids: + sections: set[tuple[str, str]] = set() + for fe in entries.get(sys_id, []): + local, status = resolve_local_file(fe, db) + if not _accepts(fe, local, status): + continue + sha1 = by_path.get(local) + if sha1 is not None: + sections.update((m, c) for m, c, _ in places.get(sha1, set())) + if sections: + cls.held[sys_id] = sections + finally: + os.chdir(previous) + + def test_every_id_with_files_links(self) -> None: + linked, total = siterender.link_coverage(self.ids) + with_files = set(self.held) + print(f"\n {linked} of {total} system ids linked, " + f"{len(with_files - set(UNLINKED))} of {len(with_files)} with files") + plain = sorted( + sys_id for sys_id in with_files - set(UNLINKED) + if siterender.system_section(sys_id) is None + ) + self.assertEqual(plain, [], "link these or name them in UNLINKED with the reason") + + def test_unlinked_list_is_current(self) -> None: + stale = sorted( + sys_id for sys_id in UNLINKED + if sys_id not in self.held or siterender.system_section(sys_id) is not None + ) + self.assertEqual(stale, []) + + def test_a_matching_heading_is_linked(self) -> None: + """An id whose name reads one heading, outside Other, leads there.""" + by_heading: dict[str, set[tuple[str, str]]] = {} + for mfr, consoles in self.manufacturers.items(): + for console in consoles: + for name in {siterender._heading_name(mfr, console), console}: + by_heading.setdefault(name.casefold(), set()).add((mfr, console)) + missed = [] + for sys_id in sorted(self.ids): + places = by_heading.get(siterender.system_name(sys_id).casefold(), set()) + if len(places) != 1: + continue + mfr, console = next(iter(places)) + if mfr == "Other" or ( + mfr == "Arcade" and console.casefold() in siterender._program_names + ): + continue + if siterender.system_section(sys_id) is None: + missed.append(sys_id) + self.assertEqual(missed, []) + + def test_parents_are_sections(self) -> None: + sections = { + (mfr, console) + for mfr, consoles in self.manufacturers.items() for console in consoles + } + self.assertEqual( + sorted(k for k, v in siterender.SYSTEM_PARENTS.items() if v not in sections), []) + + def test_parents_hold_the_files(self) -> None: + """A parent holds a file of the id, or of an id of the same name. + An entry its own section shadows is never read and is not judged.""" + by_name: dict[str, set[tuple[str, str]]] = {} + for sys_id, sections in self.held.items(): + by_name.setdefault(siterender.system_name(sys_id), set()).update(sections) + wrong = [] + for sys_id, parent in sorted(siterender.SYSTEM_PARENTS.items()): + if siterender._own_section(sys_id) is not None: + continue + held = by_name.get(siterender.system_name(sys_id)) + if held is not None and parent not in held: + wrong.append((sys_id, parent)) + self.assertEqual(wrong, []) + + def test_variants_lead_to_their_family(self) -> None: + expect = { + "wonderswan-color": ("bandai", "WonderSwan"), + "bandai-wswanc": ("bandai", "WonderSwan"), + "ngpc": ("snk", "Neo Geo Pocket"), + "snk-neogeo-mvs": ("snk", "Neo Geo"), + "snk-neogeo-aes": ("snk", "Neo Geo"), + "nintendo-ds": ("nintendo", "Nintendo DS"), + "nintendo-64dd": ("nintendo", "Nintendo 64DD"), + "zx-spectrum-128": ("sinclair", "ZX Spectrum"), + "pc-engine-ld": ("pioneer", "LaserActive"), + } + for sys_id, section in expect.items(): + with self.subTest(sys_id=sys_id): + self.assertEqual(siterender.system_section(sys_id), section) + + def test_arcade_heading_by_exact_name(self) -> None: + self.assertEqual( + siterender.system_section("namco-system246"), ("arcade", "Namco System 246")) + # No heading reads NAOMI: its sets are cards of the general section. + self.assertIsNone(siterender.system_section("sega-naomi")) + # Never a folder of copies a core loads. + self.assertIsNone(siterender.system_section("igs-pgm")) + + def test_folder_votes_stay_out(self) -> None: + for sys_id in ("emerson-arcadia-2001", "naomi2", "commodore-pet"): + with self.subTest(sys_id=sys_id): + self.assertIsNone(siterender.system_section(sys_id)) + self.assertNotIn(sys_id, siterender.SYSTEM_PARENTS) + + def test_emulator_rows_link_the_variant(self) -> None: + from generate_site import _named_system_link + + self.assertEqual( + _named_system_link("wonderswan-color", "../"), + "[Bandai WonderSwan Color](../systems/bandai.md#wonderswan)") + + +class SearchAliases(unittest.TestCase): + + def test_platform_names(self) -> None: + self.assertIn("psx", siterender.system_search_aliases("sony-playstation")) + self.assertIn("dc", siterender.system_search_aliases("sega-dreamcast")) + self.assertIn("gba", siterender.system_search_aliases("nintendo-gba")) + + def test_one_typed_word_per_name(self) -> None: + siterender.system_search_aliases("sony-playstation") + for sys_id, names in siterender._search_aliases.items(): + with self.subTest(sys_id=sys_id): + self.assertNotIn(sys_id, names) + self.assertEqual(len({n.casefold() for n in names}), len(names)) + for name in names: + self.assertNotRegex(name, r"[\s,]") + + def test_unknown_id(self) -> None: + self.assertEqual(siterender.system_search_aliases("no-such-system"), []) + + +class BuildCount(unittest.TestCase): + + def test_generator_prints_the_count(self) -> None: + source = (REPO_ROOT / "scripts" / "generate_site.py").read_text(encoding="utf-8") + self.assertIn("link_coverage(", source) + self.assertIn("system ids linked", source) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index f9163a67..e4deae8a 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_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_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture