mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-01 00:27:20 -05:00
chore: merge folded cards and one install command
This commit is contained in:
commit
793b16cf67
15 files changed
+974
-66
No files matched your search
+364
-46
@@ -32,6 +32,7 @@ import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from collections import Counter
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -5392,6 +5393,17 @@ def _places_lines(places: list[Place]) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _places_markdown(places: list[Place]) -> list[str]:
|
||||
"""_places_lines in Markdown, for a card written as a list: the places
|
||||
as code spans, which the glossary and the line breaks treat as code."""
|
||||
by_place: dict[tuple[str, ...], list[str]] = {}
|
||||
for name, href, dests in places:
|
||||
by_place.setdefault(tuple(dests), []).append(f"[{name}]({href})")
|
||||
ordered = sorted(by_place.items(), key=lambda item: (not item[0], -len(item[1]), item[0]))
|
||||
return [(" and ".join(_code(d) for d in dests) + ": " if dests else "") + ", ".join(links)
|
||||
for dests, links in ordered]
|
||||
|
||||
|
||||
def _inline_card_html(text: str) -> str:
|
||||
"""A card value written in Markdown (code spans, links), as the HTML a
|
||||
fact list holds. HTML already in the value is kept."""
|
||||
@@ -5658,8 +5670,11 @@ def _system_file_card(
|
||||
for label, value in (("SHA1", sha1_full), ("MD5", md5_full),
|
||||
("CRC32", f.get("crc32")), ("SHA256", f.get("sha256"))):
|
||||
if value and value != "unknown":
|
||||
facts.append((label, [f'<span class="rb-hash">{html.escape(str(value))}</span>'],
|
||||
False))
|
||||
# Starlight writes the value bare: its cell carries the hash
|
||||
# style and the copy button (_fact_list).
|
||||
shown = (html.escape(str(value)) if markup.starlight()
|
||||
else f'<span class="rb-hash">{html.escape(str(value))}</span>')
|
||||
facts.append((label, [shown], False))
|
||||
|
||||
if not markup.starlight():
|
||||
# The size yardstick pages are cut by: the list a card was when the
|
||||
@@ -5667,26 +5682,109 @@ def _system_file_card(
|
||||
lines.extend(search_excluded([f"- {label}: {', '.join(values)}"
|
||||
for label, values, _searched in facts]))
|
||||
else:
|
||||
# A grid of facts, label then value: two bullet lists of code chips
|
||||
# read as a dump. What the search skips is one list with one
|
||||
# attribute: on each row it was 77 KB of the Sony page.
|
||||
read = [fact for fact in facts if fact[2] and searchable]
|
||||
skipped = [fact for fact in facts if fact not in read]
|
||||
for group, attributes in ((read, ""), (skipped, f" {skip}")):
|
||||
if group:
|
||||
lines.append(f'<dl class="rb-facts"{attributes}>')
|
||||
# One value cell per label: the grid pairs each dt with one dd.
|
||||
# A searched value keeps its label out: the excerpt ran the two
|
||||
# together, "On the setup's listAmberELEC".
|
||||
label_skip = f" {skip}" if not attributes else ""
|
||||
lines.extend(f"<dt{label_skip}>{label}</dt><dd>"
|
||||
+ "<br>".join(_inline_card_html(v) for v in values) + "</dd>"
|
||||
for label, values, _searched in group)
|
||||
lines.append("</dl>")
|
||||
lines.extend(_card_fact_groups(facts, sha1_full, searchable))
|
||||
lines.extend(["", "</div>", ""])
|
||||
return lines
|
||||
|
||||
|
||||
# What a system card shows at a glance: who lists the file and who reads it.
|
||||
# Its fingerprints and the rest open on demand, so a page of 250 cards reads
|
||||
# as a list of files, not a wall of hashes.
|
||||
_CARD_KEY_FACTS = ("On the setup's list", "Packed for its emulators by", "Emulators")
|
||||
_CARD_HASHES = ("SHA1", "MD5", "CRC32", "SHA256")
|
||||
# The words a card's fold names its other facts by.
|
||||
_CARD_MORE_WORDS = {"Romset": "romset", "Collection path": "collection path",
|
||||
"Catalogued": "catalogues"}
|
||||
# A SHA1 shown short: as long as the card's own address (sha1-<12 hex>).
|
||||
SHORT_HASH = 12
|
||||
|
||||
|
||||
def _listed_words(words: list[str]) -> str:
|
||||
""""a", "a and b", "a, b and c", first letter capital."""
|
||||
text = words[0] if len(words) == 1 else f"{', '.join(words[:-1])} and {words[-1]}"
|
||||
return text[:1].upper() + text[1:]
|
||||
|
||||
|
||||
def _fact_list(facts: list[tuple[str, list[str], bool]], skip: str,
|
||||
copy: dict[str, str] | None = None) -> list[str]:
|
||||
"""Facts as a grid, label then value, one value cell per label.
|
||||
|
||||
A label in *copy* holds one hash: its cell takes the hash style, and
|
||||
the site script a copy button (site.ts). *copy* gives the cell's
|
||||
classes. One element, where a span in the cell was one more per hash.
|
||||
"""
|
||||
if not facts:
|
||||
return []
|
||||
copy = copy or {}
|
||||
|
||||
def cell(label: str, values: list[str]) -> str:
|
||||
if label in copy:
|
||||
return f'<dd class="{copy[label]}">{values[0]}</dd>'
|
||||
return "<dd>" + "<br>".join(_inline_card_html(v) for v in values) + "</dd>"
|
||||
|
||||
return [f'<dl class="rb-facts"{skip}>',
|
||||
*(f"<dt>{label}</dt>{cell(label, values)}" for label, values, _searched in facts),
|
||||
"</dl>"]
|
||||
|
||||
|
||||
def card_fold(summary: str, body: list[str], skip: bool = True) -> list[str]:
|
||||
"""A fold of a file card: closed, its summary naming what it holds.
|
||||
|
||||
Native details: no script needed, and the text inside stays in the page
|
||||
(and in the search, unless *skip*). No class: the card styles its folds,
|
||||
and a class was 21 bytes on each of 250 cards."""
|
||||
attribute = f" {markup.search_exclude()}" if skip else ""
|
||||
summary_line = f"<summary>{html.escape(summary, quote=False)}</summary>"
|
||||
# Each tag on a line of its own, or MDX reads the fold as a paragraph
|
||||
# left open. Markdown inside also takes a blank line around it.
|
||||
gap = [] if body and body[0].startswith("<") else [""]
|
||||
return [f"<details{attribute}>", summary_line, *gap, *body, *gap, "</details>"]
|
||||
|
||||
|
||||
def _card_fact_groups(facts: list[tuple[str, list[str], bool]], sha1: str,
|
||||
searchable: bool) -> list[str]:
|
||||
"""A system card's facts under Starlight: the key ones open, a short
|
||||
SHA1 first, then the hashes and the rest in one closed fold.
|
||||
|
||||
The search skips all of them, as it did when they were one list: on
|
||||
each row the attribute was 77 KB of the Sony page. Hashes are found by
|
||||
files/?hash=, which the search box offers for a pasted hash.
|
||||
"""
|
||||
skip = f" {markup.search_exclude()}"
|
||||
key = [fact for fact in facts if fact[0] in _CARD_KEY_FACTS]
|
||||
hashes = [fact for fact in facts if fact[0] in _CARD_HASHES]
|
||||
more = [fact for fact in facts if fact not in key and fact not in hashes]
|
||||
searched = [fact for fact in facts if fact[2] and searchable]
|
||||
lines: list[str] = []
|
||||
if searched:
|
||||
# No fact is searched today. One that is keeps its label out: the
|
||||
# excerpt ran the two together, "On the setup's listAmberELEC".
|
||||
lines += ['<dl class="rb-facts">',
|
||||
*(f"<dt{skip}>{label}</dt><dd>"
|
||||
+ "<br>".join(_inline_card_html(v) for v in values) + "</dd>"
|
||||
for label, values, _searched in searched),
|
||||
"</dl>"]
|
||||
key = [fact for fact in key if fact not in searched]
|
||||
more = [fact for fact in more if fact not in searched]
|
||||
glance = []
|
||||
if sha1 and sha1 != "unknown":
|
||||
# The first characters, enough to tell two dumps apart at a glance.
|
||||
# Its button copies the whole value, read from the fold below: the
|
||||
# value twice was 54 bytes more on each card.
|
||||
glance.append(("SHA1", [html.escape(sha1[:SHORT_HASH])], False))
|
||||
lines += _fact_list(glance + key, skip, {"SHA1": "rb-hash"})
|
||||
# One fold: two were four more elements on each of 250 cards.
|
||||
folded = hashes + more
|
||||
if folded:
|
||||
words = (["hashes"] if hashes else []) + [
|
||||
_CARD_MORE_WORDS.get(label, label.lower()) for label, _values, _searched in more]
|
||||
# On one line: nothing reads it as Markdown, and a page cut by size
|
||||
# holds its cards in the same bytes.
|
||||
lines += card_fold(_listed_words(words),
|
||||
["".join(_fact_list(folded, "", dict.fromkeys(_CARD_HASHES, "rb-hash")))])
|
||||
return lines
|
||||
|
||||
|
||||
SEARCH_TABLE_BYTES = 3000
|
||||
|
||||
|
||||
@@ -7635,15 +7733,18 @@ def _render_emulator_file(
|
||||
if fmax:
|
||||
bounds.append(f"max {_fmt_size(fmax)}")
|
||||
details.append(f"Size: {', '.join(bounds)}")
|
||||
# Starlight folds the hashes and gives each a copy button.
|
||||
copyable = markup.starlight()
|
||||
if fsha1:
|
||||
details.append(f"SHA1: {_hash_spans(fsha1)}")
|
||||
details.append(f"SHA1: {_hash_spans(fsha1, copyable)}")
|
||||
if fmd5:
|
||||
details.append(f"MD5: {_hash_spans(fmd5)}")
|
||||
details.append(f"MD5: {_hash_spans(fmd5, copyable)}")
|
||||
if fcrc32:
|
||||
crcs = fcrc32 if isinstance(fcrc32, list) else [fcrc32]
|
||||
details.append("CRC32: " + ", ".join(f"`{crc}`" for crc in crcs))
|
||||
details.append("CRC32: " + ", ".join(
|
||||
markup.hash_value(crc, True) if copyable else f"`{crc}`" for crc in crcs))
|
||||
if fsha256:
|
||||
details.append(f"SHA256: {_hash_spans(fsha256)}")
|
||||
details.append(f"SHA256: {_hash_spans(fsha256, copyable)}")
|
||||
if fadler32:
|
||||
# Printed as the downloads spell a hash, one per code span: the list
|
||||
# repr read `['0x66f334fe', ...]`. Dolphin hashes its DSP ROMs over
|
||||
@@ -7710,7 +7811,14 @@ def _render_emulator_file(
|
||||
if copy:
|
||||
details.append(_held_copy_line(copy, consumers))
|
||||
plat_links = _platform_links(sha1, consumers) if sha1 else []
|
||||
if plat_links:
|
||||
if plat_links and markup.starlight():
|
||||
# Grouped by where the setups put the file, as the system card
|
||||
# says it: twelve setups repeating scph5502.bin took twelve
|
||||
# names and twelve paths where a few say it. One sentence a
|
||||
# place: a list or a line break was one more element a place.
|
||||
places = _places_markdown(_platform_places(sha1, consumers))
|
||||
details.append("Platforms: " + ". ".join(places))
|
||||
elif plat_links:
|
||||
details.append(f"Platforms: {', '.join(plat_links)}")
|
||||
|
||||
if dest and dest != fname and dest != fpath:
|
||||
@@ -7726,6 +7834,11 @@ def _render_emulator_file(
|
||||
if size_range:
|
||||
details.append(f"Size range: {size_range}")
|
||||
|
||||
if markup.starlight():
|
||||
lines.extend(_emulator_card_folds(f, details, fnote, profile, in_repo, contents,
|
||||
data_href))
|
||||
lines.extend(["", "</div>", ""])
|
||||
return lines
|
||||
if details:
|
||||
for d in details:
|
||||
lines.append(f"- {d}")
|
||||
@@ -7738,26 +7851,7 @@ def _render_emulator_file(
|
||||
f"- Not collectable: {_escape_tags(str(f['unsourceable']))} "
|
||||
"([acknowledged gaps](../gaps.md#acknowledged-gaps))"
|
||||
)
|
||||
if contents:
|
||||
# Every member with the hashes the emulator checks: MAME and FBNeo
|
||||
# verify the members, never the archive, so a member's crc32 is the
|
||||
# value a reader compares a set against.
|
||||
lines.append(f"- Contents ({len(contents)} members):")
|
||||
for c in contents:
|
||||
if isinstance(c, dict):
|
||||
parts = []
|
||||
if c.get("description"):
|
||||
parts.append(_escape_tags(str(c["description"])))
|
||||
if c.get("size"):
|
||||
parts.append(_fmt_size(c["size"]))
|
||||
if c.get("crc32"):
|
||||
parts.append(f"CRC32 `{c['crc32']}`")
|
||||
if c.get("sha1"):
|
||||
parts.append(f"SHA1 `{c['sha1']}`")
|
||||
extra = f": {', '.join(parts)}" if parts else ""
|
||||
lines.append(f" - `{c.get('name', '')}`{extra}")
|
||||
else:
|
||||
lines.append(f" - {c}")
|
||||
lines.extend(_contents_lines(contents))
|
||||
lines[body_start:] = _search_skipped_apart(lines[body_start:])
|
||||
lines.append("")
|
||||
lines.append("</div>")
|
||||
@@ -7765,6 +7859,148 @@ def _render_emulator_file(
|
||||
return lines
|
||||
|
||||
|
||||
def _contents_lines(contents: list) -> list[str]:
|
||||
"""Every member with the hashes the emulator checks: MAME and FBNeo
|
||||
verify the members, never the archive, so a member's crc32 is the
|
||||
value a reader compares a set against."""
|
||||
if not contents:
|
||||
return []
|
||||
lines = [f"- Contents ({len(contents)} members):"]
|
||||
for c in contents:
|
||||
if isinstance(c, dict):
|
||||
parts = []
|
||||
if c.get("description"):
|
||||
parts.append(_escape_tags(str(c["description"])))
|
||||
if c.get("size"):
|
||||
parts.append(_fmt_size(c["size"]))
|
||||
if c.get("crc32"):
|
||||
parts.append(f"CRC32 `{c['crc32']}`")
|
||||
if c.get("sha1"):
|
||||
parts.append(f"SHA1 `{c['sha1']}`")
|
||||
extra = f": {', '.join(parts)}" if parts else ""
|
||||
lines.append(f" - `{c.get('name', '')}`{extra}")
|
||||
else:
|
||||
lines.append(f" - {c}")
|
||||
return lines
|
||||
|
||||
|
||||
# What an emulator card shows at a glance: where the file goes, how big it
|
||||
# is, which setups list it and which copy answers it. Fingerprints and the
|
||||
# reading details open on demand.
|
||||
_ENTRY_KEY_FACTS = ("Path:", "Standalone path:", "Read from:", "Found by:", "System:",
|
||||
"Size:", "Aliases:", "Collection copy:", "Collection copies:",
|
||||
"Platforms", "Destination:")
|
||||
_ENTRY_HASHES = ("SHA1:", "MD5:", "CRC32:", "SHA256:", "Adler32")
|
||||
# The words the fold of an emulator card names its facts by.
|
||||
_ENTRY_MORE_WORDS = (
|
||||
("Priority", "priority"), ("Fast boot", "fast boot"), ("Validation", "validation"),
|
||||
("On a failed", "failed check"), ("Installed by the platform's own package", "packages"),
|
||||
("Not in the platform's package", "packages"), ("Never opened", "unread setups"),
|
||||
("Asked for by", "source"), ("Source", "source"), ("Config key", "config key"),
|
||||
("Pattern", "pattern"), ("Region check", "region check"), ("Size note", "size note"),
|
||||
("Size range", "size range"),
|
||||
)
|
||||
|
||||
|
||||
def _more_words(lines: list[str]) -> list[str]:
|
||||
"""The words a fold names its facts by, once each, in their order."""
|
||||
words: list[str] = []
|
||||
for line in lines:
|
||||
text = re.sub(r"<[^>]+>", "", line.removeprefix("- "))
|
||||
word = next((w for prefix, w in _ENTRY_MORE_WORDS if text.startswith(prefix)),
|
||||
text.split(":", 1)[0].lower())
|
||||
if word not in words:
|
||||
words.append(word)
|
||||
return words
|
||||
|
||||
|
||||
def _short_hash_line(f: dict) -> str:
|
||||
"""The entry's strongest declared hash, cut to SHORT_HASH characters,
|
||||
with a copy button for the whole value. "" when it declares none."""
|
||||
from hashing import parse_hash_list # noqa: PLC0415
|
||||
|
||||
for field, label in (("sha1", "SHA1"), ("md5", "MD5"), ("sha256", "SHA256"), ("crc32", "CRC32")):
|
||||
values = parse_hash_list(f.get(field) or "")
|
||||
if values:
|
||||
value = str(values[0])
|
||||
more = f" and {len(values) - 1} more" if len(values) > 1 else ""
|
||||
return (f'{label}: <span class="rb-hash" data-copy="{html.escape(value)}">'
|
||||
f"{html.escape(value[:SHORT_HASH])}</span>{more}")
|
||||
return ""
|
||||
|
||||
|
||||
def _emulator_card_folds(
|
||||
f: dict, details: list[str], fnote: object, profile: dict, in_repo: bool,
|
||||
contents: list, data_href: str,
|
||||
) -> list[str]:
|
||||
"""An emulator card's body under Starlight: the key facts and the notes
|
||||
open, the hashes and the rest in one closed fold.
|
||||
|
||||
Lines the search reads and lines it skips are kept in two runs: mixed,
|
||||
each change of side was one more list and one more block, five lists
|
||||
on a card of a dozen lines.
|
||||
"""
|
||||
def skipped(line: str) -> bool:
|
||||
return f"- {line}".startswith(SEARCH_SKIPPED)
|
||||
|
||||
key = [d for d in details if d.startswith(_ENTRY_KEY_FACTS)]
|
||||
hashes = [d for d in details if d.startswith(_ENTRY_HASHES)]
|
||||
more = [d for d in details if d not in key and d not in hashes]
|
||||
short = _short_hash_line(f)
|
||||
shut_open = [d for d in key if skipped(d)]
|
||||
sized = next((i for i, d in enumerate(shut_open) if d.startswith("Size:")), None)
|
||||
if short and sized is not None:
|
||||
# On the size's line: what tells this dump from another, one list
|
||||
# item fewer on each card.
|
||||
shut_open[sized] += ", " + short.replace(": ", " ", 1)
|
||||
elif short:
|
||||
shut_open.insert(0, short)
|
||||
# What the search reads first, the notes included, then what it skips:
|
||||
# one list each.
|
||||
open_lines = [d for d in key if not skipped(d)]
|
||||
if fnote:
|
||||
open_lines.append(data_root_words(
|
||||
_escape_tags(link_prose_citations(profile, str(fnote))), data_href))
|
||||
if not in_repo and f.get("unsourceable"):
|
||||
# The badge alone sent readers after a dump that cannot exist, and the
|
||||
# reason was only on the gaps page, which the card did not link.
|
||||
open_lines.append(
|
||||
f"Not collectable: {_escape_tags(str(f['unsourceable']))} "
|
||||
"([acknowledged gaps](../gaps.md#acknowledged-gaps))"
|
||||
)
|
||||
lines = [f"- {d}" for d in open_lines]
|
||||
shut_fold = [f"- {d}" for d in hashes + [m for m in more if skipped(m)]]
|
||||
read_fold = [f"- {m}" for m in more if not skipped(m)]
|
||||
# The members of an archive, a list of their own under the fold's
|
||||
# summary, which counts them: a "Contents" item holding them was two
|
||||
# more elements and 44 bytes on each of 200 MAME cards.
|
||||
members = [f"* {line.removeprefix(' - ')}" for line in _contents_lines(contents)[1:]]
|
||||
fold: list[str] = []
|
||||
if shut_fold or read_fold or members:
|
||||
words = (["hashes"] if hashes else []) + _more_words(
|
||||
[line for line in shut_fold + read_fold if not line.startswith(
|
||||
tuple(f"- {h}" for h in _ENTRY_HASHES))])
|
||||
words = words if len(words) <= 3 else [*words[:3], "more"]
|
||||
if members:
|
||||
words.append(_count(len(members), "member"))
|
||||
hidden_part = [*shut_fold, "", *members] if members else shut_fold
|
||||
body = (hidden_part if not read_fold else [
|
||||
"", markup.block_open("div", f'class="rb-search-skip" {markup.search_exclude()}'),
|
||||
"", *hidden_part, "", "</div>", "", *read_fold])
|
||||
fold = card_fold(_listed_words(words), body, skip=False)
|
||||
# A fold the search skips whole sits in the skipped block before it, with
|
||||
# no attribute of its own: on a page of 200 MAME cards the attribute, the
|
||||
# class and a block each were 12 KB.
|
||||
hidden = bool(fold) and not read_fold
|
||||
shut = [f"- {d}" for d in shut_open] + (["", *fold] if hidden else [])
|
||||
if shut:
|
||||
lines += ["", markup.block_open("div", f'class="rb-search-skip" {markup.search_exclude()}'),
|
||||
"", *shut, "", "</div>", ""]
|
||||
if fold and not hidden:
|
||||
lines += ["", *fold, ""]
|
||||
return lines
|
||||
|
||||
|
||||
def _held_copy_line(copy: tuple[str, str], consumers: FileConsumers) -> str:
|
||||
"""Where a copy the database does not index sits: a ZIP member under
|
||||
bios/, or a file or tree of a data directory the packs fetch."""
|
||||
@@ -8350,6 +8586,85 @@ def _ps_quote(value: str) -> str:
|
||||
return value if re.fullmatch(r"[\w.+-]+", value) else "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
# The OS tabs of a system's install box: labels the site's other command
|
||||
# tabs use, so the reader's choice carries from page to page.
|
||||
INSTALL_PICKER_OSES = ("windows", "macos", "linux", "steamdeck")
|
||||
|
||||
|
||||
def _install_args(platform: str, system: str, shell: str) -> str:
|
||||
quoted = _ps_quote(system) if shell == "powershell" else shlex.quote(system)
|
||||
return f"--platform {platform} --system {quoted}"
|
||||
|
||||
|
||||
def _install_picker(declared: list[tuple[str, str]]) -> list[str]:
|
||||
"""One command, for the setup the reader picks, under the OS tabs.
|
||||
|
||||
Every setup's command for a console was written out, twice: 12 long
|
||||
lines for the PSP, about 360 page elements once highlighted. The
|
||||
picker shows one and the script (site.ts) rewrites it for the setup
|
||||
chosen, remembered from page to page. Without the script the picker
|
||||
stays hidden and the plain list of every command shows, open.
|
||||
"""
|
||||
def attribute(text: str) -> str:
|
||||
# Quotes kept as written: a system id quoted for the shell ('Neo Geo').
|
||||
return html.escape(text, quote=False).replace('"', """)
|
||||
|
||||
names = Counter(platform for platform, _system in declared)
|
||||
options = "".join(
|
||||
f'<option value="{attribute(platform)}" '
|
||||
f'data-sh="{attribute(_install_args(platform, system, "sh"))}" '
|
||||
f'data-ps="{attribute(_install_args(platform, system, "powershell"))}">'
|
||||
+ html.escape(_platform_display(platform) + (f" ({system})" if names[platform] > 1 else ""),
|
||||
quote=False)
|
||||
+ "</option>"
|
||||
for platform, system in declared)
|
||||
lead = declared[0]
|
||||
args = {key: _install_args(*lead, TERMINALS[key][2]) for key in INSTALL_PICKER_OSES}
|
||||
return [
|
||||
"Each platform names the system its own way.",
|
||||
"",
|
||||
'<div class="rb-install" data-rb-install="" hidden>',
|
||||
"",
|
||||
f'<label class="rb-install-setup">Your setup <select>{options}</select></label>',
|
||||
"",
|
||||
*terminal_tabs(INSTALL_PICKER_OSES, args, steps=False),
|
||||
"",
|
||||
"</div>",
|
||||
"",
|
||||
# Each command as inline code, no highlighting: the list is the
|
||||
# fallback and the reference, and highlighted it was most of the
|
||||
# box's elements.
|
||||
'<details class="rb-install-all" open>',
|
||||
"<summary>Every setup's command</summary>",
|
||||
"",
|
||||
*_command_list("Terminal (Linux, macOS, Steam Deck):", declared, "sh"),
|
||||
*_command_list("PowerShell (Windows):", declared, "powershell"),
|
||||
"</details>",
|
||||
]
|
||||
|
||||
|
||||
def _command_list(label: str, declared: list[tuple[str, str]], shell: str) -> list[str]:
|
||||
"""Every setup's command for one shell, a line each, with a copy button.
|
||||
|
||||
Inline HTML code, so no break element is added at each slash. A break
|
||||
point follows the URL's scheme: the Markdown around the code made the
|
||||
bare URL a link inside the command, and a scheme with nothing after it
|
||||
is no link. The break adds no character to a copy."""
|
||||
def command(platform: str, system: str) -> str:
|
||||
text = html.escape(install_command(shell, _install_args(platform, system, shell)), quote=False)
|
||||
return text.replace("://", "://<wbr>")
|
||||
|
||||
# One HTML line: the glossary leaves HTML alone, and it marked a word
|
||||
# inside a command.
|
||||
names = Counter(platform for platform, _system in declared)
|
||||
items = "".join(
|
||||
f"<li>{html.escape(_platform_display(platform), quote=False)}"
|
||||
+ (f" ({html.escape(system, quote=False)})" if names[platform] > 1 else "")
|
||||
+ f': <code data-copy="">{command(platform, system)}</code></li>'
|
||||
for platform, system in declared)
|
||||
return [label, "", f"<ul>{items}</ul>", ""]
|
||||
|
||||
|
||||
def _console_install_block(console: str, declared: list[tuple[str, str]]) -> list[str]:
|
||||
"""Each platform's command for one console, by the system id it gives it,
|
||||
for a terminal and for PowerShell, each under its setup's name: RetroBat
|
||||
@@ -8367,6 +8682,8 @@ def _console_install_block(console: str, declared: list[tuple[str, str]]) -> lis
|
||||
# the files of Other arcade files".
|
||||
summary = ("Install these files" if console.casefold().endswith(" files")
|
||||
else f"Install the files of {console}")
|
||||
if markup.starlight() and len(declared) > 1:
|
||||
return search_excluded(markup.details("tip", summary, _install_picker(declared)))
|
||||
return search_excluded(markup.details("tip", summary, [
|
||||
"Each platform names the system its own way.",
|
||||
"",
|
||||
@@ -8485,8 +8802,9 @@ def _render_structured_blocks(profile: dict) -> list[str]:
|
||||
return lines
|
||||
|
||||
|
||||
def _hash_spans(value: str | list) -> str:
|
||||
"""Each hash a profile accepts, in full.
|
||||
def _hash_spans(value: str | list, copy: bool = False) -> str:
|
||||
"""Each hash a profile accepts, in full, each with a copy button when
|
||||
*copy*.
|
||||
|
||||
Cut to 12 characters with the rest in a title attribute, 208 hashes
|
||||
that only a profile declares were out of reach of the site search: it
|
||||
@@ -8495,7 +8813,7 @@ def _hash_spans(value: str | list) -> str:
|
||||
from hashing import parse_hash_list # noqa: PLC0415
|
||||
|
||||
return ", ".join(
|
||||
markup.hash_value(v) for v in parse_hash_list(value)
|
||||
markup.hash_value(v, copy) for v in parse_hash_list(value)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -156,13 +156,15 @@ def image(alt: str, src: str, **attributes: str) -> str:
|
||||
return f'<img src="{html.escape(src)}" alt="{html.escape(alt)}"{attrs}>'
|
||||
|
||||
|
||||
def hash_value(value: object) -> str:
|
||||
def hash_value(value: object, copy: bool = False) -> str:
|
||||
"""A hash printed whole, in the hash style. One element under Starlight:
|
||||
a span around a code span was 1,400 extra elements on a page of cards."""
|
||||
a span around a code span was 1,400 extra elements on a page of cards.
|
||||
*copy* marks it for the copy button the site script adds after it."""
|
||||
text = str(value).replace("`", "'")
|
||||
if _engine == "mkdocs":
|
||||
return f'<span class="rb-hash">`{text}`</span>'
|
||||
return f'<code class="rb-hash">{html.escape(text, quote=False)}</code>'
|
||||
marker = ' data-copy=""' if copy else ""
|
||||
return f'<code class="rb-hash"{marker}>{html.escape(text, quote=False)}</code>'
|
||||
|
||||
|
||||
def block_open(tag: str, attributes: str = "") -> str:
|
||||
|
||||
@@ -5049,7 +5049,7 @@ class CollectionCardsCarryFullHashes(unittest.TestCase):
|
||||
"crc32": "504086c1", "sha256": "b" * 64}
|
||||
text = "\n".join(_system_file_card(entry, FileConsumers({}, {})))
|
||||
for value in (entry["sha1"], entry["md5"], entry["crc32"], entry["sha256"]):
|
||||
self.assertIn(f'<span class="rb-hash">{value}</span>', text)
|
||||
self.assertIn(f'<dd class="rb-hash">{value}</dd>', text)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""File cards and install boxes read at a glance (density round, 2026-10-27).
|
||||
|
||||
A system page wrote every setup's install command for each console, twice:
|
||||
12 long lines for the PSP. A file card showed four full hashes, the
|
||||
collection path and the catalogue records before the next file. Now a box
|
||||
shows one command for the setup picked, and a card shows the name, the
|
||||
size, the download, a short SHA1, the setups and the emulators. The rest
|
||||
sits in closed folds, every fact still on the page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import types
|
||||
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 as gs # noqa: E402
|
||||
import sitemarkup as markup # noqa: E402
|
||||
from cross_reference import FileConsumers # noqa: E402
|
||||
|
||||
DIST = REPO_ROOT / "website" / "dist"
|
||||
SITE_TS = REPO_ROOT / "website" / "src" / "scripts" / "site.ts"
|
||||
|
||||
SHA1 = "30a33f3651f8e1b0967a5dfe6ff714baebcce41a"
|
||||
MD5 = "866855cc330b9b95cc69135fb7b41d38"
|
||||
CRC32 = "7b57fa78"
|
||||
SHA256 = "3ab7fcf6956d0281ada94d501fc6d1cfa8fc034f139e25fed6c829f723d92a95"
|
||||
|
||||
|
||||
class _Visible(HTMLParser):
|
||||
"""Words shown before any fold is opened: text outside closed details
|
||||
(their summary counts), hidden elements, scripts and styles."""
|
||||
|
||||
VOID = {"br", "img", "input", "wbr", "hr", "meta", "link", "source"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.stack: list[tuple[str, object]] = []
|
||||
self.hidden = 0
|
||||
self.words: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list) -> None:
|
||||
if tag in self.VOID:
|
||||
return
|
||||
names = dict(attrs)
|
||||
if tag == "summary" and self.stack and self.stack[-1] == ("details", True):
|
||||
self.hidden -= 1
|
||||
self.stack.append((tag, "shown"))
|
||||
return
|
||||
hides = tag in ("script", "style") or "hidden" in names or (
|
||||
tag == "details" and "open" not in names)
|
||||
self.hidden += hides
|
||||
self.stack.append((tag, hides))
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
while self.stack:
|
||||
name, hides = self.stack.pop()
|
||||
if hides == "shown":
|
||||
self.hidden += 1
|
||||
elif hides:
|
||||
self.hidden -= 1
|
||||
if name == tag:
|
||||
break
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self.hidden:
|
||||
self.words += data.split()
|
||||
|
||||
|
||||
def visible_words(html_text: str) -> list[str]:
|
||||
parser = _Visible()
|
||||
parser.feed(html_text)
|
||||
return parser.words
|
||||
|
||||
|
||||
def _main(page: str) -> str:
|
||||
text = (DIST / page / "index.html").read_text(encoding="utf-8")
|
||||
return text[text.index("<main"):text.index("</main>")]
|
||||
|
||||
|
||||
def _card_consumers(sha1: str) -> types.SimpleNamespace:
|
||||
return types.SimpleNamespace(
|
||||
platforms={sha1: {"batocera", "retroarch"}}, emulators={sha1: {"ppsspp"}},
|
||||
platform_systems={sha1: {"batocera": {"sony-psp"}, "retroarch": {"sony-psp"}}},
|
||||
platform_destinations={sha1: {"batocera": {"psp/ppge_atlas.zim"},
|
||||
"retroarch": {"PPSSPP/ppge_atlas.zim"}}},
|
||||
installed={sha1: {"knulli": {"PPSSPP/ppge_atlas.zim"}}},
|
||||
emulator_entries={}, emulator_files={},
|
||||
)
|
||||
|
||||
|
||||
def _card() -> dict:
|
||||
return {"name": "ppge_atlas.zim", "sha1": SHA1, "md5": MD5, "crc32": CRC32,
|
||||
"sha256": SHA256, "size": 666539,
|
||||
"path": "bios/Sony/PlayStation Portable/ppge_atlas.zim",
|
||||
"provenance": {"tosec": {"name": "ppge_atlas (PPSSPP).zim"}}}
|
||||
|
||||
|
||||
class ASystemCardShowsTheAnswerFirst(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.lines = gs._system_file_card(_card(), _card_consumers(SHA1))
|
||||
self.text = "\n".join(self.lines)
|
||||
self.fold = self.text[self.text.index("<details"):self.text.index("</details>")]
|
||||
self.shown = self.text.replace(self.fold, "")
|
||||
|
||||
def test_the_open_part(self) -> None:
|
||||
for fact in ("<code>ppge_atlas.zim</code>", ">650.9 KB<", ">Download</a>",
|
||||
"<dt>On the setup's list</dt>", "<dt>Packed for its emulators by</dt>",
|
||||
"<dt>Emulators</dt>", f'<dt>SHA1</dt><dd class="rb-hash">{SHA1[:12]}</dd>'):
|
||||
self.assertIn(fact, self.shown)
|
||||
|
||||
def test_every_hash_whole_in_one_fold_with_the_rest(self) -> None:
|
||||
for label, value in (("SHA1", SHA1), ("MD5", MD5), ("CRC32", CRC32), ("SHA256", SHA256)):
|
||||
self.assertIn(f'<dt>{label}</dt><dd class="rb-hash">{value}</dd>', self.fold)
|
||||
shown = " ".join(visible_words(self.text))
|
||||
for value in (MD5, CRC32, SHA256, SHA1):
|
||||
self.assertNotIn(value, shown)
|
||||
self.assertIn("<dt>Collection path</dt>", self.fold)
|
||||
self.assertIn("<dt>Catalogued</dt>", self.fold)
|
||||
self.assertEqual(self.text.count("<details"), 1)
|
||||
self.assertIn("<summary>Hashes, collection path and catalogues</summary>", self.fold)
|
||||
|
||||
def test_the_fold_is_out_of_the_search(self) -> None:
|
||||
self.assertTrue(self.fold.startswith("<details data-pagefind-ignore>\n<summary>"))
|
||||
|
||||
def test_no_fact_is_lost(self) -> None:
|
||||
# The yardstick list a page is cut by holds every fact: the
|
||||
# Starlight card holds each of its values.
|
||||
with markup.using("mkdocs"):
|
||||
yardstick = "\n".join(gs._system_file_card(_card(), _card_consumers(SHA1)))
|
||||
for label, values in re.findall(r"(?m)^- ([^:]+): (.*)$", yardstick):
|
||||
self.assertIn(f"<dt>{label}</dt>", self.text)
|
||||
for value in re.findall(r"[0-9a-f]{8,64}|`[^`]+`", values):
|
||||
self.assertIn(value.strip("`"), self.text, label)
|
||||
|
||||
def test_a_card_reads_in_few_words(self) -> None:
|
||||
# Measured on this card: 31 words open, 40 before the round.
|
||||
self.assertLessEqual(len(visible_words(self.text)), min(40, int(31 * 1.05)))
|
||||
|
||||
|
||||
class AnEmulatorCardFoldsItsHashes(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
entry = {"name": "scph5501.bin", "md5": "490f666e1afb15b7362b406ed1cea246",
|
||||
"sha1": "b05def971d8ec59f346f2d9ac21fb742e3eb6917", "size": 524288,
|
||||
"required": True, "note": "Read at boot.", "priority": 5,
|
||||
"validation": ["md5"], "source_ref": "src/core/bios.cpp:47"}
|
||||
profile = {"emulator": "DuckStation", "files": [entry], "systems": ["sony-playstation"]}
|
||||
self.text = "\n".join(gs._render_emulator_file(
|
||||
entry, profile, None, profile["files"], lambda _f: True, anchor="file-scph5501-bin"))
|
||||
self.fold = self.text[self.text.index("<details"):self.text.index("</details>")]
|
||||
self.shown = self.text.replace(self.fold, "")
|
||||
|
||||
def test_a_short_hash_open_and_every_hash_folded(self) -> None:
|
||||
# On the size's line.
|
||||
self.assertIn('- Size: 512.0 KB, SHA1 <span class="rb-hash" '
|
||||
'data-copy="b05def971d8ec59f346f2d9ac21fb742e3eb6917">b05def971d8e</span>',
|
||||
self.shown)
|
||||
self.assertIn('<code class="rb-hash" data-copy="">490f666e1afb15b7362b406ed1cea246</code>',
|
||||
self.fold)
|
||||
self.assertNotIn("490f666e1afb15b7362b406ed1cea246", self.shown)
|
||||
|
||||
def test_notes_open_the_rest_folded(self) -> None:
|
||||
self.assertIn("Read at boot.", self.shown)
|
||||
for fact in ("Priority: 5", "Validation: md5", "Source:"):
|
||||
self.assertIn(fact, self.fold)
|
||||
self.assertNotIn(fact, self.shown)
|
||||
self.assertIn("<summary>Hashes, priority, validation and more</summary>", self.fold)
|
||||
|
||||
def test_the_markdown_yardstick_is_unchanged(self) -> None:
|
||||
with markup.using("mkdocs"):
|
||||
entry = {"name": "a.bin", "md5": "b" * 32, "size": 4}
|
||||
text = "\n".join(gs._render_emulator_file(
|
||||
entry, {"files": [entry]}, None, [entry], lambda _f: True))
|
||||
self.assertNotIn("<details", text)
|
||||
self.assertIn("- MD5: ", text)
|
||||
|
||||
|
||||
PAIRS = [("emudeck", "sony-psp"), ("retroarch", "sony-psp"), ("retrobat", "Neo Geo")]
|
||||
|
||||
|
||||
class AnInstallBoxShowsOneCommand(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS))
|
||||
|
||||
def test_one_command_per_tab_for_the_setup_picked(self) -> None:
|
||||
tabs = self.text[self.text.index("<Tabs"):self.text.index("</Tabs>")]
|
||||
self.assertEqual(re.findall(r'<TabItem label="([^"]+)"', tabs),
|
||||
["Windows", "macOS", "Linux", "Steam Deck"])
|
||||
self.assertEqual(tabs.count("--platform"), 4)
|
||||
self.assertEqual(tabs.count("--platform emudeck --system sony-psp"), 4)
|
||||
|
||||
def test_the_setup_list_carries_every_command(self) -> None:
|
||||
select = re.search(r"<select>(.*?)</select>", self.text).group(1)
|
||||
options = re.findall(r'<option value="([^"]+)" data-sh="([^"]+)" data-ps="([^"]+)">', select)
|
||||
self.assertEqual([value for value, _sh, _ps in options], ["emudeck", "retroarch", "retrobat"])
|
||||
self.assertIn(("retrobat", "--platform retrobat --system 'Neo Geo'",
|
||||
"--platform retrobat --system 'Neo Geo'"), options)
|
||||
|
||||
def test_without_script_the_list_of_every_command_shows(self) -> None:
|
||||
self.assertIn('<div class="rb-install" data-rb-install="" hidden>', self.text)
|
||||
listed = self.text[self.text.index('<details class="rb-install-all" open>'):]
|
||||
for platform, _system in PAIRS:
|
||||
self.assertEqual(listed.count(f"--platform {platform} --system"), 2, platform)
|
||||
self.assertIn("install.sh | sh -s -- --platform retrobat --system 'Neo Geo'</code>", listed)
|
||||
self.assertIn("install.ps1))) --platform retrobat --system 'Neo Geo'</code>", listed)
|
||||
# The URL is no link inside the command, and the break adds nothing.
|
||||
self.assertIn("https://<wbr>raw.githubusercontent.com", listed)
|
||||
|
||||
def test_one_setup_keeps_its_two_blocks(self) -> None:
|
||||
text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS[:1]))
|
||||
self.assertNotIn("<select", text)
|
||||
self.assertIn('```sh title="Terminal"', text)
|
||||
self.assertIn('```powershell title="PowerShell"', text)
|
||||
|
||||
def test_the_yardstick_is_unchanged(self) -> None:
|
||||
# The budgets that cut a system page are measured on this Markdown:
|
||||
# a box that changed it would move cards to other pages.
|
||||
with markup.using("mkdocs"):
|
||||
text = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS))
|
||||
self.assertNotIn("<select", text)
|
||||
self.assertEqual(text.count("# RetroBat"), 2)
|
||||
self.assertEqual(text.count("install.sh | sh"), 3)
|
||||
|
||||
|
||||
class TheSetupListLooksLikeAList(unittest.TestCase):
|
||||
"""The setup list had a rule of its own without the arrow, and read as
|
||||
a text field at 390 and 1440 px."""
|
||||
|
||||
def test_the_select_takes_the_site_arrow(self) -> None:
|
||||
css = (REPO_ROOT / "website" / "src" / "styles" / "retrobios.css").read_text(encoding="utf-8")
|
||||
shared = re.search(r"\.sl-markdown-content select \{([^}]*)\}", css)
|
||||
self.assertIsNotNone(shared)
|
||||
self.assertIn("appearance: none", shared.group(1))
|
||||
self.assertIn("var(--rb-select-arrow)", shared.group(1))
|
||||
# No rule for the install box's select overrides it.
|
||||
self.assertNotRegex(css, r"rb-install-setup select")
|
||||
# The arrow is defined for both themes.
|
||||
self.assertEqual(len(re.findall(r"--rb-select-arrow:", css)), 2)
|
||||
# The box's select sits in Markdown content, where the shared rule applies.
|
||||
box = "\n".join(gs._console_install_block("PlayStation Portable", PAIRS))
|
||||
self.assertIn('<label class="rb-install-setup">Your setup <select>', box)
|
||||
|
||||
def test_the_built_select_shows_the_arrow(self) -> None:
|
||||
if not (DIST / "systems").is_dir():
|
||||
self.skipTest("no built site")
|
||||
css = "".join(path.read_text(encoding="utf-8") for path in (DIST / "_astro").glob("*.css"))
|
||||
self.assertNotIn("rb-install-setup select", css)
|
||||
self.assertRegex(css, r"\.sl-markdown-content select\{[^}]*--rb-select-arrow")
|
||||
|
||||
|
||||
class TheScriptWiresBoth(unittest.TestCase):
|
||||
def test_pickers_and_copy_buttons_run(self) -> None:
|
||||
source = SITE_TS.read_text(encoding="utf-8")
|
||||
self.assertIn("\ninstallPickers();\n", source)
|
||||
self.assertIn("\ncopyButtons();\n", source)
|
||||
self.assertIn("const SETUP_KEY = 'rb-setup';", source)
|
||||
self.assertIn("const COPYABLE = '[data-copy], .rb-sys-file dd.rb-hash';", source)
|
||||
|
||||
|
||||
# Visible words of each built page: measured before the round and after it,
|
||||
# the ceiling being the after value with 5 % of room.
|
||||
WORD_CEILINGS = {
|
||||
"systems/sony": (2012, 1407),
|
||||
"systems/sony-playstation": (2709, 2130),
|
||||
"systems/nintendo": (3598, 2941),
|
||||
"systems/sega": (4151, 3370),
|
||||
"emulators/duckstation": (1188, 1117),
|
||||
"emulators/yaps2": (3159, 2655),
|
||||
}
|
||||
|
||||
|
||||
class BuiltPagesReadInFewerWords(unittest.TestCase):
|
||||
def test_visible_words_under_the_ceiling(self) -> None:
|
||||
if not (DIST / "systems").is_dir():
|
||||
self.skipTest("no built site")
|
||||
for page, (before, after) in WORD_CEILINGS.items():
|
||||
with self.subTest(page=page):
|
||||
ceiling = min(before, int(after * 1.05))
|
||||
self.assertLessEqual(len(visible_words(_main(page))), ceiling)
|
||||
|
||||
def test_a_built_card_holds_its_hashes(self) -> None:
|
||||
if not (DIST / "systems").is_dir():
|
||||
self.skipTest("no built site")
|
||||
main = _main("systems/sony")
|
||||
card = main[main.index('id="sha1-30a33f3651f8"'):]
|
||||
card = card[:card.index('class="rb-sys-file"')]
|
||||
self.assertRegex(card, r'<dd class="rb-hash">30a33f3651f8</dd>')
|
||||
self.assertIn('<details data-pagefind-ignore', card)
|
||||
self.assertIn(">30a33f3651f8e1b0967a5dfe6ff714baebcce41a</dd>", card)
|
||||
# No link inside a command of the list: the URL is text.
|
||||
listed = main[main.index("rb-install-all"):]
|
||||
self.assertNotRegex(listed[:listed.index("</details>")], r"<code[^>]*>[^<]*<a ")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -369,7 +369,7 @@ class AVariantShowsWhoPinsIt(unittest.TestCase):
|
||||
".variants/advision.zip.1c086064</code>", block)
|
||||
self.assertIn('<a href="../platforms/batocera.md">Batocera</a>', block)
|
||||
self.assertIn('<a href="../platforms/retrobat.md">RetroBat</a>', block)
|
||||
self.assertIn('<span class="rb-hash">' + "c" * 40 + "</span>", block)
|
||||
self.assertIn('<dd class="rb-hash">' + "c" * 40 + "</dd>", block)
|
||||
self.assertIn("Catalogued", block)
|
||||
|
||||
|
||||
@@ -992,7 +992,8 @@ class TheOneLinerIsWhereUsersLand(unittest.TestCase):
|
||||
page = generate_site.generate_system_page(
|
||||
"Microsoft", {"MSX": [entry]}, FileConsumers({"files": {}}),
|
||||
{"MSX": [("recalbox", "msx1,msx2,msxturbor"), ("batocera", "msx1")]})
|
||||
self.assertIn("--platform recalbox --system msx1,msx2,msxturbor\n", page)
|
||||
# The whole system id, up to the end of the command.
|
||||
self.assertRegex(page, r"--platform recalbox --system msx1,msx2,msxturbor(\n|</code>)")
|
||||
self.assertIn("--platform batocera --system msx1", page)
|
||||
|
||||
def test_a_system_is_offered_only_where_the_manifest_records_it(self):
|
||||
|
||||
@@ -285,7 +285,12 @@ class CommandBlocks(unittest.TestCase):
|
||||
self.assertIn("install.ps1", group)
|
||||
block = "\n".join(generate_site._console_install_block(
|
||||
"PlayStation", [("batocera", "psx"), ("recalbox", "psx")]))
|
||||
self.assertEqual(block.count("install.sh | sh"), block.count("install.ps1))) "))
|
||||
# Every setup's options in both forms: the list of every command,
|
||||
# and the one command under the OS tabs.
|
||||
sh_args = re.findall(r"install\.sh \| sh -s -- ([^<\n]+)", block)
|
||||
ps_args = re.findall(r"install\.ps1\)\)\) ([^<\n]+)", block)
|
||||
self.assertEqual(sorted(set(sh_args)), sorted(set(ps_args)))
|
||||
self.assertEqual(len(set(sh_args)), 2)
|
||||
self.assertIn('```powershell title="PowerShell"', block)
|
||||
for key, guide in siteguides.GUIDES.items():
|
||||
if "windows" in guide.run:
|
||||
|
||||
@@ -182,8 +182,14 @@ class TheCardLinesSayWhatTheyMean(unittest.TestCase):
|
||||
def test_the_facts_are_out_of_the_search(self):
|
||||
# The setup list made every card's excerpt a run of names and
|
||||
# paths (round 4, V6): the card's title is what the search reads.
|
||||
lists = [line for line in self.lines if line.startswith("<dl")]
|
||||
# The facts shown open are one list, the others one fold, both
|
||||
# out of the search.
|
||||
shown = self.lines[:next(i for i, line in enumerate(self.lines) if line.startswith("<details"))]
|
||||
lists = [line for line in shown if line.startswith("<dl")]
|
||||
self.assertEqual(lists, ['<dl class="rb-facts" data-pagefind-ignore>'])
|
||||
folds = [line for line in self.lines if line.startswith("<details")]
|
||||
self.assertEqual(len(folds), 1)
|
||||
self.assertEqual(folds, ["<details data-pagefind-ignore>"])
|
||||
|
||||
def test_the_page_defines_both_lines(self):
|
||||
page = gs.generate_system_page("Sony", {"PlayStation": [_file("scph5501.bin", "4")]},
|
||||
@@ -206,15 +212,17 @@ class FileFactsAreAGrid(unittest.TestCase):
|
||||
"provenance": {"redump": {"name": "ps-30a.bin"},
|
||||
"tosec": {"name": "SCPH-5501 (1996).bin"}}}
|
||||
lines = gs._system_file_card(entry, FileConsumers({"files": {}}))
|
||||
facts = [line for line in lines if line.startswith("<dt>")]
|
||||
self.assertTrue(facts)
|
||||
for line in facts:
|
||||
self.assertEqual(line.count("<dt>"), 1, line)
|
||||
self.assertEqual(line.count("<dd>"), 1, line)
|
||||
text = "\n".join(lines)
|
||||
self.assertIn('<dt>SHA1</dt><dd><span class="rb-hash">' + "4" * 40 + "</span></dd>", text)
|
||||
# One value cell after each label, in the open list and in the fold.
|
||||
facts = re.findall(r"<dt>[^<]*</dt><dd[^>]*>.*?</dd>", text)
|
||||
self.assertTrue(facts)
|
||||
self.assertEqual(len(facts), text.count("<dt>"))
|
||||
self.assertEqual(text.count("<dt>"), len(re.findall(r"<dd[ >]", text)))
|
||||
# The hash is the cell's text, styled by the cell: no element around it.
|
||||
self.assertIn('<dt>SHA1</dt><dd class="rb-hash">' + "4" * 40 + "</dd>", text)
|
||||
self.assertNotIn("<code class=\"rb-hash\">", text)
|
||||
catalogued = next(line for line in facts if line.startswith("<dt>Catalogued"))
|
||||
self.assertNotIn('<span class="rb-hash">', text)
|
||||
catalogued = next(fact for fact in facts if fact.startswith("<dt>Catalogued"))
|
||||
self.assertIn("<br>", catalogued)
|
||||
self.assertNotRegex(text, r"(?m)^- ")
|
||||
|
||||
|
||||
@@ -141,7 +141,10 @@ class TheVisitorsTab(unittest.TestCase):
|
||||
block = source[source.index("const osTabs = () =>"):source.index("// The sidebar lists")]
|
||||
self.assertIn("savedTab(OS_SYNC_KEY)", block)
|
||||
self.assertIn("set.switchTab(set.tabs[index], index, false)", block)
|
||||
self.assertNotIn("localStorage.setItem", source)
|
||||
self.assertNotIn("localStorage.setItem", block)
|
||||
# The one choice the script saves is the setup picked in an install
|
||||
# box, under its own key: never the OS guess.
|
||||
self.assertEqual(re.findall(r"localStorage\.setItem\((\w+)", source), ["SETUP_KEY"])
|
||||
self.assertNotIn("keepTabInView", source)
|
||||
|
||||
|
||||
|
||||
@@ -149,7 +149,8 @@ class TheNeoGeoOpensOnItsSet(unittest.TestCase):
|
||||
pairs = gs._install_pairs([("batocera", "snk-neogeo")], "a" * 40, consumers)
|
||||
self.assertIn(("retroarch", "arcade"), pairs)
|
||||
block = "\n".join(gs._console_install_block("Neo Geo", pairs))
|
||||
self.assertIn("# RetroArch\n", block)
|
||||
self.assertIn("<li>RetroArch: <code", block)
|
||||
self.assertIn("--platform retroarch --system arcade</code>", block)
|
||||
|
||||
def test_the_built_section(self) -> None:
|
||||
html = _page(DIST / "systems" / "snk" / "index.html")
|
||||
|
||||
@@ -260,8 +260,9 @@ class EnginesGoByEngine(unittest.TestCase):
|
||||
if not SYSTEMS.is_dir():
|
||||
self.skipTest("pages not generated")
|
||||
for old, new in gs.SYSTEM_PAGES_MOVED.items():
|
||||
self.assertFalse((CONTENT / old).exists(), old)
|
||||
self.assertTrue((CONTENT / new).exists(), new)
|
||||
self.assertFalse((CONTENT / old).exists() or (CONTENT / old).with_suffix(".mdx").exists(), old)
|
||||
# A page with tabs is written as MDX.
|
||||
self.assertTrue((CONTENT / new).exists() or (CONTENT / new).with_suffix(".mdx").exists(), new)
|
||||
redirects = CONTENT.parents[1] / "generated" / "redirects.json"
|
||||
if redirects.is_file():
|
||||
table = json.loads(redirects.read_text(encoding="utf-8"))
|
||||
|
||||
+193
-1
@@ -1,7 +1,8 @@
|
||||
// Progressive enhancement of the generated pages: table captions, filters
|
||||
// and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file
|
||||
// hashed on this device), moved cards, a hash typed in the search, glossary
|
||||
// terms on a tap, the OS tab of the visitor's system, the current page in
|
||||
// terms on a tap, the OS tab of the visitor's system, the setup picked in an
|
||||
// install box, copy buttons on hashes, the current page in
|
||||
// the sidebar, and the 404 page's hints.
|
||||
// Which tables stack on a phone is decided when the page is built
|
||||
// (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint.
|
||||
@@ -957,6 +958,195 @@ const glossaryTerms = () => {
|
||||
document.addEventListener('click', close);
|
||||
};
|
||||
|
||||
// Copy buttons. A system card's hash cell (dd.rb-hash) gets one: in its
|
||||
// fold, each hash copies itself, and the short SHA1 shown open copies the
|
||||
// whole value, the fold's first hash. An emulator card's hash and each
|
||||
// command of an install box's list carry data-copy: the value to copy, or
|
||||
// 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';
|
||||
|
||||
// What the button copies, in words: the label of its grid row, else the
|
||||
// words before the value ("MD5: ", ", SHA1 ", "RetroArch: "), else the
|
||||
// label its list item opens on.
|
||||
const copyLabel = (value: HTMLElement) => {
|
||||
const term = (value.tagName === 'DD' ? value : value.closest('dd'))?.previousElementSibling;
|
||||
if (term?.tagName === 'DT') return term.textContent?.trim() ?? '';
|
||||
const before = (value.previousSibling?.textContent ?? '').replace(/[:\s]+$/, '').split(',').pop()?.trim();
|
||||
if (before) return before;
|
||||
return /^\s*([^:]+):/.exec(value.closest('li')?.textContent ?? '')?.[1]?.trim() ?? '';
|
||||
};
|
||||
|
||||
// The value's own text, without the button a cell holds.
|
||||
const shownText = (value: Element) =>
|
||||
[...value.childNodes].filter((node) => !(node instanceof HTMLButtonElement)).map((node) => node.textContent).join('').trim();
|
||||
|
||||
const copyText = (value: HTMLElement) => {
|
||||
const own = value.dataset.copy;
|
||||
// An MDX page writes an empty attribute as "true".
|
||||
if (own && own !== 'true') return own;
|
||||
if (value.matches('dd.rb-hash') && !value.closest('details')) {
|
||||
const whole = value.closest('.rb-sys-file')?.querySelector('details dd.rb-hash');
|
||||
if (whole) return shownText(whole);
|
||||
}
|
||||
return shownText(value);
|
||||
};
|
||||
|
||||
const addCopyButtons = (root: ParentNode) => {
|
||||
for (const value of root.querySelectorAll<HTMLElement>(COPYABLE)) {
|
||||
if (value.hasAttribute('data-copy-ready') || value.closest('details:not([open])')) continue;
|
||||
value.setAttribute('data-copy-ready', '');
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'rb-copy';
|
||||
button.textContent = 'Copy';
|
||||
// The word that replaces "Copy" is read out.
|
||||
button.setAttribute('aria-live', 'polite');
|
||||
const label = copyLabel(value);
|
||||
button.setAttribute('aria-label', label ? `Copy ${label}` : 'Copy');
|
||||
// A grid cell holds its button, a value elsewhere is followed by it.
|
||||
if (value.tagName === 'DD') value.append(button);
|
||||
else value.after(button);
|
||||
}
|
||||
};
|
||||
|
||||
const copyButtons = () => {
|
||||
if (!document.querySelector(COPYABLE)) return;
|
||||
addCopyButtons(document);
|
||||
document.addEventListener('toggle', (event) => {
|
||||
const fold = event.target as HTMLDetailsElement;
|
||||
if (fold.open) addCopyButtons(fold);
|
||||
}, true);
|
||||
document.addEventListener('click', async (event) => {
|
||||
const button = (event.target as Element).closest?.<HTMLButtonElement>('button.rb-copy');
|
||||
if (!button) return;
|
||||
const value = button.parentElement?.matches(COPYABLE)
|
||||
? button.parentElement
|
||||
: (button.previousElementSibling as HTMLElement | null);
|
||||
if (!value?.matches(COPYABLE)) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyText(value));
|
||||
button.textContent = 'Copied';
|
||||
} catch {
|
||||
// No clipboard (an insecure page, a refused permission): the value
|
||||
// is selected, for Ctrl+C.
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(value.tagName === 'DD' && value.firstChild ? value.firstChild : value);
|
||||
getSelection()?.removeAllRanges();
|
||||
getSelection()?.addRange(range);
|
||||
button.textContent = 'Selected';
|
||||
}
|
||||
window.setTimeout(() => { button.textContent = 'Copy'; }, 1500);
|
||||
});
|
||||
};
|
||||
|
||||
// A system's install box: one command, for the setup picked in its list,
|
||||
// under the site's OS tabs. The box is built with the first setup's command;
|
||||
// the choice rewrites the options of each command and its copy text, and is
|
||||
// remembered for the next box and the next page. Without this script the
|
||||
// picker stays hidden and the list of every command shows instead.
|
||||
const SETUP_KEY = 'rb-setup';
|
||||
|
||||
const savedSetup = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(SETUP_KEY);
|
||||
} catch {
|
||||
// Storage refused (a private window, blocked site data): no saved setup.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// The colours a flag and a value take in the highlighted sh command, read
|
||||
// from the box's own first command so a rewritten one looks the same.
|
||||
type ArgStyles = { flag: string; value: string; space: string };
|
||||
|
||||
const argStyles = (block: Element): ArgStyles | null => {
|
||||
const leaves = [...block.querySelectorAll<HTMLElement>('pre[data-language="sh"] .ec-line .code span')].filter((span) => !span.children.length);
|
||||
const at = leaves.findIndex((leaf) => leaf.textContent === '--platform');
|
||||
if (at < 0) return null;
|
||||
const value = leaves.slice(at + 1).find((leaf) => leaf.textContent?.trim());
|
||||
const space = leaves.find((leaf) => leaf.textContent === ' ');
|
||||
return { flag: leaves[at].getAttribute('style') ?? '', value: value?.getAttribute('style') ?? '', space: space?.getAttribute('style') ?? '' };
|
||||
};
|
||||
|
||||
// Rewrites the options of a highlighted command, from " --platform": the
|
||||
// installer's own part keeps its colours, the options take *styles*.
|
||||
const setCommandArgs = (frame: Element, args: string, styles: ArgStyles | null) => {
|
||||
const line = frame.querySelector<HTMLElement>('.ec-line .code');
|
||||
if (!line) return;
|
||||
const text = line.textContent ?? '';
|
||||
const start = text.indexOf(' --platform ');
|
||||
if (start < 0) return;
|
||||
const walker = document.createTreeWalker(line, NodeFilter.SHOW_TEXT);
|
||||
let seen = 0;
|
||||
const range = document.createRange();
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
const length = node.textContent?.length ?? 0;
|
||||
if (seen + length > start) {
|
||||
range.setStart(node, start - seen);
|
||||
break;
|
||||
}
|
||||
seen += length;
|
||||
}
|
||||
range.setEndAfter(line.lastChild!);
|
||||
range.deleteContents();
|
||||
for (const part of ` ${args}`.split(/(\s+)/)) {
|
||||
if (!part) continue;
|
||||
const span = document.createElement('span');
|
||||
const space = /^\s+$/.test(part);
|
||||
const style = space ? styles?.space : part.startsWith('-') ? styles?.flag : styles?.value;
|
||||
if (style) span.setAttribute('style', style);
|
||||
span.textContent = part;
|
||||
// The theme colours a span that has no class: a word kept whole is
|
||||
// wrapped, as the build wraps it (ec-unbroken-tokens.mjs).
|
||||
if (space) {
|
||||
line.append(span);
|
||||
} else {
|
||||
const whole = document.createElement('span');
|
||||
whole.className = 'rb-nb';
|
||||
whole.append(span);
|
||||
line.append(whole);
|
||||
}
|
||||
}
|
||||
frame.querySelector('.copy button')?.setAttribute('data-code', text.slice(0, start) + ` ${args}`);
|
||||
};
|
||||
|
||||
const installPickers = () => {
|
||||
const blocks = [...document.querySelectorAll<HTMLElement>('[data-rb-install]')];
|
||||
if (!blocks.length) return;
|
||||
const pick = (setup: string | null) => {
|
||||
for (const block of blocks) {
|
||||
const select = block.querySelector('select');
|
||||
if (!select) continue;
|
||||
if (setup && [...select.options].some((option) => option.value === setup)) select.value = setup;
|
||||
const option = select.selectedOptions[0];
|
||||
if (!option) continue;
|
||||
const styles = (block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles;
|
||||
for (const frame of block.querySelectorAll('.expressive-code')) {
|
||||
const shell = frame.querySelector('pre')?.dataset.language === 'powershell' ? 'ps' : 'sh';
|
||||
setCommandArgs(frame, option.dataset[shell] ?? '', styles ?? null);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const block of blocks) {
|
||||
(block as HTMLElement & { rbStyles?: ArgStyles | null }).rbStyles = argStyles(block);
|
||||
block.hidden = false;
|
||||
const list = block.nextElementSibling;
|
||||
if (list instanceof HTMLDetailsElement && list.classList.contains('rb-install-all')) list.open = false;
|
||||
block.querySelector('select')?.addEventListener('change', (event) => {
|
||||
const setup = (event.target as HTMLSelectElement).value;
|
||||
try {
|
||||
localStorage.setItem(SETUP_KEY, setup);
|
||||
} catch {
|
||||
// Storage refused: the choice holds for this page only.
|
||||
}
|
||||
pick(setup);
|
||||
});
|
||||
}
|
||||
pick(savedSetup());
|
||||
};
|
||||
|
||||
// The 404 page: a search button, and the list a moved page belongs to.
|
||||
const notFound = () => {
|
||||
document.querySelector('[data-rb-open-search]')?.addEventListener('click', () => {
|
||||
@@ -981,5 +1171,7 @@ breakPaths();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
osTabs();
|
||||
installPickers();
|
||||
copyButtons();
|
||||
showCurrentPage();
|
||||
notFound();
|
||||
@@ -17,6 +17,9 @@
|
||||
.rb-bar inline coverage bar, width from --v
|
||||
.rb-details-* collapsible block, neutral unless caution or danger
|
||||
dl.rb-facts the facts of a file card, label and value side by side
|
||||
.rb-install-all the list of every command in a system's install box
|
||||
.rb-copy the copy button site.ts adds after a hash
|
||||
.rb-install-setup the setup list of a system's install box
|
||||
abbr.rb-term a glossary term, its definition on hover or tap
|
||||
*/
|
||||
|
||||
@@ -568,6 +571,50 @@ body:has(table.rb-entries) { --sl-content-width: var(--rb-grid-width); }
|
||||
.sl-markdown-content details.rb-entry ul { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
/* A fold of a file card (Hashes, the rest): no frame, a small summary in
|
||||
the muted colour, a 24 px target (WCAG 2.5.8). */
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) { margin: 0.3rem 0 0; padding: 0; border: 0; font-size: 0.85rem; }
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary {
|
||||
min-height: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--rb-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary::before { background-color: var(--rb-text-secondary); }
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > summary:hover { color: var(--sl-color-white); }
|
||||
.sl-markdown-content :is(:is(.rb-sys-file, .rb-file-entry) details, details.rb-install-all) > :not(summary) { margin-top: 0.2rem; }
|
||||
|
||||
/* The copy button after a hash (site.ts): the Download button's frame,
|
||||
smaller, a 24 px target that stays inside a 24 px line, so it never
|
||||
covers the fold below it (WCAG 2.5.8). */
|
||||
.rb-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 24px;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--rb-border-strong);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-family: var(--sl-font);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
color: var(--rb-link);
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rb-copy:hover { background: var(--sl-color-accent-low); }
|
||||
|
||||
/* A system's install box: the setup list above the OS tabs, and the list
|
||||
of every command, which wraps anywhere rather than scroll a phone. */
|
||||
.sl-markdown-content .rb-install-all code { overflow-wrap: anywhere; }
|
||||
.sl-markdown-content .rb-install-setup { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
|
||||
/* Its select is styled as every other choice list on the site (the
|
||||
arrow of --rb-select-arrow, below): a rule of its own hid the arrow. */
|
||||
|
||||
/* Collapsible blocks are quiet: a closed block must not outweigh the
|
||||
command it explains. Colour is kept for caution and danger. */
|
||||
.sl-markdown-content details.rb-details {
|
||||
|
||||
@@ -68,6 +68,27 @@ if (mode === 'weight') {
|
||||
if (n === 0) await page.click('button[data-open-modal]');
|
||||
const input = page.locator('dialog input[type="text"], dialog input[type="search"]').first();
|
||||
await input.fill(query);
|
||||
if (/^[0-9a-f]+$/.test(query) && [8, 32, 40, 64].includes(query.length)) {
|
||||
// A pasted hash: the search offers "Look up this hash" first, and
|
||||
// the lookup names the cards that hold the value. Its results
|
||||
// are those cards.
|
||||
const lookup = await page.waitForSelector('.rb-search-hash a', { timeout: 15000 });
|
||||
const found = await context.newPage();
|
||||
await found.goto(await lookup.evaluate((a) => a.href), { waitUntil: 'networkidle' });
|
||||
await found.waitForFunction(() => /^Found|^No file/.test(document.getElementById('rb-file-lookup-status')?.textContent ?? ''), null, { timeout: 15000 });
|
||||
results = await found.evaluate(() => {
|
||||
const cards = [...document.querySelectorAll('#rb-file-lookup-result a')].filter((a) => a.textContent === 'Open its card');
|
||||
return {
|
||||
count: document.getElementById('rb-file-lookup-status')?.textContent ?? '',
|
||||
top: cards.slice(0, 5).map((a) => `Open its card | ${new URL(a.href).pathname}${new URL(a.href).hash}`),
|
||||
};
|
||||
});
|
||||
await found.close();
|
||||
await page.waitForTimeout(300);
|
||||
const transfer = sum(rows.slice(mark), () => true);
|
||||
console.log(JSON.stringify({ query, transfer_kb: (transfer / 1024).toFixed(1), ...results }));
|
||||
continue;
|
||||
}
|
||||
await page.waitForSelector('.pagefind-ui__result', { timeout: 15000 });
|
||||
await page.waitForTimeout(1200);
|
||||
results = await page.evaluate(() => {
|
||||
|
||||
@@ -119,6 +119,12 @@ QUERIES: dict[str, tuple[tuple[str, ...], int]] = {
|
||||
"pgm.zip": (("emulators/fbneo/",), 3),
|
||||
# A dump name in a wanted list folded under its filter box.
|
||||
"speeddos": (("provenance-tosec/",), 1),
|
||||
# A pasted hash of any kind finds the file's card, its hashes now in a
|
||||
# closed fold: the MD5, CRC32 and SHA256 of scph5501.bin. The search box
|
||||
# offers "Look up this hash", and the lookup opens the card.
|
||||
"490f666e1afb15b7362b406ed1cea246": (("systems/sony-playstation/",), 1),
|
||||
"8d8cb7e4": (("systems/sony-playstation/",), 1),
|
||||
"11052b6499e466bbf0a709b1f9cb6834a9418e66680387912451e971cf8a1fef": (("systems/sony-playstation/",), 1),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| 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_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` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
|
||||
Reference in new issue
Block a user