mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: round 3 emulator pages
This commit is contained in:
commit
62d0d62968
7 files changed
+421
-83
No files matched your search
+201
-76
@@ -5239,6 +5239,15 @@ def generate_emulators_index(profiles: dict) -> str:
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def comes_with_app(f: dict) -> bool:
|
||||
"""The emulator installs the file in every build the profile documents.
|
||||
|
||||
``bundled`` narrowed by ``mode`` is installed by one build only, and the
|
||||
other build still reads it from the user's folder.
|
||||
"""
|
||||
return f.get("bundled") is True and not f.get("mode")
|
||||
|
||||
|
||||
def _file_badges(f: dict, in_repo: bool) -> list[str]:
|
||||
"""The status chips shown beside a file name.
|
||||
|
||||
@@ -5258,7 +5267,14 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]:
|
||||
archive = f.get("archive", "")
|
||||
ftype = f.get("type", "")
|
||||
badges = []
|
||||
if required:
|
||||
shipped = comes_with_app(f)
|
||||
if shipped:
|
||||
# NetherSX2's three interface fonts sat beside the BIOS as
|
||||
# "required" and "missing": the app ships them, nobody fetches them.
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-info">comes with the app</span>'
|
||||
)
|
||||
elif required:
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-danger">required</span>'
|
||||
)
|
||||
@@ -5266,18 +5282,18 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]:
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-muted">optional</span>'
|
||||
)
|
||||
if in_repo:
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-success">in repo</span>'
|
||||
)
|
||||
# Whether the collection holds a file the app ships says nothing to
|
||||
# the reader: no status chip.
|
||||
if shipped:
|
||||
status = ""
|
||||
elif in_repo:
|
||||
status = '<span class="rb-badge rb-badge-success">in repo</span>'
|
||||
elif f.get("unsourceable"):
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-muted">unsourceable</span>'
|
||||
)
|
||||
status = '<span class="rb-badge rb-badge-muted">unsourceable</span>'
|
||||
else:
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-warning">missing</span>'
|
||||
)
|
||||
status = '<span class="rb-badge rb-badge-warning">missing</span>'
|
||||
if status:
|
||||
badges.append(status)
|
||||
if hle:
|
||||
badges.append(
|
||||
'<span class="rb-badge rb-badge-info">HLE fallback</span>'
|
||||
@@ -5299,7 +5315,7 @@ def _file_badges(f: dict, in_repo: bool) -> list[str]:
|
||||
badges.append(
|
||||
f'<span class="rb-badge rb-badge-muted">{storage}</span>'
|
||||
)
|
||||
if bundled:
|
||||
if bundled and not shipped:
|
||||
# Said in words: the pack leaves the file out because the emulator,
|
||||
# in the build the entry names, already installs it.
|
||||
build = f" {f['mode']}" if f.get("mode") in ("standalone", "libretro") else ""
|
||||
@@ -5685,6 +5701,32 @@ def _directory_copies(entry: dict, consumers: FileConsumers) -> list[str]:
|
||||
f"([browse]({tree}))"]
|
||||
|
||||
|
||||
ROMSET_FAQ = "../wiki/technical-faq.md#which-mame-version-do-the-arcade-bios-sets-match"
|
||||
_MAME_RELEASE = re.compile(r"^0\.\d")
|
||||
|
||||
|
||||
def emulator_romset(profile: dict) -> str:
|
||||
"""The romset an arcade emulator's sets must match, "MAME 0.78", or "".
|
||||
|
||||
``mame_version`` where the profile has one (MAME4droid 2024 is app
|
||||
1.38.1 on MAME 0.289), else the version of a MAME build or of a
|
||||
FinalBurn core, whose sets follow its own release.
|
||||
"""
|
||||
if profile.get("type") in ("alias", "launcher", "test"):
|
||||
return ""
|
||||
emulator = str(profile.get("emulator", ""))
|
||||
version = str(profile.get("core_version") or "")
|
||||
if profile.get("mame_version"):
|
||||
return f"MAME {profile['mame_version']}"
|
||||
if "MAME" in emulator and _MAME_RELEASE.match(version):
|
||||
return f"MAME {version}"
|
||||
if emulator.startswith("FinalBurn Neo") and version:
|
||||
return f"FinalBurn Neo {version}"
|
||||
if (emulator.startswith("FB Alpha") or "FBA" in emulator) and version:
|
||||
return f"FB Alpha {version}"
|
||||
return ""
|
||||
|
||||
|
||||
def _render_metadata_rows(profile: dict) -> list[str]:
|
||||
"""Scalar profile fields, one table row each.
|
||||
|
||||
@@ -5692,6 +5734,9 @@ def _render_metadata_rows(profile: dict) -> list[str]:
|
||||
row: a dict rendered into a table cell is unreadable.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
romset = emulator_romset(profile)
|
||||
if romset:
|
||||
lines.append(f"| Romset | [{romset}]({ROMSET_FAQ}) |")
|
||||
for field, label in [
|
||||
("core", "Core ID"),
|
||||
("core_name", "Core name"),
|
||||
@@ -5713,7 +5758,12 @@ def _render_metadata_rows(profile: dict) -> list[str]:
|
||||
val = profile.get(field)
|
||||
if val is None or val == "" or isinstance(val, (dict, list)):
|
||||
continue
|
||||
if field == "bios_size" and isinstance(val, int):
|
||||
if field == "bios_directory" and profile.get("type") == "standalone" \
|
||||
and not str(val).startswith(("/", "~", "$")) and ":" not in str(val):
|
||||
# A bare `bios/` read as a place on the device: it is a folder
|
||||
# inside the app's own data folder, which the notes describe.
|
||||
lines.append(f"| {label} | {_code(val)}, inside the app's own data folder |")
|
||||
elif field == "bios_size" and isinstance(val, int):
|
||||
lines.append(f"| {label} | {_fmt_size(val)} ({val:,} bytes) |")
|
||||
elif isinstance(val, str) and val.startswith("http"):
|
||||
lines.append(f"| {label} | [{val}]({val}) |")
|
||||
@@ -5970,45 +6020,42 @@ def _availability_check(
|
||||
return _file_available
|
||||
|
||||
|
||||
def _emulator_file_summary(
|
||||
files: list[dict], available: Callable[[dict], bool]
|
||||
) -> list[str]:
|
||||
"""The counts above an emulator's file table, and its categories.
|
||||
|
||||
Counted by slot, as verify --emulator and the gaps page count: raze
|
||||
lists every GRP its code accepts under one name, and holding one fills
|
||||
the slot. Counted per entry, the page announced 46 missing where 12
|
||||
slots are empty.
|
||||
"""
|
||||
def _emulator_slots(files: list[dict]) -> list[list[dict]]:
|
||||
"""The entries of each slot, as verify --emulator and the gaps page
|
||||
count them, leaving out the slots the app ships."""
|
||||
from cross_reference import slot_key
|
||||
|
||||
slots: dict[tuple, list[dict]] = {}
|
||||
for f in files:
|
||||
slots.setdefault(slot_key(f), []).append(f)
|
||||
in_repo_count = sum(1 for group in slots.values() if any(available(f) for f in group))
|
||||
unsourceable_count = sum(
|
||||
1 for group in slots.values()
|
||||
if not any(available(f) for f in group) and all(f.get("unsourceable") for f in group)
|
||||
)
|
||||
missing_count = len(slots) - in_repo_count - unsourceable_count
|
||||
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))
|
||||
return [group for group in slots.values() if not all(comes_with_app(f) for f in group)]
|
||||
|
||||
|
||||
def _emulator_file_summary(files: list[dict]) -> list[str]:
|
||||
"""What the emulator reads, above its file table, and its categories.
|
||||
|
||||
Counted by slot, as verify --emulator and the gaps page count: raze
|
||||
lists every GRP its code accepts under one name, and holding one fills
|
||||
the slot. Counted per entry, the page announced 46 missing where 12
|
||||
slots are empty. The files the app ships are counted apart: NetherSX2
|
||||
said "4 required" for one BIOS and three fonts in its package.
|
||||
"""
|
||||
slots = _emulator_slots(files)
|
||||
shipped = sum(1 for f in files if comes_with_app(f))
|
||||
req_count = sum(1 for group in slots if any(f.get("required") for f in group))
|
||||
hle_count = sum(1 for group in slots if any(f.get("hle_fallback") for f in group))
|
||||
|
||||
# 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.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)"
|
||||
lines = [
|
||||
f"{total}: {req_count:,} required, {len(slots) - req_count:,} optional. "
|
||||
f"{_said_as_list(held)}.",
|
||||
"",
|
||||
]
|
||||
if sum(len(group) for group in slots) != len(slots):
|
||||
total += f" ({sum(len(group) for group in slots):,} accepted versions)"
|
||||
line = f"{total}: {req_count:,} required, {len(slots) - req_count:,} optional."
|
||||
if hle_count:
|
||||
line += f" {hle_count:,} {'has' if hle_count == 1 else 'have'} an HLE fallback."
|
||||
if shipped:
|
||||
line += f" {_count(shipped, 'more file')} {'comes' if shipped == 1 else 'come'} with the app."
|
||||
lines = [line, ""]
|
||||
|
||||
categories = [f.get("category", "bios") for f in files]
|
||||
if "game_data" in categories or "bios_zip" in categories:
|
||||
@@ -6023,6 +6070,23 @@ def _emulator_file_summary(
|
||||
return lines
|
||||
|
||||
|
||||
def _emulator_held_line(files: list[dict], available: Callable[[dict], bool]) -> list[str]:
|
||||
"""What this collection holds of them, one quiet line under the table:
|
||||
a reader comes for what the emulator needs, the project's counts after."""
|
||||
slots = _emulator_slots(files)
|
||||
if not slots:
|
||||
return []
|
||||
in_repo = sum(1 for group in slots if any(available(f) for f in group))
|
||||
unsourceable = sum(
|
||||
1 for group in slots
|
||||
if not any(available(f) for f in group) and all(f.get("unsourceable") for f in group)
|
||||
)
|
||||
held = [f"{in_repo:,} in repo", f"{len(slots) - in_repo - unsourceable:,} missing"]
|
||||
if unsourceable:
|
||||
held.append(f"{unsourceable:,} unsourceable")
|
||||
return [f"<p><small>In this collection: {_said_as_list(held)}.</small></p>", ""]
|
||||
|
||||
|
||||
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)
|
||||
@@ -6077,8 +6141,18 @@ def _data_tree_page(ref: str, entry: dict, pages: list[tuple[str, list[str]]],
|
||||
part = f" (part {index + 1} of {len(pages)})" if len(pages) > 1 else ""
|
||||
readers = _said_as_list([f"[{_escape_tags(display)}]({key}.md#data-directories)"
|
||||
for key, display in _DATA_READERS.get(ref, [])])
|
||||
# A file list out of the search: "dolphin" found three parts of the
|
||||
# dolphin-sys tree, on their title, before the Dolphin page.
|
||||
front: dict = {"level": "reference", "search": "exclude"}
|
||||
# The reader the tree is named after (dolphin for dolphin-sys), else
|
||||
# the first by name.
|
||||
readers = _DATA_READERS.get(ref) or []
|
||||
owner = max(readers, default=None,
|
||||
key=lambda reader: len(reader[0]) if ref.startswith(f"{reader[0]}-") else 0)
|
||||
if owner:
|
||||
front["parent"] = {"label": owner[1], "link": f"{owner[0]}.md"}
|
||||
lines = [
|
||||
"---", "level: reference", "---", "",
|
||||
"---", yaml.safe_dump(front, sort_keys=False, allow_unicode=True).rstrip("\n"), "---", "",
|
||||
f"# The {ref} data directory{part}", "",
|
||||
f"{entry.get('description') or 'A data directory'}: "
|
||||
f"{_count(len(members), 'file')}, as the packs ship them."
|
||||
@@ -6103,7 +6177,8 @@ EMULATOR_CARDS_PER_PAGE = 220
|
||||
# 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"))
|
||||
CARD_SECTIONS = (("required", "Required"), ("optional", "Optional"), ("game_data", "Game data"),
|
||||
("with_app", "Comes with the app"))
|
||||
_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
|
||||
@@ -6112,6 +6187,8 @@ EMULATOR_SUMMARY_MIN = 6
|
||||
|
||||
|
||||
def _card_section(f: dict) -> str:
|
||||
if comes_with_app(f):
|
||||
return "with_app"
|
||||
if f.get("category") == "game_data":
|
||||
return "game_data"
|
||||
return "required" if f.get("required") else "optional"
|
||||
@@ -6135,35 +6212,64 @@ def emulator_parts(name: str, files: list) -> list[tuple[str, list[int]]]:
|
||||
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``."""
|
||||
def _sectioned(files: list, indexes: list[int], render: Callable[[list[int]], list[str]],
|
||||
fold: bool = False) -> list[str]:
|
||||
"""Each section's heading, then ``render`` of its entries among ``indexes``.
|
||||
|
||||
``fold``: every section after Required in one closed block, for a page
|
||||
whose table above already lists each entry.
|
||||
"""
|
||||
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)])
|
||||
if not group:
|
||||
continue
|
||||
body = render(group)
|
||||
if fold and key != "required":
|
||||
body = markup.details("note", _count(len(group), "file"), body)
|
||||
lines.extend([f"## {title}", "", *body])
|
||||
return lines
|
||||
|
||||
|
||||
def _hash_short(f: dict) -> str:
|
||||
"""The shortest way to tell two dumps apart in a table: an md5 or crc32 prefix."""
|
||||
"""The shortest way to tell two dumps apart in a table: the first 12
|
||||
characters of an md5, crc32 or sha1, the whole value in its title."""
|
||||
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]
|
||||
full = html.escape(str(value).lower().split(",")[0].strip().removeprefix("0x"))
|
||||
# Kept on one line: the label above the value doubled each row.
|
||||
return f"{field.upper()}\u00a0{_code(short)}"
|
||||
return f'{field.upper()}\u00a0<code title="{full}">{full[:12]}</code>'
|
||||
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 '-'} |")
|
||||
def _summary_table(files: list[dict], indexes: list[int], cell: Callable[[int, str], str]) -> list[str]:
|
||||
"""The table of an emulator's entries, one row each, ``cell`` writing
|
||||
the linked name of entry i from its shown path.
|
||||
|
||||
|
||||
_SUMMARY_HEAD = ["| File | Region | Required | Hash |", "|------|--------|----------|------|"]
|
||||
A column every row leaves empty is not written: AetherSX2's Region
|
||||
column held "-" on every row and pushed Required off a phone screen.
|
||||
Nor is Required when every row gives the same answer, which the
|
||||
section heading or the count above already says: in MAME's index it
|
||||
turned 1,564 rows into label and value pairs on a phone.
|
||||
"""
|
||||
rows = []
|
||||
for i in indexes:
|
||||
f = files[i]
|
||||
needed = "with the app" if comes_with_app(f) else "yes" if f.get("required") else "no"
|
||||
rows.append((cell(i, str(f.get("path") or f.get("name", ""))),
|
||||
_region_words(f.get("region"), 3), needed, _hash_short(f)))
|
||||
keep = [0] + [column for column in (1, 3) if any(row[column] for row in rows)]
|
||||
if len({row[2] for row in rows}) > 1:
|
||||
keep.append(2)
|
||||
keep.sort()
|
||||
heads = ("File", "Region", "Required", "Hash")
|
||||
return [
|
||||
"| " + " | ".join(heads[c] for c in keep) + " |",
|
||||
"|" + "|".join("------" for _c in keep) + "|",
|
||||
*("| " + " | ".join(row[c] or "-" for c in keep) + " |" for row in rows),
|
||||
]
|
||||
|
||||
|
||||
def plan_emulator_pages(profiles: dict) -> None:
|
||||
@@ -6207,7 +6313,8 @@ def _emulator_intro(emu_name: str, profile: dict) -> str:
|
||||
label = system_name(sys_id)
|
||||
if label not in names:
|
||||
names.append(label)
|
||||
files = [f for f in profile.get("files") or [] if isinstance(f, dict)]
|
||||
# What the app installs itself is no file for the reader to get.
|
||||
files = [f for f in profile.get("files") or [] if isinstance(f, dict) and not comes_with_app(f)]
|
||||
required = [f for f in files if f.get("required") is True]
|
||||
sentences = []
|
||||
if names:
|
||||
@@ -6307,10 +6414,19 @@ def emulator_pages(
|
||||
|
||||
part_pages: dict[str, str] = {}
|
||||
intro = _emulator_intro(emu_name, profile)
|
||||
romset = emulator_romset(profile)
|
||||
lines = [
|
||||
f"# {emu_name}",
|
||||
"",
|
||||
*([intro, ""] if intro else []),
|
||||
]
|
||||
if romset and any(isinstance(f, dict) and f.get("category") == "bios_zip" for f in files):
|
||||
# The commonest arcade failure is a set from another MAME version,
|
||||
# and its answer sat on a Reference page no arcade page linked.
|
||||
lines.extend([f"Its BIOS ZIPs must come from the {romset} romset. "
|
||||
f"[Which version each core reads]({ROMSET_FAQ}).", ""])
|
||||
card_start = len(lines)
|
||||
lines += [
|
||||
markup.block_open("div", 'class="rb-meta-card"'),
|
||||
"",
|
||||
"| Detail | Value |",
|
||||
@@ -6374,9 +6490,6 @@ def emulator_pages(
|
||||
if 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:
|
||||
lines.append(f"| MAME version | {mame_ver} |")
|
||||
author = profile.get("author", "")
|
||||
if author:
|
||||
lines.append(f"| Author | {author} |")
|
||||
@@ -6387,6 +6500,9 @@ def emulator_pages(
|
||||
lines.append("")
|
||||
lines.append("</div>")
|
||||
lines.append("")
|
||||
# Reference for the few who check a revision: stacked on a phone, the
|
||||
# card took two screens above the files.
|
||||
lines[card_start:] = markup.details("note", "About this profile", lines[card_start:])
|
||||
|
||||
lines.extend(_install_block(name, profile))
|
||||
|
||||
@@ -6407,7 +6523,8 @@ def emulator_pages(
|
||||
_file_available = _availability_check(db, data_names)
|
||||
|
||||
lines.extend(["## Files", ""])
|
||||
lines.extend(_emulator_file_summary(files, _file_available))
|
||||
lines.extend(_emulator_file_summary(files))
|
||||
held = _emulator_held_line(files, _file_available)
|
||||
|
||||
anchors = emulator_file_anchors(files)
|
||||
aliases = emulator_file_aliases(files, anchors)
|
||||
@@ -6418,15 +6535,21 @@ def emulator_pages(
|
||||
parts = emulator_parts(name, files)
|
||||
if not parts:
|
||||
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),
|
||||
]))
|
||||
indexed = len(files) >= EMULATOR_SUMMARY_MIN
|
||||
if indexed:
|
||||
lines.extend(search_excluded(_summary_table(
|
||||
files, order, lambda i, shown: f"[{_code(shown)}](#{anchors[i]})")))
|
||||
lines.append("")
|
||||
lines.extend(held)
|
||||
# Below the table, which indexes them, the cards past the
|
||||
# required ones fold: DuckStation's page was 59 phone screens,
|
||||
# 102 optional cards after its three BIOS.
|
||||
lines.extend(_sectioned(
|
||||
files, order, lambda group: [line for i in group for line in cards[i]]))
|
||||
files, order, lambda group: [line for i in group for line in cards[i]],
|
||||
fold=indexed))
|
||||
else:
|
||||
lines.extend(_card_index(emu_name, files, anchors, aliases, parts))
|
||||
lines.extend(held)
|
||||
for number, (stem, entries) in enumerate(parts, 1):
|
||||
part_pages[stem] = _emulator_part_page(
|
||||
name, emu_name, number, parts, [cards[i] for i in entries],
|
||||
@@ -6506,16 +6629,18 @@ def _card_index(
|
||||
"""
|
||||
page_of = {index: stem for stem, entries in parts for index in entries}
|
||||
|
||||
def cell(i: int, shown: str) -> str:
|
||||
# The id sits on the link itself, one element fewer per row: MAME's
|
||||
# 1,564 rows put 13,300 elements on its page.
|
||||
href = markup.site_href(f"emulators/{page_of[i]}/#{anchors[i]}")
|
||||
alias = f'<span id="{aliases[i]}"></span>' if aliases[i] else ""
|
||||
return (f'{alias}<a id="{anchors[i]}" href="{href}">'
|
||||
f"<code>{html.escape(shown, quote=False)}</code></a>")
|
||||
|
||||
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>", "",
|
||||
"", *_summary_table(files, group, cell), "", "</section>", "",
|
||||
]
|
||||
|
||||
return [
|
||||
|
||||
@@ -41,6 +41,7 @@ class Page:
|
||||
empty_headers: int = 0
|
||||
runtime_diagrams: int = 0
|
||||
size: int = 0
|
||||
elements: int = 0
|
||||
# A moved page's old address: a meta refresh to the new one, no content.
|
||||
redirect: str = ""
|
||||
|
||||
@@ -52,6 +53,15 @@ class Page:
|
||||
# The largest page was 10.5 MB (systems/game-engines/) and took 21 s of
|
||||
# main-thread work on a throttled phone. Split pages now top out at 0.9 MB.
|
||||
MAX_PAGE_BYTES = 1_000_000
|
||||
# Elements outside scripts and styles: the browser lays out and styles each.
|
||||
# emulators/mame held 13,300 and blocked the main thread for 235 ms. Its
|
||||
# index now carries each card's id on the link itself.
|
||||
MAX_PAGE_ELEMENTS = 10_000
|
||||
# Pages allowed past it, each with the reason, removed when it comes under.
|
||||
OVER_ELEMENT_BUDGET = {
|
||||
# One table of every file to source, 10,193 elements on 2026-10-23.
|
||||
"gaps-files-to-source/index.html": "the file table is not split",
|
||||
}
|
||||
# Pagefind loads its index in pieces: the metadata, then the chunks the words
|
||||
# typed fall in (stored compressed). 59 KB for the largest chunk and 7.5 KB of
|
||||
# metadata on 2026-10-22, 5.2 MB for the whole index on disk.
|
||||
@@ -77,6 +87,8 @@ class _PageParser(HTMLParser):
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
data = {key: value or "" for key, value in attrs}
|
||||
if tag not in ("script", "style"):
|
||||
self.page.elements += 1
|
||||
if tag in _LITERAL_TAGS:
|
||||
self._literal_depth += 1
|
||||
if re.fullmatch(r"h[1-6]", tag):
|
||||
@@ -282,6 +294,9 @@ def _check_pages(
|
||||
report(path, f"{page.runtime_diagrams} diagram(s) drawn by a script at runtime")
|
||||
if page.size > MAX_PAGE_BYTES:
|
||||
report(path, f"{page.size:,} bytes of HTML, over the {MAX_PAGE_BYTES:,} budget")
|
||||
if page.elements > MAX_PAGE_ELEMENTS \
|
||||
and path.relative_to(site).as_posix() not in OVER_ELEMENT_BUDGET:
|
||||
report(path, f"{page.elements:,} elements, over the {MAX_PAGE_ELEMENTS:,} budget")
|
||||
jumps = _heading_jumps(page.headings)
|
||||
if jumps:
|
||||
report(path, f"heading levels skipped: {sorted(set(jumps))}")
|
||||
|
||||
@@ -3967,16 +3967,17 @@ class TheSiteCountsSlotsNotVersions(unittest.TestCase):
|
||||
59 files, 46 missing, where verify --emulator said 23 slots, 12 missing."""
|
||||
|
||||
def test_one_held_version_fills_the_slot(self):
|
||||
from scripts.generate_site import _emulator_file_summary
|
||||
from scripts.generate_site import _emulator_file_summary, _emulator_held_line
|
||||
|
||||
files = [
|
||||
{"name": "DUKE3D.GRP", "crc32": "1"},
|
||||
{"name": "DUKE3D.GRP", "crc32": "2"},
|
||||
{"name": "BLOOD.RFF", "crc32": "3"},
|
||||
]
|
||||
line = _emulator_file_summary(files, lambda f: f.get("crc32") == "2")[0]
|
||||
line = _emulator_file_summary(files)[0]
|
||||
self.assertIn("**2 files** (3 accepted versions)", line)
|
||||
self.assertIn("1 in repo and 1 missing", line)
|
||||
held = _emulator_held_line(files, lambda f: f.get("crc32") == "2")[0]
|
||||
self.assertIn("1 in repo and 1 missing", held)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ class RequiredFilesComeFirst(unittest.TestCase):
|
||||
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)
|
||||
self.assertIn(f'MD5\u00a0<code title="{"ab" * 16}">{"ab" * 6}</code>', 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))
|
||||
@@ -152,7 +152,7 @@ class TheSummaryLineIsProse(unittest.TestCase):
|
||||
{"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))
|
||||
text = "\n".join(gs._emulator_file_summary(files))
|
||||
self.assertNotIn(" | ", text)
|
||||
self.assertNotIn("1399", text)
|
||||
self.assertIn("1,401 optional", text)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Emulator pages say what to get, and fit (round 3, package 7).
|
||||
|
||||
NetherSX2 counted three interface fonts its app ships among "4 required"
|
||||
files and opened its file list on the project's counts. AetherSX2's summary
|
||||
held an empty Region column that pushed Required off a phone. No MAME or
|
||||
FinalBurn page said which romset its sets must match. DuckStation's page was
|
||||
59 phone screens of optional cards, and MAME's index put 13,300 elements on
|
||||
one page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
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 validate_site # noqa: E402
|
||||
|
||||
EMPTY_DB = {"files": {}, "indexes": {"by_name": {}}}
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
|
||||
|
||||
def _profile(name: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / "emulators" / f"{name}.yml").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _page(name: str, profile: dict | None = None) -> str:
|
||||
return gs.generate_emulator_page(name, profile or _profile(name), 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 EachArcadePageNamesItsRomset(unittest.TestCase):
|
||||
def test_the_romset_by_profile(self):
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003")), "MAME 0.78")
|
||||
# The app's version is 1.38.1, its MAME is 0.289.
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame4droid-2024")), "MAME 0.289")
|
||||
self.assertEqual(gs.emulator_romset(_profile("mame2003_plus")), "MAME 0.78 (plus backports)")
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbneo")).startswith("FinalBurn Neo "))
|
||||
self.assertTrue(gs.emulator_romset(_profile("fbalpha2012")).startswith("FB Alpha "))
|
||||
self.assertEqual(gs.emulator_romset(_profile("duckstation")), "")
|
||||
# HBMAME publishes no MAME release number.
|
||||
self.assertEqual(gs.emulator_romset(_profile("hbmame")), "")
|
||||
|
||||
def test_a_row_and_a_line_under_the_intro(self):
|
||||
page = _page("mame2003")
|
||||
self.assertIn(f"| Romset | [MAME 0.78]({gs.ROMSET_FAQ}) |", page)
|
||||
self.assertNotIn("| MAME version |", _page("mame2016"))
|
||||
line = page.index("Its BIOS ZIPs must come from the MAME 0.78 romset.")
|
||||
self.assertLess(line, page.index("About this profile"))
|
||||
self.assertIn(f"[Which version each core reads]({gs.ROMSET_FAQ})", page)
|
||||
|
||||
def test_the_faq_answer_exists(self):
|
||||
faq = (REPO_ROOT / "wiki" / "technical-faq.md").read_text(encoding="utf-8")
|
||||
self.assertIn("## Which MAME version do the arcade BIOS sets match?", faq)
|
||||
|
||||
def test_no_line_without_bios_zips(self):
|
||||
self.assertNotIn("Its BIOS ZIPs", _page("duckstation"))
|
||||
|
||||
|
||||
class WhatTheAppShipsIsNoFileToGet(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.profile = _profile("nethersx2")
|
||||
self.page = _page("nethersx2", self.profile)
|
||||
|
||||
def test_the_badge(self):
|
||||
font = next(f for f in self.profile["files"] if f["name"] == "Roboto-Regular.ttf")
|
||||
badges = " ".join(gs._file_badges(font, False))
|
||||
self.assertIn(">comes with the app</span>", badges)
|
||||
self.assertNotIn(">required</span>", badges)
|
||||
self.assertNotIn(">missing</span>", badges)
|
||||
# Installed by one build only: the other still reads it from the folder.
|
||||
dual = " ".join(gs._file_badges({"name": "a", "bundled": True, "mode": "standalone",
|
||||
"required": True}, False))
|
||||
self.assertIn(">required</span>", dual)
|
||||
self.assertIn("installed with the standalone emulator", dual)
|
||||
|
||||
def test_counts_leave_them_out(self):
|
||||
shipped = sum(1 for f in self.profile["files"] if gs.comes_with_app(f))
|
||||
self.assertGreater(shipped, 0)
|
||||
summary = self.page[self.page.index("## Files"):]
|
||||
self.assertIn(f"{shipped} more files come with the app.", summary)
|
||||
self.assertIn(": 1 required,", summary)
|
||||
self.assertIn("It reads 8 files, 1 of them required", self.page)
|
||||
|
||||
def test_their_own_section_after_the_others(self):
|
||||
heads = re.findall(r"^## (.+)$", self.page, re.M)
|
||||
self.assertEqual(heads[heads.index("Files") + 1:heads.index("Files") + 3],
|
||||
["Required", "Optional"])
|
||||
self.assertIn("Comes with the app", heads)
|
||||
section = self.page[self.page.index("## Required"):self.page.index("## Optional")]
|
||||
self.assertNotIn("Roboto-Regular.ttf", section)
|
||||
|
||||
def test_the_collection_counts_go_under_the_table(self):
|
||||
held = self.page.index("<p><small>In this collection:")
|
||||
table = self.page.index("| File |")
|
||||
self.assertLess(table, held)
|
||||
self.assertLess(held, self.page.index("## Required"))
|
||||
self.assertNotIn("in repo", self.page[self.page.index("## Files"):table])
|
||||
|
||||
def test_the_folder_is_the_apps_own(self):
|
||||
self.assertIn("| BIOS directory | `bios/`, inside the app's own data folder |", self.page)
|
||||
|
||||
|
||||
class TheSummaryTableFitsItsContent(unittest.TestCase):
|
||||
def test_an_empty_region_column_goes(self):
|
||||
page = _page("aethersx2")
|
||||
table = next(line for line in page.splitlines() if line.startswith("| File |"))
|
||||
self.assertNotIn("Region", table)
|
||||
|
||||
def test_a_constant_required_column_goes(self):
|
||||
rows = gs._summary_table(_files(3, required=True), [0, 1, 2], lambda _i, s: s)
|
||||
self.assertEqual(rows[0], "| File |")
|
||||
|
||||
def test_hashes_are_short_with_the_whole_value_in_the_title(self):
|
||||
md5 = "0123456789abcdef" * 2
|
||||
cell = gs._hash_short({"md5": md5})
|
||||
self.assertEqual(cell, f'MD5 <code title="{md5}">{md5[:12]}</code>')
|
||||
|
||||
def test_the_split_index_holds_each_id_on_its_link(self):
|
||||
files = _files(gs.EMULATOR_CARDS_PER_PAGE + 1, category="bios_zip")
|
||||
main = gs.emulator_pages("big", {"emulator": "Big", "type": "standalone",
|
||||
"files": files}, {"files": {}})["big"]
|
||||
anchor = gs.emulator_file_anchors(files)[0]
|
||||
self.assertIn(f'<a id="{anchor}" href="/retrobios/emulators/big-files-1/#{anchor}">', main)
|
||||
self.assertNotIn(f'<span id="{anchor}"></span>', main)
|
||||
|
||||
|
||||
class LongPagesFold(unittest.TestCase):
|
||||
def test_optional_cards_fold_below_the_table(self):
|
||||
files = _files(gs.EMULATOR_SUMMARY_MIN) + [{"name": "req.bin", "required": True}]
|
||||
page = _page("x", {"type": "standalone", "files": files})
|
||||
required = page[page.index("## Required"):page.index("## Optional")]
|
||||
self.assertNotIn("<details", required)
|
||||
optional = page[page.index("## Optional"):]
|
||||
self.assertTrue(optional.split("\n", 3)[2].startswith("<details class=\"rb-details"))
|
||||
self.assertIn(f"<summary>{gs.EMULATOR_SUMMARY_MIN} files</summary>", optional)
|
||||
|
||||
def test_a_few_cards_stay_open(self):
|
||||
page = _page("x", {"type": "standalone", "files": _files(2)})
|
||||
self.assertNotIn("<summary>2 files</summary>", page)
|
||||
|
||||
def test_the_profile_card_folds(self):
|
||||
page = _page("duckstation")
|
||||
start = page.index("<summary>About this profile</summary>")
|
||||
self.assertLess(start, page.index('<div class="rb-meta-card">'))
|
||||
self.assertLess(page.index("| Type |"), page.index("</details>", start))
|
||||
|
||||
|
||||
class DataDirectoryPagesStayOutOfSearch(unittest.TestCase):
|
||||
def test_front_matter(self):
|
||||
saved = dict(gs._DATA_MEMBERS), dict(gs._DATA_READERS)
|
||||
self.addCleanup(lambda: (gs._DATA_MEMBERS.clear(), gs._DATA_MEMBERS.update(saved[0]),
|
||||
gs._DATA_READERS.clear(), gs._DATA_READERS.update(saved[1])))
|
||||
gs._DATA_MEMBERS["dolphin-sys"] = {"a.bin": {"sha1": "a" * 40, "size": 1}}
|
||||
gs._DATA_READERS["dolphin-sys"] = [("dolphin-mmjr", "Dolphin MMJR"), ("dolphin", "Dolphin")]
|
||||
page = gs._data_tree_page("dolphin-sys", {}, [("data-dolphin-sys", ["a.bin"])], 0)
|
||||
front = yaml.safe_load(page.split("---\n")[1])
|
||||
self.assertEqual(front["search"], "exclude")
|
||||
self.assertEqual(front["parent"], {"label": "Dolphin", "link": "dolphin.md"})
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "data-dolphin-sys").is_dir(), "site not built")
|
||||
def test_the_built_page_is_not_indexed(self):
|
||||
html = (DIST / "emulators" / "data-dolphin-sys" / "index.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("data-pagefind-body", html)
|
||||
|
||||
|
||||
class AnElementBudget(unittest.TestCase):
|
||||
def test_validate_site_reports_a_crowded_page(self):
|
||||
self.assertEqual(validate_site.MAX_PAGE_ELEMENTS, 10_000)
|
||||
(REPO_ROOT / "tmp").mkdir(exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
||||
page = Path(tmp) / "crowded" / "index.html"
|
||||
page.parent.mkdir()
|
||||
page.write_text("<html><body>" + "<i></i>" * 10_001
|
||||
+ "<script>" + "<b>" * 5 + "</script></body></html>")
|
||||
issues = validate_site.validate_site(Path(tmp))
|
||||
self.assertTrue(any("elements, over the 10,000 budget" in issue for issue in issues))
|
||||
|
||||
@unittest.skipUnless((DIST / "emulators" / "mame" / "index.html").is_file(), "site not built")
|
||||
def test_mame_is_under_it(self):
|
||||
page = validate_site._parse_page(DIST / "emulators" / "mame" / "index.html")
|
||||
self.assertLess(page.elements, validate_site.MAX_PAGE_ELEMENTS)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -100,7 +100,7 @@ class AnEmulatorWithTooManyCardsIsSplit(unittest.TestCase):
|
||||
main = pages["big"]
|
||||
anchors = generate_site.emulator_file_anchors(files)
|
||||
for anchor in anchors:
|
||||
self.assertIn(f'<span id="{anchor}"></span>', main)
|
||||
self.assertIn(f'<a id="{anchor}" href=', main)
|
||||
self.assertIn('class="rb-card-index"', main)
|
||||
self.assertNotIn("rb-file-title", main)
|
||||
self.assertIn(f'id="{anchors[0]}" class="rb-file-title"', pages["big-files-1"])
|
||||
|
||||
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
|
||||
| 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_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `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_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `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_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `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_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `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_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
|
||||
Reference in new issue
Block a user