From 8adb84696dd7794da37be907d2978c591744473b Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:56:07 +0100 Subject: [PATCH 1/3] feat: fold card hashes, one install command System and emulator file cards show the name, size, download, a short SHA1 with a copy button, the setups and the emulators. The full hashes and the other facts sit in one closed fold, each hash with a copy button. A system's install box shows one command under the OS tabs for the setup picked in a list, remembered across pages. Without the script the list of every command shows, open. The Markdown the page budgets are measured on is unchanged, so no card moves. --- scripts/generate_site.py | 410 +++++++++++++++++++++++++++---- scripts/sitemarkup.py | 8 +- tests/test_audit_regressions.py | 2 +- tests/test_site_density_cards.py | 276 +++++++++++++++++++++ tests/test_site_exports.py | 5 +- tests/test_site_r2_entry.py | 7 +- tests/test_site_r2_systems.py | 24 +- tests/test_site_r4_components.py | 5 +- tests/test_site_r4_systems.py | 3 +- tests/test_site_r5_systems.py | 5 +- website/src/scripts/site.ts | 194 ++++++++++++++- website/src/styles/retrobios.css | 55 +++++ wiki/testing-guide.md | 2 +- 13 files changed, 929 insertions(+), 67 deletions(-) create mode 100644 tests/test_site_density_cards.py diff --git a/scripts/generate_site.py b/scripts/generate_site.py index dd04f5697..64c7affda 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -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 @@ -5325,6 +5326,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.""" @@ -5591,8 +5603,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'{html.escape(str(value))}'], - 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'{html.escape(str(value))}') + facts.append((label, [shown], False)) if not markup.starlight(): # The size yardstick pages are cut by: the list a card was when the @@ -5600,26 +5615,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'
') - # 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"{label}
" - + "
".join(_inline_card_html(v) for v in values) + "
" - for label, values, _searched in group) - lines.append("
") + lines.extend(_card_fact_groups(facts, sha1_full, searchable)) lines.extend(["", "", ""]) 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'
{values[0]}
' + return "
" + "
".join(_inline_card_html(v) for v in values) + "
" + + return [f'
', + *(f"
{label}
{cell(label, values)}" for label, values, _searched in facts), + "
"] + + +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"{html.escape(summary, quote=False)}" + # 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"", summary_line, *gap, *body, *gap, ""] + + +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 += ['
', + *(f"{label}
" + + "
".join(_inline_card_html(v) for v in values) + "
" + for label, values, _searched in searched), + "
"] + 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 @@ -7564,15 +7662,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 @@ -7639,7 +7740,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: @@ -7655,6 +7763,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(["", "", ""]) + return lines if details: for d in details: lines.append(f"- {d}") @@ -7667,26 +7780,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("") @@ -7694,6 +7788,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}: ' + f"{html.escape(value[:SHORT_HASH])}{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, "", "", "", *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, "", "", ""] + 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.""" @@ -8170,6 +8406,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'" + 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.", + "", + '", + "", + # 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. + '
', + "Every setup's command", + "", + *_command_list("Terminal (Linux, macOS, Steam Deck):", declared, "sh"), + *_command_list("PowerShell (Windows):", declared, "powershell"), + "
", + ] + + +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("://", "://") + + # 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"
  • {html.escape(_platform_display(platform), quote=False)}" + + (f" ({html.escape(system, quote=False)})" if names[platform] > 1 else "") + + f': {command(platform, system)}
  • ' + for platform, system in declared) + return [label, "", f"", ""] + + 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 @@ -8187,6 +8502,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.", "", @@ -8305,8 +8622,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 @@ -8315,7 +8633,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) ) diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py index a2a24f2d1..2660f04b9 100644 --- a/scripts/sitemarkup.py +++ b/scripts/sitemarkup.py @@ -156,13 +156,15 @@ def image(alt: str, src: str, **attributes: str) -> str: return f'{html.escape(alt)}' -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'`{text}`' - return f'{html.escape(text, quote=False)}' + marker = ' data-copy=""' if copy else "" + return f'{html.escape(text, quote=False)}' def block_open(tag: str, attributes: str = "") -> str: diff --git a/tests/test_audit_regressions.py b/tests/test_audit_regressions.py index 51d767ee4..21e351c09 100644 --- a/tests/test_audit_regressions.py +++ b/tests/test_audit_regressions.py @@ -5053,7 +5053,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'{value}', text) + self.assertIn(f'
    {value}
    ', text) diff --git a/tests/test_site_density_cards.py b/tests/test_site_density_cards.py new file mode 100644 index 000000000..6d924ad31 --- /dev/null +++ b/tests/test_site_density_cards.py @@ -0,0 +1,276 @@ +"""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("")] + + +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("")] + self.shown = self.text.replace(self.fold, "") + + def test_the_open_part(self) -> None: + for fact in ("ppge_atlas.zim", ">650.9 KB<", ">Download", + "
    On the setup's list
    ", "
    Packed for its emulators by
    ", + "
    Emulators
    ", f'
    SHA1
    {SHA1[:12]}
    '): + 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'
    {label}
    {value}
    ', self.fold) + shown = " ".join(visible_words(self.text)) + for value in (MD5, CRC32, SHA256, SHA1): + self.assertNotIn(value, shown) + self.assertIn("
    Collection path
    ", self.fold) + self.assertIn("
    Catalogued
    ", self.fold) + self.assertEqual(self.text.count("Hashes, collection path and catalogues", self.fold) + + def test_the_fold_is_out_of_the_search(self) -> None: + self.assertTrue(self.fold.startswith("
    \n")) + + 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"
    {label}
    ", 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("")] + 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 b05def971d8e', + self.shown) + self.assertIn('490f666e1afb15b7362b406ed1cea246', + 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("Hashes, priority, validation and more", 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(" 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("")] + self.assertEqual(re.findall(r' None: + select = re.search(r"", self.text).group(1) + options = re.findall(r'