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 diff --git a/scripts/generate_site.py b/scripts/generate_site.py index f033e38c..fd3db8ea 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 193fbcfb..592b75f0 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_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