mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 04:39:06 -05:00
feat: emulator pages lead with what is needed
# Conflicts: # wiki/testing-guide.md
This commit is contained in:
commit
eb6ca6d38a
5 files changed
+404
-61
No files matched your search
@@ -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
@@ -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()}*"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user