From add2d871901c7fb7f8f4218e226caee66c533692 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:47:34 +0200 Subject: [PATCH 1/4] feat: cite the handheld systems that run retroarch One row per system the installer detects RetroArch on, with the card folder and the devices each system's own pages list, every value carrying its source. Android and Linux handhelds become tiles of the handheld group. --- platforms/_guides.yml | 94 ++++++++++++++++++++++++++ scripts/siteguides.py | 152 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 232 insertions(+), 14 deletions(-) diff --git a/platforms/_guides.yml b/platforms/_guides.yml index d04de3e1..7bd61954 100644 --- a/platforms/_guides.yml +++ b/platforms/_guides.yml @@ -63,6 +63,17 @@ device_groups: uses: retroarch runs_on: Android folder: '`RetroArch/system/` on the internal storage' + # Kinds of handheld that run a setup of their own: a tile each beside the + # setups of the group, leading to where their folder is written. + tiles: + - name: Android + # goretroid.com/products/retroid-pocket-5-handheld: "OS: Android 13". + # ayntec.com/products/ayn-odin-3: "Android 15". + text: Retroid, AYN Odin and other Android handhelds + link: platforms/retroarch.md#where-the-files-go + - name: Linux handhelds + text: ArkOS, muOS, Onion, spruceOS + link: platforms/linux-handhelds.md fpga: label: MiSTer FPGA description: >- @@ -114,6 +125,89 @@ terminals: `bios` folder after `--dest`. shell: sh +# The guide for the handheld systems that run RetroArch, one row for each +# system the installer detects it on (_registry.yml, retroarch install.detect). +# A folder is the one seen on the card from a computer, and a device list the +# one the system's own site, wiki or README gives. Each is written only with +# the first-party page that states it (`*_source`), and left out when no such +# page states it: the row then says the command finds the folder. +handheld_guide: + page: linux-handhelds + title: Linux handhelds + uses: retroarch + summary: >- + ArkOS, muOS, Onion, spruceOS and the other systems below run **RetroArch**, an app + with a small emulator for each console. They use its BIOS files. + systems: + - name: ArkOS + folder: '`bios` on the **EASYROMS** part of the card' + folder_source: https://github.com/christianhaitian/arkos/wiki/ArkOS-Emulators-and-Ports-information + devices: [RG353M, RG353V, RG503, RGB30, RK2023, RG351MP, RG351V, R35S, RGB10] + devices_source: https://github.com/christianhaitian/arkos/wiki + - name: muOS + folder: '`MUOS/bios` on the card' + folder_source: https://github.com/MustardOS/internal/blob/main/share/web/tools/bios-checker.html + devices: [RG35XX H, RG35XX Plus, RG35XX SP, RG35XX 2024, RG34XX H, RG40XX H, RG40XX V, + RG28XX H, RGCUBEXX, TrimUI Brick, TrimUI Smart Pro] + devices_source: https://muos.dev/devices + - name: Onion + folder: '`BIOS` at the root of the card' + folder_source: https://onionui.github.io/docs/emulators + devices: [Miyoo Mini, Miyoo Mini+] + devices_source: https://github.com/OnionUI/Onion + - name: spruceOS + folder: '`BIOS` at the root of the card' + folder_source: https://github.com/spruceUI/spruceOS/wiki/10.-Adding-BIOS + devices: [Miyoo A30, Miyoo Flip, Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip, + TrimUI Smart Pro, TrimUI Brick, Anbernic RG35XX Plus, RG40XX H, Powkiddy RGB30] + devices_source: https://github.com/spruceUI/spruceOS/wiki/Supported-Devices + - name: Allium + devices: [Miyoo Mini, Miyoo Mini Plus, Miyoo Mini Flip] + devices_source: https://github.com/goweiwen/Allium + - name: Anbernic stock firmware + - name: ArchR + folder: '`/storage/roms/bios`, on a part Windows and macOS cannot open' + folder_source: https://arch-r.io/docs/roms/ + devices: [R36S, R33S, R36S Soysauce, Odroid Go Advance, Odroid Go Super, RG351V, + RG351M, GameForce Chi, MagicX XU10, Powkiddy RGB10, Powkiddy RGB10X, K36] + devices_source: https://github.com/archr-linux/Arch-R + - name: CrossMix-OS + folder: '`BIOS` at the root of the card' + folder_source: https://github.com/cizia64/CrossMix-OS/wiki/Emulators + devices: [TrimUI Smart Pro] + devices_source: https://github.com/cizia64/CrossMix-OS + - name: dArkOS + folder: '`bios` on the **EASYROMS** part of the card' + folder_source: https://github.com/christianhaitian/dArkOS/wiki/dArkOS-Emulators-and-Ports-information + devices: [Odroid Go Advance, RGB10, RG351MP, G350] + devices_source: https://github.com/christianhaitian/dArkOS/wiki + - name: JELOS + - name: KOLOS + devices: [RG353P, RG353M, RG353V, RG353VS, RG503, Powkiddy RK2023, RGB30, RGB10 Max 3] + devices_source: https://github.com/AnonLinuxOS/KOLOS + - name: Koriki + folder: '`BIOS` at the root of the card' + folder_source: https://github.com/Rparadise-Team/Koriki/wiki/En:-02.-Content-installation + devices: [Miyoo Mini] + devices_source: https://github.com/Rparadise-Team/Koriki + - name: MiyooCFW + devices: [BittBoy, Pocket Go, PowKiddy Q90, PowKiddy V90, PowKiddy Q20 Mini, Sup M3, XYC Q8] + devices_source: https://github.com/MiyooCFW/docs/blob/main/docs/index.md + - name: plumOS + folder: '`bios` on the **PLUMOS** part of the card' + folder_source: https://github.com/game-de-it/plumOS-XU20V32/blob/main/docs/developer/storage-and-updates.md + devices: [MagicX XU20 V32] + devices_source: https://github.com/game-de-it/plumOS-XU20V32 + - name: RetroOZ + devices: [OGS, OGA, RGB10Max, RGB10Max2] + devices_source: https://github.com/southoz/RetroOZ/wiki + - name: UnofficialOS + folder: '`roms/bios` on the games card' + folder_source: https://github.com/RetroGFX/UnofficialOS/wiki/Emulators-and-Game-Engines + devices: [R35S, R36S, RG351M, RG351P, RG351V, RG353M, RG353P, RG353V, RG503, RG552, + RGB30, RGB20 Pro, Odroid Go Advance, Odroid Go Super] + devices_source: https://github.com/RetroGFX/UnofficialOS/wiki + guides: retroarch: summary: >- diff --git a/scripts/siteguides.py b/scripts/siteguides.py index 991e1e21..c8bbe383 100644 --- a/scripts/siteguides.py +++ b/scripts/siteguides.py @@ -11,6 +11,7 @@ parts the pages share. The registry keeps the folder the pack unzips into from __future__ import annotations +import re import textwrap from dataclasses import dataclass, fields from pathlib import Path @@ -67,11 +68,65 @@ class Guide: finish: str = "" +@dataclass(frozen=True) +class Tile: + """A tile beside the setups of a device group that is no setup of its + own: a kind of device that runs one (Android handhelds run RetroArch). + *link* is a site path, like the setup tiles' ``platforms/.md``.""" + + name: str + text: str + link: str + + +@dataclass(frozen=True) +class HandheldSystem: + """One handheld system on the handheld guide: a name the installer + detects, the BIOS folder seen on the card from a computer, and the + devices the system's own site lists. Each value comes with the address + of the page that states it, and is left out when no first-party page does.""" + + name: str + folder: str = "" + folder_source: str = "" + devices: tuple[str, ...] = () + devices_source: str = "" + + +@dataclass(frozen=True) +class HandheldGuide: + """The guide page for the systems that run a setup on a handheld + (ArkOS, muOS, Onion... run RetroArch). *devices* are the names a reader + types, searched on that page; *systems* one row each.""" + + page: str + title: str + summary: str + uses: str + systems: tuple[HandheldSystem, ...] + + @property + def path(self) -> str: + return f"platforms/{self.page}.md" + + @property + def devices(self) -> tuple[str, ...]: + seen: dict[str, None] = {} + for system in self.systems: + seen.update(dict.fromkeys(system.devices)) + return tuple(seen) + + _GUIDE_KEYS = {f.name for f in fields(Guide)} _REQUIRED = ("summary", "folders", "run") -_TOP_KEYS = {"install_base", "trust_items", "device_groups", "terminals", "guides"} -_GROUP_KEYS = {"label", "description", "anchor", "also_runs"} +_TOP_KEYS = {"install_base", "trust_items", "device_groups", "terminals", "guides", + "handheld_guide"} +_GROUP_KEYS = {"label", "description", "anchor", "also_runs", "tiles"} _ALSO_KEYS = ("setup", "summary", "uses", "runs_on", "folder") +_TILE_KEYS = ("name", "text", "link") +_HANDHELD_KEYS = {"page", "title", "summary", "uses", "systems"} +_SYSTEM_KEYS = {"name", "folder", "folder_source", "devices", "devices_source"} +_SOURCE = re.compile(r"https://\S+") _TERMINAL_KEYS = ("label", "how", "shell") @@ -152,6 +207,72 @@ def _guide(name: str, data: object, extract_to: str, terminals: dict) -> Guide: return Guide(**values) +def detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: + """Named systems a platform runs on, handhelds that run RetroArch for one, + with the BIOS folder the installer writes on each, read from the + detection rules of the registry.""" + seen: dict[str, str] = {} + for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: + name, folder = rule.get("name"), rule.get("bios_path") + if name and folder and rule.get("installs", True) and name not in seen: + seen[name] = folder + return sorted(seen.items(), key=lambda item: item[0].casefold()) + + +def _source(value: object, where: str) -> str: + text = _text(value, where) + if not _SOURCE.fullmatch(text): + raise GuideError(f"{where}: expected one https address, found {text!r}") + return text + + +def _handheld_system(data: object, where: str) -> HandheldSystem: + if not isinstance(data, dict): + raise GuideError(f"{where}: expected a mapping") + _unknown(data, _SYSTEM_KEYS, where) + values: dict = {"name": _text(data.get("name"), f"{where}.name")} + for value, source in (("folder", "folder_source"), ("devices", "devices_source")): + if (value in data) != (source in data): + raise GuideError(f"{where}: {value} and {source} go together, " + "a value is written only with the page that states it") + if value in data: + values[value] = (_texts(data[value], f"{where}.{value}") if value == "devices" + else _text(data[value], f"{where}.{value}")) + values[source] = _source(data[source], f"{where}.{source}") + return HandheldSystem(**values) + + +def _handheld_guide(data: object, registry: dict, guides: dict, where: str) -> HandheldGuide | None: + """The handheld guide, checked against the systems the registry detects + the setup on: one row for each, and none it does not detect.""" + if data is None: + return None + if not isinstance(data, dict): + raise GuideError(f"{where}: expected a mapping") + _unknown(data, _HANDHELD_KEYS, where) + uses = _text(data.get("uses"), f"{where}.uses") + if uses not in guides: + raise GuideError(f"{where}.uses: {uses!r} is not a platform with a guide") + page = _text(data.get("page"), f"{where}.page") + if page in registry: + raise GuideError(f"{where}.page: {page!r} is the page of a registered platform") + raw = data.get("systems") + if not isinstance(raw, list) or not raw: + raise GuideError(f"{where}.systems: expected a list of systems") + systems = tuple(_handheld_system(row, f"{where}.systems[{i}]") for i, row in enumerate(raw)) + named = [system.name for system in systems] + detected = [name for name, _folder in detected_systems(registry, uses)] + if sorted(named, key=str.casefold) != detected: + raise GuideError( + f"{where}.systems: one row per system the registry detects {uses} on, " + f"missing {sorted(set(detected) - set(named))}, " + f"not detected {sorted(set(named) - set(detected))}") + return HandheldGuide( + page=page, title=_text(data.get("title"), f"{where}.title"), + summary=_text(data.get("summary"), f"{where}.summary"), uses=uses, systems=systems, + ) + + def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: """The guides file, checked against the registry. @@ -183,6 +304,7 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: groups: dict[str, tuple[str, str]] = {} anchors: dict[str, str] = {} also_runs: dict[str, tuple[tuple[str, str, str, str, str], ...]] = {} + tiles: dict[str, tuple[Tile, ...]] = {} for key, entry in (data.get("device_groups") or {}).items(): at = f"{path.name}: device_groups.{key}" if not isinstance(entry, dict): @@ -203,6 +325,15 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: rows.append(values) if rows: also_runs[key] = tuple(rows) + group_tiles = [] + for i, row in enumerate(entry.get("tiles") or []): + row_at = f"{at}.tiles[{i}]" + if not isinstance(row, dict): + raise GuideError(f"{row_at}: expected a mapping") + _unknown(row, _TILE_KEYS, row_at) + group_tiles.append(Tile(*(_text(row.get(k), f"{row_at}.{k}") for k in _TILE_KEYS))) + if group_tiles: + tiles[key] = tuple(group_tiles) unregistered = sorted(set(raw_guides) - set(registry)) if unregistered: @@ -228,8 +359,11 @@ def load_guides(path: Path = GUIDES_FILE, registry: dict | None = None) -> dict: "device_groups": groups, "group_anchors": anchors, "also_runs": also_runs, + "tiles": tiles, "terminals": terminals, "guides": guides, + "handheld_guide": _handheld_guide(data.get("handheld_guide"), registry, guides, + f"{path.name}: handheld_guide"), } @@ -238,24 +372,14 @@ INSTALL_BASE: str = _LOADED["install_base"] DEVICE_GROUPS: dict[str, tuple[str, str]] = _LOADED["device_groups"] GROUP_ANCHORS: dict[str, str] = _LOADED["group_anchors"] ALSO_RUNS: dict[str, tuple[tuple[str, str, str, str, str], ...]] = _LOADED["also_runs"] +GROUP_TILES: dict[str, tuple[Tile, ...]] = _LOADED["tiles"] +HANDHELD_GUIDE: HandheldGuide | None = _LOADED["handheld_guide"] TERMINALS: dict[str, tuple[str, str, str]] = _LOADED["terminals"] TRUST_ITEMS: tuple[str, ...] = _LOADED["trust_items"] GUIDES: dict[str, Guide] = _LOADED["guides"] _REGISTRY: dict = _LOADED["registry"] -def detected_systems(registry: dict, platform: str) -> list[tuple[str, str]]: - """Named systems a platform runs on, handhelds that run RetroArch for one, - with the BIOS folder the installer writes on each, read from the - detection rules of the registry.""" - seen: dict[str, str] = {} - for rule in (registry.get(platform, {}).get("install") or {}).get("detect") or []: - name, folder = rule.get("name"), rule.get("bios_path") - if name and folder and rule.get("installs", True) and name not in seen: - seen[name] = folder - return sorted(seen.items(), key=lambda item: item[0].casefold()) - - def folder_list(name: str, registry: dict | None = None) -> list[tuple[str, str]]: """Every folder a guide names, as (where, path): the common ones, the rarer builds, and the systems the registry detects when the guide says so.""" From aac613bea78427178a6d1437709681a84e099e86 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:47:38 +0200 Subject: [PATCH 2/4] feat: answer version and handheld questions Setup pages name the setup version the ZIP was made from, offer a partial install, answer a file in place but reported missing, and link the offline install. Get your files folds its tables into one closed block with part buttons. The setup comparison moves to the verification modes page. New Linux handhelds guide page. --- scripts/generate_site.py | 426 ++++++++++++++++++++++++------------ tests/test_doc_surfaces.py | 3 +- tests/test_site_copy.py | 5 +- tests/test_site_funnel.py | 7 +- tests/test_site_r2_entry.py | 4 +- tests/test_site_r3_entry.py | 273 +++++++++++++++++++++++ tests/test_which_pack.py | 2 +- wiki/testing-guide.md | 2 +- 8 files changed, 576 insertions(+), 146 deletions(-) create mode 100644 tests/test_site_r3_entry.py diff --git a/scripts/generate_site.py b/scripts/generate_site.py index df4b4a93..0902c30f 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -83,8 +83,12 @@ from siteguides import ( ALSO_RUNS, DEVICE_GROUPS, GROUP_ANCHORS, + GROUP_TILES, GUIDES, + HANDHELD_GUIDE, Guide, + HandheldGuide, + HandheldSystem, FENCE_TITLES, INSTALL_BASE, TERMINALS, @@ -450,13 +454,17 @@ def _next_block(links: list[str]) -> list[str]: return ["## Next steps", "", *(f"- {link}" for link in links), ""] -def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "") -> list[str]: - """The setup tiles of one device group: logo, name and the hardware it runs on.""" +def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "", + group: str = "") -> list[str]: + """The setup tiles of one device group: logo, name and the hardware it + runs on, then the group's other tiles: a kind of device that runs one of + the setups (Android handhelds, Linux handhelds).""" return markup.platform_tiles([ - (display[name], f"{prefix}platforms/{name}.md", - str(registry.get(name, {}).get("logo") or ""), - GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name])) - for name in members + *((display[name], f"{prefix}platforms/{name}.md", + str(registry.get(name, {}).get("logo") or ""), + GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name])) + for name in members), + *((tile.name, f"{prefix}{tile.link}", "", tile.text) for tile in GROUP_TILES.get(group, ())), ]) @@ -468,10 +476,10 @@ def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> l alone: list[str] = [] for group, (label, _description) in DEVICE_GROUPS.items(): members = _group_keys(registry, group, display) - if len(members) == 1: + if len(members) == 1 and not GROUP_TILES.get(group): alone += members elif members: - lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix)] + lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix, group)] if alone: lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix)] return lines @@ -510,13 +518,15 @@ def generate_home( "", f"Free. No account. Works with {works_with}.", "", - "- A game says BIOS missing? [When a game does not start](wiki/troubleshooting.md)", - "- Have a file already? Reference: [Check it](files/index.md)", + "- BIOS missing, even with the file there? " + "[When a game does not start](wiki/troubleshooting.md)", + "- Reference: [Check your file](files/index.md)", "", "## Do I need it?", "", "- **Yes**, if your emulator says a BIOS is missing.", - "- **Yes** for PlayStation 1 and 2, Saturn, Dreamcast and Neo Geo.", + "- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.", + "- **Better with it** for Dreamcast: some games need it.", "- **Usually not** for NES, Super Nintendo, Mega Drive and Game Boy.", "", "Details: [Every console, one by one](systems/index.md)", @@ -635,8 +645,8 @@ def release_line(platform_name: str, record: dict) -> list[str]: built = _release_date(tag) lines = [ f"Pack {tag}{f', built {built}' if built else ''}. " - f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) " - f"[signature]({base}/SHA256SUMS.txt.sig)", + f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) and its " + f"[signature]({base}/SHA256SUMS.txt.sig).", ] config = load_platform_config(platform_name, "platforms") page_version = str(config.get("version") or "") @@ -648,9 +658,42 @@ def release_line(platform_name: str, record: dict) -> list[str]: return lines +# What a reader on another version of the setup does with the pack. Our data +# says which list the pack was made from, not what an older list lacks. +OTHER_VERSION = ("Another version? After copying, `--check` names any file missing " + "or different.") + + +def setup_version(platform_name: str, record: dict | None = None) -> str: + """The setup and version whose BIOS list the pack was made from + ("Batocera 43.1"): the version in the pack's name, else the version the + platform YAML was read at. Empty when neither says, or when the + platform has no YAML to read a version from.""" + try: + config = load_platform_config(platform_name, "platforms") + except FileNotFoundError: + return "" + pack_name = release_record.pack_name_for(platform_name, record or {}) + version = (_pack_version(pack_name) if pack_name else "") or str(config.get("version") or "") + if not version: + return "" + return f"{config.get('platform', platform_name)} {version.removeprefix('v')}" + + +def version_line(platform_name: str, record: dict, named: bool = False) -> str: + """The version the pack fits, and what to do on another. *named* when + the release line already named the pack's version.""" + made_from = setup_version(platform_name, record) + if named or not made_from: + return OTHER_VERSION + return f"Made from the BIOS list of {made_from}. {OTHER_VERSION}" + + def _pack_buttons(platform_name: str, label: str = "Pack") -> str: """Buttons to the files of the platform's pack in the recorded release, - and the release they come from under them.""" + the release they come from, and the setup version its list was read at. + "Does this pack fit my version?" is the first question a frontend user + asks.""" record = release_record.load_record() buttons = download_links( platform_name, @@ -659,13 +702,9 @@ def _pack_buttons(platform_name: str, label: str = "Pack") -> str: label, button=lambda text, url: markup.button(text, url, primary=True), ) - return "\n\n".join([buttons, *release_line(platform_name, record)]) - - -def _released_files(platform_name: str) -> str: - """File count of the pack the latest release serves, as a table cell.""" - files, _size = release_totals(platform_name, release_record.load_record()) - return f"{files:,}" if files else "-" + released = release_line(platform_name, record) + return "\n\n".join([buttons, *released, + version_line(platform_name, record, named=len(released) > 1)]) # The home tile counts the whole collection. Labelled "Files" above a table @@ -2222,15 +2261,14 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]: def generate_platform_index(coverages: dict, registry: dict | None = None) -> str: - """The Your setup tab: every platform by the device it runs on, then the - figures of each one under a reference heading.""" + """The Your setup tab: every platform by the device it runs on, and one + line to the table that sets them side by side (setup_table), which is + reference matter and sits with how each setup checks its files.""" registry = registry or {} - total_present = sum(c["present"] for c in coverages.values()) - lines = [ "# Your setup", "", - "Pick what you play on. Its page gives the folder, the command and the ZIP.", + "Each setup's page gives the folder, the command and the ZIP.", "", ] for group, (label, description) in DEVICE_GROUPS.items(): @@ -2243,71 +2281,67 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st (f"[{coverages[name]['platform']}]({name}.md)", [GUIDES[name].summary]) for name in members ]) - lines += [ - "## Reference: every platform at a glance { #reference }", + f"Reference: [Every setup side by side](../{SETUP_TABLE_LINK})", "", - level_label("reference"), - "", - f"{setup_count()} supported platforms with {total_present:,} verified files.", - "", - "| Platform | On its BIOS list | Files in the pack | Checked by | Status | Download |", - "|----------|------:|------:|-----------|--------|----------|", ] - - mode_labels = { - "md5": 'MD5', - "sha1": 'SHA1', - "existence": 'existence', - } - - archived_any = False - for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]): - display = cov["platform"] - - mode_html = mode_labels.get( - cov["mode"], - f'{cov["mode"]}', - ) - status = registry.get(name, {}).get("status", "active") - if status == "archived": - archived_any = True - status_html = 'archived' - else: - status_html = 'active' - - lines.append( - f"| [{display}]({name}.md) | " - f"{cov['present']:,} | {_released_files(name)} | " - f"{mode_html} | {status_html} | " - f"{_pack_cell(name, release_record.load_record(), '../')} |" - ) - - lines.extend( - [ - "", - "The BIOS list is what the platform publishes. The pack adds the " - "files its emulators load and the data directories they read, " - "which is the count an extraction shows. " - "Checked by is the test each platform runs on its own, replicated " - "from its source code.", - "", - "Reference: [How each platform checks files](../wiki/verification-modes.md).", - ] - ) - - if archived_any: - lines.extend( - [ - "", - "An archived platform keeps its configuration and still gets a " - "pack, but upstream is no longer scraped on a schedule.", - ] - ) - return "\n".join(lines) + "\n" +# Where the side-by-side table of setups is published: with how each setup +# checks its files, the column a reader compares. +SETUP_TABLE_PAGE = "wiki/verification-modes.md" +SETUP_TABLE_ANCHOR = "every-setup-at-a-glance" +SETUP_TABLE_LINK = f"{SETUP_TABLE_PAGE}#{SETUP_TABLE_ANCHOR}" +# How a setup checks a file, in the words of a reader. +_HOW_IT_CHECKS = {"existence": "name only", "md5": "MD5", "sha1": "SHA-1"} + + +def setup_table(coverages: dict, registry: dict | None = None) -> list[str]: + """Every setup side by side: the files on its own list, the files in its + ZIP, and how it checks them. Written under the Reference level, on the + page that explains each way of checking.""" + registry = registry or {} + lines = [ + f"## Every setup at a glance {{ #{SETUP_TABLE_ANCHOR} }}", + "", + "| Setup | Files on its own list | Files in the ZIP | How it checks |", + "|-------|------:|------:|---------------|", + ] + archived = False + for name, cov in sorted(coverages.items(), key=lambda item: item[1]["platform"].casefold()): + status = registry.get(name, {}).get("status") + archived |= status == "archived" + listed = f"{cov['total']:,}" if cov["total"] else "No list of its own" + files, _size = release_totals(name, release_record.load_record()) + zipped = f"{files:,}" if files else "No ZIP yet" + lines.append( + f"| [{cov['platform']}](../platforms/{name}.md)" + f"{' (archived)' if status == 'archived' else ''} | {listed} | {zipped} | " + f"{_HOW_IT_CHECKS.get(cov['mode'], cov['mode'])} |" + ) + lines += [ + "", + "The ZIP adds the files its emulators load to the files on the setup's own list.", + "", + ] + if archived: + lines += ["An archived setup keeps its page and its ZIP. Its list is no longer " + "read again on a schedule.", ""] + return lines + + +def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -> str: + """The comparison table placed before the Next steps of its page, or at + its end.""" + table = "\n".join(setup_table(coverages, registry)) + marker = "\n## Next steps" + if marker in page: + head, tail = page.split(marker, 1) + return f"{head.rstrip()}\n\n{table}{marker}{tail}" + return f"{page.rstrip()}\n\n{table}" + + # A platform system whose file list passes this much Markdown gets a page # of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page. PLATFORM_SECTION_BUDGET = 200_000 @@ -2799,6 +2833,17 @@ def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[st ] +# The second most asked question about BIOS files (14 % of the community +# sample): the file is there and the setup still reports it missing. +# `--check` lists every file whose hash is wrong by name (install.py, the +# "wrong hash" lines of check_lines). +MISSING_IN_PLACE = "In place but reported missing? `--check` names a file with the wrong contents." + +# Under "From your computer": a device with no network installs from the ZIP +# (`--from-pack`). The id is the one wiki/installer.md gives its section. +OFFLINE_LINE = ("No internet on the device? " + "[Install from the ZIP](../wiki/installer.md#without-a-network).") + # Shown in the "On the device" tab of a guide that has no `terminal:` of its own. DEVICE_TERMINAL_FALLBACK = "Or fill the folder from your computer, with the ZIP." @@ -2851,13 +2896,24 @@ def _guide_download(name: str, display: str, guide: Guide, extract_to: str | Non "", ] elif record.get("tag"): + made_from = setup_version(name) lines += [ - f"No {display} ZIP yet. The command installs the same files.", + f"No {display} ZIP yet. The command installs the same files" + + (f", from the BIOS list of {made_from}." if made_from else "."), "", ] else: lines += [_pack_buttons(name, f"Download {display} Pack"), ""] - return lines + return [*lines, *only_some_consoles(name, "../")] + + +def only_some_consoles(name: str, prefix: str = "") -> list[str]: + """The way to a smaller download, in the words Get your files uses.""" + return [ + f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}" + "#only-some-consoles), or press **c** when the command asks.", + "", + ] def _guide_install(name: str, guide: Guide, heading: str) -> list[str]: @@ -2883,6 +2939,72 @@ def _guide_install(name: str, guide: Guide, heading: str) -> list[str]: ] +def devices_meta(devices: tuple[str, ...]) -> str: + """The device names a reader types, as the `devices` search field of the + page: an attribute on an empty element, commas out (the search engine + splits its keys on commas), so no excerpt shows them.""" + names = " ".join(name.replace(",", " ") for name in devices) + return f'' + + +# Devices named in a row of the handheld guide. The rest stay in the +# page's search field and on the list the row links. +HANDHELD_ROW_DEVICES = 6 + + +def _handheld_row(system: HandheldSystem) -> str: + shown = ", ".join(system.devices[:HANDHELD_ROW_DEVICES]) + more = len(system.devices) - HANDHELD_ROW_DEVICES + devices = (f"{shown}{f' and {more} more' if more > 0 else ''} " + f"([list]({system.devices_source}))" if system.devices else "") + folder = (f"{system.folder} ([source]({system.folder_source}))" if system.folder + else "Not written by the system. The command finds it on the device.") + return f"| {system.name} | {devices} | {folder} |" + + +def generate_handheld_guide(guide: HandheldGuide) -> str: + """The guide for the handheld systems that run a setup (ArkOS, muOS, + Onion... run RetroArch): one row per system the installer detects, the + folder seen on the card from a computer and the devices, each cited from + the system's own pages, then that setup's ZIP and the command for a card. + + A handheld owner starts from the device name. The systems were named + only in a closed note, with a path seen from inside the device.""" + uses = GUIDES[guide.uses] + display = load_platform_config(guide.uses, "platforms").get("platform", guide.uses) + args = {"windows": f"--platform {guide.uses} --dest E:\\BIOS", + "macos": f"--platform {guide.uses} --dest /path/to/card/BIOS", + "linux": f"--platform {guide.uses} --dest /path/to/card/BIOS"} + return "\n".join([ + f"# {guide.title}", + "", + guide.summary, + "", + devices_meta(guide.devices), + "", + "## Where the files go { #where-the-files-go }", + "", + "Put the SD card in a computer. Find your system below.", + "", + "| System | Devices | BIOS folder on the card |", + "|--------|---------|-------------------------|", + *(_handheld_row(system) for system in guide.systems), + "", + *_guide_download(guide.uses, display, uses, None, "## Download { #download }"), + "## Fill the card from a computer { #install }", + "", + "On Windows, note the card's letter, like **E:**. Put its BIOS folder after " + "`--dest`, written as in the table.", + "", + *terminal_tabs(("windows", "macos", "linux"), args), + *trust_box("../"), + *_next_block([ + f"[{display}]({guide.uses}.md): every folder and every file", + "[When a game does not start](../wiki/troubleshooting.md)", + ]), + ]) + "\n" + + def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: bool) -> list[str]: """The guide part of a platform page: folder, command, pack, check, next. @@ -2916,6 +3038,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: lines += [ *_guide_download(name, display, guide, extract_to, "## From your computer { #download }"), + OFFLINE_LINE, + "", *_guide_install(name, guide, "## On the device { #install }"), ] else: @@ -2934,6 +3058,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: "", _check_sentence(display, cov["mode"], cov["config"], no_list), "", + MISSING_IN_PLACE, + "", *([guide.finish, ""] if guide.finish else []), *_next_block([ "[First install, step by step](../wiki/first-install.md)", @@ -7327,8 +7453,10 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str: pack = release_record.pack_for(name, record) if record.get("tag") and not (pack and pack.get("assets")): return f"No ZIP yet. [Use the command]({prefix}platforms/{name}.md#install)." - display = load_platform_config(name, "platforms").get("platform", name) - links = download_links(name, record, REPO_URL, display) + # Small buttons, one per part, the size after the last: links wrapped + # mid-label ("part 1 of / 3") in a narrow cell. + links = download_links(name, record, REPO_URL, "ZIP", + button=lambda text, url: markup.button(text, url)) size = pack.get("download_size") if pack else None return f"{links} ({_fmt_size(size)})" if size else links @@ -7353,17 +7481,62 @@ def _own_root_folders(registry: dict) -> dict[str, str]: return folders +def _folder_row(key: str, display: dict[str, str], registry: dict, record: dict, + own_root: dict[str, str]) -> str: + """One setup's row: where it runs, its BIOS folder and its ZIP. A setup + whose ZIP holds its own top folder says where to unzip it, and the + BIOS folder itself when it has no ZIP to unzip.""" + guide = GUIDES[key] + pack = release_record.pack_for(key, record) + zipped = bool(pack and pack.get("assets")) + folder = (f"Unzip into `{own_root[key]}`" if key in own_root and zipped + else _folder_cell(key, guide)) + return (f"| [{display[key]}](platforms/{key}.md) | " + f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | " + f"{_pack_cell(key, record)} |") + + +def _folder_tables(registry: dict, record: dict, display: dict[str, str]) -> list[str]: + """Every setup's folder and ZIP, one table per device group, the groups + of one setup together under "Other setups" as on the tiles. A row for + each setup that runs another (LaunchBox, Android) under its group.""" + own_root = _own_root_folders(registry) + header = ["| Setup | Runs on | BIOS folder | ZIP |", "|-------|---------|-------------|-----|"] + lines: list[str] = [] + alone: list[str] = [] + for group, (label, _description) in DEVICE_GROUPS.items(): + members = _group_keys(registry, group, display) + if len(members) == 1 and not ALSO_RUNS.get(group): + alone += members + continue + if not members: + continue + lines += [f'**{label}**', "", *header] + lines += [_folder_row(key, display, registry, record, own_root) for key in members] + lines += [ + f"| {setup} | {runs_on} | {folder}, same as " + f"[{display[uses]}](platforms/{uses}.md) | {_pack_cell(uses, record)} |" + for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ()) + ] + lines.append("") + if alone: + anchors = "".join(f'' + for key in alone) + lines += [f"{anchors}**Other setups**", "", *header, + *(_folder_row(key, display, registry, record, own_root) for key in alone), ""] + return lines + + def generate_which_pack() -> str: """The device picker: every registered platform as a tile under the - device it runs on, then one table per device with its BIOS folder and - its ZIP, the console picker, and the command for a setup not listed. + device it runs on, the ZIP advice with every folder and ZIP in one closed + table, the console picker, and the command for a setup not listed. - The tiles are the choice. A second set of cards, five tables and the - picker offered the same choice three times.""" + The tiles are the choice. Open tables under them listed every setup a + second time and made the page 11,800 px tall on a phone.""" registry = load_platform_registry("platforms") record = release_record.load_record() display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry} - own_root = _own_root_folders(registry) all_tabs = ("windows", "macos", "linux", "steamdeck", "android") lines = [ "# Get your files", @@ -7375,52 +7548,19 @@ def generate_which_pack() -> str: *setup_picker(registry, display), "## Folders and ZIP files { #manual-download }", "", - "- A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.", - "- Unzip them all into the BIOS folder of your setup. Put the files " - "straight in it, not in a new folder.", - "- Some files inside stay zipped, like `neogeo.zip`. Leave them as they are.", - "- **Unzip into** means the ZIP holds its own top folder. Unzip it there.", + "- A big ZIP comes in parts. Download every part.", + "- Unzip them into your BIOS folder. Put the files straight in it, " + "not in a new folder.", + "- Leave the ZIPs inside, like `neogeo.zip`, as they are.", + "- A few ZIPs hold their own top folder. Their setup's page says where " + "to unzip them.", "", + *markup.details("note", "Every folder and ZIP in one table", + _folder_tables(registry, record, display)), ] - no_zip = False - for group, (label, _description) in DEVICE_GROUPS.items(): - members = _group_keys(registry, group, display) - if not members: - continue - lines += [ - f"### {label} {{ #{GROUP_ANCHORS[group]} }}", - "", - "| Setup | Runs on | BIOS folder | ZIP |", - "|-------|---------|-------------|-----|", - ] - for key in members: - guide = GUIDES[key] - folder = (f"Unzip into `{own_root[key]}`" if key in own_root - else _folder_cell(key, guide)) - pack = release_record.pack_for(key, record) - no_zip |= bool(record.get("tag") and not (pack and pack.get("assets"))) - lines.append( - f"| [{display[key]}](platforms/{key}.md) | " - f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | " - f"{_pack_cell(key, record)} |" - ) - for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ()): - lines.append( - f"| {setup} | {runs_on} | {folder}, same as " - f"[{display[uses]}](platforms/{uses}.md) | {_pack_cell(uses, record)} |" - ) - lines.append("") - if group == "handheld": - lines += markup.details("note", "Other handhelds that run RetroArch", [ - "They use the RetroArch files, in the folder below. The command " - "finds it.", - "", - "| System | Runs | BIOS folder |", - "|--------|------|-------------|", - *(f"| {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |" - for name, folder in _detected_systems(registry, "retroarch")), - ]) - if no_zip: + no_zip = any(not (pack and pack.get("assets")) + for pack in (release_record.pack_for(key, record) for key in registry)) + if record.get("tag") and no_zip: lines += ["Setups added after the last release have no ZIP until the next one.", ""] lines += [ f"The ZIPs come from the [latest release]({RELEASE_URL}). The command may " @@ -7529,6 +7669,10 @@ def _platform_nav(coverages: dict, registry: dict) -> list: for name in sorted(coverages, key=lambda n: coverages[n]["platform"].casefold()) if registry.get(name, {}).get("device_group") == group ] + # The handheld guide sits with the group whose tile leads to it. + members += [{HANDHELD_GUIDE.title: HANDHELD_GUIDE.path} + for tile in GROUP_TILES.get(group, ()) + if HANDHELD_GUIDE and tile.link == HANDHELD_GUIDE.path] if members: nav.append({label: members}) return nav @@ -7890,6 +8034,8 @@ def _generate(args: argparse.Namespace) -> None: str(docs / "platforms" / "index.md"), generate_platform_index(coverages, registry), ) + if HANDHELD_GUIDE: + write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE)) for name, cov in coverages.items(): # The emulators this platform runs, not every one of the system. runs = resolve_platform_cores(cov["config"], profiles) @@ -8016,6 +8162,10 @@ def _generate(args: argparse.Namespace) -> None: for src_file in wiki_src.glob("*.md"): shutil.copy2(src_file, wiki_dest / src_file.name) _record(wiki_dest / src_file.name) + table_page = docs / SETUP_TABLE_PAGE + if table_page.is_file(): + write_if_changed(str(table_page), with_setup_table( + table_page.read_text(encoding="utf-8"), coverages, registry)) # data-model.md is generated (contains live DB stats) write_if_changed( str(wiki_dest / "data-model.md"), generate_wiki_data_model(db, profiles) diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py index 21cfd770..86c51ed9 100644 --- a/tests/test_doc_surfaces.py +++ b/tests/test_doc_surfaces.py @@ -169,7 +169,8 @@ class DownloadLinksNameTheFile(unittest.TestCase): self.assertNotIn("releases/latest", row) for pack in RECORD["packs"].values(): for asset in pack["assets"]: - self.assertIn(f"/releases/download/{RECORD['tag']}/{asset})", page) + self.assertRegex(page, re.escape(f"/releases/download/{RECORD['tag']}/{asset}") + + r"[)\"]") def test_a_platform_the_release_lacks_links_no_release(self): """KNULLI, AmberELEC and EmuELEC were registered after the recorded diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py index ebf80951..c4f7bd06 100644 --- a/tests/test_site_copy.py +++ b/tests/test_site_copy.py @@ -37,7 +37,10 @@ START_PAGES = { "wiki/glossary.md": 200, } # Level 1: the guide part of each setup page, above its Reference heading. -SETUP_CEILING = 220 +# 220 until each page also answered three asked questions in about 50 words: +# which setup version the ZIP was made from (7 % of the community sample), +# how to get only some consoles, and a file in place but reported missing (14 %). +SETUP_CEILING = 280 # The setup index is a card per setup, one sentence each. SETUP_INDEX_CEILING = 300 diff --git a/tests/test_site_funnel.py b/tests/test_site_funnel.py index 2a58bfa4..68d744bf 100644 --- a/tests/test_site_funnel.py +++ b/tests/test_site_funnel.py @@ -134,8 +134,11 @@ class EveryPlatformOnThePicker(unittest.TestCase): def test_handheld_systems_that_run_retroarch_are_named(self): """muOS, ArkOS and the rest are detected as RetroArch by the installer and were named on no page a user reads.""" - page = _which_pack() - self.assertIn("| muOS | [RetroArch](platforms/retroarch.md) | `/run/muos/storage/bios` |", page) + from siteguides import HANDHELD_GUIDE + + page = generate_site.generate_handheld_guide(HANDHELD_GUIDE) + self.assertIn("| muOS |", page) + self.assertIn("`MUOS/bios` on the card", page) self.assertNotIn("| TrimUI |", page) diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py index ccc4c1c1..9a01f98f 100644 --- a/tests/test_site_r2_entry.py +++ b/tests/test_site_r2_entry.py @@ -115,7 +115,6 @@ class OneSetupCount(unittest.TestCase): self.assertEqual(siteguides.setup_count(), count) self.assertIn(f"and {count - 3} more.", _page("index.md")) self.assertIn(f'{count}', _page("explore.md")) - self.assertIn(f"{count} supported platforms", _page("platforms/index.md")) class SetupTiles(unittest.TestCase): @@ -123,7 +122,8 @@ class SetupTiles(unittest.TestCase): for name in ("index.md", "which-pack.md"): text = _page(name) with self.subTest(page=name): - self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY)) + extra = sum(len(tiles) for tiles in siteguides.GROUP_TILES.values()) + self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY) + extra) for key in REGISTRY: self.assertIn(f'href="/retrobios/platforms/{key}/"', text) diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py new file mode 100644 index 00000000..a7fb1aca --- /dev/null +++ b/tests/test_site_r3_entry.py @@ -0,0 +1,273 @@ +"""Entry pages and guides (round 3, package 4). + +A handheld owner found no tile for Android, ArkOS, muOS, Onion or spruceOS: +the systems sat in a closed note with a path seen from inside the device. +The setup version a pack fits was named only under Reference, the setup page +offered the full pack only, Get your files listed every setup twice, the Your +setup index ended on a table in project words, and the home page said a +Dreamcast needs its BIOS while Flycast boots without one. +""" + +from __future__ import annotations + +import re +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) +sys.path.insert(0, str(REPO_ROOT)) + +import generate_site # noqa: E402 +import release_record # noqa: E402 +import siteguides # noqa: E402 +from common import load_platform_config, load_platform_registry # noqa: E402 +from tests import sitepages # noqa: E402 + +REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms")) +GUIDE = siteguides.HANDHELD_GUIDE + + +def _page(name: str) -> str: + sitepages.require() + return sitepages.page(name).read_text(encoding="utf-8") + + +def _guide_part(text: str) -> str: + """The guide part of a setup page, glossary marks taken off.""" + match = re.search(r"^## Reference\b", text, re.M) + text = text[: match.start()] if match else text + return re.sub(r"]*>", "", text) + + +def _display(key: str) -> str: + return load_platform_config(key, str(REPO_ROOT / "platforms")).get("platform", key) + + +class HandheldWayIn(unittest.TestCase): + def test_android_and_linux_tiles_on_home_and_get_your_files(self): + tiles = siteguides.GROUP_TILES.get("handheld", ()) + self.assertEqual([tile.name for tile in tiles], ["Android", "Linux handhelds"]) + for name in ("index.md", "which-pack.md"): + text = _page(name) + with self.subTest(page=name): + self.assertIn('href="/retrobios/platforms/retroarch/#where-the-files-go"', text) + self.assertIn('href="/retrobios/platforms/linux-handhelds/"', text) + self.assertIn(">Retroid, AYN Odin and other Android handhelds", text) + + def test_the_closed_note_is_gone(self): + self.assertNotIn("Other handhelds that run RetroArch", _page("which-pack.md")) + + def test_one_row_per_detected_system(self): + detected = [name for name, _folder in siteguides.detected_systems(REGISTRY, GUIDE.uses)] + self.assertEqual(sorted((s.name for s in GUIDE.systems), key=str.casefold), detected) + text = _page(GUIDE.path) + for system in GUIDE.systems: + with self.subTest(system=system.name): + self.assertIn(f"| {system.name} |", text) + + def test_every_folder_and_device_list_is_cited(self): + for system in GUIDE.systems: + with self.subTest(system=system.name): + if system.folder: + self.assertTrue(system.folder_source.startswith("https://")) + if system.devices: + self.assertTrue(system.devices_source.startswith("https://")) + for name in ("ArkOS", "muOS", "Onion", "spruceOS"): + system = next(s for s in GUIDE.systems if s.name == name) + with self.subTest(system=name): + self.assertTrue(system.folder and system.devices) + + def test_a_value_without_its_page_is_refused(self): + with self.assertRaises(siteguides.GuideError): + siteguides._handheld_system({"name": "Onion", "folder": "`BIOS`"}, "x") + with self.assertRaises(siteguides.GuideError): + siteguides._handheld_system( + {"name": "Onion", "devices": ["Miyoo Mini"], "devices_source": "a blog"}, "x") + + def test_the_guide_rejects_a_missing_or_unknown_system(self): + data = {"page": "x", "title": "X", "summary": "S", "uses": "retroarch", + "systems": [{"name": "Onion"}]} + with self.assertRaises(siteguides.GuideError): + siteguides._handheld_guide(data, REGISTRY, siteguides.GUIDES, "x") + + def test_devices_are_a_search_field_with_no_text(self): + text = _page(GUIDE.path) + match = re.search(r'', text) + self.assertIsNotNone(match) + self.assertIn("R36S", match[1]) + self.assertIn("Miyoo Mini", match[1]) + self.assertNotIn(",", match[1]) + # The names typed most often are also on the page itself. + self.assertIn("R36S", text.split(match[0], 1)[1]) + + def test_the_guide_is_in_the_handheld_group_of_the_navigation(self): + coverages = {key: {"platform": _display(key)} for key in REGISTRY} + nav = generate_site.generate_nav(coverages, {}, {}, REGISTRY) + setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab) + groups = {next(iter(item)): next(iter(item.values())) + for item in setup if isinstance(item, dict)} + label = siteguides.DEVICE_GROUPS["handheld"][0] + self.assertIn({GUIDE.title: GUIDE.path}, groups[label]) + + def test_the_guide_fills_the_card_and_never_its_root(self): + text = _page(GUIDE.path) + self.assertIn("--platform retroarch --dest E:\\BIOS", text) + self.assertNotRegex(text, r"--dest E:\\\s") + + +class PackNamesItsVersion(unittest.TestCase): + def test_the_line_under_the_release(self): + record = release_record.load_record() + if not release_record.pack_for("batocera", record): + self.skipTest("release.json has no Batocera pack") + block = generate_site._pack_buttons("batocera") + version = generate_site._pack_version(release_record.pack_name_for("batocera", record)) + self.assertIn(f"Made from the BIOS list of Batocera {version}. " + + generate_site.OTHER_VERSION, block) + + def test_a_version_gap_is_said_once(self): + record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": { + "assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}} + released = generate_site.release_line("retrobat", record) + line = generate_site.version_line("retrobat", record, named=len(released) > 1) + self.assertEqual(line, generate_site.OTHER_VERSION) + + def test_the_v_prefix_is_dropped(self): + self.assertEqual(generate_site.setup_version("retroarch", {}), + f"RetroArch {load_platform_config('retroarch', 'platforms')['version'].lstrip('v')}") + + def test_every_setup_page_names_a_version(self): + for key in REGISTRY: + with self.subTest(platform=key): + text = _guide_part(_page(f"platforms/{key}.md")) + self.assertRegex(text, r"(?:from the BIOS list of|This pack was built for) \S") + + +class SetupPagesOfferPartOfThePack(unittest.TestCase): + def test_pick_them_and_press_c(self): + for key in REGISTRY: + with self.subTest(platform=key): + text = _guide_part(_page(f"platforms/{key}.md")) + self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text) + self.assertIn("or press **c** when the command asks.", text) + + def test_the_anchor_exists(self): + self.assertIn("## Only some consoles?", _page("which-pack.md")) + + def test_a_file_in_place_but_missing_is_answered(self): + for key in REGISTRY: + with self.subTest(platform=key): + self.assertIn("In place but reported missing?", + _guide_part(_page(f"platforms/{key}.md"))) + + +class OfflineLine(unittest.TestCase): + def test_under_from_your_computer(self): + text = _page("platforms/batocera.md") + start = text.index("## From your computer") + line = text.index("No internet on the device?") + self.assertLess(start, line) + self.assertLess(line, text.index("## On the device")) + self.assertIn("/retrobios/wiki/installer/#without-a-network", text) + self.assertIn("{ #without-a-network }", (REPO_ROOT / "wiki" / "installer.md").read_text( + encoding="utf-8")) + + +class GetYourFilesListsEachSetupOnce(unittest.TestCase): + def setUp(self): + self.text = _page("which-pack.md") + + def test_the_tables_are_in_one_closed_block(self): + block = re.search(r'
\s*Every folder and ZIP ' + r'in one table(.*?)
', self.text, re.S) + self.assertIsNotNone(block) + outside = self.text.replace(block[0], "") + self.assertNotIn("| Setup | Runs on | BIOS folder | ZIP |", outside) + for key in REGISTRY: + with self.subTest(platform=key): + self.assertEqual(block[1].count(f"| [{_display(key)}](/retrobios/platforms/{key}/) |"), 1) + + def test_single_setup_groups_share_one_table(self): + self.assertIn("**Other setups**", self.text) + self.assertNotIn("**MiSTer FPGA**", self.text) + self.assertNotIn("**Self-hosted game library**", self.text) + + def test_no_unzip_into_without_a_zip(self): + record = release_record.load_record() + for line in self.text.splitlines(): + if "Unzip into" in line: + key = re.search(r"/retrobios/platforms/([a-z0-9]+)/", line)[1] + with self.subTest(platform=key): + self.assertTrue(release_record.pack_for(key, record)) + + def test_parts_are_buttons_with_the_size_after(self): + rows = [line for line in self.text.splitlines() if "Part 1 of" in line] + self.assertTrue(rows) + for row in rows: + self.assertRegex(row, r'Part 1 of \d') + self.assertRegex(row, r" \(\d[\d.]* [KMG]B\) \|$") + + def test_the_zip_note(self): + for line in ("A big ZIP comes in parts. Download every part.", + "Leave the ZIPs inside, like `neogeo.zip`, as they are.", + "Their setup's page says where to unzip them."): + self.assertIn(line, self.text) + self.assertNotIn("**Unzip into** means", self.text) + + +class YourSetupIndexInPlainWords(unittest.TestCase): + def test_the_index_links_the_table(self): + text = _page("platforms/index.md") + self.assertNotIn("| Platform |", text) + self.assertNotIn("at a glance", text.split("---", 2)[-1].lower().replace( + "every setup at a glance", "")) + self.assertIn("/retrobios/wiki/verification-modes/#every-setup-at-a-glance", text) + + def test_the_table_on_verification_modes(self): + text = _page("wiki/verification-modes.md") + self.assertIn("| Setup | Files on its own list | Files in the ZIP | How it checks |", text) + table = text.split("## Every setup at a glance", 1)[1].split("\n## ", 1)[0] + self.assertNotIn("existence", table) + self.assertIn("| name only |", table) + self.assertIn("No list of its own", table) + self.assertLess(text.index("## Every setup at a glance"), text.index("## Next steps")) + + def test_insertion_keeps_next_steps_last(self): + page = "# T\n\nBody.\n\n## Next steps\n\n- [A](a.md)\n" + out = generate_site.with_setup_table(page, {}, {}) + self.assertTrue(out.rstrip().endswith("- [A](a.md)")) + self.assertIn("## Every setup at a glance", out) + + +class HomeCopy(unittest.TestCase): + def test_dreamcast_and_the_check_link(self): + home = re.sub(r"]*>", "", _page("index.md")) + self.assertIn("- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.", home) + self.assertIn("- **Better with it** for Dreamcast: some games need it.", home) + # The level label opens its line, never the middle of a sentence. + self.assertNotIn("? Reference:", home) + self.assertIn("- Reference: [Check your file]", home) + self.assertIn("BIOS missing, even with the file there?", home) + + def test_flycast_boots_without_the_dreamcast_bios(self): + import yaml + + profile = yaml.safe_load((REPO_ROOT / "emulators" / "flycast.yml").read_text(encoding="utf-8")) + boot = next(f for f in profile["files"] if f.get("name") == "dc_boot.bin") + self.assertFalse(boot.get("required")) + self.assertTrue(boot.get("hle_fallback")) + + +class DownloadLine(unittest.TestCase): + def test_checksums_and_signature_read_as_two(self): + record = release_record.load_record() + if not release_record.pack_for("batocera", record): + self.skipTest("release.json has no Batocera pack") + line = generate_site.release_line("batocera", record)[0] + self.assertRegex(line, r"\[SHA256SUMS\.txt\]\([^)]+\) and its \[signature\]\([^)]+\)\.$") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_which_pack.py b/tests/test_which_pack.py index 7d214096..fef42ee7 100644 --- a/tests/test_which_pack.py +++ b/tests/test_which_pack.py @@ -32,7 +32,7 @@ class WhichPackRows(unittest.TestCase): record = release_record.load_record() page = generate_site.generate_which_pack() row = re.compile(r"^\| \[[^\]]+\]\(platforms/([a-z0-9]+)\.md\) \|(.*)$", re.M) - asset = re.compile(r"/releases/download/[^/]+/([^)\s]+)\)") + asset = re.compile(r"/releases/download/[^/]+/([^)\s\"]+)[)\"]") checked = 0 for key, rest in row.findall(page): linked = set(asset.findall(rest)) diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md index 2e67ef63..117ddcf8 100644 --- a/wiki/testing-guide.md +++ b/wiki/testing-guide.md @@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards. | Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` | | Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` | | Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` | -| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | +| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_entry`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` | ## Test architecture From c5ba17d3dd2c2cc008c475fc4695cb3f1e41112f Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:50:52 +0200 Subject: [PATCH 3/4] refactor: fit setup pages in 220 words The new answers are shortened and the safety list, shown in full on the home page, is linked from each setup page instead of repeated. The setup ceiling stays at 220. --- scripts/generate_site.py | 33 +++++++++++++++++---------------- tests/test_site_copy.py | 5 +---- tests/test_site_r3_entry.py | 18 ++++++++++++++---- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 0902c30f..b620a626 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -660,8 +660,7 @@ def release_line(platform_name: str, record: dict) -> list[str]: # What a reader on another version of the setup does with the pack. Our data # says which list the pack was made from, not what an older list lacks. -OTHER_VERSION = ("Another version? After copying, `--check` names any file missing " - "or different.") +OTHER_VERSION = "Other version? Run `--check` after copying." def setup_version(platform_name: str, record: dict | None = None) -> str: @@ -686,7 +685,7 @@ def version_line(platform_name: str, record: dict, named: bool = False) -> str: made_from = setup_version(platform_name, record) if named or not made_from: return OTHER_VERSION - return f"Made from the BIOS list of {made_from}. {OTHER_VERSION}" + return f"Made for {made_from}. {OTHER_VERSION}" def _pack_buttons(platform_name: str, label: str = "Pack") -> str: @@ -2816,13 +2815,10 @@ def _check_sentence(display: str, mode: str, config: dict, no_list: bool) -> str return (f"{display} has no BIOS list, so it checks nothing itself. Each " "emulator looks for its own files when a game starts.") if config.get("hash_rule") == "size_and_any_hash": - return (f"{display} checks each file's size and fingerprint too. It " - "reports a file with the wrong size or contents.") + return f"{display} also checks each file's size and contents." if digest_algorithm(mode): - return (f"{display} checks each file's fingerprint too. It reports a file " - "with the wrong contents.") - return (f"{display} only checks that a file with the right name is there. " - "`--check` also checks what is inside.") + return f"{display} also checks each file's contents." + return f"{display} checks names only. `--check` also checks contents." def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[str]: @@ -2837,7 +2833,11 @@ def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[st # sample): the file is there and the setup still reports it missing. # `--check` lists every file whose hash is wrong by name (install.py, the # "wrong hash" lines of check_lines). -MISSING_IN_PLACE = "In place but reported missing? `--check` names a file with the wrong contents." +MISSING_IN_PLACE = "Reported missing anyway? `--check` names a wrong file." + +# Under every command of a setup page: the safety list is the one the home +# page shows in full, so the setup page links it. +SAFE_LINK = "[Is it safe?](../index.md#is-it-safe)" # Under "From your computer": a device with no network installs from the ZIP # (`--from-pack`). The id is the one wiki/installer.md gives its section. @@ -2899,7 +2899,7 @@ def _guide_download(name: str, display: str, guide: Guide, extract_to: str | Non made_from = setup_version(name) lines += [ f"No {display} ZIP yet. The command installs the same files" - + (f", from the BIOS list of {made_from}." if made_from else "."), + + (f", made for {made_from}." if made_from else "."), "", ] else: @@ -2911,7 +2911,7 @@ def only_some_consoles(name: str, prefix: str = "") -> list[str]: """The way to a smaller download, in the words Get your files uses.""" return [ f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}" - "#only-some-consoles), or press **c** when the command asks.", + "#only-some-consoles), or press **c** in the command.", "", ] @@ -2921,8 +2921,7 @@ def _guide_install(name: str, guide: Guide, heading: str) -> list[str]: lines = [ heading, "", - "The command finds the folder, downloads what is missing, and checks " - "each file.", + "It finds the folder and checks each file.", "", ] if guide.on_card: @@ -2935,7 +2934,8 @@ def _guide_install(name: str, guide: Guide, heading: str) -> list[str]: return [ *lines, *terminal_tabs(guide.run, platform_args(name, guide), how={"device": device}), - *trust_box("../"), + SAFE_LINK, + "", ] @@ -2997,7 +2997,8 @@ def generate_handheld_guide(guide: HandheldGuide) -> str: "`--dest`, written as in the table.", "", *terminal_tabs(("windows", "macos", "linux"), args), - *trust_box("../"), + SAFE_LINK, + "", *_next_block([ f"[{display}]({guide.uses}.md): every folder and every file", "[When a game does not start](../wiki/troubleshooting.md)", diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py index c4f7bd06..ebf80951 100644 --- a/tests/test_site_copy.py +++ b/tests/test_site_copy.py @@ -37,10 +37,7 @@ START_PAGES = { "wiki/glossary.md": 200, } # Level 1: the guide part of each setup page, above its Reference heading. -# 220 until each page also answered three asked questions in about 50 words: -# which setup version the ZIP was made from (7 % of the community sample), -# how to get only some consoles, and a file in place but reported missing (14 %). -SETUP_CEILING = 280 +SETUP_CEILING = 220 # The setup index is a card per setup, one sentence each. SETUP_INDEX_CEILING = 300 diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py index a7fb1aca..7732956a 100644 --- a/tests/test_site_r3_entry.py +++ b/tests/test_site_r3_entry.py @@ -124,7 +124,7 @@ class PackNamesItsVersion(unittest.TestCase): self.skipTest("release.json has no Batocera pack") block = generate_site._pack_buttons("batocera") version = generate_site._pack_version(release_record.pack_name_for("batocera", record)) - self.assertIn(f"Made from the BIOS list of Batocera {version}. " + self.assertIn(f"Made for Batocera {version}. " + generate_site.OTHER_VERSION, block) def test_a_version_gap_is_said_once(self): @@ -142,7 +142,7 @@ class PackNamesItsVersion(unittest.TestCase): for key in REGISTRY: with self.subTest(platform=key): text = _guide_part(_page(f"platforms/{key}.md")) - self.assertRegex(text, r"(?:from the BIOS list of|This pack was built for) \S") + self.assertRegex(text, r"(?:[Mm]ade for|This pack was built for) \S") class SetupPagesOfferPartOfThePack(unittest.TestCase): @@ -151,7 +151,7 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase): with self.subTest(platform=key): text = _guide_part(_page(f"platforms/{key}.md")) self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text) - self.assertIn("or press **c** when the command asks.", text) + self.assertIn("or press **c** in the command.", text) def test_the_anchor_exists(self): self.assertIn("## Only some consoles?", _page("which-pack.md")) @@ -159,10 +159,20 @@ class SetupPagesOfferPartOfThePack(unittest.TestCase): def test_a_file_in_place_but_missing_is_answered(self): for key in REGISTRY: with self.subTest(platform=key): - self.assertIn("In place but reported missing?", + self.assertIn("Reported missing anyway?", _guide_part(_page(f"platforms/{key}.md"))) +class SafetyListIsLinked(unittest.TestCase): + def test_setup_pages_link_the_home_list(self): + for key in REGISTRY: + with self.subTest(platform=key): + text = _guide_part(_page(f"platforms/{key}.md")) + self.assertIn("[Is it safe?](/retrobios/#is-it-safe)", text) + self.assertNotIn("Is it safe?", text) + self.assertIn("## Is it safe? { #is-it-safe }", _page("index.md")) + + class OfflineLine(unittest.TestCase): def test_under_from_your_computer(self): text = _page("platforms/batocera.md") From 863b1195280d50b05c7abdea60de07a66d2d03c3 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:56:35 +0200 Subject: [PATCH 4/4] feat: load tile logos past the first row lazily Home on a phone over 4G: 217.3 KiB to 134.5 KiB on first load, 19 images to 10. --- scripts/generate_site.py | 14 +++++++++----- scripts/sitemarkup.py | 22 ++++++++++++++++------ tests/test_site_r3_entry.py | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 11 deletions(-) diff --git a/scripts/generate_site.py b/scripts/generate_site.py index b620a626..441ce1ea 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -455,7 +455,7 @@ def _next_block(links: list[str]) -> list[str]: def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "", - group: str = "") -> list[str]: + group: str = "", eager: int = 0) -> list[str]: """The setup tiles of one device group: logo, name and the hardware it runs on, then the group's other tiles: a kind of device that runs one of the setups (Android handhelds, Linux handhelds).""" @@ -465,13 +465,14 @@ def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name])) for name in members), *((tile.name, f"{prefix}{tile.link}", "", tile.text) for tile in GROUP_TILES.get(group, ())), - ]) + ], eager=eager) def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> list[str]: """Every setup as a tile, under the device group it belongs to. The setups alone in their group share one row: "MiSTer FPGA" above - "MiSTer FPGA" said nothing.""" + "MiSTer FPGA" said nothing. Only the first row of logos loads with the + page: the 19 logos were most of the bytes of the home page.""" lines: list[str] = [] alone: list[str] = [] for group, (label, _description) in DEVICE_GROUPS.items(): @@ -479,9 +480,12 @@ def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> l if len(members) == 1 and not GROUP_TILES.get(group): alone += members elif members: - lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix, group)] + eager = 0 if lines else markup.FIRST_ROW_TILES + lines += [f"**{label}**", "", + *_tiles(registry, members, display, prefix, group, eager)] if alone: - lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix)] + eager = 0 if lines else markup.FIRST_ROW_TILES + lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix, eager=eager)] return lines diff --git a/scripts/sitemarkup.py b/scripts/sitemarkup.py index 207636bf..f6ae72a8 100644 --- a/scripts/sitemarkup.py +++ b/scripts/sitemarkup.py @@ -190,27 +190,37 @@ def initials(name: str) -> str: return "".join(capitals[:2]) or name[:2].upper() -def _tile_mark(name: str, logo: str) -> str: +def _tile_mark(name: str, logo: str, lazy: bool = False) -> str: + """A tile's logo, or its initials. *lazy* for a tile below the first + screen: the browser fetches its logo when the reader scrolls near it.""" if logo: - return f'' + loading = ' loading="lazy"' if lazy else "" + return (f'') # The same square as a logo, drawn on white like the logos are. return ('') -def platform_tiles(tiles: list[tuple[str, str, str, str]]) -> list[str]: +# Tiles in the first row of a grid at its widest, two columns of 15rem in +# the content column: the logos the reader may see without scrolling. +FIRST_ROW_TILES = 2 + + +def platform_tiles(tiles: list[tuple[str, str, str, str]], eager: int = 0) -> list[str]: """One link per setup: its logo (or its initials), its name and where it - runs. *tiles* are (name, target, logo, runs_on). + runs. *tiles* are (name, target, logo, runs_on). The first *eager* + logos load with the page, the others when the reader scrolls to them. One HTML line: a grid of links is a menu, not prose, and the Markdown around it reads none of it.""" links = "".join( f'' - f"{_tile_mark(name, logo)}" + f"{_tile_mark(name, logo, lazy=index >= eager)}" f'{html.escape(name)}' f'{inline_html(runs_on)}' - for name, target, logo, runs_on in tiles + for index, (name, target, logo, runs_on) in enumerate(tiles) ) return [f'
{links}
', ""] diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py index 7732956a..08aefa5d 100644 --- a/tests/test_site_r3_entry.py +++ b/tests/test_site_r3_entry.py @@ -22,6 +22,7 @@ sys.path.insert(0, str(REPO_ROOT)) import generate_site # noqa: E402 import release_record # noqa: E402 import siteguides # noqa: E402 +import sitemarkup # noqa: E402 from common import load_platform_config, load_platform_registry # noqa: E402 from tests import sitepages # noqa: E402 @@ -270,6 +271,23 @@ class HomeCopy(unittest.TestCase): self.assertTrue(boot.get("hle_fallback")) +class TileLogosLoadWhenSeen(unittest.TestCase): + def test_the_helper(self): + tiles = [(f"S{i}", "x.md", f"{i}.png", "here") for i in range(4)] + html = "".join(sitemarkup.platform_tiles(tiles, eager=2)) + logos = re.findall(r"]*>", html) + self.assertEqual(['loading="lazy"' in logo for logo in logos], [False, False, True, True]) + self.assertNotIn("loading=", "".join(sitemarkup.platform_tiles(tiles[:1], eager=1))) + + def test_only_the_first_row_is_eager_on_entry_pages(self): + for name in ("index.md", "which-pack.md"): + logos = re.findall(r']*>', _page(name)) + with self.subTest(page=name): + self.assertGreater(len(logos), sitemarkup.FIRST_ROW_TILES) + eager = [logo for logo in logos if 'loading="lazy"' not in logo] + self.assertEqual(eager, logos[: sitemarkup.FIRST_ROW_TILES]) + + class DownloadLine(unittest.TestCase): def test_checksums_and_signature_read_as_two(self): record = release_record.load_record()