feat: round 3 entry pages

This commit is contained in:
Abdessamad Derraz committed 2026-10-23 11:02:29 +02:00
commit 703f05657f
10 files changed
+865 -177

No files matched your search

+94
View File
@@ -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: >-
+305 -150
View File
@@ -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": '<span class="rb-badge rb-badge-success">MD5</span>',
"sha1": '<span class="rb-badge rb-badge-success">SHA1</span>',
"existence": '<span class="rb-badge rb-badge-info">existence</span>',
}
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'<span class="rb-badge rb-badge-muted">{cov["mode"]}</span>',
)
status = registry.get(name, {}).get("status", "active")
if status == "archived":
archived_any = True
status_html = '<span class="rb-badge rb-badge-muted">archived</span>'
else:
status_html = '<span class="rb-badge rb-badge-success">active</span>'
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'<span data-pagefind-meta="devices:{html.escape(names, quote=True)}"></span>'
# 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'<span id="{GROUP_ANCHORS[group]}"></span>**{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'<span id="{GROUP_ANCHORS[registry[key]["device_group"]]}"></span>'
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)
+138 -14
View File
@@ -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/<key>.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."""
+16 -6
View File
@@ -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'<img class="rb-card-logo" src="{html.escape(logo)}" alt="" width="40" height="40">'
loading = ' loading="lazy"' if lazy else ""
return (f'<img class="rb-card-logo" src="{html.escape(logo)}" alt="" '
f'width="40" height="40"{loading}>')
# The same square as a logo, drawn on white like the logos are.
return ('<svg class="rb-card-logo" viewBox="0 0 40 40" width="40" height="40" aria-hidden="true">'
'<text x="20" y="25" text-anchor="middle" font-size="14" font-weight="700" '
f'fill="#2b2e5c">{html.escape(initials(name))}</text></svg>')
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'<a class="rb-card rb-tile" href="{html.escape(target)}">'
f"{_tile_mark(name, logo)}"
f"{_tile_mark(name, logo, lazy=index >= eager)}"
f'<strong class="rb-tile-name">{html.escape(name)}</strong>'
f'<span class="rb-tile-runs">{inline_html(runs_on)}</span></a>'
for name, target, logo, runs_on in tiles
for index, (name, target, logo, runs_on) in enumerate(tiles)
)
return [f'<div class="rb-cards rb-tiles">{links}</div>', ""]
+2 -1
View File
@@ -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
+5 -2
View File
@@ -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)
+2 -2
View File
@@ -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'<span class="rb-stat-value">{count}</span>', _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)
+301
View File
@@ -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"</?abbr[^>]*>", "", 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</span>", 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'<span data-pagefind-meta="devices:([^"]*)"></span>', 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("<summary>Is it safe?</summary>", 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'<details class="rb-details[^"]*">\s*<summary>Every folder and ZIP '
r'in one table</summary>(.*?)</details>', 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'<a class="rb-button" href="[^"]+">Part 1 of \d</a>')
self.assertRegex(row, r"</a> \(\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"</?abbr[^>]*>", "", _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"<img [^>]*>", 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'<img class="rb-card-logo"[^>]*>', _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()
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -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