diff --git a/tests/test_site_r3_wiki.py b/tests/test_site_r3_wiki.py
new file mode 100644
index 00000000..78970435
--- /dev/null
+++ b/tests/test_site_r3_wiki.py
@@ -0,0 +1,302 @@
+"""Wiki copy (round 3, package 5).
+
+Each class names the finding it holds: a legal page that opened on case law,
+no arcade answer where players look, a Dreamcast "yes" the Dreamcast
+emulator contradicts, an offline install no one could find, setups with no
+page of their own, link labels that named another page, an exit status that
+could not tell missing files from a failure, and FAQ answers the community
+asks for (region and language, keys and firmware). Every claim the copy
+makes about an emulator or the installer is held against the repository.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import unittest
+from pathlib import Path
+
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT))
+
+from tests import sitepages # noqa: E402
+
+WIKI = REPO_ROOT / "wiki"
+FENCED = re.compile(r"^```.*?^```", re.S | re.M)
+
+
+def _wiki(name: str) -> str:
+ return (WIKI / name).read_text(encoding="utf-8")
+
+
+def _section(text: str, heading: str) -> str:
+ start = re.search(rf"(?m)^#+ {re.escape(heading)}(?:\s*\{{[^}}]*\}})?\n", text)
+ if start is None:
+ raise AssertionError(f"no section {heading!r}")
+ rest = text[start.end():]
+ end = re.search(r"(?m)^#{1,2} ", FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest))
+ return rest[: end.start()] if end else rest
+
+
+def _flat(text: str) -> str:
+ return " ".join(text.split())
+
+
+def _headings(text: str) -> list[str]:
+ return re.findall(r"(?m)^#{2,3} (.+?)\s*$", FENCED.sub("", text))
+
+
+def _profile(name: str) -> dict:
+ return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
+
+
+def _entry(profile: dict, name: str) -> dict:
+ for entry in profile.get("files", []):
+ if entry.get("name") == name:
+ return entry
+ raise AssertionError(f"{name} not in the profile")
+
+
+class LegalOpensInPlainWords(unittest.TestCase):
+ """C5: Is it legal? is a Start here page and opened on case law."""
+
+ def setUp(self):
+ self.text = _wiki("legal.md")
+ self.opening = self.text.split("## The reasoning in detail", 1)[0]
+
+ def test_the_answer_comes_before_the_reasoning(self):
+ self.assertIn("## The reasoning in detail", self.text)
+ self.assertIn("It depends on your country. Nothing here is legal advice.", self.opening)
+ for case in ("Connectix", "Accolade", "DMCA", "CFR", "Chamberlain", "Yuzu"):
+ with self.subTest(case=case):
+ self.assertNotIn(case, self.opening)
+ self.assertIn(case, self.text)
+
+ def test_the_plain_lines_hold_against_the_detail(self):
+ opening = _flat(self.opening)
+ self.assertIn("encryption keys (Switch, 3DS, Wii U)", opening)
+ self.assertIn("weakest part of the project's position", self.text)
+ self.assertIn("issues/new", opening)
+ self.assertIn("path or hash", opening)
+ self.assertIn("identifying the file by path or hash", _flat(self.text))
+
+ def test_the_detail_sits_under_the_reasoning_heading(self):
+ detail = self.text.split("## The reasoning in detail", 1)[1]
+ detail = detail.split("## Asking for a file to be removed", 1)[0]
+ self.assertEqual(re.findall(r"(?m)^## ", detail), [])
+ self.assertIn("### Emulation and BIOS redistribution", detail)
+
+ def test_a_contact_section_names_only_a_channel_that_exists(self):
+ section = _section(self.text, "Contact")
+ self.assertIn("https://github.com/Abdess/retrobios/issues", section)
+ self.assertNotIn("@", section)
+
+
+class ArcadeAnswerWherePlayersLook(unittest.TestCase):
+ """C3, N3: the romset answer sat in Reference only."""
+
+ def test_troubleshooting_answers_after_step_three(self):
+ text = _wiki("troubleshooting.md")
+ headings = _headings(text)
+ self.assertIn("An arcade game does not start", headings)
+ self.assertGreater(headings.index("An arcade game does not start"),
+ headings.index("3. Check the folder and the names"))
+ section = _flat(_section(text, "An arcade game does not start"))
+ for words in ("neogeo.zip", "same emulator version", "MAME version", "Details:",
+ "../emulators/index.md", "Run the command once."):
+ with self.subTest(words=words):
+ self.assertIn(words, section)
+
+ def test_the_faq_links_the_technical_answer(self):
+ section = _section(_wiki("faq.md"), "My arcade game does not start")
+ anchor = "technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
+ self.assertIn(anchor, section)
+ self.assertIn("## Which MAME version do the arcade BIOS sets match?", _wiki("technical-faq.md"))
+ self.assertLessEqual(len(re.findall(r"[.!?](?:\s|$)", section)), 2)
+
+ def test_emulator_pages_name_the_version_the_copy_promises(self):
+ for name in ("mame2003", "mame2000", "fbneo"):
+ with self.subTest(profile=name):
+ profile = _profile(name)
+ self.assertTrue(profile.get("core_version") or profile.get("version"))
+
+ def test_the_check_step_holds_the_searched_words(self):
+ self.assertIn("## 1. Check my BIOS folder { #checking-what-you-installed }",
+ _wiki("troubleshooting.md"))
+
+
+class BiosFineWhatNext(unittest.TestCase):
+ """Community need 11: the page assumed the BIOS was the cause."""
+
+ def setUp(self):
+ self.section = _flat(_section(_wiki("troubleshooting.md"),
+ "The BIOS is fine, the game still fails"))
+
+ def test_the_three_causes(self):
+ for words in ("Another emulator?", "A BIOS setting?", ".cue", ".m3u"):
+ with self.subTest(words=words):
+ self.assertIn(words, self.section)
+
+ def test_the_named_setting_is_the_core_option(self):
+ # libretro/pcsx_rearmed frontend/libretro_core_options.h: key
+ # pcsx_rearmed_bios, label "BIOS Selection", values Auto and HLE.
+ self.assertIn("**BIOS Selection** to **Auto**", self.section)
+ notes = _profile("pcsx_rearmed").get("notes", "")
+ self.assertIn('pcsx_rearmed_bios = "HLE"', notes)
+
+
+class DreamcastIsBetterWithIt(unittest.TestCase):
+ """C7: "Yes for Dreamcast" while Flycast runs without dc_boot.bin."""
+
+ def test_flycast_falls_back(self):
+ entry = _entry(_profile("flycast"), "dc_boot.bin")
+ self.assertFalse(entry.get("required"))
+ self.assertTrue(entry.get("hle_fallback"))
+
+ def test_no_page_says_yes_for_dreamcast(self):
+ for name in ("faq.md", "what-are-bios-files.md"):
+ text = _wiki(name)
+ with self.subTest(page=name):
+ yes = next(line for line in text.splitlines() if line.startswith("- **Yes"))
+ self.assertNotIn("Dreamcast", yes)
+ self.assertRegex(_flat(text), r"\*\*Better with it:?\*\*:? (for )?Dreamcast")
+
+
+class OfflineInstallIsFindable(unittest.TestCase):
+ """N7: no page linked the offline install, and search did not find it."""
+
+ ANCHOR = "without-a-network-offline-install"
+
+ def test_the_installer_heading_says_offline(self):
+ text = _wiki("installer.md")
+ self.assertIn(f"## Without a network (offline install) {{ #{self.ANCHOR} }}", text)
+ # Links written before the rename still land.
+ self.assertIn('', text)
+ self.assertNotIn("](#without-a-network)", text)
+
+ def test_the_faq_answers_next_to_the_sd_card(self):
+ text = _wiki("faq.md")
+ headings = _headings(text)
+ self.assertEqual(headings.index("No internet on the device?"),
+ headings.index("Fill an SD card from a computer?") + 1)
+ section = _section(text, "No internet on the device?")
+ self.assertIn("--from-pack", section)
+ self.assertIn(f"installer.md#{self.ANCHOR}", section)
+
+ def test_from_pack_is_an_installer_option(self):
+ self.assertIn('"--from-pack"', (REPO_ROOT / "install.py").read_text(encoding="utf-8"))
+
+
+class SetupsWithoutAPage(unittest.TestCase):
+ """N9, J13: ES-DE, LaunchBox and iOS found nothing."""
+
+ def test_the_answer(self):
+ section = _flat(_section(_wiki("faq.md"), "My setup is not in the list"))
+ for words in ("--dest", "ES-DE", "LaunchBox", "iPhone"):
+ with self.subTest(words=words):
+ self.assertIn(words, section)
+ # No folder is written: the copy names none it cannot cite.
+ self.assertNotRegex(section, r"[~/\\][A-Za-z]")
+
+ def test_the_installer_fills_the_emulators_a_launcher_starts(self):
+ installer = _flat(_wiki("installer.md"))
+ self.assertIn("they reference emulators rather than owning a BIOS directory", installer)
+ self.assertIn("the installer fills their own BIOS folders", installer)
+
+
+class LinkLabelsMatchPages(unittest.TestCase):
+ """N5: "Your setup's page" landed on a page titled Get your files."""
+
+ def test_links_to_get_your_files_carry_its_name(self):
+ for name in ("first-install.md", "troubleshooting.md", "faq.md"):
+ with self.subTest(page=name):
+ for label in re.findall(r"\[([^\]]+)\]\(\.\./which-pack\.md\)", _wiki(name)):
+ self.assertEqual(label, "Get your files")
+
+
+class ExitStatusAndPinnedInstaller(unittest.TestCase):
+ """D10, J12: --check exits 1 for missing files and for failures, and a
+ packager had no fixed installer to point at."""
+
+ def setUp(self):
+ self.text = _wiki("installer.md")
+ self.source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
+
+ def test_the_check_event_tells_missing_from_failure(self):
+ section = _flat(_section(self.text, "Exit status"))
+ self.assertIn("read the `check` event", section)
+ self.assertIn("`missing` and `wrong_hash` count what is left", section)
+ fields = re.search(r'"check": \(([^)]*)\)', self.source).group(1)
+ for field in ("missing", "wrong_hash"):
+ with self.subTest(field=field):
+ self.assertIn(f'"{field}"', fields)
+
+ def test_a_packager_pins_installer_and_lists_to_one_tag(self):
+ section = _section(self.text, "One line")
+ self.assertIn("/retrobios//install.py", section)
+ self.assertIn("RETROBIOS_REF=", section)
+ self.assertIn('os.environ.get("RETROBIOS_REF"', self.source)
+
+
+class SmallCopyFixes(unittest.TestCase):
+ """C P3: the SD card example and the end line of a run."""
+
+ def test_the_sd_card_example_names_its_setup(self):
+ section = _flat(_section(_wiki("faq.md"), "Fill an SD card from a computer?"))
+ self.assertIn("For RetroArch on a Switch card in drive `E:`", section)
+ guides = (REPO_ROOT / "platforms" / "_guides.yml").read_text(encoding="utf-8")
+ self.assertIn("where: Nintendo Switch", guides)
+ self.assertIn("`retroarch/cores/system/` on the SD card", guides)
+ self.assertIn("--dest E:\\retroarch\\cores\\system", section)
+
+ def test_the_end_line_is_the_printed_one(self):
+ section = _section(_wiki("first-install.md"), "5. Read the last line")
+ self.assertIn("`N files installed, M already up to date.`", section)
+ source = (REPO_ROOT / "install.py").read_text(encoding="utf-8")
+ self.assertIn("installed, \"\n f\"{_plural(up_to_date, 'file')} already up to date.", source)
+
+
+class CommunityAnswers(unittest.TestCase):
+ """Community needs 6, 10 and 15: region and language, keys versus
+ firmware versus BIOS, zip or unzip."""
+
+ def setUp(self):
+ self.faq = _wiki("faq.md")
+
+ def test_language_comes_from_the_game(self):
+ section = _flat(_section(self.faq, "Does the BIOS set the game's language?"))
+ self.assertIn("comes from the game", section)
+ self.assertIn("Flycast's **Language** option", section)
+
+ def test_keys_firmware_bios(self):
+ section = _flat(_section(self.faq, "Keys, firmware or BIOS?"))
+ for name, profile in (("scph5501.bin", "duckstation"), ("PS3UPDAT.PUP", "rpcs3"),
+ ("prod.keys", "eden")):
+ with self.subTest(name=name):
+ self.assertIn(f"`{name}`", section)
+ _entry(_profile(profile), name)
+ self.assertIn("Firmware (system NCAs) must be installed through Eden's UI",
+ _profile("eden")["notes"])
+
+ def test_zip_or_unzip_stays(self):
+ self.assertIn("neogeo.zip", _section(self.faq, "Zip or unzip?"))
+
+
+class StartPagesStayInBudget(unittest.TestCase):
+ """The built pages: the new answers fit the level 0 ceilings."""
+
+ def test_ceilings(self):
+ from tests import test_site_copy
+
+ sitepages.require()
+ for name in ("wiki/faq.md", "wiki/troubleshooting.md", "wiki/first-install.md",
+ "wiki/what-are-bios-files.md"):
+ with self.subTest(page=name):
+ self.assertLessEqual(test_site_copy._load(name).words(),
+ test_site_copy.START_PAGES[name])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 2e67ef63..ac364de8 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_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` |
## Test architecture