diff --git a/tests/test_site_r5_emulators.py b/tests/test_site_r5_emulators.py
new file mode 100644
index 00000000..66bdb3bc
--- /dev/null
+++ b/tests/test_site_r5_emulators.py
@@ -0,0 +1,268 @@
+"""Emulator pages (round 5, package 7).
+
+61 profiles cite the folder their app reads its data from (data_root) and
+no page printed it: AetherSX2's step said "inside the app's own data
+folder", ten pages named the field itself, and every install block offered
+Linux, Windows and Android commands, for MAME4droid an Android-only app.
+215 pages printed `--emulator X` for a name no published manifest takes.
+"""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+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 / "scripts"))
+
+import generate_site as gs # noqa: E402
+
+CONTENT = REPO_ROOT / "website" / "src" / "content" / "docs"
+EMULATORS = CONTENT / "emulators"
+
+
+def _profile(name: str) -> dict:
+ return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
+
+
+def _profiles_with_data_root() -> dict[str, dict]:
+ found = {}
+ for path in sorted((REPO_ROOT / "emulators").glob("*.yml")):
+ profile = yaml.safe_load(path.read_text(encoding="utf-8"))
+ if isinstance(profile, dict) and isinstance(profile.get("data_root"), dict):
+ found[path.stem] = profile
+ return found
+
+
+def _page(name: str) -> str:
+ for suffix in (".mdx", ".md"):
+ path = EMULATORS / f"{name}{suffix}"
+ if path.exists():
+ return path.read_text(encoding="utf-8")
+ return ""
+
+
+def _generated() -> bool:
+ return EMULATORS.is_dir() and any(EMULATORS.iterdir())
+
+
+def _install_section(page: str) -> str:
+ match = re.search(r"Install this emulator's files(.*?)",
+ page, re.S)
+ return match[1] if match else ""
+
+
+def _tab_labels(text: str) -> set[str]:
+ return set(re.findall(r'', text))
+
+
+def _entries(profile: dict) -> list[dict]:
+ return [f for f in profile.get("files") or [] if isinstance(f, dict)]
+
+
+def _alternatives(root: str) -> list[str]:
+ return [part.strip().rstrip("/") for part in root.split(", else ")]
+
+
+class FolderOnlyTable(unittest.TestCase):
+ """The entries whose joined path is not the app's folder: one table in
+ the generator, each row with its reason, and it cannot rot."""
+
+ def test_every_row_names_a_profile_and_entries_that_exist(self) -> None:
+ roots = _profiles_with_data_root()
+ for profile_name, patterns, keys, reason in gs.FOLDER_ONLY:
+ with self.subTest(profile=profile_name, patterns=patterns):
+ self.assertIn(profile_name, roots, "no profile with data_root by that name")
+ self.assertTrue(reason.strip())
+ entries = _entries(roots[profile_name])
+ for pattern in patterns:
+ self.assertTrue(
+ any(gs.folder_only_matches(pattern, f) for f in entries),
+ f"{pattern!r} names no entry of {profile_name}")
+ self.assertLessEqual(set(keys), set(roots[profile_name]["data_root"]))
+
+ def test_a_listed_entry_is_never_joined_to_the_root(self) -> None:
+ roots = _profiles_with_data_root()
+ for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
+ profile = roots[profile_name]
+ for entry in _entries(profile):
+ if not any(gs.folder_only_matches(p, entry) for p in patterns):
+ continue
+ raw = {str(entry.get(k) or "") for k in ("path", "standalone_path")} - {""}
+ for key in keys or tuple(profile["data_root"]):
+ with self.subTest(profile=profile_name, entry=entry.get("name"), key=key):
+ self.assertEqual(gs.standalone_place(profile_name, profile, entry, key), "")
+ self.assertEqual(gs.standalone_place(profile_name, profile, entry), "")
+ block = "\n".join(gs.data_folder_block(profile_name, profile))
+ for root in _alternatives(str(profile["data_root"][key]["path"])):
+ for place in raw:
+ self.assertNotIn(f"{root}/{place}", block)
+
+ def test_built_pages_print_no_joined_path_for_a_listed_entry(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ roots = _profiles_with_data_root()
+ for profile_name, patterns, keys, _reason in gs.FOLDER_ONLY:
+ profile = roots[profile_name]
+ pages = [p.read_text(encoding="utf-8")
+ for p in EMULATORS.glob(f"{profile_name}*.md*")]
+ for entry in _entries(profile):
+ if not any(gs.folder_only_matches(p, entry) for p in patterns):
+ continue
+ for key in keys or tuple(profile["data_root"]):
+ for root in _alternatives(str(profile["data_root"][key]["path"])):
+ for place in {entry.get("path"), entry.get("standalone_path")} - {None, ""}:
+ joined = f"{root}/{place}"
+ for text in pages:
+ self.assertNotIn(joined, text, profile_name)
+
+
+class DataFolderPrinted(unittest.TestCase):
+ def test_every_profile_prints_each_of_its_folders(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ for name, profile in _profiles_with_data_root().items():
+ if profile.get("type") in ("alias", "test"):
+ continue
+ page = _page(name)
+ with self.subTest(profile=name):
+ self.assertIn('id="data-folder"', page)
+ for root in profile["data_root"].values():
+ for alternative in _alternatives(str(root["path"])):
+ self.assertIn(alternative, page)
+
+ def test_ryujinx_keys_sit_in_system(self) -> None:
+ block = "\n".join(gs.data_folder_block("ryujinx", _profile("ryujinx")))
+ self.assertIn("`%APPDATA%/Ryujinx/system/`", block)
+ self.assertIn("`~/Library/Application Support/Ryujinx/system/`", block)
+ self.assertEqual(_tab_labels(block), {"Windows", "macOS", "Linux"})
+
+ def test_aethersx2_step_names_the_android_folder(self) -> None:
+ profile = _profile("aethersx2")
+ steps = gs._any_name_steps("AetherSX2", profile, gs.any_name_bios(profile), "", "aethersx2")
+ self.assertIn("2. Put it in this folder:", steps)
+ self.assertIn("`Android/data/xyz.aethersx2.android/files/bios/`, on the internal storage.",
+ steps)
+ self.assertNotIn("app's own data folder", steps)
+
+ def test_one_system_is_a_line_not_a_tab(self) -> None:
+ block = "\n".join(gs.data_folder_block("mame4droid", _profile("mame4droid")))
+ self.assertTrue(block.startswith("On Android: `Android/data/com.seleuco.mame4droid/files/`"))
+ self.assertNotIn(" None:
+ profile = {"type": "standalone", "data_root": {"linux": {"path": "~/x", "source_ref": "a"}}}
+ self.assertEqual(gs.standalone_place("x", profile, {"name": "a.bin"}), "")
+ self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "a", "bundled": True}),
+ "")
+ self.assertEqual(gs.standalone_place("x", profile, {"name": "a", "path": "b/a"}), "b/a")
+ dual = {**profile, "type": "standalone + libretro"}
+ self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "core/a"}), "")
+ self.assertEqual(gs.standalone_place("x", dual, {"name": "a", "path": "c/a",
+ "standalone_path": "a"}), "a")
+
+ def test_the_metadata_card_has_a_row_per_system(self) -> None:
+ rows = gs._render_metadata_rows(_profile("cxbx-reloaded"))
+ self.assertIn("| Data folder (Windows) | `%APPDATA%/Cxbx-Reloaded/` |", rows)
+ self.assertFalse(any("Data folder (Linux)" in row for row in rows))
+
+ def test_no_page_names_the_field(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ named = sorted(str(p.relative_to(CONTENT)) for p in CONTENT.rglob("*.md*")
+ if "data_root" in p.read_text(encoding="utf-8")
+ and not p.relative_to(CONTENT).as_posix().startswith("wiki/profiling"))
+ self.assertEqual(named, [])
+
+
+class InstallBlockWhereItWorks(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls) -> None:
+ spec = importlib.util.spec_from_file_location("install", REPO_ROOT / "install.py")
+ cls.install = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(cls.install)
+ cls.accepted: dict[str, set[str]] = {}
+ for path in sorted((REPO_ROOT / "install").glob("*.json")):
+ manifest = json.loads(path.read_text(encoding="utf-8"))
+ names = cls.install.emulator_names(manifest, manifest.get("files") or [],
+ manifest.get("omitted_files") or [])
+ for alias, profile in names.items():
+ cls.accepted.setdefault(alias, set()).add(profile)
+
+ def test_every_printed_command_names_this_profile_in_a_manifest(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ for path in sorted(EMULATORS.glob("*.md*")):
+ text = path.read_text(encoding="utf-8")
+ for name in set(re.findall(r"--emulator ([\w.+-]+)", text)):
+ with self.subTest(page=path.name, name=name):
+ self.assertIn(path.stem, self.accepted.get(name, set()))
+
+ def test_no_os_tab_outside_the_data_root(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ labels = {key: gs.TERMINALS[key][0] for key in gs.TERMINALS}
+ for name, profile in _profiles_with_data_root().items():
+ allowed = {labels["device"]}
+ for key in profile["data_root"]:
+ allowed |= {labels[t] for t in gs._DATA_ROOT_TERMINALS[key]}
+ with self.subTest(profile=name):
+ self.assertLessEqual(_tab_labels(_install_section(_page(name))), allowed)
+
+ def test_android_only_app_gets_no_desktop_command(self) -> None:
+ self.assertEqual(gs._install_block("mame4droid", _profile("mame4droid")), [])
+ command, setups, oses = gs.emulator_install_route("cxbx-reloaded", _profile("cxbx-reloaded"))
+ self.assertEqual(oses, ("windows",))
+
+ def test_a_lone_app_is_named_with_the_setup_that_launches_it(self) -> None:
+ text = "\n".join(gs._install_block("ryujinx", _profile("ryujinx")))
+ self.assertRegex(text, r"With [^:]*EmuDeck[^:]*: the installer finds")
+ self.assertNotIn("Android (Termux)", text)
+ self.assertNotIn("RetroArch", text)
+
+ def test_a_dual_app_says_what_the_command_fills(self) -> None:
+ text = "\n".join(gs._install_block("duckstation", _profile("duckstation")))
+ self.assertIn("For DuckStation inside RetroArch, Batocera or another setup.", text)
+
+ def test_no_files_no_command(self) -> None:
+ self.assertEqual(gs._install_block("beetle_wswan", _profile("beetle_wswan")), [])
+
+ def test_tabs_follow_the_keys_given(self) -> None:
+ tabs = "\n".join(gs.install_commands("--emulator x", oses=("linux", "steamdeck")))
+ self.assertEqual(_tab_labels(tabs), {"Linux", "Steam Deck"})
+ self.assertIn('syncKey="os"', tabs)
+ blocks = "\n".join(gs.install_commands("--emulator x"))
+ self.assertIn("Windows (PowerShell):", blocks)
+
+
+class SmallFixes(unittest.TestCase):
+ def test_structured_blocks_closed_below_the_files(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ page = _page("cemu")
+ self.assertLess(page.index("## Files"), page.index("Firmware titles"))
+ self.assertNotRegex(page, r"]*open>\s*Firmware titles")
+
+ def test_region_choice_summary(self) -> None:
+ lines = gs._emulator_file_summary(_profile("duckstation")["files"])
+ self.assertIn("One of 3 is required, for your games' region.", lines[0])
+ self.assertNotIn("3 required", lines[0])
+
+ def test_no_emulator_part_says_where_it_sits(self) -> None:
+ if not _generated():
+ self.skipTest("site pages not generated")
+ parts = sorted(EMULATORS.glob("*-files-*.md*"))
+ if not parts:
+ self.skipTest("no split emulator page")
+ said = [p.name for p in parts if "\nPart of [" in p.read_text(encoding="utf-8")]
+ self.assertEqual(said, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 03e47443..c4a6a26d 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -57,7 +57,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_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
-| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
+| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture