From 47b0169c714e632d05a8fe6d1d311cf7bbd4993e Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 02:22:05 +0200
Subject: [PATCH 1/2] docs: name duckstation's pinned build and string
core_version is v0.1-12070: git describe at 4122fed9 counts 12,070 commits past tag 0.1, as gen_scmversion.sh does. The NTSC-U licence string is quoted as system.cpp:959 spells it, with its two spaces.
---
emulators/duckstation.yml | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/emulators/duckstation.yml b/emulators/duckstation.yml
index f1987aa0..adc25e36 100644
--- a/emulators/duckstation.yml
+++ b/emulators/duckstation.yml
@@ -6,7 +6,7 @@ upstream: "https://github.com/stenzek/duckstation"
logo: "https://raw.githubusercontent.com/stenzek/duckstation/master/data/resources/images/duck.png"
profiled_date: "2026-03-23"
source_commit: "4122fed9ad3e6e5c2ecf1516cd51675a5931623e"
-core_version: "v0.1"
+core_version: "v0.1-12070"
display_name: "Sony - PlayStation (DuckStation)"
cores: [duckstation, "Duckstation"]
found_by: content
@@ -27,7 +27,8 @@ notes: |
Each image has: region, region_check, fast_boot_patch type, priority score.
The console region picks the image, Auto by default. NTSC-U answers the
- "Sony Computer Entertainment Amer ica" licence string, a SCUS or SLUS
+ licence string `Sony Computer Entertainment Amer ica` (two spaces, as
+ src/core/system.cpp:959 spells it), a SCUS or SLUS
executable, and every disc whose region the detector cannot read
(DiscRegion::Other, NonPS1), so a disc of any other market boots an NTSC-U image.
ref: src/core/system.cpp:650-662, 926-995, 1723-1724, 1755
From 3841012c049cbcb02ad6a5a6dffabefa8b2c454a Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 02:22:09 +0200
Subject: [PATCH 2/2] feat: emulator pages lead with what is needed
The intro names systems and, when required files are one per region, says to pick the one for the game's region. Cards go under Required, Optional and Game data with a summary table above, split pages in the same order. Header and index read singular classes, system names, one spelling per core and plain counts.
---
scripts/generate_site.py | 270 +++++++++++++++++++++++++-------
tests/test_audit_regressions.py | 2 +-
tests/test_site_r2_emulators.py | 186 ++++++++++++++++++++++
wiki/testing-guide.md | 2 +-
4 files changed, 401 insertions(+), 59 deletions(-)
create mode 100644 tests/test_site_r2_emulators.py
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index f60c3d43..bc9c1661 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -153,6 +153,21 @@ CLS_LABELS = {
"other": "Other",
}
+# The same classes said of one emulator: its page read "Classification:
+# Official ports".
+CLS_LABEL_ONE = {
+ "official_port": "Official port",
+ "community_fork": "Community fork",
+ "pure_libretro": "Pure libretro",
+ "game_engine": "Game engine",
+ "enhanced_fork": "Enhanced fork",
+ "frozen_snapshot": "Frozen snapshot",
+ "embedded_hle": "Embedded HLE",
+ "launcher": "Launcher",
+ "unclassified": "Unclassified",
+ "other": "Other",
+}
+
def _copy_places(db: dict) -> dict[str, set[tuple[str, str, str]]]:
"""sha1 -> every (manufacturer, console, file name) holding a copy.
@@ -3710,6 +3725,57 @@ def generate_system_page(
# Emulator pages
+def _named_system_link(sys_id: str, prefix: str = "") -> str:
+ """A system by its display name, linked where _system_link links it.
+
+ The emulator pages printed the id ("sony-playstation") where every
+ other page says "Sony PlayStation". The id stays in the address only.
+ """
+ from siterender import _page_by_norm # noqa: PLC0415
+
+ name = system_name(sys_id).replace("[", r"\[").replace("]", r"\]")
+ page = _system_page_map.get(sys_id) or _page_by_norm(sys_id)
+ if not page:
+ return name
+ slug, console = page
+ return f"[{name}]({console_href(slug, console, prefix)})"
+
+
+def _distinct_spellings(names: list) -> list[str]:
+ """Names as a reader tells them apart: one per spelling, ignoring case.
+
+ DuckStation's cores list `Duckstation` for EmuELEC, whose launch name is
+ spelled so, and the page read "duckstation, Duckstation". The profile
+ keeps both, the core lookup being case-sensitive.
+ """
+ seen: dict[str, str] = {}
+ for name in map(str, names):
+ seen.setdefault(name.casefold(), name)
+ return list(seen.values())
+
+
+# Region slugs whose words are not the slug title-cased.
+_REGION_WORDS = {
+ "uk": "UK",
+ "asia-ntsc": "Asia (NTSC)",
+ "asia-pal": "Asia (PAL)",
+ "world": "every region",
+}
+
+
+def _region_words(value: object, most: int = 0) -> str:
+ """A region: value in words. ``most`` caps the regions named, the rest
+ counted: one Saturn BIOS serves seven."""
+ raw = value if isinstance(value, list) else [value] if value else []
+ words = [_REGION_WORDS.get(str(r), str(r).replace("-", " ").title()) for r in raw]
+ if most and len(words) > most:
+ words = words[:most] + [f"{len(words) - most} more"]
+ return _said_as_list(words)
+
+
+_CATEGORY_WORDS = {"bios": "BIOS", "game_data": "game data", "bios_zip": "BIOS ZIP"}
+
+
def generate_emulators_index(profiles: dict) -> str:
unique = {
k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test")
@@ -3781,22 +3847,23 @@ def generate_emulators_index(profiles: dict) -> str:
[
f"## {label} {{ #{cls} }}",
"",
- f"*{desc}* -- {len(entries)} profiles",
+ f"{desc}. {_count(len(entries), 'profile')}.",
"",
- "| Engine | Systems | Files |",
+ "| Emulator | Systems | Files |",
"|--------|---------|-------|",
]
)
for name, p in entries:
emu_name = p.get("emulator", name)
- systems = p.get("systems", [])
+ systems = list(dict.fromkeys(
+ _named_system_link(s, "../") for s in p.get("systems") or []))
files = p.get("files", [])
sys_str = ", ".join(systems[:3])
if len(systems) > 3:
- sys_str += f" +{len(systems) - 3}"
+ sys_str += f" and {len(systems) - 3} more"
file_count = len(files)
- file_str = str(file_count) if file_count else "-"
+ file_str = f"{file_count:,}" if file_count else "-"
lines.append(f"| [{emu_name}]({name}.md) | {sys_str} | {file_str} |")
lines.append("")
@@ -3863,14 +3930,12 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]:
)
if category and category != "bios":
badges.append(
- f'{category}'
+ f'{_CATEGORY_WORDS.get(category, category)}'
)
if region:
- region_str = (
- ", ".join(region) if isinstance(region, list) else str(region)
- )
+ # Labelled: "optional in repo japan" read as one run-on phrase.
badges.append(
- f'{region_str}'
+ f'Region: {_region_words(region)}'
)
if storage and storage != "embedded":
badges.append(
@@ -4064,7 +4129,7 @@ def _render_emulator_file(
if found:
details.append(f"Found by: {found}")
if fsystem:
- details.append(f"System: {_system_link(fsystem, '../')}")
+ details.append(f"System: {_named_system_link(fsystem, '../')}")
if size:
if isinstance(size, list):
size_str = " / ".join(_fmt_size(s) for s in size)
@@ -4567,35 +4632,40 @@ def _emulator_file_summary(
req_count = sum(1 for group in slots.values() if any(f.get("required") for f in group))
hle_count = sum(1 for group in slots.values() if any(f.get("hle_fallback") for f in group))
- held = f"{in_repo_count} in repo, {missing_count} missing"
+ # Sentences, one number format: the line read "1,563 files | 164
+ # required, 1399 optional | 1563 in repo".
+ held = [f"{in_repo_count:,} in repo", f"{missing_count:,} missing"]
if unsourceable_count:
- held += f", {unsourceable_count} unsourceable"
+ held.append(f"{unsourceable_count:,} unsourceable")
+ if hle_count:
+ held.append(f"{hle_count:,} with HLE fallback")
total = f"**{_count(len(slots), 'file')}**"
if len(slots) != len(files):
- total += f" ({len(files)} accepted versions)"
- parts = [
- total,
- f"{req_count} required, {len(slots) - req_count} optional",
- held,
+ total += f" ({len(files):,} accepted versions)"
+ lines = [
+ f"{total}: {req_count:,} required, {len(slots) - req_count:,} optional. "
+ f"{_said_as_list(held)}.",
+ "",
]
- if hle_count:
- parts.append(f"{hle_count} with HLE fallback")
- lines = [" | ".join(parts)]
categories = [f.get("category", "bios") for f in files]
if "game_data" in categories or "bios_zip" in categories:
cats = [
- f"{categories.count(key)} {label}"
+ f"{categories.count(key):,} {label}"
for key, label in (
- ("bios", "BIOS"), ("game_data", "game data"), ("bios_zip", "BIOS ZIPs")
+ ("bios", "BIOS"), ("bios_zip", "BIOS ZIPs"), ("game_data", "game data")
)
if categories.count(key)
]
- lines.append(f"Categories: {', '.join(cats)}")
- lines.append("")
+ lines.extend([f"By kind: {_said_as_list(cats)}.", ""])
return lines
+def _said_as_list(items: list[str]) -> str:
+ """"a, b and c"."""
+ return ", ".join(items[:-1]) + f" and {items[-1]}" if len(items) > 1 else "".join(items)
+
+
# Data directory members and revisions, read once per build.
_DATA_MEMBERS: dict[str, dict[str, dict]] = {}
_DATA_REVISIONS: dict[str, dict] = {}
@@ -4609,17 +4679,72 @@ _DATA_REVISIONS: dict[str, dict] = {}
EMULATOR_CARDS_PER_PAGE = 400
-def emulator_parts(name: str, files: list) -> list[tuple[str, range]]:
+# The headings an emulator's cards go under, in page order: what it needs
+# first. DuckStation's three required BIOS were cards 12 to 14 under one
+# heading, and MAME opened on 25 floppy sound samples.
+CARD_SECTIONS = (("required", "Required"), ("optional", "Optional"), ("game_data", "Game data"))
+_SECTION_RANK = {key: rank for rank, (key, _title) in enumerate(CARD_SECTIONS)}
+
+# Below this many cards the page is one screen of them, and a table above
+# would repeat it.
+EMULATOR_SUMMARY_MIN = 6
+
+
+def _card_section(f: dict) -> str:
+ if f.get("category") == "game_data":
+ return "game_data"
+ return "required" if f.get("required") else "optional"
+
+
+def _display_order(files: list) -> list[int]:
+ """Entry indexes in page order: required, optional, then game data,
+ each in profile order."""
+ return sorted(range(len(files)), key=lambda i: _SECTION_RANK[_card_section(files[i])])
+
+
+def emulator_parts(name: str, files: list) -> list[tuple[str, list[int]]]:
"""The pages an emulator's cards go to, with the entries each holds,
- or none when they fit on the emulator's page. Balanced, in profile order."""
+ or none when they fit on the emulator's page. Balanced, in page order."""
if len(files) <= EMULATOR_CARDS_PER_PAGE:
return []
+ order = _display_order(files)
count = -(-len(files) // EMULATOR_CARDS_PER_PAGE)
size = -(-len(files) // count)
- return [(f"{name}-files-{index + 1}", range(start, min(start + size, len(files))))
+ return [(f"{name}-files-{index + 1}", order[start:start + size])
for index, start in enumerate(range(0, len(files), size))]
+def _sectioned(files: list, indexes: list[int], render: Callable[[list[int]], list[str]]) -> list[str]:
+ """Each section's heading, then ``render`` of its entries among ``indexes``."""
+ lines: list[str] = []
+ for key, title in CARD_SECTIONS:
+ group = [i for i in indexes if _card_section(files[i]) == key]
+ if group:
+ lines.extend([f"## {title}", "", *render(group)])
+ return lines
+
+
+def _hash_short(f: dict) -> str:
+ """The shortest way to tell two dumps apart in a table: an md5 or crc32 prefix."""
+ for field in ("md5", "crc32", "sha1"):
+ value = f.get(field)
+ value = value[0] if isinstance(value, list) and value else value
+ if value:
+ short = str(value).lower().split(",")[0].strip().removeprefix("0x")[:8]
+ # Kept on one line: the label above the value doubled each row.
+ return f"{field.upper()}\u00a0{_code(short)}"
+ return ""
+
+
+def _summary_row(f: dict, link: str, ids: str = "") -> str:
+ shown = str(f.get("path") or f.get("name", ""))
+ return (f"| {ids}[{_code(shown)}]({link}) | {_region_words(f.get('region'), 3) or '-'} "
+ f"| {'yes' if f.get('required') else 'no'} | {_hash_short(f) or '-'} |")
+
+
+_SUMMARY_HEAD = ["| File | Region | Required | Hash |", "|------|--------|----------|------|"]
+
+
def plan_emulator_pages(profiles: dict) -> None:
"""Record which page holds each emulator card, before any page links one."""
stems = set()
@@ -4662,20 +4787,30 @@ def _emulator_intro(emu_name: str, profile: dict) -> str:
if label not in names:
names.append(label)
files = [f for f in profile.get("files") or [] if isinstance(f, dict)]
- required = sum(1 for f in files if f.get("required") is True)
+ required = [f for f in files if f.get("required") is True]
sentences = []
if names:
shown = names if len(names) <= 4 else names[:3] + [f"{len(names) - 3} more systems"]
- listed = shown[0] if len(shown) == 1 else ", ".join(shown[:-1]) + f" and {shown[-1]}"
- sentences.append(f"{emu_name} emulates {listed}.")
- if files:
+ sentences.append(f"{emu_name} emulates {_said_as_list(shown)}.")
+ by_region = _regional_choice(required)
+ if by_region:
+ # DuckStation needs the BIOS of the game's region, not all three: the
+ # page said "3 of them required" and read as "get all of these".
+ others = len(files) - len(required)
+ sentences.append(f"It picks the BIOS for your game's region: {by_region}.")
+ if others:
+ sentences.append(
+ f"The {_count(others, 'other file')} {'is' if others == 1 else 'are'} optional.")
+ elif files:
sentences.append(
- f"It reads {_count(len(files), 'file')}, {required} of them required: "
+ f"It reads {_count(len(files), 'file')}, {len(required):,} of them required: "
"without a required file it does not start games."
if required else
f"It reads {_count(len(files), 'file')}, none of them required: it starts "
"games without them."
)
+ if required and len(names) > 1:
+ sentences.append("A required file is needed only by the machines that use it.")
if profile.get("found_by") == "content":
sentences.append("It recognises a file by its contents, so any file name works.")
if any(f.get("source_ref") for f in files):
@@ -4683,6 +4818,21 @@ def _emulator_intro(emu_name: str, profile: dict) -> str:
return " ".join(sentences)
+def _regional_choice(required: list[dict], most: int = 6) -> str:
+ """The required files as one choice by region, or "" when they are not one.
+
+ Only when every required entry carries a region: and they name at least
+ two: region: is the territory the code selects the file for, so the
+ reader needs the one of the game's region.
+ """
+ if not required or len(required) > most or not all(f.get("region") for f in required):
+ return ""
+ if len({_region_words(f["region"]) for f in required}) < 2:
+ return ""
+ choices = [f"{f.get('name', '')} ({_region_words(f['region'], 3)})" for f in required]
+ return ", ".join(choices[:-1]) + f" or {choices[-1]}"
+
+
def emulator_pages(
name: str,
profile: dict,
@@ -4747,7 +4897,7 @@ def emulator_pages(
f"| Type | {emu_type} |",
]
if classification:
- cls_display = CLS_LABELS.get(classification, classification)
+ cls_display = CLS_LABEL_ONE.get(classification, classification)
lines.append(f"| Classification | {cls_display} |")
if isinstance(source_raw, dict):
parts = []
@@ -4799,9 +4949,9 @@ def emulator_pages(
f"[JSON](../api/v1/emulators/{name}.json) |"
)
if cores:
- lines.append(f"| Cores | {', '.join(str(c) for c in cores)} |")
+ lines.append(f"| Cores | {', '.join(_distinct_spellings(cores))} |")
if systems:
- sys_links = [_system_link(s, "../") for s in systems]
+ sys_links = list(dict.fromkeys(_named_system_link(s, "../") for s in systems))
lines.append(f"| Systems | {', '.join(sys_links)} |")
mame_ver = profile.get("mame_version", "")
if mame_ver:
@@ -4846,8 +4996,14 @@ def emulator_pages(
]
parts = emulator_parts(name, files)
if not parts:
- for card in cards:
- lines.extend(card)
+ order = _display_order(files)
+ if len(files) >= EMULATOR_SUMMARY_MIN:
+ lines.extend(search_excluded([
+ *_SUMMARY_HEAD,
+ *(_summary_row(files[i], f"#{anchors[i]}") for i in order),
+ ]))
+ lines.extend(_sectioned(
+ files, order, lambda group: [line for i in group for line in cards[i]]))
else:
lines.extend(_card_index(emu_name, files, anchors, aliases, parts))
for number, (stem, entries) in enumerate(parts, 1):
@@ -4931,26 +5087,27 @@ def _card_index(
before the split lands on the row, and site.js follows it to the card.
"""
page_of = {index: stem for stem, entries in parts for index in entries}
- lines = [
+
+ def table(group: list[int]) -> list[str]:
+ rows = [
+ _summary_row(
+ files[i], f"{page_of[i]}.md#{anchors[i]}",
+ "".join(f'' for a in (anchors[i], aliases[i]) if a))
+ for i in group
+ ]
+ return [
+ markup.block_open("section", f'class="rb-card-index" {markup.search_exclude()}'),
+ "", *_SUMMARY_HEAD, *rows, "", "", "",
+ ]
+
+ return [
f"{emu_name} reads more files than one page holds: each entry below "
- f"links to its card, on {_count(len(parts), 'page')} by profile order "
+ f"links to its card, on {_count(len(parts), 'page')}: "
+ ", ".join(f"[{number}]({stem}.md)" for number, (stem, _e) in enumerate(parts, 1))
+ ".",
"",
- markup.block_open("section", f'class="rb-card-index" {markup.search_exclude()}'),
- "",
- "| File | Required | Category |",
- "|------|----------|----------|",
+ *_sectioned(files, _display_order(files), table),
]
- for index, (f, anchor, alias) in enumerate(zip(files, anchors, aliases)):
- ids = "".join(f'' for a in (anchor, alias) if a)
- shown = str(f.get("path") or f.get("name", ""))
- lines.append(
- f"| {ids}[{_code(shown)}]({page_of[index]}.md#{anchor}) "
- f"| {'yes' if f.get('required') else 'no'} | {f.get('category', 'bios')} |"
- )
- lines.extend(["", "", ""])
- return lines
def _emulator_part_page(
@@ -4967,11 +5124,10 @@ def _emulator_part_page(
"",
f"Part of [{emu_name}]({name}.md), whose page has the profile, the install "
f"command and every entry. This part holds {_count(len(files), 'entry', 'entries')}, "
- f"{_code(first)} to {_code(last)}, in the profile's order. The other parts: {others}.",
+ f"{_code(first)} to {_code(last)}. The other parts: {others}.",
"",
- "## Files",
- "",
- *(line for card in cards for line in card),
+ *_sectioned(files, list(range(len(files))),
+ lambda group: [line for i in group for line in cards[i]]),
]
lines.extend(_next_block([f"[{emu_name}]({name}.md), the profile and every entry"]))
lines.extend([f"*Generated on {_timestamp()}*"])
diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py
index 337ea1e3..9fa932fc 100644
--- a/tests/test_audit_regressions.py
+++ b/tests/test_audit_regressions.py
@@ -3953,7 +3953,7 @@ class TheSiteCountsSlotsNotVersions(unittest.TestCase):
]
line = _emulator_file_summary(files, lambda f: f.get("crc32") == "2")[0]
self.assertIn("**2 files** (3 accepted versions)", line)
- self.assertIn("1 in repo, 1 missing", line)
+ self.assertIn("1 in repo and 1 missing", line)
diff --git a/tests/test_site_r2_emulators.py b/tests/test_site_r2_emulators.py
new file mode 100644
index 00000000..71526367
--- /dev/null
+++ b/tests/test_site_r2_emulators.py
@@ -0,0 +1,186 @@
+"""Emulator pages say what is needed first, in names (round 2, package 7).
+
+DuckStation's page opened on "It reads 105 files, 3 of them required" and
+read as "get all three", its three BIOS were cards 12 to 14 under one
+heading, its header said "Official ports" and "duckstation, Duckstation",
+and every system was an id ("sony-playstation").
+"""
+
+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 / "scripts"))
+
+import generate_site as gs # noqa: E402
+import siterender # noqa: E402
+
+EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
+
+
+def _profile(name: str) -> dict:
+ with open(REPO_ROOT / "emulators" / f"{name}.yml", encoding="utf-8") as handle:
+ return yaml.safe_load(handle)
+
+
+def _page(name: str, profile: dict) -> str:
+ return gs.generate_emulator_page(name, profile, EMPTY_DB, None, set())
+
+
+def _files(count: int, **fields: object) -> list[dict]:
+ return [{"name": f"f{i:03d}.bin", **fields} for i in range(count)]
+
+
+class TheIntroSaysWhatIsNeeded(unittest.TestCase):
+ def test_one_bios_per_region(self):
+ profile = _profile("duckstation")
+ intro = gs._emulator_intro("DuckStation", profile)
+ self.assertIn(
+ "It picks the BIOS for your game's region: scph5500.bin (Japan), "
+ "scph5501.bin (North America and Latin America) or scph5502.bin "
+ "(Europe and Oceania).", intro)
+ others = len(profile["files"]) - 3
+ self.assertIn(f"The {others} other files are optional.", intro)
+ self.assertNotIn("3 of them required", intro)
+ self.assertIn("DuckStation emulates Sony PlayStation.", intro)
+
+ def test_required_files_without_regions_keep_the_count(self):
+ intro = gs._emulator_intro("X", {"files": [
+ {"name": "a.bin", "required": True}, {"name": "b.bin"}]})
+ self.assertIn("It reads 2 files, 1 of them required", intro)
+
+ def test_several_machines_say_who_needs_a_required_file(self):
+ intro = gs._emulator_intro("X", {
+ "systems": ["sony-playstation", "sega-saturn"],
+ "files": [{"name": "a.bin", "required": True}]})
+ self.assertIn("A required file is needed only by the machines that use it.", intro)
+ one = gs._emulator_intro("X", {
+ "systems": ["sony-playstation"], "files": [{"name": "a.bin", "required": True}]})
+ self.assertNotIn("machines that use it", one)
+
+
+class TheHeaderReadsAsWords(unittest.TestCase):
+ def setUp(self) -> None:
+ self.page = _page("duckstation", _profile("duckstation"))
+
+ def test_singular_classification(self):
+ self.assertIn("| Classification | Official port |", self.page)
+
+ def test_cores_once_per_spelling(self):
+ self.assertIn("| Cores | duckstation |", self.page)
+
+ def test_systems_by_name(self):
+ row = next(line for line in self.page.splitlines() if line.startswith("| Systems |"))
+ self.assertIn("Sony PlayStation", row)
+ self.assertNotIn("[sony-playstation]", row)
+
+ def test_region_badge_is_labelled(self):
+ self.assertIn(">Region: Japan", self.page)
+ self.assertNotIn('rb-badge-muted">japan', self.page)
+
+ def test_profile_version_names_the_pinned_build(self):
+ # DuckStation numbers builds v0.1-: the pin,
+ # 4122fed9, is 12,070 commits past it.
+ self.assertRegex(str(_profile("duckstation")["core_version"]), r"^v0\.1-\d+$")
+
+ def test_the_second_spelling_is_a_platform_name(self):
+ # The lookup is case-sensitive and EmuELEC launches "Duckstation":
+ # the page hides the duplicate, the profile keeps it.
+ with open(REPO_ROOT / "platforms" / "emuelec.yml", encoding="utf-8") as handle:
+ emuelec = yaml.safe_load(handle)
+ self.assertIn("Duckstation", emuelec["cores"])
+ self.assertIn("Duckstation", _profile("duckstation")["cores"])
+
+
+class RequiredFilesComeFirst(unittest.TestCase):
+ def test_sections_in_need_order(self):
+ files = [
+ {"name": "data.pak", "category": "game_data", "required": True},
+ {"name": "opt.bin"},
+ {"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16},
+ ]
+ page = _page("x", {"type": "standalone", "files": files})
+ heads = [line for line in page.splitlines() if line.startswith("## ")]
+ self.assertEqual(heads[:4], ["## Files", "## Required", "## Optional", "## Game data"])
+ self.assertLess(page.index("## Required"), page.index("req.bin"))
+ self.assertLess(page.index("req.bin"), page.index("## Optional"))
+ self.assertLess(page.index("## Game data"), page.index("data.pak"))
+
+ def test_the_table_of_contents_turns_on(self):
+ page = _page("duckstation", _profile("duckstation"))
+ self.assertGreater(len(re.findall(r"^## ", page, re.M)), 2)
+
+ def test_a_summary_table_above_the_cards(self):
+ files = _files(gs.EMULATOR_SUMMARY_MIN - 1) + [
+ {"name": "req.bin", "required": True, "region": ["japan"], "md5": "ab" * 16}]
+ page = _page("x", {"type": "standalone", "files": files})
+ self.assertIn("| File | Region | Required | Hash |", page)
+ row = next(line for line in page.splitlines() if "req.bin" in line and "| yes |" in line)
+ self.assertIn("(#file-req-bin", row)
+ self.assertIn("| Japan |", row)
+ self.assertIn("MD5\u00a0`abababab`", row)
+ # The required row is the first one.
+ table = page.index("| File | Region | Required | Hash |")
+ self.assertLess(page.index("req.bin", table), page.index("f000.bin", table))
+
+ def test_no_table_for_a_few_cards(self):
+ page = _page("x", {"type": "standalone", "files": _files(2)})
+ self.assertNotIn("| File | Region | Required | Hash |", page)
+
+ def test_a_split_page_puts_required_on_the_first_part(self):
+ count = gs.EMULATOR_CARDS_PER_PAGE * 2
+ files = _files(count) + [{"name": "last.bin", "required": True}]
+ parts = gs.emulator_parts("big", files)
+ self.assertIn(count, parts[0][1])
+ pages = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
+ "files": files}, {"files": {}})
+ main = pages["big"]
+ self.assertLess(main.index("## Required"), main.index("## Optional"))
+ self.assertLess(main.index("last.bin"), main.index("f000.bin"))
+ self.assertIn("## Required", pages["big-files-1"])
+
+
+class TheSummaryLineIsProse(unittest.TestCase):
+ def test_no_pipes_and_one_number_format(self):
+ files = _files(1563, category="bios_zip") + [
+ {"name": "a.pak", "category": "game_data"}, {"name": "b.pak", "category": "game_data"}]
+ for f in files[:164]:
+ f["required"] = True
+ text = "\n".join(gs._emulator_file_summary(files, lambda _f: True))
+ self.assertNotIn(" | ", text)
+ self.assertNotIn("1399", text)
+ self.assertIn("1,401 optional", text)
+ self.assertIn("By kind: 1,563 BIOS ZIPs and 2 game data.", text)
+
+ def test_category_badge_in_words(self):
+ badges = " ".join(gs._file_badges({"name": "a", "category": "game_data"}, True))
+ self.assertIn(">game data", badges)
+
+
+class TheIndexNamesEmulatorsAndSystems(unittest.TestCase):
+ def test_columns_and_counts(self):
+ saved = dict(siterender._system_page_map)
+ self.addCleanup(siterender.set_system_page_map, saved)
+ siterender.set_system_page_map({"sony-playstation": ("sony", "PlayStation")})
+ page = gs.generate_emulators_index({
+ "duckstation": {"emulator": "DuckStation", "core_classification": "official_port",
+ "systems": ["sony-playstation"], "files": _files(3)},
+ })
+ self.assertIn("| Emulator | Systems | Files |", page)
+ self.assertNotIn("| Engine |", page)
+ self.assertNotIn(" -- ", page)
+ self.assertIn("1 profile.", page)
+ row = next(line for line in page.splitlines() if line.startswith("| [DuckStation]"))
+ self.assertIn(f"[{siterender.system_name('sony-playstation')}]", row)
+ self.assertIn("(../systems/sony.md#playstation)", row)
+ self.assertNotIn("sony-playstation |", row)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 353e260c..4a152b2c 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -56,7 +56,7 @@ Every module in `tests/`, by the area it guards.
| 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_check_freshness`, `test_dedup`, `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_profile_sync`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_scraper_contract`, `test_upstream`, `test_upstream_index` |
-| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `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_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
+| Site, docs and repository contracts | rendered pages, sources | `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_doc_pages`, `test_doc_surfaces`, `test_optional_dependencies`, `test_prose_style`, `test_schema_semantic_gate`, `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_emulators`, `test_site_r2_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture