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/generate_site.py b/scripts/generate_site.py index 21588d04..17f19f10 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,30 +454,38 @@ 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 = "", 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).""" 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, ())), + ], 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(): 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)] + 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 @@ -510,13 +522,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 +649,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 +662,41 @@ 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 = "Other version? Run `--check` after copying." + + +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 for {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 +705,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 @@ -2223,15 +2265,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(): @@ -2244,71 +2285,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 @@ -2783,13 +2820,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]: @@ -2800,6 +2834,21 @@ 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 = "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. +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." @@ -2852,13 +2901,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", made for {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** in the command.", + "", + ] def _guide_install(name: str, guide: Guide, heading: str) -> list[str]: @@ -2866,8 +2926,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: @@ -2880,10 +2939,78 @@ 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, + "", ] +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), + SAFE_LINK, + "", + *_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. @@ -2917,6 +3044,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: @@ -2935,6 +3064,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)", @@ -7328,8 +7459,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 @@ -7354,17 +7487,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", @@ -7376,52 +7554,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 " @@ -7537,6 +7682,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 @@ -7898,6 +8047,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) @@ -8038,6 +8189,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/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.""" 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_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_funnel.py b/tests/test_site_funnel.py index ee4a1bb7..d9ff2b25 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..08aefa5d --- /dev/null +++ b/tests/test_site_r3_entry.py @@ -0,0 +1,301 @@ +"""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 +import sitemarkup # 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 for 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"(?:[Mm]ade for|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** in the command.", 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("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") + 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 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() + 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 2cee4434..7955feec 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_components`, `test_site_r3_css`, `test_site_r3_nav`, `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_components`, `test_site_r3_css`, `test_site_r3_entry`, `test_site_r3_nav`, `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