feat: round 3 emulator pages

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 11:02:31 +02:00
commit 62d0d62968
7 files changed
+421 -83

No files matched your search

+201 -76
View File
@@ -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 [
+15
View File
@@ -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))}")
+4 -3
View File
@@ -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)
+2 -2
View File
@@ -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)
+197
View File
@@ -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()
+1 -1
View File
@@ -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"])
+1 -1
View File
@@ -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