feat: emulator pages lead with what is needed

# Conflicts:
#	wiki/testing-guide.md
This commit is contained in:
Abdessamad Derraz committed 2026-10-23 02:24:38 +02:00
commit eb6ca6d38a
5 files changed
+404 -61

No files matched your search

+3 -2
View File
@@ -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
+213 -57
View File
@@ -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'<span class="rb-badge rb-badge-info">{category}</span>'
f'<span class="rb-badge rb-badge-info">{_CATEGORY_WORDS.get(category, category)}</span>'
)
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'<span class="rb-badge rb-badge-muted">{region_str}</span>'
f'<span class="rb-badge rb-badge-muted">Region: {_region_words(region)}</span>'
)
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'<span id="{a}"></span>' 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, "", "</section>", "",
]
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'<span id="{a}"></span>' 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(["", "</section>", ""])
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()}*"])
+1 -1
View File
@@ -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)
+186
View File
@@ -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</span>", self.page)
self.assertNotIn('rb-badge-muted">japan</span>', self.page)
def test_profile_version_names_the_pinned_build(self):
# DuckStation numbers builds v0.1-<commits since tag 0.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</span>", 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()
+1 -1
View File
@@ -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_r2_nav`, `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_nav`, `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