', text)
+
+ def test_status_words(self):
+ cases = {
+ ("missing", ""): ("Missing", "danger"),
+ ("untested", "declared hash contradicted by the local file"): ("Wrong hash", "warning"),
+ ("untested", "expected 1234... got 5678..."): ("Wrong hash", "warning"),
+ ("untested", "bios/x read error"): ("Untested", "warning"),
+ }
+ for (status, reason), (word, tone) in cases.items():
+ row = self._row({"name": "x", "status": status, "reason": reason}, {"md5": "c" * 32})
+ self.assertIn(f'
{word} ', row)
+ if reason:
+ self.assertIn(f"Not confirmed: {reason}", row)
+
+ def test_accepted_values_all_in_the_details(self):
+ values = ["1" * 32, "2" * 32, "3" * 32]
+ row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)})
+ self.assertIn(f'data-copy="{values[0]}"', row)
+ self.assertIn(f">{values[0][:8]} +2", row)
+ for value in values:
+ self.assertIn(f'
{value}', row)
+
+ def test_markdown_never_reads_the_text(self):
+ row = self._row({"name": "*a_b*[1].bin", "status": "ok"}, {})
+ self.assertIn("*a_b*[1].bin", row)
+
+ def test_a_long_list_gets_a_filter(self):
+ rows = ["
"] * generate_site.FILTER_ROWS
+ block = generate_site._platform_file_list(rows, False, "arcade")
+ self.assertTrue(block[0].startswith('
'))
+ self.assertNotIn("rb-table-filter", "".join(
+ generate_site._platform_file_list(rows[1:], False, "arcade")))
+
+
+class ReferencesNameTheirRoleOnce(unittest.TestCase):
+ PROFILE = {"type": "libretro", "source": "https://example.org/port",
+ "upstream": "https://example.org/emu"}
+ REFS = "source libretro/libretro.c:1, source src/a.c:2, source src/b.c:3, upstream src/c.c:4"
+
+ def test_grouped_by_role(self):
+ text = generate_site._refs_by_role(self.PROFILE, self.REFS)
+ self.assertEqual(text, "glue `libretro/libretro.c:1` and port `src/a.c:2`, `src/b.c:3` "
+ "and upstream `src/c.c:4`")
+ for word in generate_site.ROLE_WORDS.values():
+ self.assertIn(f"**{word}**", generate_site.ROLE_LEGEND)
+
+ def test_other_pages_keep_the_role_after_each_reference(self):
+ text = generate_site._source_ref_markdown(self.PROFILE, self.REFS)
+ self.assertEqual(text.count("(emulator code, libretro port)"), 2)
+ self.assertIn("`src/c.c:4` (upstream emulator)", text)
+
+
+def _unescaped(text: str) -> str:
+ return html.unescape(text)
+
+
+@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json absent")
+class NothingIsLost(unittest.TestCase):
+ """Every fact a file row carried is on the page or its parts: each
+ declared hash whole, the served copy, every reader's references."""
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ from common import load_database
+ from generate_readme import compute_coverage
+
+ db = load_database(str(REPO_ROOT / "database.json"))
+ cls.pages = {}
+ for name in ("batocera", "retroarch"):
+ cov = compute_coverage(name, str(REPO_ROOT / "platforms"), db)
+ moved: dict[str, str] = {}
+ page = generate_site.generate_platform_page(name, cov, {}, {}, moved)
+ cls.pages[name] = (cov, page, moved)
+
+ def test_every_fact_is_there(self):
+ for name, (cov, page, moved) in self.pages.items():
+ text = _unescaped("\n".join([page, *moved.values()]))
+ systems = {d.get("system") for d in cov["details"]}
+ for sys_id, system in cov["config"].get("systems", {}).items():
+ if sys_id not in systems:
+ continue
+ for entry in system.get("files", []):
+ for algorithm in ("sha1", "md5", "crc32", "sha256"):
+ for value in re.split(r"[,\s]+", str(entry.get(algorithm) or "")):
+ if value:
+ self.assertIn(value, text, (name, entry.get("name"), algorithm))
+ for detail in cov["details"]:
+ self.assertIn(detail["name"], text)
+ if detail.get("path") and detail.get("status") != "missing":
+ self.assertIn(detail["path"], text, (name, detail["name"]))
+ readers = (detail.get("category_evidence") or {}).get("read_by", [])
+ # Cores of other systems that share the name are named, not
+ # cited, when a core of this system reads the file.
+ running = [reader for reader in readers if reader.get("runs_system")]
+ for reader in running or readers:
+ for url in re.findall(r"\]\((https://[^)]+)\)", generate_site._category_evidence_md(
+ {"read_by": [reader]})):
+ self.assertIn(url, text, (name, detail["name"], reader["emulator"]))
+
+ def test_the_role_is_a_word_defined_once(self):
+ for name, (_cov, page, moved) in self.pages.items():
+ for text in (page, *moved.values()):
+ self.assertNotIn("(emulator code, libretro port)", text, name)
+ self.assertEqual(text.count(generate_site.ROLE_LEGEND), 1, name)
+
+
+# Visible words (outside closed details) measured on the built site,
+# 2026-10-27. A ceiling is the measured value plus 5 %, never above the
+# value before the rows were tables (Batocera 2,433, AmberELEC 1,301, RomM
+# 1,607, RetroArch 2,207, the largest main page 2,459, the largest part
+# 19,127: RetroDECK's arcade list).
+PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192}
+MAIN_PAGE_CEILING = 2459
+PART_PAGE_CEILING = 2219
+
+_SITE = REPO_ROOT / "website" / "dist" / "platforms"
+
+
+@unittest.skipUnless(_SITE.is_dir(), "website/dist not built")
+class VisibleWordsStayUnderTheirCeiling(unittest.TestCase):
+ def test_platform_pages(self):
+ from common import list_registered_platforms
+
+ mains = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
+ for page in sorted(_SITE.glob("*/index.html")):
+ name = page.parent.name
+ text = page.read_text(encoding="utf-8")
+ if 'http-equiv="refresh"' in text[:2000]:
+ continue
+ visible, _total = page_words(text)
+ ceiling = PAGE_CEILINGS.get(name, MAIN_PAGE_CEILING if name in mains else PART_PAGE_CEILING)
+ self.assertLessEqual(visible, ceiling, name)
+
+
+# Each platform with its part pages, before the rows were rewritten: pages,
+# elements (outside scripts and styles, as validate_site counts them) and
+# bytes of HTML, on the built site of 2026-10-27.
+BEFORE_WEIGHT = {
+ "batocera": (12, 18_854, 1_211_117),
+ "amberelec": (1, 7_928, 473_388),
+ "romm": (2, 9_561, 572_487),
+ "retroarch": (14, 21_503, 1_430_802),
+}
+
+
+@unittest.skipUnless(_SITE.is_dir(), "website/dist not built")
+class APlatformWeighsNoMoreThanBefore(unittest.TestCase):
+ """The split budget measured Markdown length: rows written as a table
+ were longer Markdown, 38 systems moved to new part pages and Batocera
+ with its parts rose from 18,854 to 21,875 elements."""
+
+ def test_pages_elements_and_bytes(self):
+ from validate_site import _PageParser
+
+ for name, (pages, elements, size) in BEFORE_WEIGHT.items():
+ paths, weight = [], 0
+ for path in [_SITE / name / "index.html", *sorted(_SITE.glob(f"{name}-*/index.html"))]:
+ parser = _PageParser(path)
+ parser.feed(path.read_text(encoding="utf-8"))
+ # A part folded back into its platform page left a redirect.
+ if not parser.page.redirect:
+ paths.append(path)
+ weight += parser.page.elements
+ self.assertLessEqual(len(paths), pages, name)
+ self.assertLessEqual(weight, elements, name)
+ self.assertLessEqual(sum(path.stat().st_size for path in paths), size, name)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_site_r2_reference.py b/tests/test_site_r2_reference.py
index 44f3cea91..cd6f9aaee 100644
--- a/tests/test_site_r2_reference.py
+++ b/tests/test_site_r2_reference.py
@@ -158,16 +158,13 @@ class EachEntryNamesItsFile(unittest.TestCase):
# Existence mode reports no path: nothing is claimed, held or not.
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
- def test_a_long_system_list_is_cut_between_files(self):
- body = ["2/2 files verified", ""]
- for n in range(4):
- body += [f"- `f{n}.bin` - OK", " " + "x" * 100]
- runs = generate_site._file_runs(body, 250)
- self.assertEqual(len(runs), 2)
- for run in runs:
- self.assertEqual(run[:2], body[:2])
- self.assertTrue(run[2].startswith("- "))
- self.assertEqual(sum(len(run) - 2 for run in runs), len(body) - 2)
+ def test_a_long_system_list_is_cut_between_rows_by_elements(self):
+ rows = [[f'f{n}.bin x y ']
+ for n in range(4)]
+ # Three elements a row: two rows fit in a budget of seven.
+ runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight)
+ self.assertEqual([len(run) for run in runs], [2, 2])
+ self.assertEqual(sum(runs, []), sum(rows, []))
class AlsoNamedIsNotCited(unittest.TestCase):
diff --git a/tests/test_site_r3_reference.py b/tests/test_site_r3_reference.py
index c363b529b..4b15c11dd 100644
--- a/tests/test_site_r3_reference.py
+++ b/tests/test_site_r3_reference.py
@@ -393,7 +393,7 @@ class PlatformPagesLinkTheirSystemsAndEmulators(unittest.TestCase):
def test_a_moved_list_opens_on_its_console_and_names_its_parent(self):
# Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own.
- files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(1200)]
+ files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(2000)]
moved: dict[str, str] = {}
generate_site._render_platform_systems(
{"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {})
diff --git a/tests/test_site_validation.py b/tests/test_site_validation.py
index ad953d906..35acb41e7 100644
--- a/tests/test_site_validation.py
+++ b/tests/test_site_validation.py
@@ -377,7 +377,7 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase):
path.read_text(encoding="utf-8") for path in sitepages.pages()
if sitepages.name(path) == "platforms/retroarch.md"
or sitepages.name(path).startswith("platforms/retroarch-"))
- self.assertIn("CRC32: `91e93107`", text)
+ self.assertIn('CRC32: 91e93107', text)
class PlainHouseStyle(unittest.TestCase):
diff --git a/tests/test_site_weight.py b/tests/test_site_weight.py
index 90eef5d5b..8918eba61 100644
--- a/tests/test_site_weight.py
+++ b/tests/test_site_weight.py
@@ -119,7 +119,7 @@ class APlatformSystemOverItsBudgetMoves(unittest.TestCase):
config = {("arcade", f["name"]): [{"name": f["name"], "md5": "a" * 32}] for f in files}
moved: dict[str, str] = {}
budget = generate_site.PLATFORM_SECTION_BUDGET
- generate_site.PLATFORM_SECTION_BUDGET = 500
+ generate_site.PLATFORM_SECTION_BUDGET = 5
try:
lines = generate_site._render_platform_systems(
{"arcade": files}, config, {}, ("deck", "Deck"), moved)
diff --git a/website/src/data/platform-parts.json b/website/src/data/platform-parts.json
new file mode 100644
index 000000000..7c119e52a
--- /dev/null
+++ b/website/src/data/platform-parts.json
@@ -0,0 +1,82 @@
+{
+ "platforms/batocera-acorn-bbc-micro.md": "platforms/batocera.md#acorn-bbc-micro",
+ "platforms/batocera-apple-ii.md": "platforms/batocera.md#apple-ii",
+ "platforms/batocera-apple-macintosh-ii.md": "platforms/batocera.md#apple-macintosh-ii",
+ "platforms/batocera-archimedes.md": "platforms/batocera.md#archimedes",
+ "platforms/batocera-atari-st.md": "platforms/batocera.md#atari-st",
+ "platforms/batocera-commodore-amiga.md": "platforms/batocera.md#commodore-amiga",
+ "platforms/batocera-elektronika-bk.md": "platforms/batocera.md#elektronika-bk",
+ "platforms/batocera-microsoft-msx.md": "platforms/batocera.md#microsoft-msx",
+ "platforms/batocera-nintendo-ds.md": "platforms/batocera.md#nintendo-ds",
+ "platforms/batocera-sega-dreamcast-arcade.md": "platforms/batocera.md#sega-dreamcast-arcade",
+ "platforms/batocera-sony-playstation.md": "platforms/batocera.md#sony-playstation",
+ "platforms/emuelec-microsoft-msx.md": "platforms/emuelec.md#microsoft-msx",
+ "platforms/knulli-acorn-bbc-micro.md": "platforms/knulli.md#acorn-bbc-micro",
+ "platforms/knulli-apple-macintosh-ii.md": "platforms/knulli.md#apple-macintosh-ii",
+ "platforms/knulli-archimedes.md": "platforms/knulli.md#archimedes",
+ "platforms/knulli-atari-st.md": "platforms/knulli.md#atari-st",
+ "platforms/knulli-commodore-amiga.md": "platforms/knulli.md#commodore-amiga",
+ "platforms/knulli-sega-dreamcast-arcade.md": "platforms/knulli.md#sega-dreamcast-arcade",
+ "platforms/lakka-arcade.md": "platforms/lakka.md#arcade",
+ "platforms/lakka-commodore-amiga.md": "platforms/lakka.md#commodore-amiga",
+ "platforms/lakka-nec-pc-98.md": "platforms/lakka.md#nec-pc-98",
+ "platforms/lakka-nintendo-ds.md": "platforms/lakka.md#nintendo-ds",
+ "platforms/lakka-nintendo-gamecube.md": "platforms/lakka.md#nintendo-gamecube",
+ "platforms/lakka-nintendo-sgb.md": "platforms/lakka.md#nintendo-sgb",
+ "platforms/lakka-nintendo-snes.md": "platforms/lakka.md#nintendo-snes",
+ "platforms/lakka-scummvm.md": "platforms/lakka.md#scummvm",
+ "platforms/lakka-sega-dreamcast-arcade.md": "platforms/lakka.md#sega-dreamcast-arcade",
+ "platforms/lakka-sega-saturn.md": "platforms/lakka.md#sega-saturn",
+ "platforms/lakka-sinclair-zx-spectrum.md": "platforms/lakka.md#sinclair-zx-spectrum",
+ "platforms/lakka-sony-playstation-2.md": "platforms/lakka.md#sony-playstation-2",
+ "platforms/lakka-sony-playstation.md": "platforms/lakka.md#sony-playstation",
+ "platforms/recalbox-atari-st.md": "platforms/recalbox.md#atari-st",
+ "platforms/recalbox-commodore-amiga.md": "platforms/recalbox.md#commodore-amiga",
+ "platforms/recalbox-microsoft-msx.md": "platforms/recalbox.md#microsoft-msx",
+ "platforms/retroarch-arcade.md": "platforms/retroarch.md#arcade",
+ "platforms/retroarch-commodore-amiga.md": "platforms/retroarch.md#commodore-amiga",
+ "platforms/retroarch-nec-pc-engine.md": "platforms/retroarch.md#nec-pc-engine",
+ "platforms/retroarch-nintendo-ds.md": "platforms/retroarch.md#nintendo-ds",
+ "platforms/retroarch-nintendo-gamecube.md": "platforms/retroarch.md#nintendo-gamecube",
+ "platforms/retroarch-nintendo-sgb.md": "platforms/retroarch.md#nintendo-sgb",
+ "platforms/retroarch-nintendo-snes.md": "platforms/retroarch.md#nintendo-snes",
+ "platforms/retroarch-scummvm.md": "platforms/retroarch.md#scummvm",
+ "platforms/retroarch-sega-dreamcast-arcade.md": "platforms/retroarch.md#sega-dreamcast-arcade",
+ "platforms/retroarch-sega-saturn.md": "platforms/retroarch.md#sega-saturn",
+ "platforms/retroarch-sinclair-zx-spectrum.md": "platforms/retroarch.md#sinclair-zx-spectrum",
+ "platforms/retroarch-sony-playstation-2.md": "platforms/retroarch.md#sony-playstation-2",
+ "platforms/retroarch-sony-playstation.md": "platforms/retroarch.md#sony-playstation",
+ "platforms/retrobat-atari-st.md": "platforms/retrobat.md#atari-st",
+ "platforms/retrobat-commodore-amiga.md": "platforms/retrobat.md#commodore-amiga",
+ "platforms/retrobat-microsoft-msx.md": "platforms/retrobat.md#microsoft-msx",
+ "platforms/retrobat-nec-pc-engine.md": "platforms/retrobat.md#nec-pc-engine",
+ "platforms/retrobat-nintendo-ds.md": "platforms/retrobat.md#nintendo-ds",
+ "platforms/retrobat-sega-saturn.md": "platforms/retrobat.md#sega-saturn",
+ "platforms/retrodeck-arcade-2.md": "platforms/retrodeck.md#arcade",
+ "platforms/retrodeck-arcade-3.md": "platforms/retrodeck.md#arcade",
+ "platforms/retrodeck-arcade-4.md": "platforms/retrodeck.md#arcade",
+ "platforms/retrodeck-arcade.md": "platforms/retrodeck.md#arcade",
+ "platforms/retrodeck-atari-400-800.md": "platforms/retrodeck.md#atari-400-800",
+ "platforms/retrodeck-microsoft-msx.md": "platforms/retrodeck.md#microsoft-msx",
+ "platforms/retrodeck-nintendo-snes.md": "platforms/retrodeck.md#nintendo-snes",
+ "platforms/retrodeck-sega-saturn.md": "platforms/retrodeck.md#sega-saturn",
+ "platforms/retrodeck-sinclair-zx-spectrum.md": "platforms/retrodeck.md#sinclair-zx-spectrum",
+ "platforms/retrodeck-sony-playstation-2.md": "platforms/retrodeck.md#sony-playstation-2",
+ "platforms/retrodeck-sony-playstation.md": "platforms/retrodeck.md#sony-playstation",
+ "platforms/retropie-arcade.md": "platforms/retropie.md#arcade",
+ "platforms/retropie-commodore-amiga.md": "platforms/retropie.md#commodore-amiga",
+ "platforms/retropie-nec-pc-98.md": "platforms/retropie.md#nec-pc-98",
+ "platforms/retropie-nec-pc-engine.md": "platforms/retropie.md#nec-pc-engine",
+ "platforms/retropie-nintendo-ds.md": "platforms/retropie.md#nintendo-ds",
+ "platforms/retropie-nintendo-gamecube.md": "platforms/retropie.md#nintendo-gamecube",
+ "platforms/retropie-nintendo-sgb.md": "platforms/retropie.md#nintendo-sgb",
+ "platforms/retropie-nintendo-snes.md": "platforms/retropie.md#nintendo-snes",
+ "platforms/retropie-scummvm.md": "platforms/retropie.md#scummvm",
+ "platforms/retropie-sega-dreamcast-arcade.md": "platforms/retropie.md#sega-dreamcast-arcade",
+ "platforms/retropie-sega-saturn.md": "platforms/retropie.md#sega-saturn",
+ "platforms/retropie-sinclair-zx-spectrum.md": "platforms/retropie.md#sinclair-zx-spectrum",
+ "platforms/retropie-snk-neogeo-cd.md": "platforms/retropie.md#snk-neogeo-cd",
+ "platforms/retropie-sony-playstation-2.md": "platforms/retropie.md#sony-playstation-2",
+ "platforms/retropie-sony-playstation.md": "platforms/retropie.md#sony-playstation",
+ "platforms/romm-sony-playstation-2.md": "platforms/romm.md#sony-playstation-2"
+}
diff --git a/website/src/scripts/site.ts b/website/src/scripts/site.ts
index 7b9cb3bcd..3f4470325 100644
--- a/website/src/scripts/site.ts
+++ b/website/src/scripts/site.ts
@@ -965,7 +965,9 @@ const glossaryTerms = () => {
// empty when the text shown is the value. The buttons are added where the
// reader sees the values, and when a fold opens for the ones inside it, so a
// page of 250 closed folds holds none of them.
-const COPYABLE = '[data-copy], .rb-sys-file dd.rb-hash';
+// A platform row's button carries its own data-copy: it is not a value to
+// give a button.
+const COPYABLE = '[data-copy]:not(button), .rb-sys-file dd.rb-hash';
// What the button copies, in words: the label of its grid row, else the
// words before the value ("MD5: ", ", SHA1 ", "RetroArch: "), else the
@@ -1012,7 +1014,7 @@ const addCopyButtons = (root: ParentNode) => {
};
const copyButtons = () => {
- if (!document.querySelector(COPYABLE)) return;
+ if (!document.querySelector(`${COPYABLE}, button.rb-copy[data-copy]`)) return;
addCopyButtons(document);
document.addEventListener('toggle', (event) => {
const fold = event.target as HTMLDetailsElement;
@@ -1021,6 +1023,28 @@ const copyButtons = () => {
document.addEventListener('click', async (event) => {
const button = (event.target as Element).closest?.('button.rb-copy');
if (!button) return;
+ // A platform row's button shows a short hash and copies the whole one
+ // from data-copy: its text stays, a mark shows for two seconds.
+ const own = button.dataset.copy;
+ if (own && own !== 'true') {
+ try {
+ await navigator.clipboard.writeText(own);
+ let status = document.querySelector('#rb-copy-status');
+ if (!status) {
+ status = document.createElement('p');
+ status.id = 'rb-copy-status';
+ status.className = 'rb-visually-hidden';
+ status.setAttribute('role', 'status');
+ document.body.append(status);
+ }
+ status.textContent = `Copied ${own}`;
+ button.dataset.copied = '';
+ window.setTimeout(() => delete button.dataset.copied, 2000);
+ } catch {
+ // No clipboard: the full value is in the row's details.
+ }
+ return;
+ }
const value = button.parentElement?.matches(COPYABLE)
? button.parentElement
: (button.previousElementSibling as HTMLElement | null);
@@ -1147,6 +1171,38 @@ const installPickers = () => {
pick(savedSetup());
};
+// A platform's file list (div.rb-files) is rows of a details and its copy
+// button. The filter box written above a long one hides both of a row.
+const filterFiles = () => {
+ for (const list of document.querySelectorAll('.rb-table-filter + .rb-files')) {
+ const controls = list.previousElementSibling!;
+ const input = controls.querySelector('input');
+ const count = controls.querySelector('.rb-table-filter-count');
+ if (!input || !count) continue;
+ const rows = [...list.querySelectorAll(':scope > details.rb-file')];
+ let searchable: { row: HTMLElement; text: string }[] | null = null;
+ input.addEventListener('input', () => {
+ searchable ??= rows.map((row) => ({
+ row,
+ text: `${row.querySelector('summary')?.textContent ?? ''} ${row.nextElementSibling?.matches('button.rb-copy') ? row.nextElementSibling.textContent : ''}`
+ .normalize('NFKD')
+ .toLocaleLowerCase(),
+ }));
+ const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
+ let visible = 0;
+ for (const { row, text } of searchable) {
+ row.hidden = !!query && !text.includes(query);
+ const button = row.nextElementSibling;
+ if (button?.matches('button.rb-copy')) (button as HTMLElement).hidden = row.hidden;
+ if (!row.hidden) visible += 1;
+ }
+ count.textContent = query
+ ? `${visible.toLocaleString('en')} of ${rows.length.toLocaleString('en')} rows`
+ : `${rows.length.toLocaleString('en')} rows`;
+ });
+ }
+};
+
// The 404 page: a search button, and the list a moved page belongs to.
const notFound = () => {
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
@@ -1170,8 +1226,9 @@ enhanceTables();
breakPaths();
hashSearch();
glossaryTerms();
+copyButtons();
+filterFiles();
osTabs();
installPickers();
-copyButtons();
showCurrentPage();
notFound();
diff --git a/website/src/styles/retrobios.css b/website/src/styles/retrobios.css
index 1021b7eb7..4b560aa21 100644
--- a/website/src/styles/retrobios.css
+++ b/website/src/styles/retrobios.css
@@ -636,6 +636,95 @@ body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); }
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
+/* A platform's file list (generate_site._platform_file_list), on the
+ pattern of the Tested block: each row a details whose summary is the
+ row (name, folder, status), its body the evidence across the whole
+ list. The short hash sits on its copy button (site.ts) beside the
+ summary, since a summary is itself a control. The details reaches under
+ the button column, so the row rule and an opened body run full width. */
+.sl-markdown-content .rb-files {
+ --rb-hash-column: 6.75rem;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) var(--rb-hash-column);
+ margin-top: 0.75rem;
+ border-top: 1px solid var(--rb-border-strong);
+}
+.sl-markdown-content .rb-files > * { margin: 0; }
+.sl-markdown-content .rb-files > details.rb-file {
+ grid-column: 1;
+ margin-right: calc(-1 * var(--rb-hash-column));
+ padding: 0 0 0 1.75rem;
+ border: 0;
+ border-bottom: 1px solid var(--rb-border);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+.sl-markdown-content .rb-files > details.rb-file[open] { padding-bottom: 0.6rem; background: var(--rb-surface); }
+.sl-markdown-content .rb-files > details.rb-file > summary {
+ display: grid;
+ grid-template-columns: 1.25rem minmax(0, 1fr) 9rem 6rem;
+ gap: 0.5rem;
+ align-items: center;
+ min-height: 2.25rem;
+ margin-left: -1.75rem;
+ padding: 0.3rem calc(var(--rb-hash-column) + 0.5rem) 0.3rem 0;
+ font: 0.8125rem/1.4 var(--sl-font-mono);
+ cursor: pointer;
+}
+.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary { grid-template-columns: 1.25rem minmax(0, 1fr) 6rem; }
+.sl-markdown-content .rb-files > details.rb-file > summary:hover { background: var(--rb-surface); }
+.sl-markdown-content .rb-files > details.rb-file > summary::before { margin: 0; background-color: var(--rb-text-secondary); }
+.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 3; padding: 0; background: none; color: var(--rb-text-secondary); }
+.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: -2; justify-self: start; font-family: var(--sl-font); }
+.sl-markdown-content .rb-files > button.rb-copy {
+ position: relative;
+ grid-column: 2;
+ align-self: start;
+ justify-self: start;
+ margin-top: 0.4rem;
+}
+.sl-markdown-content .rb-files > [hidden] { display: none; }
+
+.sl-markdown-content .rb-files button.rb-copy {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3em;
+ min-height: 24px;
+ padding: 0 0.35em;
+ border: 1px solid var(--rb-border-strong);
+ border-radius: 3px;
+ background: var(--sl-color-bg);
+ color: var(--sl-color-white);
+ font: 0.8125rem/1.4 var(--sl-font-mono);
+ cursor: pointer;
+ white-space: nowrap;
+}
+.sl-markdown-content .rb-files button.rb-copy::after {
+ content: '';
+ width: 0.9em;
+ height: 0.9em;
+ background: var(--rb-text-secondary);
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 1h8a2 2 0 0 1 2 2v8h-2V3H5zM3 5h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v7h7V7z'/%3E%3C/svg%3E") center / contain no-repeat;
+}
+.sl-markdown-content .rb-files button.rb-copy[data-copied]::after {
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 11.2 2.8 8 1.4 9.4 6 14l8.6-8.6-1.4-1.4z'/%3E%3C/svg%3E");
+ background: var(--rb-ok-fg);
+}
+.sl-markdown-content .rb-files button.rb-copy:hover { border-color: var(--rb-link); }
+@media (max-width: 50rem) {
+ /* On a phone the folder goes under the name, the status stays on the
+ first line. A row with no folder prints nothing for it. */
+ .sl-markdown-content .rb-files > details.rb-file > summary,
+ .sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary {
+ grid-template-columns: 1.25rem minmax(0, 1fr) auto;
+ row-gap: 0.1rem;
+ }
+ .sl-markdown-content .rb-files > details.rb-file > summary::before { grid-row: 1; }
+ .sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 2; grid-row: 2; }
+ .sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: 3; grid-row: 1; }
+}
+
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
Caution and danger keep their colour, as a 1 px border all round. */
.sl-markdown-content .starlight-aside {
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 074b1236f..fb000aaf2 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -64,6 +64,7 @@ Every module in `tests/`, by the area it guards.
| 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_search`, `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_version_basis`, `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_entry_tables`, `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_search`, `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_density_cards`, `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_search`, `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_version_basis`, `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_density`, `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_search`, `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