mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
chore: merge platform file rows
This commit is contained in:
commit
917ab7c94b
13 files changed
+867
-159
No files matched your search
+292
-125
@@ -3242,41 +3242,35 @@ def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -
|
||||
return f"{page.rstrip()}\n\n{table}"
|
||||
|
||||
|
||||
# A platform system whose file list passes this much Markdown gets a page
|
||||
# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page.
|
||||
PLATFORM_SECTION_BUDGET = 200_000
|
||||
# A system's own page in runs of at most this much Markdown: RetroDECK's
|
||||
# arcade list, each file with its served copy and pinned readers, rendered
|
||||
# 1.4 MB of HTML on one page.
|
||||
PLATFORM_PART_BUDGET = 350_000
|
||||
# The Markdown of a platform page's file lists, past which the largest
|
||||
# A platform's file lists are split by the share of a page they render to,
|
||||
# in thousandths: their elements against validate_site's MAX_PAGE_ELEMENTS,
|
||||
# their bytes against the 600,000 of test_site_r2_gate, whichever is
|
||||
# larger. Measured in Markdown characters, the lists split anew whenever
|
||||
# their markup changed: rows written as tables moved 38 more systems to
|
||||
# pages of their own.
|
||||
PAGE_ELEMENTS = 10_000
|
||||
PAGE_BYTES = 600_000
|
||||
# A system whose list passes this share gets a page of its own.
|
||||
PLATFORM_SECTION_BUDGET = 400
|
||||
# A system's own page in runs of at most this share.
|
||||
PLATFORM_PART_BUDGET = 700
|
||||
# The share of a platform page's file lists, past which the largest
|
||||
# systems move to pages of their own.
|
||||
PLATFORM_REFERENCE_BUDGET = 250_000
|
||||
PLATFORM_REFERENCE_BUDGET = 600
|
||||
|
||||
|
||||
def _file_runs(body: list[str], budget: int) -> list[list[str]]:
|
||||
"""A system's file list in runs of at most ``budget`` characters.
|
||||
# Every part page a platform list has had, with the system it held:
|
||||
# platforms/batocera-atari-st.md -> platforms/batocera.md#atari-st. When a
|
||||
# budget or a list changes, a part can fold back into its platform page,
|
||||
# and its address then redirects there instead of ending on the 404 page.
|
||||
PLATFORM_PARTS_FILE = (Path(__file__).resolve().parent.parent
|
||||
/ "website" / "src" / "data" / "platform-parts.json")
|
||||
|
||||
The lines before the first file (counts, emulators) open every run. A
|
||||
file is never cut from its hash and evidence lines.
|
||||
"""
|
||||
first = next((i for i, line in enumerate(body) if line.startswith("- ")), len(body))
|
||||
head, items = body[:first], []
|
||||
for line in body[first:]:
|
||||
if line.startswith("- ") or not items:
|
||||
items.append([line])
|
||||
else:
|
||||
items[-1].append(line)
|
||||
runs: list[list[str]] = [[]]
|
||||
size = 0
|
||||
for item in items:
|
||||
weight = sum(len(line) + 1 for line in item)
|
||||
if runs[-1] and size + weight > budget:
|
||||
runs.append([])
|
||||
size = 0
|
||||
runs[-1].extend(item)
|
||||
size += weight
|
||||
return [head + run for run in runs]
|
||||
|
||||
def _published_platform_parts() -> dict[str, str]:
|
||||
if not PLATFORM_PARTS_FILE.is_file():
|
||||
return {}
|
||||
return json.loads(PLATFORM_PARTS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# A list moved off a reference page (gaps, provenance) is cut in parts of
|
||||
@@ -3284,13 +3278,18 @@ def _file_runs(body: list[str], budget: int) -> list[list[str]]:
|
||||
LIST_PART_BUDGET = 300_000
|
||||
|
||||
|
||||
def _blocks_in_runs(blocks: list[list[str]], budget: int) -> list[list[str]]:
|
||||
"""Blocks (table rows, folded lists) in runs of at most ``budget``
|
||||
characters, a block never cut."""
|
||||
def _characters(lines: list[str]) -> int:
|
||||
return sum(len(line) + 1 for line in lines)
|
||||
|
||||
|
||||
def _blocks_in_runs(blocks: list[list[str]], budget: int,
|
||||
weigh: Callable[[list[str]], int] = _characters) -> list[list[str]]:
|
||||
"""Blocks (table rows, folded lists) in runs of at most ``budget``, by
|
||||
default in characters, a block never cut."""
|
||||
runs: list[list[str]] = [[]]
|
||||
size = 0
|
||||
for block in blocks:
|
||||
weight = sum(len(line) + 1 for line in block)
|
||||
weight = weigh(block)
|
||||
if runs[-1] and size + weight > budget:
|
||||
runs.append([])
|
||||
size = 0
|
||||
@@ -3427,6 +3426,188 @@ def _launcher_refs(platform: str, config: dict) -> dict[str, list[str]]:
|
||||
return found
|
||||
|
||||
|
||||
# The characters of a hash a row shows, as many as the served copy's sha1.
|
||||
SHORT_HASH = 8
|
||||
# The hash a row shows first, when the list does not say which it checks.
|
||||
_ROW_HASHES = ("sha1", "md5", "sha256", "crc32")
|
||||
# An untested entry whose reason is a hash the bytes do not match.
|
||||
_WRONG_HASH = re.compile(r"hash|mismatch|expected|must match", re.IGNORECASE)
|
||||
# A list this long gets a filter box above it, as a table of RECORD_ROWS
|
||||
# rows does (website/src/plugins/rehype-stack-tables.mjs).
|
||||
FILTER_ROWS = 20
|
||||
|
||||
|
||||
def _status_chip(f: dict) -> str:
|
||||
"""The row's status as a chip, its word and its colour saying the same."""
|
||||
status = f.get("status")
|
||||
if status == "ok":
|
||||
word, tone = "OK", "success"
|
||||
elif status == "missing":
|
||||
word, tone = "Missing", "danger"
|
||||
elif status == "untested":
|
||||
word = "Wrong hash" if _WRONG_HASH.search(str(f.get("reason") or "")) else "Untested"
|
||||
tone = "warning"
|
||||
else:
|
||||
word, tone = str(status), "muted"
|
||||
return f'<span class="rb-badge rb-badge-{tone}">{html.escape(word)}</span>'
|
||||
|
||||
|
||||
def _copy_hash(algorithm: str, value: str | list) -> str:
|
||||
"""A declared hash cut to SHORT_HASH characters on a button that copies
|
||||
it whole. A list of accepted values (Recalbox) copies its first. Outside
|
||||
a form a button submits nothing, so it carries no type."""
|
||||
from hashing import parse_hash_list # noqa: PLC0415
|
||||
|
||||
values = parse_hash_list(value)
|
||||
if not values:
|
||||
return ""
|
||||
first = html.escape(values[0])
|
||||
more = f" +{len(values) - 1}" if len(values) > 1 else ""
|
||||
return (f'<button class="rb-copy" data-copy="{first}" '
|
||||
f'aria-label="Copy {algorithm.upper()} {first[:SHORT_HASH]}{more}">'
|
||||
f'{first[:SHORT_HASH]}{more}</button>')
|
||||
|
||||
|
||||
# Characters Markdown or MDX would read inside the raw HTML of a row: an
|
||||
# MDX page parses the text of its HTML as Markdown, a .md page does not.
|
||||
_INERT = str.maketrans({"*": "*", "_": "_", "`": "`", "[": "[", "]": "]"})
|
||||
_INLINE_MD = re.compile(
|
||||
r"\[(?P<label>(?:`[^`]*`|[^\]`])+)\]\((?P<url>[^)\s]+)\)"
|
||||
r"|`(?P<code>[^`]+)`"
|
||||
r"|\*\*(?P<bold>[^*]+)\*\*"
|
||||
)
|
||||
|
||||
|
||||
def _text_html(text: str) -> str:
|
||||
"""Plain text as HTML that neither Markdown nor MDX rewrites."""
|
||||
return html.escape(html.unescape(text), quote=False).translate(_INERT)
|
||||
|
||||
|
||||
def _inline_html(markdown: str) -> str:
|
||||
"""The inline Markdown of a row's evidence (links, code, bold) as HTML.
|
||||
|
||||
Code spans become plain text and a linked path loses its code element:
|
||||
one per reference and per reader was the bulk of the elements of a
|
||||
platform page, and the words say the same without them.
|
||||
"""
|
||||
out: list[str] = []
|
||||
position = 0
|
||||
for match in _INLINE_MD.finditer(markdown):
|
||||
out.append(_text_html(markdown[position:match.start()]))
|
||||
if match["url"]:
|
||||
out.append(f'<a href="{html.escape(match["url"])}">'
|
||||
f'{_text_html(match["label"].replace("`", ""))}</a>')
|
||||
elif match["code"]:
|
||||
out.append(_text_html(match["code"]))
|
||||
else:
|
||||
out.append(f"<strong>{_text_html(match['bold'])}</strong>")
|
||||
position = match.end()
|
||||
out.append(_text_html(markdown[position:]))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _row_path(fname: str, cfg_entry: dict) -> str:
|
||||
"""Where the file goes, when its name alone does not say: three GameCube
|
||||
IPL.bin differ only by GC/USA, GC/EUR and GC/JAP. A destination that is
|
||||
the name says nothing, and one that ends with it shows its folder."""
|
||||
destination = str(cfg_entry.get("destination") or "")
|
||||
if destination.endswith("/" + fname):
|
||||
return destination[:len(destination) - len(fname)]
|
||||
return "" if destination == fname else destination
|
||||
|
||||
|
||||
def _platform_file_row(f: dict, cfg_entry: dict, hash_type: str = "",
|
||||
launched: list[str] | None = None) -> str:
|
||||
"""One file of a platform's list as one row, written as HTML.
|
||||
|
||||
The row is a details element whose summary holds the name, the folder
|
||||
when there is one and the status, followed by the short hash on its copy
|
||||
button: a button cannot sit in a summary, which is itself a control.
|
||||
The details hold every hash the platform declares, the size, the copy
|
||||
that serves it and its readers, across the width of the list.
|
||||
"""
|
||||
fname = f["name"]
|
||||
hashes = {
|
||||
"sha1": cfg_entry.get("sha1", f.get("sha1", "")),
|
||||
"md5": cfg_entry.get("md5", f.get("expected_md5", "")),
|
||||
# The crc32 is what System.dat and clrmamepro print. RetroDECK
|
||||
# declares sha256 too (dsifirmware.bin).
|
||||
"crc32": str(cfg_entry.get("crc32") or ""),
|
||||
"sha256": str(cfg_entry.get("sha256") or ""),
|
||||
}
|
||||
size = cfg_entry.get("size", f.get("size", 0))
|
||||
shown = next((alg for alg in (hash_type, *_ROW_HASHES) if alg in hashes and hashes[alg]), "")
|
||||
|
||||
facts: list[str] = []
|
||||
# One code element per value: Recalbox lists accepted md5s joined by
|
||||
# commas, and each is a value a reader compares a dump with.
|
||||
facts.extend(f"{alg.upper()}: {_hash_spans(hashes[alg])}"
|
||||
for alg in ("sha1", "md5", "crc32", "sha256") if hashes[alg])
|
||||
# The size closes the hash lines: a line of its own was one more
|
||||
# element per row.
|
||||
if size and facts:
|
||||
facts[-1] += f" ({_fmt_size(size)})"
|
||||
elif size:
|
||||
facts.append(f"Size: {_fmt_size(size)}")
|
||||
if f.get("status") == "untested" and f.get("reason"):
|
||||
facts.append(_text_html(f"Not confirmed: {f['reason']}"))
|
||||
# Not every entry is firmware: the platform may list a game's data or a
|
||||
# file no core it runs opens.
|
||||
kind = {"game_data": "Game data, not firmware",
|
||||
"unread": "No core the platform runs opens it"}.get(f.get("category", ""))
|
||||
if kind:
|
||||
facts.append(kind)
|
||||
served = _served_line(f)
|
||||
if served:
|
||||
facts.append(_inline_html(served))
|
||||
if f.get("category_evidence"):
|
||||
facts.append(_inline_html(_category_evidence_md(f["category_evidence"], launched)))
|
||||
|
||||
path = _row_path(fname, cfg_entry)
|
||||
summary = (_text_html(fname) + (f"<code>{_text_html(path)}</code>" if path else "")
|
||||
+ _status_chip(f))
|
||||
copy = _copy_hash(shown, hashes[shown]) if shown else ""
|
||||
return (f'<details class="rb-file"><summary>{summary}</summary>'
|
||||
f'{"<br>".join(facts)}</details>{copy}')
|
||||
|
||||
|
||||
def _platform_file_list(rows: list[str], has_path: bool, anchor: str) -> list[str]:
|
||||
"""A system's rows as one HTML block, with a filter box when long.
|
||||
|
||||
The list drops its path column when no row has one, so a blank cell
|
||||
never reads as missing data.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if len(rows) >= FILTER_ROWS:
|
||||
field = f"rb-files-filter-{anchor}"
|
||||
lines += [
|
||||
'<div class="rb-table-filter">'
|
||||
f'<label for="{field}">Filter files</label>'
|
||||
f'<input id="{field}" type="search" autocomplete="off" placeholder="File, path, status, hash...">'
|
||||
f'<span class="rb-table-filter-count" aria-live="polite">{len(rows):,} rows</span></div>',
|
||||
"",
|
||||
]
|
||||
kind = "rb-files rb-files-path" if has_path else "rb-files"
|
||||
return [*lines, f'<div class="{kind}">', *rows, "</div>"]
|
||||
|
||||
|
||||
# The elements a line puts on the page: its HTML tags, and the links and
|
||||
# code spans of the Markdown before a list.
|
||||
_ELEMENT = re.compile(r"<[a-zA-Z]|\]\(|`[^`]+`")
|
||||
|
||||
|
||||
def _element_weight(lines: list[str]) -> int:
|
||||
"""How many elements the lines render to."""
|
||||
return sum(len(_ELEMENT.findall(line)) for line in lines)
|
||||
|
||||
|
||||
def _page_share(lines: list[str]) -> float:
|
||||
"""The share of a page the lines render to, in thousandths: the rows
|
||||
are HTML already, so their length is their weight in bytes."""
|
||||
return max(_element_weight(lines) * 1000 / PAGE_ELEMENTS,
|
||||
_characters(lines) * 1000 / PAGE_BYTES)
|
||||
|
||||
|
||||
def _render_platform_systems(
|
||||
by_system: dict, config_files: dict, emulator_files: dict,
|
||||
platform: tuple[str, str] | None = None, moved: dict[str, str] | None = None,
|
||||
@@ -3454,102 +3635,42 @@ def _render_platform_systems(
|
||||
|
||||
sys_emus = _system_emulators(sys_id, emulator_files, by_system_declared.get(sys_id))
|
||||
anchor = sys_id.replace(" ", "-")
|
||||
body = [f"{ok_count}/{total} files verified"]
|
||||
head = [f"{ok_count}/{total} files verified"]
|
||||
if sys_emus:
|
||||
emu_links = ", ".join(_emulator_named_link(e, "../") for e in sorted(sys_emus))
|
||||
body[-1] += "\\"
|
||||
body.append(f"Emulators: {emu_links}")
|
||||
body.append("")
|
||||
head[-1] += "\\"
|
||||
head.append(f"Emulators: {emu_links}")
|
||||
head.append("")
|
||||
|
||||
# File listing. The sort is stable, so same-named entries keep the
|
||||
# order verify reported them in, which is the declaration order.
|
||||
# One row per file, the evidence in its details. The sort is stable,
|
||||
# so same-named entries keep the order verify reported them in,
|
||||
# which is the declaration order.
|
||||
rows: list[str] = []
|
||||
has_path = False
|
||||
seen_names: dict[str, int] = {}
|
||||
for f in sorted(files, key=lambda x: x["name"]):
|
||||
status = f["status"]
|
||||
fname = f["name"]
|
||||
declared = config_files.get((sys_id, fname), [])
|
||||
nth = seen_names.get(fname, 0)
|
||||
seen_names[fname] = nth + 1
|
||||
cfg_entry = declared[nth] if nth < len(declared) else {}
|
||||
sha1 = cfg_entry.get("sha1", f.get("sha1", ""))
|
||||
md5 = cfg_entry.get("md5", f.get("expected_md5", ""))
|
||||
size = cfg_entry.get("size", f.get("size", 0))
|
||||
has_path = has_path or bool(_row_path(fname, cfg_entry))
|
||||
rows.append(_platform_file_row(
|
||||
f, cfg_entry, str((config or {}).get("hash_type") or ""),
|
||||
launched.get(fname.rsplit("/", 1)[-1].casefold())))
|
||||
body = head + _platform_file_list(rows, has_path, anchor)
|
||||
|
||||
if status == "ok":
|
||||
status_display = "OK"
|
||||
elif status == "untested":
|
||||
reason = f.get("reason", "")
|
||||
status_display = f"untested: {reason}" if reason else "untested"
|
||||
elif status == "missing":
|
||||
status_display = "**missing**"
|
||||
else:
|
||||
status_display = status
|
||||
# Not every entry is firmware: the platform may list a game's data
|
||||
# or a file no core it runs opens.
|
||||
kind = {"game_data": "game data",
|
||||
"unread": "no core the platform runs opens it"}.get(f.get("category", ""))
|
||||
if kind:
|
||||
status_display += f", {kind}"
|
||||
|
||||
size_str = _fmt_size(size) if size else ""
|
||||
details = [status_display]
|
||||
if size_str:
|
||||
details.append(size_str)
|
||||
|
||||
# Where the file goes, when its name alone does not say: three
|
||||
# GameCube IPL.bin differ only by GC/USA, GC/EUR and GC/JAP.
|
||||
destination = str(cfg_entry.get("destination") or "")
|
||||
where = f" at `{destination}`" if destination and destination != fname else ""
|
||||
body.append(f"- `{fname}`{where} - {', '.join(details)}")
|
||||
item_line = len(body) - 1
|
||||
facts: list[str] = []
|
||||
# Every hash the platform declares, whole: the crc32 is what
|
||||
# System.dat and clrmamepro print, and pasting it found nothing
|
||||
# for a pinned file the collection holds under other bytes.
|
||||
crc32 = str(cfg_entry.get("crc32") or "")
|
||||
# RetroDECK declares sha256 too: dsifirmware.bin's was in the CSV
|
||||
# exports only, and pasting it into the search found nothing.
|
||||
sha256 = str(cfg_entry.get("sha256") or "")
|
||||
if sha1 or md5 or crc32 or sha256:
|
||||
hash_parts = []
|
||||
# One code span per value: Recalbox lists accepted md5s joined
|
||||
# by commas, and the search indexed the joined run as a single
|
||||
# word, so the second and later values were never found.
|
||||
if sha1:
|
||||
hash_parts.append(f"SHA1: {_hash_spans(sha1)}")
|
||||
if md5:
|
||||
hash_parts.append(f"MD5: {_hash_spans(md5)}")
|
||||
if crc32:
|
||||
hash_parts.append(f"CRC32: `{crc32}`")
|
||||
if sha256:
|
||||
hash_parts.append(f"SHA256: `{sha256}`")
|
||||
facts.append(" | ".join(hash_parts))
|
||||
served = _served_line(f)
|
||||
if served:
|
||||
facts.append(served)
|
||||
if f.get("category_evidence"):
|
||||
facts.append(_category_evidence_md(
|
||||
f["category_evidence"], launched.get(fname.rsplit("/", 1)[-1].casefold())))
|
||||
# One line each, hashes, the copy that serves it, its readers: as
|
||||
# continuation lines they ran into one paragraph.
|
||||
if facts:
|
||||
body[item_line] += "\\"
|
||||
body.extend(f" {fact}" + ("\\" if n < len(facts) - 1 else "")
|
||||
for n, fact in enumerate(facts))
|
||||
|
||||
# Measured as the Markdown the split was tuned on, a collapsible
|
||||
# block indented by four, so both engines split the same systems.
|
||||
size = sum(len(line) + 1 for line in body)
|
||||
# Measured in the share of a page the list renders to.
|
||||
size = _page_share(body)
|
||||
if use_collapsible:
|
||||
status_tag = "OK" if ok_count == total else _count(total - ok_count, "issue")
|
||||
title = f"{_count(total, 'file')} ({status_tag})"
|
||||
size += len(f'??? note "{title}"') + 2 + 4 * sum(1 for line in body if line)
|
||||
block = markup.details("note", title, body)
|
||||
block = markup.details("note", f"{_count(total, 'file')} ({status_tag})", body)
|
||||
while block and not block[-1]:
|
||||
block.pop()
|
||||
else:
|
||||
block = body
|
||||
sections.append({"sys_id": sys_id, "anchor": anchor, "body": body, "block": block,
|
||||
sections.append({"sys_id": sys_id, "anchor": anchor, "head": head, "rows": rows,
|
||||
"has_path": has_path, "block": block,
|
||||
"size": size, "total": total, "ok": ok_count})
|
||||
|
||||
# A list longer than its budget goes to a page of its own, then the
|
||||
@@ -3568,7 +3689,7 @@ def _render_platform_systems(
|
||||
kept -= sec["size"]
|
||||
|
||||
for sec in sections:
|
||||
sys_id, anchor, body = sec["sys_id"], sec["anchor"], sec["body"]
|
||||
sys_id, anchor = sec["sys_id"], sec["anchor"]
|
||||
total, ok_count = sec["total"], sec["ok"]
|
||||
# Every system is a heading with the same anchor in both layouts:
|
||||
# emulator cards link here, and the site search splits a page at its
|
||||
@@ -3578,7 +3699,9 @@ def _render_platform_systems(
|
||||
lines.append("")
|
||||
if moved is not None and platform and sys_id in own_page:
|
||||
key, display = platform
|
||||
runs = _file_runs(body, PLATFORM_PART_BUDGET)
|
||||
runs = [sec["head"] + _platform_file_list(run, sec["has_path"], anchor)
|
||||
for run in _blocks_in_runs([[row] for row in sec["rows"]],
|
||||
PLATFORM_PART_BUDGET, _page_share)]
|
||||
stems = [f"{key}-{anchor}" + (f"-{n}" if n > 1 else "")
|
||||
for n in range(1, len(runs) + 1)]
|
||||
for n, (stem, run) in enumerate(zip(stems, runs), 1):
|
||||
@@ -3595,15 +3718,18 @@ def _render_platform_systems(
|
||||
*([""] + [", ".join(
|
||||
f"[part {i}]({other}.md)" if other != stem else f"part {i}"
|
||||
for i, other in enumerate(stems, 1))] if len(runs) > 1 else []),
|
||||
"", "## Files", "",
|
||||
"", "## Files", "", *markup.details("note", ROLE_LEGEND_TITLE, [ROLE_LEGEND]),
|
||||
*search_excluded(run),
|
||||
"", _date_line(),
|
||||
]) + "\n"
|
||||
where = (f"[a page of their own]({stems[0]}.md)" if len(stems) == 1 else
|
||||
"pages of their own: " + ", ".join(
|
||||
f"[part {i}]({stem}.md)" for i, stem in enumerate(stems, 1)))
|
||||
# The count and status a folded system shows in its summary,
|
||||
# linked to the page that lists the files.
|
||||
tag = "OK" if ok_count == total else _count(total - ok_count, "issue")
|
||||
count = f"{_count(total, 'file')} ({tag})"
|
||||
lines.extend([
|
||||
f"{_count(total, 'file')}, {ok_count} verified: listed on {where}.", "",
|
||||
f"[{count}]({stems[0]}.md)" if len(stems) == 1 else f"{count}: " + ", ".join(
|
||||
f"[part {i}]({stem}.md)" for i, stem in enumerate(stems, 1)),
|
||||
"",
|
||||
])
|
||||
continue
|
||||
# The section's heading stays searchable, its file list with every
|
||||
@@ -3639,6 +3765,40 @@ def _site_profiles() -> dict[str, dict]:
|
||||
return load_emulator_profiles(str(_REPO_ROOT / "emulators"))
|
||||
|
||||
|
||||
# Whose code a source reference cites, in one word: a platform page names
|
||||
# each role once in ROLE_LEGEND. Written after every reference, "(emulator
|
||||
# code, libretro port)" ran through Batocera's 14,000 words.
|
||||
ROLE_WORDS = {
|
||||
"libretro glue": "glue",
|
||||
"emulator code, libretro port": "port",
|
||||
"upstream emulator": "upstream",
|
||||
"this build": "build",
|
||||
}
|
||||
ROLE_LEGEND_TITLE = "Source reference words"
|
||||
ROLE_LEGEND = (
|
||||
"In a file's details, a word before its source references says whose code they are. "
|
||||
"**glue** means libretro glue, **port** means emulator code in the libretro port, "
|
||||
"**upstream** means the upstream emulator and **build** means this build's own code."
|
||||
)
|
||||
|
||||
|
||||
def _refs_by_role(profile: dict, value: str | dict | None) -> str:
|
||||
"""A source_ref's parts grouped by whose code they cite, each role named
|
||||
once by its ROLE_WORDS word: `glue a.c:1, b.c:2 and port c.c:3`."""
|
||||
from sitesources import source_ref_items
|
||||
|
||||
texts = []
|
||||
for label, items in source_ref_items(profile, value):
|
||||
by_role: dict[str, list[str]] = {}
|
||||
for part, role in items:
|
||||
by_role.setdefault(role, []).append(part)
|
||||
text = " and ".join(
|
||||
(f"{ROLE_WORDS.get(role, role)} " if role else "") + ", ".join(parts)
|
||||
for role, parts in by_role.items())
|
||||
texts.append(f"**{label}:** {text}" if label else text)
|
||||
return ", ".join(texts)
|
||||
|
||||
|
||||
def _category_evidence_md(evidence: dict, launched: list[str] | None = None) -> str:
|
||||
"""What establishes an entry's category, as the page shows it.
|
||||
|
||||
@@ -3654,7 +3814,7 @@ def _category_evidence_md(evidence: dict, launched: list[str] | None = None) ->
|
||||
# plain code with the role word ("source") left in front.
|
||||
profile = profiles.get(reader["emulator"], {})
|
||||
refs = [
|
||||
(f"`{e['name']}` at {_source_ref_markdown(profile, e['source_ref'])}"
|
||||
(f"`{e['name']}` at {_refs_by_role(profile, e['source_ref'])}"
|
||||
if e.get("source_ref") else f"`{e['name']}`")
|
||||
+ (" (by name)" if e.get("by") == "name" else "")
|
||||
for e in reader.get("entries", [])
|
||||
@@ -4177,8 +4337,7 @@ def generate_platform_page(
|
||||
"",
|
||||
level_label("reference"),
|
||||
"",
|
||||
"For checking the work: the counts, where the list comes from, and "
|
||||
"every file with its hashes.",
|
||||
"For checking the work: counts, the list's source and each file's hashes.",
|
||||
"",
|
||||
*(line for note in (GUIDES[name].reference_notes if name in GUIDES else ())
|
||||
for line in (note, "")),
|
||||
@@ -4309,6 +4468,8 @@ def generate_platform_page(
|
||||
f"| <span data-pagefind-ignore>{ok_count}/{total}</span> | {status} | {emu_str} |"
|
||||
)
|
||||
lines.append("")
|
||||
if by_system:
|
||||
lines.extend(markup.details("note", ROLE_LEGEND_TITLE, [ROLE_LEGEND]))
|
||||
|
||||
lines.extend(
|
||||
_render_platform_systems(by_system, config_files, emulator_files,
|
||||
@@ -11749,6 +11910,7 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
)
|
||||
if HANDHELD_GUIDE:
|
||||
write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
|
||||
published_parts = _published_platform_parts()
|
||||
for name, cov in coverages.items():
|
||||
# The emulators this platform runs, not every one of the system.
|
||||
runs = resolve_platform_cores(cov["config"], profiles)
|
||||
@@ -11763,6 +11925,9 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
if stem in coverages:
|
||||
raise RuntimeError(f"a system page takes the platform {stem}'s address")
|
||||
PART_PAGES.add(f"platforms/{stem}.md")
|
||||
anchor = re.search(rf"\]\({re.escape(name)}\.md#([^)\s]+)\)", page)
|
||||
if anchor:
|
||||
published_parts[f"platforms/{stem}.md"] = f"platforms/{name}.md#{anchor[1]}"
|
||||
# One console of one setup's list, a reference: "playstation
|
||||
# bios" found five setups' lists before the console's page.
|
||||
# Weighed down, they still won on their title, which the page
|
||||
@@ -11934,6 +12099,8 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
stale += _sweep_top_pages(docs)
|
||||
|
||||
print("Adding page metadata and structured data...")
|
||||
write_if_changed(str(PLATFORM_PARTS_FILE),
|
||||
json.dumps(dict(sorted(published_parts.items())), indent=1) + "\n")
|
||||
lastmod = decorate_markdown_pages(docs)
|
||||
|
||||
nav = generate_nav(coverages, manufacturers, profiles, registry,
|
||||
@@ -11950,7 +12117,7 @@ def _generate(args: argparse.Namespace) -> None:
|
||||
json.dumps(sitestarlight.sidebar(nav, present, _overview_anchors(nav, docs)),
|
||||
indent=1) + "\n")
|
||||
write_if_changed(str(generated / "redirects.json"),
|
||||
json.dumps(sitestarlight.redirects(profiles, present), indent=1,
|
||||
json.dumps(sitestarlight.redirects(profiles, present, published_parts), indent=1,
|
||||
sort_keys=True) + "\n")
|
||||
write_if_changed(str(generated / "lastmod.json"),
|
||||
json.dumps(dict(sorted(lastmod.items())), indent=1) + "\n")
|
||||
|
||||
+27
-12
@@ -425,6 +425,24 @@ def _two_repositories(profile: dict) -> bool:
|
||||
def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
|
||||
"""Render source_ref values as pinned links when their forge is known.
|
||||
|
||||
Each part is followed by whose code it cites, in words, when the profile
|
||||
reads two repositories. source_ref_items() gives the same parts with
|
||||
that role apart, for a page that names the roles once in a legend.
|
||||
"""
|
||||
rendered_groups = []
|
||||
for label, items in source_ref_items(profile, value):
|
||||
text = ", ".join(f"{part} ({role})" if role else part for part, role in items)
|
||||
if label:
|
||||
text = f"**{label}:** {text}"
|
||||
rendered_groups.append(text)
|
||||
return ", ".join(rendered_groups)
|
||||
|
||||
|
||||
def source_ref_items(profile: dict, value: str | dict | None) -> list[tuple[str, list[tuple[str, str]]]]:
|
||||
"""Each group of source_ref values: its label and its parts, each part
|
||||
as Markdown with the role_label() of the code it cites ("" when the
|
||||
profile reads one repository).
|
||||
|
||||
A profile can declare two repositories: the libretro port in ``source`` and
|
||||
the original emulator in ``upstream``. A path links into the one whose
|
||||
tree SOURCE_PATHS says holds it. Any other path is rendered as plain
|
||||
@@ -432,18 +450,18 @@ def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
|
||||
link to the wrong repository is a false citation. An address inside a
|
||||
shipped binary reads as one (offset 0x8094-0x80ac in libnes.so).
|
||||
"""
|
||||
rendered_groups: list[str] = []
|
||||
groups: list[tuple[str, list[tuple[str, str]]]] = []
|
||||
path_re = re.compile(r"^[A-Za-z0-9_.@/+~-]+$")
|
||||
for label, refs in source_ref_values(value):
|
||||
candidates = _forge_sources(profile, label)
|
||||
by_role = role_sources(profile, label)
|
||||
rendered: list[str] = []
|
||||
rendered: list[tuple[str, str]] = []
|
||||
current_role = ""
|
||||
binary = ""
|
||||
for part in split_source_ref(refs):
|
||||
if is_binary_citation(part.path):
|
||||
text, binary = _binary_citation(part.path, binary)
|
||||
rendered.append(text)
|
||||
rendered.append((text, ""))
|
||||
continue
|
||||
binary = ""
|
||||
role, bare = ref_role(part.path)
|
||||
@@ -473,22 +491,19 @@ def _source_ref_markdown(profile: dict, value: str | dict | None) -> str:
|
||||
else:
|
||||
selected = _holder(candidates, part.path)
|
||||
|
||||
tag = (f" ({role_label(profile, current_role, part.path)})"
|
||||
if current_role and _two_repositories(profile) else "")
|
||||
role = (role_label(profile, current_role, part.path)
|
||||
if current_role and _two_repositories(profile) else "")
|
||||
if selected is None:
|
||||
rendered.append(f"`{display}`{tag}")
|
||||
rendered.append((f"`{display}`", role))
|
||||
else:
|
||||
repo, pin = selected
|
||||
url = _source_permalink(
|
||||
repo, pin, real_path, part.start, part.end
|
||||
)
|
||||
rendered.append(f"[`{display}`]({url}){tag}")
|
||||
rendered.append((f"[`{display}`]({url})", role))
|
||||
|
||||
text = ", ".join(rendered) if rendered else f"`{refs}`"
|
||||
if label:
|
||||
text = f"**{label}:** {text}"
|
||||
rendered_groups.append(text)
|
||||
return ", ".join(rendered_groups)
|
||||
groups.append((label, rendered or [(f"`{refs}`", "")]))
|
||||
return groups
|
||||
|
||||
|
||||
def _binary_citation(path: str, previous: str) -> tuple[str, str]:
|
||||
|
||||
@@ -809,19 +809,23 @@ MOVED_PAGES = {"wiki/getting-started.md": "which-pack.md"}
|
||||
_CORE_NAME = re.compile(r"[a-z0-9_-]+")
|
||||
|
||||
|
||||
def redirects(profiles: dict, present: set[str]) -> dict[str, str]:
|
||||
def redirects(profiles: dict, present: set[str],
|
||||
moved: dict[str, str] | None = None) -> dict[str, str]:
|
||||
"""Old and alternative addresses, for src/generated/redirects.json.
|
||||
|
||||
A page that moved, once its source is gone, and each name a profile's
|
||||
`cores:` gives the emulator (the buildbot's `mednafen_psx` for
|
||||
beetle_psx), to the profile's page. A name two profiles claim, or one
|
||||
that is already a page's address, is left out. Addresses are written
|
||||
under the site root, without its base."""
|
||||
under the site root, without its base. ``moved`` adds pages that moved
|
||||
by generation, old page to new page, the new one with an optional
|
||||
#fragment."""
|
||||
def address(rel: str) -> str:
|
||||
return "/" + page_url(rel)[len(BASE):]
|
||||
page, _, fragment = rel.partition("#")
|
||||
return "/" + page_url(page)[len(BASE):] + (f"#{fragment}" if fragment else "")
|
||||
|
||||
out = {address(old): address(new) for old, new in MOVED_PAGES.items()
|
||||
if old not in present and new in present}
|
||||
out = {address(old): address(new) for old, new in {**MOVED_PAGES, **(moved or {})}.items()
|
||||
if old not in present and new.partition("#")[0] in present}
|
||||
pages = {rel.casefold() for rel in present}
|
||||
claims: dict[str, set[str]] = {}
|
||||
for key, profile in profiles.items():
|
||||
|
||||
@@ -340,7 +340,9 @@ class APlatformPageSaysWhereEachFileGoes(unittest.TestCase):
|
||||
lines = generate_site._render_platform_systems(
|
||||
{"nintendo-gamecube": files}, {("nintendo-gamecube", "IPL.bin"): declared}, {})
|
||||
for region in regions:
|
||||
self.assertIn(f"- `IPL.bin` at `dolphin-emu/Sys/GC/{region}/IPL.bin` - OK",
|
||||
# The folder: the name is before it in the row.
|
||||
self.assertIn(f"<summary>IPL.bin<code>dolphin-emu/Sys/GC/{region}/</code>"
|
||||
'<span class="rb-badge rb-badge-success">OK</span></summary>',
|
||||
"\n".join(lines))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""A platform page lists one row per file, the evidence on demand.
|
||||
|
||||
Batocera, AmberELEC and RomM ran to 14,000 to 16,000 words: every file
|
||||
spelled its full MD5, the copy serving it and each source reference of
|
||||
each reader, with "(emulator code, libretro port)" after every one. A row
|
||||
now shows the name, the folder, the status and a short hash. Opened, it
|
||||
holds every hash, the served copy and the readers across the list, the
|
||||
role of each reference named by one word that a legend defines once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import generate_site # noqa: E402
|
||||
|
||||
_SKIPPED = frozenset({"script", "style", "template"})
|
||||
_VOID = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input",
|
||||
"link", "meta", "source", "track", "wbr"})
|
||||
|
||||
|
||||
class _Words(HTMLParser):
|
||||
"""Words in <main>: all of them, and those a reader sees with every
|
||||
details block closed (a closed block shows its summary only)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.stack: list[tuple[str, bool]] = []
|
||||
self.visible = 0
|
||||
self.total = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag not in _VOID:
|
||||
closed = tag == "details" and "open" not in dict(attrs)
|
||||
self.stack.append((tag, closed))
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
for index in range(len(self.stack) - 1, -1, -1):
|
||||
if self.stack[index][0] == tag:
|
||||
del self.stack[index:]
|
||||
return
|
||||
|
||||
def _counted(self) -> bool:
|
||||
names = {name for name, _ in self.stack}
|
||||
return "main" in names and not names & _SKIPPED
|
||||
|
||||
def _shown(self) -> bool:
|
||||
for index, (_name, closed) in enumerate(self.stack):
|
||||
if closed:
|
||||
rest = self.stack[index + 1:]
|
||||
if not rest or rest[0][0] != "summary":
|
||||
return False
|
||||
return True
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self._counted():
|
||||
return
|
||||
count = len(data.split())
|
||||
self.total += count
|
||||
if self._shown():
|
||||
self.visible += count
|
||||
|
||||
|
||||
def page_words(html: str) -> tuple[int, int]:
|
||||
"""(visible words, all words) in a page's <main>."""
|
||||
parser = _Words()
|
||||
parser.feed(html)
|
||||
return parser.visible, parser.total
|
||||
|
||||
|
||||
class VisibleWordsAreCounted(unittest.TestCase):
|
||||
def test_a_closed_block_shows_its_summary_only(self):
|
||||
page = ("<main><p>one two</p><details><summary>three</summary>four five"
|
||||
"<details open><summary>six</summary>seven</details></details>"
|
||||
"<details open><summary>eight</summary>nine</details>"
|
||||
"<script>no</script></main><p>outside</p>")
|
||||
self.assertEqual(page_words(page), (5, 9))
|
||||
|
||||
|
||||
class ARowPerFile(unittest.TestCase):
|
||||
def _row(self, f: dict, entry: dict, hash_type: str = "md5") -> str:
|
||||
return generate_site._platform_file_row(f, entry, hash_type)
|
||||
|
||||
def test_the_row_at_a_glance(self):
|
||||
md5, sha1 = "a" * 32, "b" * 40
|
||||
row = self._row({"name": "bios_a.bin", "status": "ok", "size": 2048},
|
||||
{"name": "bios_a.bin", "md5": md5, "sha1": sha1, "crc32": "0badf00d",
|
||||
"destination": "bios/bios_a.bin"})
|
||||
# The summary is the row: the name, its folder, the status.
|
||||
self.assertIn('<details class="rb-file"><summary>bios_a.bin<code>bios/</code>'
|
||||
'<span class="rb-badge rb-badge-success">OK</span></summary>', row)
|
||||
# The hash the platform checks, short, on one button beside the
|
||||
# summary (a summary is a control: no button inside it).
|
||||
details, button = row.split("</details>")
|
||||
self.assertNotIn("<button", details)
|
||||
self.assertEqual(button, f'<button class="rb-copy" data-copy="{md5}" '
|
||||
f'aria-label="Copy MD5 {md5[:8]}">{md5[:8]}</button>')
|
||||
# Every hash whole in the details, with the size.
|
||||
for value in (md5, sha1, "0badf00d"):
|
||||
self.assertIn(value, details)
|
||||
self.assertIn("(2.0 KB)", details)
|
||||
|
||||
def test_a_file_at_the_root_prints_no_path(self):
|
||||
for destination in ("", "x.bin"):
|
||||
row = self._row({"name": "x.bin", "status": "ok"}, {"destination": destination})
|
||||
self.assertIn("<summary>x.bin<span", row)
|
||||
|
||||
def test_a_list_drops_the_path_column_when_no_row_has_one(self):
|
||||
files = [{"name": "a.bin", "status": "ok"}, {"name": "b.bin", "status": "ok"}]
|
||||
system = "nintendo-gamecube"
|
||||
config = {(system, "a.bin"): [{"destination": "a.bin"}], (system, "b.bin"): [{}]}
|
||||
text = "\n".join(generate_site._render_platform_systems({system: files}, config, {}))
|
||||
self.assertIn('<div class="rb-files">', text)
|
||||
config[(system, "b.bin")] = [{"destination": "sub/b.bin"}]
|
||||
text = "\n".join(generate_site._render_platform_systems({system: files}, config, {}))
|
||||
self.assertIn('<div class="rb-files rb-files-path">', text)
|
||||
|
||||
def test_status_words(self):
|
||||
cases = {
|
||||
("missing", ""): ("Missing", "danger"),
|
||||
("untested", "declared hash contradicted by the local file"): ("Wrong hash", "warning"),
|
||||
("untested", "expected 1234... got 5678..."): ("Wrong hash", "warning"),
|
||||
("untested", "bios/x read error"): ("Untested", "warning"),
|
||||
}
|
||||
for (status, reason), (word, tone) in cases.items():
|
||||
row = self._row({"name": "x", "status": status, "reason": reason}, {"md5": "c" * 32})
|
||||
self.assertIn(f'<span class="rb-badge rb-badge-{tone}">{word}</span>', row)
|
||||
if reason:
|
||||
self.assertIn(f"Not confirmed: {reason}", row)
|
||||
|
||||
def test_accepted_values_all_in_the_details(self):
|
||||
values = ["1" * 32, "2" * 32, "3" * 32]
|
||||
row = self._row({"name": "x", "status": "ok"}, {"md5": ",".join(values)})
|
||||
self.assertIn(f'data-copy="{values[0]}"', row)
|
||||
self.assertIn(f">{values[0][:8]} +2</button>", row)
|
||||
for value in values:
|
||||
self.assertIn(f'<code class="rb-hash">{value}</code>', row)
|
||||
|
||||
def test_markdown_never_reads_the_text(self):
|
||||
row = self._row({"name": "*a_b*[1].bin", "status": "ok"}, {})
|
||||
self.assertIn("*a_b*[1].bin", row)
|
||||
|
||||
def test_a_long_list_gets_a_filter(self):
|
||||
rows = ["<details></details>"] * generate_site.FILTER_ROWS
|
||||
block = generate_site._platform_file_list(rows, False, "arcade")
|
||||
self.assertTrue(block[0].startswith('<div class="rb-table-filter"><label for="rb-files-filter-arcade">'))
|
||||
self.assertNotIn("rb-table-filter", "".join(
|
||||
generate_site._platform_file_list(rows[1:], False, "arcade")))
|
||||
|
||||
|
||||
class ReferencesNameTheirRoleOnce(unittest.TestCase):
|
||||
PROFILE = {"type": "libretro", "source": "https://example.org/port",
|
||||
"upstream": "https://example.org/emu"}
|
||||
REFS = "source libretro/libretro.c:1, source src/a.c:2, source src/b.c:3, upstream src/c.c:4"
|
||||
|
||||
def test_grouped_by_role(self):
|
||||
text = generate_site._refs_by_role(self.PROFILE, self.REFS)
|
||||
self.assertEqual(text, "glue `libretro/libretro.c:1` and port `src/a.c:2`, `src/b.c:3` "
|
||||
"and upstream `src/c.c:4`")
|
||||
for word in generate_site.ROLE_WORDS.values():
|
||||
self.assertIn(f"**{word}**", generate_site.ROLE_LEGEND)
|
||||
|
||||
def test_other_pages_keep_the_role_after_each_reference(self):
|
||||
text = generate_site._source_ref_markdown(self.PROFILE, self.REFS)
|
||||
self.assertEqual(text.count("(emulator code, libretro port)"), 2)
|
||||
self.assertIn("`src/c.c:4` (upstream emulator)", text)
|
||||
|
||||
|
||||
def _unescaped(text: str) -> str:
|
||||
return html.unescape(text)
|
||||
|
||||
|
||||
@unittest.skipUnless((REPO_ROOT / "database.json").is_file(), "database.json absent")
|
||||
class NothingIsLost(unittest.TestCase):
|
||||
"""Every fact a file row carried is on the page or its parts: each
|
||||
declared hash whole, the served copy, every reader's references."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from common import load_database
|
||||
from generate_readme import compute_coverage
|
||||
|
||||
db = load_database(str(REPO_ROOT / "database.json"))
|
||||
cls.pages = {}
|
||||
for name in ("batocera", "retroarch"):
|
||||
cov = compute_coverage(name, str(REPO_ROOT / "platforms"), db)
|
||||
moved: dict[str, str] = {}
|
||||
page = generate_site.generate_platform_page(name, cov, {}, {}, moved)
|
||||
cls.pages[name] = (cov, page, moved)
|
||||
|
||||
def test_every_fact_is_there(self):
|
||||
for name, (cov, page, moved) in self.pages.items():
|
||||
text = _unescaped("\n".join([page, *moved.values()]))
|
||||
systems = {d.get("system") for d in cov["details"]}
|
||||
for sys_id, system in cov["config"].get("systems", {}).items():
|
||||
if sys_id not in systems:
|
||||
continue
|
||||
for entry in system.get("files", []):
|
||||
for algorithm in ("sha1", "md5", "crc32", "sha256"):
|
||||
for value in re.split(r"[,\s]+", str(entry.get(algorithm) or "")):
|
||||
if value:
|
||||
self.assertIn(value, text, (name, entry.get("name"), algorithm))
|
||||
for detail in cov["details"]:
|
||||
self.assertIn(detail["name"], text)
|
||||
if detail.get("path") and detail.get("status") != "missing":
|
||||
self.assertIn(detail["path"], text, (name, detail["name"]))
|
||||
readers = (detail.get("category_evidence") or {}).get("read_by", [])
|
||||
# Cores of other systems that share the name are named, not
|
||||
# cited, when a core of this system reads the file.
|
||||
running = [reader for reader in readers if reader.get("runs_system")]
|
||||
for reader in running or readers:
|
||||
for url in re.findall(r"\]\((https://[^)]+)\)", generate_site._category_evidence_md(
|
||||
{"read_by": [reader]})):
|
||||
self.assertIn(url, text, (name, detail["name"], reader["emulator"]))
|
||||
|
||||
def test_the_role_is_a_word_defined_once(self):
|
||||
for name, (_cov, page, moved) in self.pages.items():
|
||||
for text in (page, *moved.values()):
|
||||
self.assertNotIn("(emulator code, libretro port)", text, name)
|
||||
self.assertEqual(text.count(generate_site.ROLE_LEGEND), 1, name)
|
||||
|
||||
|
||||
# Visible words (outside closed details) measured on the built site,
|
||||
# 2026-10-27. A ceiling is the measured value plus 5 %, never above the
|
||||
# value before the rows were tables (Batocera 2,433, AmberELEC 1,301, RomM
|
||||
# 1,607, RetroArch 2,207, the largest main page 2,459, the largest part
|
||||
# 19,127: RetroDECK's arcade list).
|
||||
PAGE_CEILINGS = {"batocera": 2433, "amberelec": 1301, "romm": 1607, "retroarch": 2192}
|
||||
MAIN_PAGE_CEILING = 2459
|
||||
PART_PAGE_CEILING = 2219
|
||||
|
||||
_SITE = REPO_ROOT / "website" / "dist" / "platforms"
|
||||
|
||||
|
||||
@unittest.skipUnless(_SITE.is_dir(), "website/dist not built")
|
||||
class VisibleWordsStayUnderTheirCeiling(unittest.TestCase):
|
||||
def test_platform_pages(self):
|
||||
from common import list_registered_platforms
|
||||
|
||||
mains = set(list_registered_platforms(str(REPO_ROOT / "platforms"), include_archived=True))
|
||||
for page in sorted(_SITE.glob("*/index.html")):
|
||||
name = page.parent.name
|
||||
text = page.read_text(encoding="utf-8")
|
||||
if 'http-equiv="refresh"' in text[:2000]:
|
||||
continue
|
||||
visible, _total = page_words(text)
|
||||
ceiling = PAGE_CEILINGS.get(name, MAIN_PAGE_CEILING if name in mains else PART_PAGE_CEILING)
|
||||
self.assertLessEqual(visible, ceiling, name)
|
||||
|
||||
|
||||
# Each platform with its part pages, before the rows were rewritten: pages,
|
||||
# elements (outside scripts and styles, as validate_site counts them) and
|
||||
# bytes of HTML, on the built site of 2026-10-27.
|
||||
BEFORE_WEIGHT = {
|
||||
"batocera": (12, 18_854, 1_211_117),
|
||||
"amberelec": (1, 7_928, 473_388),
|
||||
"romm": (2, 9_561, 572_487),
|
||||
"retroarch": (14, 21_503, 1_430_802),
|
||||
}
|
||||
|
||||
|
||||
@unittest.skipUnless(_SITE.is_dir(), "website/dist not built")
|
||||
class APlatformWeighsNoMoreThanBefore(unittest.TestCase):
|
||||
"""The split budget measured Markdown length: rows written as a table
|
||||
were longer Markdown, 38 systems moved to new part pages and Batocera
|
||||
with its parts rose from 18,854 to 21,875 elements."""
|
||||
|
||||
def test_pages_elements_and_bytes(self):
|
||||
from validate_site import _PageParser
|
||||
|
||||
for name, (pages, elements, size) in BEFORE_WEIGHT.items():
|
||||
paths, weight = [], 0
|
||||
for path in [_SITE / name / "index.html", *sorted(_SITE.glob(f"{name}-*/index.html"))]:
|
||||
parser = _PageParser(path)
|
||||
parser.feed(path.read_text(encoding="utf-8"))
|
||||
# A part folded back into its platform page left a redirect.
|
||||
if not parser.page.redirect:
|
||||
paths.append(path)
|
||||
weight += parser.page.elements
|
||||
self.assertLessEqual(len(paths), pages, name)
|
||||
self.assertLessEqual(weight, elements, name)
|
||||
self.assertLessEqual(sum(path.stat().st_size for path in paths), size, name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -158,16 +158,13 @@ class EachEntryNamesItsFile(unittest.TestCase):
|
||||
# Existence mode reports no path: nothing is claimed, held or not.
|
||||
self.assertEqual(generate_site._served_line({"status": "ok"}), "")
|
||||
|
||||
def test_a_long_system_list_is_cut_between_files(self):
|
||||
body = ["2/2 files verified", ""]
|
||||
for n in range(4):
|
||||
body += [f"- `f{n}.bin` - OK", " " + "x" * 100]
|
||||
runs = generate_site._file_runs(body, 250)
|
||||
self.assertEqual(len(runs), 2)
|
||||
for run in runs:
|
||||
self.assertEqual(run[:2], body[:2])
|
||||
self.assertTrue(run[2].startswith("- "))
|
||||
self.assertEqual(sum(len(run) - 2 for run in runs), len(body) - 2)
|
||||
def test_a_long_system_list_is_cut_between_rows_by_elements(self):
|
||||
rows = [[f'<details class="rb-file"><summary>f{n}.bin</summary>x<br>y</details>']
|
||||
for n in range(4)]
|
||||
# Three elements a row: two rows fit in a budget of seven.
|
||||
runs = generate_site._blocks_in_runs(rows, 7, generate_site._element_weight)
|
||||
self.assertEqual([len(run) for run in runs], [2, 2])
|
||||
self.assertEqual(sum(runs, []), sum(rows, []))
|
||||
|
||||
|
||||
class AlsoNamedIsNotCited(unittest.TestCase):
|
||||
|
||||
@@ -393,7 +393,7 @@ class PlatformPagesLinkTheirSystemsAndEmulators(unittest.TestCase):
|
||||
|
||||
def test_a_moved_list_opens_on_its_console_and_names_its_parent(self):
|
||||
# Past PLATFORM_SECTION_BUDGET, so the list leaves for a page of its own.
|
||||
files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(1200)]
|
||||
files = [{"name": f"{'f' * 200}{i}.bin", "status": "ok"} for i in range(2000)]
|
||||
moved: dict[str, str] = {}
|
||||
generate_site._render_platform_systems(
|
||||
{"sony-playstation": files}, {}, {}, ("retroarch", "RetroArch"), moved, {})
|
||||
|
||||
@@ -377,7 +377,7 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase):
|
||||
path.read_text(encoding="utf-8") for path in sitepages.pages()
|
||||
if sitepages.name(path) == "platforms/retroarch.md"
|
||||
or sitepages.name(path).startswith("platforms/retroarch-"))
|
||||
self.assertIn("CRC32: `91e93107`", text)
|
||||
self.assertIn('CRC32: <code class="rb-hash">91e93107</code>', text)
|
||||
|
||||
|
||||
class PlainHouseStyle(unittest.TestCase):
|
||||
|
||||
@@ -119,7 +119,7 @@ class APlatformSystemOverItsBudgetMoves(unittest.TestCase):
|
||||
config = {("arcade", f["name"]): [{"name": f["name"], "md5": "a" * 32}] for f in files}
|
||||
moved: dict[str, str] = {}
|
||||
budget = generate_site.PLATFORM_SECTION_BUDGET
|
||||
generate_site.PLATFORM_SECTION_BUDGET = 500
|
||||
generate_site.PLATFORM_SECTION_BUDGET = 5
|
||||
try:
|
||||
lines = generate_site._render_platform_systems(
|
||||
{"arcade": files}, config, {}, ("deck", "Deck"), moved)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"platforms/batocera-acorn-bbc-micro.md": "platforms/batocera.md#acorn-bbc-micro",
|
||||
"platforms/batocera-apple-ii.md": "platforms/batocera.md#apple-ii",
|
||||
"platforms/batocera-apple-macintosh-ii.md": "platforms/batocera.md#apple-macintosh-ii",
|
||||
"platforms/batocera-archimedes.md": "platforms/batocera.md#archimedes",
|
||||
"platforms/batocera-atari-st.md": "platforms/batocera.md#atari-st",
|
||||
"platforms/batocera-commodore-amiga.md": "platforms/batocera.md#commodore-amiga",
|
||||
"platforms/batocera-elektronika-bk.md": "platforms/batocera.md#elektronika-bk",
|
||||
"platforms/batocera-microsoft-msx.md": "platforms/batocera.md#microsoft-msx",
|
||||
"platforms/batocera-nintendo-ds.md": "platforms/batocera.md#nintendo-ds",
|
||||
"platforms/batocera-sega-dreamcast-arcade.md": "platforms/batocera.md#sega-dreamcast-arcade",
|
||||
"platforms/batocera-sony-playstation.md": "platforms/batocera.md#sony-playstation",
|
||||
"platforms/emuelec-microsoft-msx.md": "platforms/emuelec.md#microsoft-msx",
|
||||
"platforms/knulli-acorn-bbc-micro.md": "platforms/knulli.md#acorn-bbc-micro",
|
||||
"platforms/knulli-apple-macintosh-ii.md": "platforms/knulli.md#apple-macintosh-ii",
|
||||
"platforms/knulli-archimedes.md": "platforms/knulli.md#archimedes",
|
||||
"platforms/knulli-atari-st.md": "platforms/knulli.md#atari-st",
|
||||
"platforms/knulli-commodore-amiga.md": "platforms/knulli.md#commodore-amiga",
|
||||
"platforms/knulli-sega-dreamcast-arcade.md": "platforms/knulli.md#sega-dreamcast-arcade",
|
||||
"platforms/lakka-arcade.md": "platforms/lakka.md#arcade",
|
||||
"platforms/lakka-commodore-amiga.md": "platforms/lakka.md#commodore-amiga",
|
||||
"platforms/lakka-nec-pc-98.md": "platforms/lakka.md#nec-pc-98",
|
||||
"platforms/lakka-nintendo-ds.md": "platforms/lakka.md#nintendo-ds",
|
||||
"platforms/lakka-nintendo-gamecube.md": "platforms/lakka.md#nintendo-gamecube",
|
||||
"platforms/lakka-nintendo-sgb.md": "platforms/lakka.md#nintendo-sgb",
|
||||
"platforms/lakka-nintendo-snes.md": "platforms/lakka.md#nintendo-snes",
|
||||
"platforms/lakka-scummvm.md": "platforms/lakka.md#scummvm",
|
||||
"platforms/lakka-sega-dreamcast-arcade.md": "platforms/lakka.md#sega-dreamcast-arcade",
|
||||
"platforms/lakka-sega-saturn.md": "platforms/lakka.md#sega-saturn",
|
||||
"platforms/lakka-sinclair-zx-spectrum.md": "platforms/lakka.md#sinclair-zx-spectrum",
|
||||
"platforms/lakka-sony-playstation-2.md": "platforms/lakka.md#sony-playstation-2",
|
||||
"platforms/lakka-sony-playstation.md": "platforms/lakka.md#sony-playstation",
|
||||
"platforms/recalbox-atari-st.md": "platforms/recalbox.md#atari-st",
|
||||
"platforms/recalbox-commodore-amiga.md": "platforms/recalbox.md#commodore-amiga",
|
||||
"platforms/recalbox-microsoft-msx.md": "platforms/recalbox.md#microsoft-msx",
|
||||
"platforms/retroarch-arcade.md": "platforms/retroarch.md#arcade",
|
||||
"platforms/retroarch-commodore-amiga.md": "platforms/retroarch.md#commodore-amiga",
|
||||
"platforms/retroarch-nec-pc-engine.md": "platforms/retroarch.md#nec-pc-engine",
|
||||
"platforms/retroarch-nintendo-ds.md": "platforms/retroarch.md#nintendo-ds",
|
||||
"platforms/retroarch-nintendo-gamecube.md": "platforms/retroarch.md#nintendo-gamecube",
|
||||
"platforms/retroarch-nintendo-sgb.md": "platforms/retroarch.md#nintendo-sgb",
|
||||
"platforms/retroarch-nintendo-snes.md": "platforms/retroarch.md#nintendo-snes",
|
||||
"platforms/retroarch-scummvm.md": "platforms/retroarch.md#scummvm",
|
||||
"platforms/retroarch-sega-dreamcast-arcade.md": "platforms/retroarch.md#sega-dreamcast-arcade",
|
||||
"platforms/retroarch-sega-saturn.md": "platforms/retroarch.md#sega-saturn",
|
||||
"platforms/retroarch-sinclair-zx-spectrum.md": "platforms/retroarch.md#sinclair-zx-spectrum",
|
||||
"platforms/retroarch-sony-playstation-2.md": "platforms/retroarch.md#sony-playstation-2",
|
||||
"platforms/retroarch-sony-playstation.md": "platforms/retroarch.md#sony-playstation",
|
||||
"platforms/retrobat-atari-st.md": "platforms/retrobat.md#atari-st",
|
||||
"platforms/retrobat-commodore-amiga.md": "platforms/retrobat.md#commodore-amiga",
|
||||
"platforms/retrobat-microsoft-msx.md": "platforms/retrobat.md#microsoft-msx",
|
||||
"platforms/retrobat-nec-pc-engine.md": "platforms/retrobat.md#nec-pc-engine",
|
||||
"platforms/retrobat-nintendo-ds.md": "platforms/retrobat.md#nintendo-ds",
|
||||
"platforms/retrobat-sega-saturn.md": "platforms/retrobat.md#sega-saturn",
|
||||
"platforms/retrodeck-arcade-2.md": "platforms/retrodeck.md#arcade",
|
||||
"platforms/retrodeck-arcade-3.md": "platforms/retrodeck.md#arcade",
|
||||
"platforms/retrodeck-arcade-4.md": "platforms/retrodeck.md#arcade",
|
||||
"platforms/retrodeck-arcade.md": "platforms/retrodeck.md#arcade",
|
||||
"platforms/retrodeck-atari-400-800.md": "platforms/retrodeck.md#atari-400-800",
|
||||
"platforms/retrodeck-microsoft-msx.md": "platforms/retrodeck.md#microsoft-msx",
|
||||
"platforms/retrodeck-nintendo-snes.md": "platforms/retrodeck.md#nintendo-snes",
|
||||
"platforms/retrodeck-sega-saturn.md": "platforms/retrodeck.md#sega-saturn",
|
||||
"platforms/retrodeck-sinclair-zx-spectrum.md": "platforms/retrodeck.md#sinclair-zx-spectrum",
|
||||
"platforms/retrodeck-sony-playstation-2.md": "platforms/retrodeck.md#sony-playstation-2",
|
||||
"platforms/retrodeck-sony-playstation.md": "platforms/retrodeck.md#sony-playstation",
|
||||
"platforms/retropie-arcade.md": "platforms/retropie.md#arcade",
|
||||
"platforms/retropie-commodore-amiga.md": "platforms/retropie.md#commodore-amiga",
|
||||
"platforms/retropie-nec-pc-98.md": "platforms/retropie.md#nec-pc-98",
|
||||
"platforms/retropie-nec-pc-engine.md": "platforms/retropie.md#nec-pc-engine",
|
||||
"platforms/retropie-nintendo-ds.md": "platforms/retropie.md#nintendo-ds",
|
||||
"platforms/retropie-nintendo-gamecube.md": "platforms/retropie.md#nintendo-gamecube",
|
||||
"platforms/retropie-nintendo-sgb.md": "platforms/retropie.md#nintendo-sgb",
|
||||
"platforms/retropie-nintendo-snes.md": "platforms/retropie.md#nintendo-snes",
|
||||
"platforms/retropie-scummvm.md": "platforms/retropie.md#scummvm",
|
||||
"platforms/retropie-sega-dreamcast-arcade.md": "platforms/retropie.md#sega-dreamcast-arcade",
|
||||
"platforms/retropie-sega-saturn.md": "platforms/retropie.md#sega-saturn",
|
||||
"platforms/retropie-sinclair-zx-spectrum.md": "platforms/retropie.md#sinclair-zx-spectrum",
|
||||
"platforms/retropie-snk-neogeo-cd.md": "platforms/retropie.md#snk-neogeo-cd",
|
||||
"platforms/retropie-sony-playstation-2.md": "platforms/retropie.md#sony-playstation-2",
|
||||
"platforms/retropie-sony-playstation.md": "platforms/retropie.md#sony-playstation",
|
||||
"platforms/romm-sony-playstation-2.md": "platforms/romm.md#sony-playstation-2"
|
||||
}
|
||||
@@ -965,7 +965,9 @@ const glossaryTerms = () => {
|
||||
// empty when the text shown is the value. The buttons are added where the
|
||||
// reader sees the values, and when a fold opens for the ones inside it, so a
|
||||
// page of 250 closed folds holds none of them.
|
||||
const COPYABLE = '[data-copy], .rb-sys-file dd.rb-hash';
|
||||
// A platform row's button carries its own data-copy: it is not a value to
|
||||
// give a button.
|
||||
const COPYABLE = '[data-copy]:not(button), .rb-sys-file dd.rb-hash';
|
||||
|
||||
// What the button copies, in words: the label of its grid row, else the
|
||||
// words before the value ("MD5: ", ", SHA1 ", "RetroArch: "), else the
|
||||
@@ -1012,7 +1014,7 @@ const addCopyButtons = (root: ParentNode) => {
|
||||
};
|
||||
|
||||
const copyButtons = () => {
|
||||
if (!document.querySelector(COPYABLE)) return;
|
||||
if (!document.querySelector(`${COPYABLE}, button.rb-copy[data-copy]`)) return;
|
||||
addCopyButtons(document);
|
||||
document.addEventListener('toggle', (event) => {
|
||||
const fold = event.target as HTMLDetailsElement;
|
||||
@@ -1021,6 +1023,28 @@ const copyButtons = () => {
|
||||
document.addEventListener('click', async (event) => {
|
||||
const button = (event.target as Element).closest?.<HTMLButtonElement>('button.rb-copy');
|
||||
if (!button) return;
|
||||
// A platform row's button shows a short hash and copies the whole one
|
||||
// from data-copy: its text stays, a mark shows for two seconds.
|
||||
const own = button.dataset.copy;
|
||||
if (own && own !== 'true') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(own);
|
||||
let status = document.querySelector<HTMLElement>('#rb-copy-status');
|
||||
if (!status) {
|
||||
status = document.createElement('p');
|
||||
status.id = 'rb-copy-status';
|
||||
status.className = 'rb-visually-hidden';
|
||||
status.setAttribute('role', 'status');
|
||||
document.body.append(status);
|
||||
}
|
||||
status.textContent = `Copied ${own}`;
|
||||
button.dataset.copied = '';
|
||||
window.setTimeout(() => delete button.dataset.copied, 2000);
|
||||
} catch {
|
||||
// No clipboard: the full value is in the row's details.
|
||||
}
|
||||
return;
|
||||
}
|
||||
const value = button.parentElement?.matches(COPYABLE)
|
||||
? button.parentElement
|
||||
: (button.previousElementSibling as HTMLElement | null);
|
||||
@@ -1147,6 +1171,38 @@ const installPickers = () => {
|
||||
pick(savedSetup());
|
||||
};
|
||||
|
||||
// A platform's file list (div.rb-files) is rows of a details and its copy
|
||||
// button. The filter box written above a long one hides both of a row.
|
||||
const filterFiles = () => {
|
||||
for (const list of document.querySelectorAll<HTMLElement>('.rb-table-filter + .rb-files')) {
|
||||
const controls = list.previousElementSibling!;
|
||||
const input = controls.querySelector('input');
|
||||
const count = controls.querySelector('.rb-table-filter-count');
|
||||
if (!input || !count) continue;
|
||||
const rows = [...list.querySelectorAll<HTMLElement>(':scope > details.rb-file')];
|
||||
let searchable: { row: HTMLElement; text: string }[] | null = null;
|
||||
input.addEventListener('input', () => {
|
||||
searchable ??= rows.map((row) => ({
|
||||
row,
|
||||
text: `${row.querySelector('summary')?.textContent ?? ''} ${row.nextElementSibling?.matches('button.rb-copy') ? row.nextElementSibling.textContent : ''}`
|
||||
.normalize('NFKD')
|
||||
.toLocaleLowerCase(),
|
||||
}));
|
||||
const query = input.value.trim().normalize('NFKD').toLocaleLowerCase();
|
||||
let visible = 0;
|
||||
for (const { row, text } of searchable) {
|
||||
row.hidden = !!query && !text.includes(query);
|
||||
const button = row.nextElementSibling;
|
||||
if (button?.matches('button.rb-copy')) (button as HTMLElement).hidden = row.hidden;
|
||||
if (!row.hidden) visible += 1;
|
||||
}
|
||||
count.textContent = query
|
||||
? `${visible.toLocaleString('en')} of ${rows.length.toLocaleString('en')} rows`
|
||||
: `${rows.length.toLocaleString('en')} rows`;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The 404 page: a search button, and the list a moved page belongs to.
|
||||
const notFound = () => {
|
||||
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
|
||||
@@ -1170,8 +1226,9 @@ enhanceTables();
|
||||
breakPaths();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
copyButtons();
|
||||
filterFiles();
|
||||
osTabs();
|
||||
installPickers();
|
||||
copyButtons();
|
||||
showCurrentPage();
|
||||
notFound();
|
||||
@@ -636,6 +636,95 @@ body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); }
|
||||
.sl-markdown-content details.rb-details > summary::before { background-color: var(--rb-text-secondary); }
|
||||
.sl-markdown-content details.rb-details[open] > summary { margin-bottom: 0.6rem; }
|
||||
|
||||
/* A platform's file list (generate_site._platform_file_list), on the
|
||||
pattern of the Tested block: each row a details whose summary is the
|
||||
row (name, folder, status), its body the evidence across the whole
|
||||
list. The short hash sits on its copy button (site.ts) beside the
|
||||
summary, since a summary is itself a control. The details reaches under
|
||||
the button column, so the row rule and an opened body run full width. */
|
||||
.sl-markdown-content .rb-files {
|
||||
--rb-hash-column: 6.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--rb-hash-column);
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--rb-border-strong);
|
||||
}
|
||||
.sl-markdown-content .rb-files > * { margin: 0; }
|
||||
.sl-markdown-content .rb-files > details.rb-file {
|
||||
grid-column: 1;
|
||||
margin-right: calc(-1 * var(--rb-hash-column));
|
||||
padding: 0 0 0 1.75rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--rb-border);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sl-markdown-content .rb-files > details.rb-file[open] { padding-bottom: 0.6rem; background: var(--rb-surface); }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) 9rem 6rem;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-height: 2.25rem;
|
||||
margin-left: -1.75rem;
|
||||
padding: 0.3rem calc(var(--rb-hash-column) + 0.5rem) 0.3rem 0;
|
||||
font: 0.8125rem/1.4 var(--sl-font-mono);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary { grid-template-columns: 1.25rem minmax(0, 1fr) 6rem; }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary:hover { background: var(--rb-surface); }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary::before { margin: 0; background-color: var(--rb-text-secondary); }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 3; padding: 0; background: none; color: var(--rb-text-secondary); }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: -2; justify-self: start; font-family: var(--sl-font); }
|
||||
.sl-markdown-content .rb-files > button.rb-copy {
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.sl-markdown-content .rb-files > [hidden] { display: none; }
|
||||
|
||||
.sl-markdown-content .rb-files button.rb-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
min-height: 24px;
|
||||
padding: 0 0.35em;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--sl-color-bg);
|
||||
color: var(--sl-color-white);
|
||||
font: 0.8125rem/1.4 var(--sl-font-mono);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sl-markdown-content .rb-files button.rb-copy::after {
|
||||
content: '';
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
background: var(--rb-text-secondary);
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M5 1h8a2 2 0 0 1 2 2v8h-2V3H5zM3 5h7a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zm0 2v7h7V7z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.sl-markdown-content .rb-files button.rb-copy[data-copied]::after {
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 11.2 2.8 8 1.4 9.4 6 14l8.6-8.6-1.4-1.4z'/%3E%3C/svg%3E");
|
||||
background: var(--rb-ok-fg);
|
||||
}
|
||||
.sl-markdown-content .rb-files button.rb-copy:hover { border-color: var(--rb-link); }
|
||||
@media (max-width: 50rem) {
|
||||
/* On a phone the folder goes under the name, the status stays on the
|
||||
first line. A row with no folder prints nothing for it. */
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary,
|
||||
.sl-markdown-content .rb-files:not(.rb-files-path) > details.rb-file > summary {
|
||||
grid-template-columns: 1.25rem minmax(0, 1fr) auto;
|
||||
row-gap: 0.1rem;
|
||||
}
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary::before { grid-row: 1; }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary > code { grid-column: 2; grid-row: 2; }
|
||||
.sl-markdown-content .rb-files > details.rb-file > summary > .rb-badge { grid-column: 3; grid-row: 1; }
|
||||
}
|
||||
|
||||
/* Asides: a neutral box for notes and tips, no side stripe, no icon.
|
||||
Caution and danger keep their colour, as a 1 px border all round. */
|
||||
.sl-markdown-content .starlight-aside {
|
||||
|
||||
@@ -64,6 +64,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| 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_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_version_basis`, `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_entry_tables`, `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_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `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_density_cards`, `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_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_version_basis`, `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_density`, `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_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_emulators`, `test_site_r4_entry`, `test_site_r4_merge`, `test_site_r4_p0`, `test_site_r4_reference`, `test_site_r4_search`, `test_site_r4_systems`, `test_site_r4_wiki`, `test_site_r5_components`, `test_site_r5_css`, `test_site_r5_emulators`, `test_site_r5_entry`, `test_site_r5_reference`, `test_site_r5_search`, `test_site_r5_systems`, `test_site_r5_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `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