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