From aac613bea78427178a6d1437709681a84e099e86 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Fri, 23 Oct 2026 10:47:38 +0200
Subject: [PATCH] feat: answer version and handheld questions
Setup pages name the setup version the ZIP was made from, offer a partial install, answer a file in place but reported missing, and link the offline install. Get your files folds its tables into one closed block with part buttons. The setup comparison moves to the verification modes page. New Linux handhelds guide page.
---
scripts/generate_site.py | 426 ++++++++++++++++++++++++------------
tests/test_doc_surfaces.py | 3 +-
tests/test_site_copy.py | 5 +-
tests/test_site_funnel.py | 7 +-
tests/test_site_r2_entry.py | 4 +-
tests/test_site_r3_entry.py | 273 +++++++++++++++++++++++
tests/test_which_pack.py | 2 +-
wiki/testing-guide.md | 2 +-
8 files changed, 576 insertions(+), 146 deletions(-)
create mode 100644 tests/test_site_r3_entry.py
diff --git a/scripts/generate_site.py b/scripts/generate_site.py
index df4b4a93..0902c30f 100644
--- a/scripts/generate_site.py
+++ b/scripts/generate_site.py
@@ -83,8 +83,12 @@ from siteguides import (
ALSO_RUNS,
DEVICE_GROUPS,
GROUP_ANCHORS,
+ GROUP_TILES,
GUIDES,
+ HANDHELD_GUIDE,
Guide,
+ HandheldGuide,
+ HandheldSystem,
FENCE_TITLES,
INSTALL_BASE,
TERMINALS,
@@ -450,13 +454,17 @@ def _next_block(links: list[str]) -> list[str]:
return ["## Next steps", "", *(f"- {link}" for link in links), ""]
-def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "") -> list[str]:
- """The setup tiles of one device group: logo, name and the hardware it runs on."""
+def _tiles(registry: dict, members: list[str], display: dict[str, str], prefix: str = "",
+ group: str = "") -> list[str]:
+ """The setup tiles of one device group: logo, name and the hardware it
+ runs on, then the group's other tiles: a kind of device that runs one of
+ the setups (Android handhelds, Linux handhelds)."""
return markup.platform_tiles([
- (display[name], f"{prefix}platforms/{name}.md",
- str(registry.get(name, {}).get("logo") or ""),
- GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name]))
- for name in members
+ *((display[name], f"{prefix}platforms/{name}.md",
+ str(registry.get(name, {}).get("logo") or ""),
+ GUIDES[name].runs_on or _runs_on(registry.get(name, {}), GUIDES[name]))
+ for name in members),
+ *((tile.name, f"{prefix}{tile.link}", "", tile.text) for tile in GROUP_TILES.get(group, ())),
])
@@ -468,10 +476,10 @@ def setup_picker(registry: dict, display: dict[str, str], prefix: str = "") -> l
alone: list[str] = []
for group, (label, _description) in DEVICE_GROUPS.items():
members = _group_keys(registry, group, display)
- if len(members) == 1:
+ if len(members) == 1 and not GROUP_TILES.get(group):
alone += members
elif members:
- lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix)]
+ lines += [f"**{label}**", "", *_tiles(registry, members, display, prefix, group)]
if alone:
lines += ["**Other setups**", "", *_tiles(registry, alone, display, prefix)]
return lines
@@ -510,13 +518,15 @@ def generate_home(
"",
f"Free. No account. Works with {works_with}.",
"",
- "- A game says BIOS missing? [When a game does not start](wiki/troubleshooting.md)",
- "- Have a file already? Reference: [Check it](files/index.md)",
+ "- BIOS missing, even with the file there? "
+ "[When a game does not start](wiki/troubleshooting.md)",
+ "- Reference: [Check your file](files/index.md)",
"",
"## Do I need it?",
"",
"- **Yes**, if your emulator says a BIOS is missing.",
- "- **Yes** for PlayStation 1 and 2, Saturn, Dreamcast and Neo Geo.",
+ "- **Yes** for PlayStation 1 and 2, Saturn and Neo Geo.",
+ "- **Better with it** for Dreamcast: some games need it.",
"- **Usually not** for NES, Super Nintendo, Mega Drive and Game Boy.",
"",
"Details: [Every console, one by one](systems/index.md)",
@@ -635,8 +645,8 @@ def release_line(platform_name: str, record: dict) -> list[str]:
built = _release_date(tag)
lines = [
f"Pack {tag}{f', built {built}' if built else ''}. "
- f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) "
- f"[signature]({base}/SHA256SUMS.txt.sig)",
+ f"[SHA256SUMS.txt]({base}/SHA256SUMS.txt) and its "
+ f"[signature]({base}/SHA256SUMS.txt.sig).",
]
config = load_platform_config(platform_name, "platforms")
page_version = str(config.get("version") or "")
@@ -648,9 +658,42 @@ def release_line(platform_name: str, record: dict) -> list[str]:
return lines
+# What a reader on another version of the setup does with the pack. Our data
+# says which list the pack was made from, not what an older list lacks.
+OTHER_VERSION = ("Another version? After copying, `--check` names any file missing "
+ "or different.")
+
+
+def setup_version(platform_name: str, record: dict | None = None) -> str:
+ """The setup and version whose BIOS list the pack was made from
+ ("Batocera 43.1"): the version in the pack's name, else the version the
+ platform YAML was read at. Empty when neither says, or when the
+ platform has no YAML to read a version from."""
+ try:
+ config = load_platform_config(platform_name, "platforms")
+ except FileNotFoundError:
+ return ""
+ pack_name = release_record.pack_name_for(platform_name, record or {})
+ version = (_pack_version(pack_name) if pack_name else "") or str(config.get("version") or "")
+ if not version:
+ return ""
+ return f"{config.get('platform', platform_name)} {version.removeprefix('v')}"
+
+
+def version_line(platform_name: str, record: dict, named: bool = False) -> str:
+ """The version the pack fits, and what to do on another. *named* when
+ the release line already named the pack's version."""
+ made_from = setup_version(platform_name, record)
+ if named or not made_from:
+ return OTHER_VERSION
+ return f"Made from the BIOS list of {made_from}. {OTHER_VERSION}"
+
+
def _pack_buttons(platform_name: str, label: str = "Pack") -> str:
"""Buttons to the files of the platform's pack in the recorded release,
- and the release they come from under them."""
+ the release they come from, and the setup version its list was read at.
+ "Does this pack fit my version?" is the first question a frontend user
+ asks."""
record = release_record.load_record()
buttons = download_links(
platform_name,
@@ -659,13 +702,9 @@ def _pack_buttons(platform_name: str, label: str = "Pack") -> str:
label,
button=lambda text, url: markup.button(text, url, primary=True),
)
- return "\n\n".join([buttons, *release_line(platform_name, record)])
-
-
-def _released_files(platform_name: str) -> str:
- """File count of the pack the latest release serves, as a table cell."""
- files, _size = release_totals(platform_name, release_record.load_record())
- return f"{files:,}" if files else "-"
+ released = release_line(platform_name, record)
+ return "\n\n".join([buttons, *released,
+ version_line(platform_name, record, named=len(released) > 1)])
# The home tile counts the whole collection. Labelled "Files" above a table
@@ -2222,15 +2261,14 @@ def decorate_markdown_pages(docs: Path) -> dict[str, str]:
def generate_platform_index(coverages: dict, registry: dict | None = None) -> str:
- """The Your setup tab: every platform by the device it runs on, then the
- figures of each one under a reference heading."""
+ """The Your setup tab: every platform by the device it runs on, and one
+ line to the table that sets them side by side (setup_table), which is
+ reference matter and sits with how each setup checks its files."""
registry = registry or {}
- total_present = sum(c["present"] for c in coverages.values())
-
lines = [
"# Your setup",
"",
- "Pick what you play on. Its page gives the folder, the command and the ZIP.",
+ "Each setup's page gives the folder, the command and the ZIP.",
"",
]
for group, (label, description) in DEVICE_GROUPS.items():
@@ -2243,71 +2281,67 @@ def generate_platform_index(coverages: dict, registry: dict | None = None) -> st
(f"[{coverages[name]['platform']}]({name}.md)", [GUIDES[name].summary])
for name in members
])
-
lines += [
- "## Reference: every platform at a glance { #reference }",
+ f"Reference: [Every setup side by side](../{SETUP_TABLE_LINK})",
"",
- level_label("reference"),
- "",
- f"{setup_count()} supported platforms with {total_present:,} verified files.",
- "",
- "| Platform | On its BIOS list | Files in the pack | Checked by | Status | Download |",
- "|----------|------:|------:|-----------|--------|----------|",
]
-
- mode_labels = {
- "md5": 'MD5',
- "sha1": 'SHA1',
- "existence": 'existence',
- }
-
- archived_any = False
- for name, cov in sorted(coverages.items(), key=lambda x: x[1]["platform"]):
- display = cov["platform"]
-
- mode_html = mode_labels.get(
- cov["mode"],
- f'{cov["mode"]}',
- )
- status = registry.get(name, {}).get("status", "active")
- if status == "archived":
- archived_any = True
- status_html = 'archived'
- else:
- status_html = 'active'
-
- lines.append(
- f"| [{display}]({name}.md) | "
- f"{cov['present']:,} | {_released_files(name)} | "
- f"{mode_html} | {status_html} | "
- f"{_pack_cell(name, release_record.load_record(), '../')} |"
- )
-
- lines.extend(
- [
- "",
- "The BIOS list is what the platform publishes. The pack adds the "
- "files its emulators load and the data directories they read, "
- "which is the count an extraction shows. "
- "Checked by is the test each platform runs on its own, replicated "
- "from its source code.",
- "",
- "Reference: [How each platform checks files](../wiki/verification-modes.md).",
- ]
- )
-
- if archived_any:
- lines.extend(
- [
- "",
- "An archived platform keeps its configuration and still gets a "
- "pack, but upstream is no longer scraped on a schedule.",
- ]
- )
-
return "\n".join(lines) + "\n"
+# Where the side-by-side table of setups is published: with how each setup
+# checks its files, the column a reader compares.
+SETUP_TABLE_PAGE = "wiki/verification-modes.md"
+SETUP_TABLE_ANCHOR = "every-setup-at-a-glance"
+SETUP_TABLE_LINK = f"{SETUP_TABLE_PAGE}#{SETUP_TABLE_ANCHOR}"
+# How a setup checks a file, in the words of a reader.
+_HOW_IT_CHECKS = {"existence": "name only", "md5": "MD5", "sha1": "SHA-1"}
+
+
+def setup_table(coverages: dict, registry: dict | None = None) -> list[str]:
+ """Every setup side by side: the files on its own list, the files in its
+ ZIP, and how it checks them. Written under the Reference level, on the
+ page that explains each way of checking."""
+ registry = registry or {}
+ lines = [
+ f"## Every setup at a glance {{ #{SETUP_TABLE_ANCHOR} }}",
+ "",
+ "| Setup | Files on its own list | Files in the ZIP | How it checks |",
+ "|-------|------:|------:|---------------|",
+ ]
+ archived = False
+ for name, cov in sorted(coverages.items(), key=lambda item: item[1]["platform"].casefold()):
+ status = registry.get(name, {}).get("status")
+ archived |= status == "archived"
+ listed = f"{cov['total']:,}" if cov["total"] else "No list of its own"
+ files, _size = release_totals(name, release_record.load_record())
+ zipped = f"{files:,}" if files else "No ZIP yet"
+ lines.append(
+ f"| [{cov['platform']}](../platforms/{name}.md)"
+ f"{' (archived)' if status == 'archived' else ''} | {listed} | {zipped} | "
+ f"{_HOW_IT_CHECKS.get(cov['mode'], cov['mode'])} |"
+ )
+ lines += [
+ "",
+ "The ZIP adds the files its emulators load to the files on the setup's own list.",
+ "",
+ ]
+ if archived:
+ lines += ["An archived setup keeps its page and its ZIP. Its list is no longer "
+ "read again on a schedule.", ""]
+ return lines
+
+
+def with_setup_table(page: str, coverages: dict, registry: dict | None = None) -> str:
+ """The comparison table placed before the Next steps of its page, or at
+ its end."""
+ table = "\n".join(setup_table(coverages, registry))
+ marker = "\n## Next steps"
+ if marker in page:
+ head, tail = page.split(marker, 1)
+ return f"{head.rstrip()}\n\n{table}{marker}{tail}"
+ return f"{page.rstrip()}\n\n{table}"
+
+
# A platform system whose file list passes this much Markdown gets a page
# of its own: RetroDECK's arcade list was 750 KB of a 1.1 MB page.
PLATFORM_SECTION_BUDGET = 200_000
@@ -2799,6 +2833,17 @@ def _folder_rows(rows: tuple[tuple[str, str], ...], indent: str = "") -> list[st
]
+# The second most asked question about BIOS files (14 % of the community
+# sample): the file is there and the setup still reports it missing.
+# `--check` lists every file whose hash is wrong by name (install.py, the
+# "wrong hash" lines of check_lines).
+MISSING_IN_PLACE = "In place but reported missing? `--check` names a file with the wrong contents."
+
+# Under "From your computer": a device with no network installs from the ZIP
+# (`--from-pack`). The id is the one wiki/installer.md gives its section.
+OFFLINE_LINE = ("No internet on the device? "
+ "[Install from the ZIP](../wiki/installer.md#without-a-network).")
+
# Shown in the "On the device" tab of a guide that has no `terminal:` of its own.
DEVICE_TERMINAL_FALLBACK = "Or fill the folder from your computer, with the ZIP."
@@ -2851,13 +2896,24 @@ def _guide_download(name: str, display: str, guide: Guide, extract_to: str | Non
"",
]
elif record.get("tag"):
+ made_from = setup_version(name)
lines += [
- f"No {display} ZIP yet. The command installs the same files.",
+ f"No {display} ZIP yet. The command installs the same files"
+ + (f", from the BIOS list of {made_from}." if made_from else "."),
"",
]
else:
lines += [_pack_buttons(name, f"Download {display} Pack"), ""]
- return lines
+ return [*lines, *only_some_consoles(name, "../")]
+
+
+def only_some_consoles(name: str, prefix: str = "") -> list[str]:
+ """The way to a smaller download, in the words Get your files uses."""
+ return [
+ f"Only some consoles? [Pick them]({prefix}which-pack.md?platform={name}"
+ "#only-some-consoles), or press **c** when the command asks.",
+ "",
+ ]
def _guide_install(name: str, guide: Guide, heading: str) -> list[str]:
@@ -2883,6 +2939,72 @@ def _guide_install(name: str, guide: Guide, heading: str) -> list[str]:
]
+def devices_meta(devices: tuple[str, ...]) -> str:
+ """The device names a reader types, as the `devices` search field of the
+ page: an attribute on an empty element, commas out (the search engine
+ splits its keys on commas), so no excerpt shows them."""
+ names = " ".join(name.replace(",", " ") for name in devices)
+ return f''
+
+
+# Devices named in a row of the handheld guide. The rest stay in the
+# page's search field and on the list the row links.
+HANDHELD_ROW_DEVICES = 6
+
+
+def _handheld_row(system: HandheldSystem) -> str:
+ shown = ", ".join(system.devices[:HANDHELD_ROW_DEVICES])
+ more = len(system.devices) - HANDHELD_ROW_DEVICES
+ devices = (f"{shown}{f' and {more} more' if more > 0 else ''} "
+ f"([list]({system.devices_source}))" if system.devices else "")
+ folder = (f"{system.folder} ([source]({system.folder_source}))" if system.folder
+ else "Not written by the system. The command finds it on the device.")
+ return f"| {system.name} | {devices} | {folder} |"
+
+
+def generate_handheld_guide(guide: HandheldGuide) -> str:
+ """The guide for the handheld systems that run a setup (ArkOS, muOS,
+ Onion... run RetroArch): one row per system the installer detects, the
+ folder seen on the card from a computer and the devices, each cited from
+ the system's own pages, then that setup's ZIP and the command for a card.
+
+ A handheld owner starts from the device name. The systems were named
+ only in a closed note, with a path seen from inside the device."""
+ uses = GUIDES[guide.uses]
+ display = load_platform_config(guide.uses, "platforms").get("platform", guide.uses)
+ args = {"windows": f"--platform {guide.uses} --dest E:\\BIOS",
+ "macos": f"--platform {guide.uses} --dest /path/to/card/BIOS",
+ "linux": f"--platform {guide.uses} --dest /path/to/card/BIOS"}
+ return "\n".join([
+ f"# {guide.title}",
+ "",
+ guide.summary,
+ "",
+ devices_meta(guide.devices),
+ "",
+ "## Where the files go { #where-the-files-go }",
+ "",
+ "Put the SD card in a computer. Find your system below.",
+ "",
+ "| System | Devices | BIOS folder on the card |",
+ "|--------|---------|-------------------------|",
+ *(_handheld_row(system) for system in guide.systems),
+ "",
+ *_guide_download(guide.uses, display, uses, None, "## Download { #download }"),
+ "## Fill the card from a computer { #install }",
+ "",
+ "On Windows, note the card's letter, like **E:**. Put its BIOS folder after "
+ "`--dest`, written as in the table.",
+ "",
+ *terminal_tabs(("windows", "macos", "linux"), args),
+ *trust_box("../"),
+ *_next_block([
+ f"[{display}]({guide.uses}.md): every folder and every file",
+ "[When a game does not start](../wiki/troubleshooting.md)",
+ ]),
+ ]) + "\n"
+
+
def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list: bool) -> list[str]:
"""The guide part of a platform page: folder, command, pack, check, next.
@@ -2916,6 +3038,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
lines += [
*_guide_download(name, display, guide, extract_to,
"## From your computer { #download }"),
+ OFFLINE_LINE,
+ "",
*_guide_install(name, guide, "## On the device { #install }"),
]
else:
@@ -2934,6 +3058,8 @@ def _platform_guide(name: str, display: str, cov: dict, registry: dict, no_list:
"",
_check_sentence(display, cov["mode"], cov["config"], no_list),
"",
+ MISSING_IN_PLACE,
+ "",
*([guide.finish, ""] if guide.finish else []),
*_next_block([
"[First install, step by step](../wiki/first-install.md)",
@@ -7327,8 +7453,10 @@ def _pack_cell(name: str, record: dict, prefix: str = "") -> str:
pack = release_record.pack_for(name, record)
if record.get("tag") and not (pack and pack.get("assets")):
return f"No ZIP yet. [Use the command]({prefix}platforms/{name}.md#install)."
- display = load_platform_config(name, "platforms").get("platform", name)
- links = download_links(name, record, REPO_URL, display)
+ # Small buttons, one per part, the size after the last: links wrapped
+ # mid-label ("part 1 of / 3") in a narrow cell.
+ links = download_links(name, record, REPO_URL, "ZIP",
+ button=lambda text, url: markup.button(text, url))
size = pack.get("download_size") if pack else None
return f"{links} ({_fmt_size(size)})" if size else links
@@ -7353,17 +7481,62 @@ def _own_root_folders(registry: dict) -> dict[str, str]:
return folders
+def _folder_row(key: str, display: dict[str, str], registry: dict, record: dict,
+ own_root: dict[str, str]) -> str:
+ """One setup's row: where it runs, its BIOS folder and its ZIP. A setup
+ whose ZIP holds its own top folder says where to unzip it, and the
+ BIOS folder itself when it has no ZIP to unzip."""
+ guide = GUIDES[key]
+ pack = release_record.pack_for(key, record)
+ zipped = bool(pack and pack.get("assets"))
+ folder = (f"Unzip into `{own_root[key]}`" if key in own_root and zipped
+ else _folder_cell(key, guide))
+ return (f"| [{display[key]}](platforms/{key}.md) | "
+ f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | "
+ f"{_pack_cell(key, record)} |")
+
+
+def _folder_tables(registry: dict, record: dict, display: dict[str, str]) -> list[str]:
+ """Every setup's folder and ZIP, one table per device group, the groups
+ of one setup together under "Other setups" as on the tiles. A row for
+ each setup that runs another (LaunchBox, Android) under its group."""
+ own_root = _own_root_folders(registry)
+ header = ["| Setup | Runs on | BIOS folder | ZIP |", "|-------|---------|-------------|-----|"]
+ lines: list[str] = []
+ alone: list[str] = []
+ for group, (label, _description) in DEVICE_GROUPS.items():
+ members = _group_keys(registry, group, display)
+ if len(members) == 1 and not ALSO_RUNS.get(group):
+ alone += members
+ continue
+ if not members:
+ continue
+ lines += [f'**{label}**', "", *header]
+ lines += [_folder_row(key, display, registry, record, own_root) for key in members]
+ lines += [
+ f"| {setup} | {runs_on} | {folder}, same as "
+ f"[{display[uses]}](platforms/{uses}.md) | {_pack_cell(uses, record)} |"
+ for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ())
+ ]
+ lines.append("")
+ if alone:
+ anchors = "".join(f''
+ for key in alone)
+ lines += [f"{anchors}**Other setups**", "", *header,
+ *(_folder_row(key, display, registry, record, own_root) for key in alone), ""]
+ return lines
+
+
def generate_which_pack() -> str:
"""The device picker: every registered platform as a tile under the
- device it runs on, then one table per device with its BIOS folder and
- its ZIP, the console picker, and the command for a setup not listed.
+ device it runs on, the ZIP advice with every folder and ZIP in one closed
+ table, the console picker, and the command for a setup not listed.
- The tiles are the choice. A second set of cards, five tables and the
- picker offered the same choice three times."""
+ The tiles are the choice. Open tables under them listed every setup a
+ second time and made the page 11,800 px tall on a phone."""
registry = load_platform_registry("platforms")
record = release_record.load_record()
display = {key: load_platform_config(key, "platforms").get("platform", key) for key in registry}
- own_root = _own_root_folders(registry)
all_tabs = ("windows", "macos", "linux", "steamdeck", "android")
lines = [
"# Get your files",
@@ -7375,52 +7548,19 @@ def generate_which_pack() -> str:
*setup_picker(registry, display),
"## Folders and ZIP files { #manual-download }",
"",
- "- A big ZIP comes in parts, like `Pack.part1of2.zip`. Download every part.",
- "- Unzip them all into the BIOS folder of your setup. Put the files "
- "straight in it, not in a new folder.",
- "- Some files inside stay zipped, like `neogeo.zip`. Leave them as they are.",
- "- **Unzip into** means the ZIP holds its own top folder. Unzip it there.",
+ "- A big ZIP comes in parts. Download every part.",
+ "- Unzip them into your BIOS folder. Put the files straight in it, "
+ "not in a new folder.",
+ "- Leave the ZIPs inside, like `neogeo.zip`, as they are.",
+ "- A few ZIPs hold their own top folder. Their setup's page says where "
+ "to unzip them.",
"",
+ *markup.details("note", "Every folder and ZIP in one table",
+ _folder_tables(registry, record, display)),
]
- no_zip = False
- for group, (label, _description) in DEVICE_GROUPS.items():
- members = _group_keys(registry, group, display)
- if not members:
- continue
- lines += [
- f"### {label} {{ #{GROUP_ANCHORS[group]} }}",
- "",
- "| Setup | Runs on | BIOS folder | ZIP |",
- "|-------|---------|-------------|-----|",
- ]
- for key in members:
- guide = GUIDES[key]
- folder = (f"Unzip into `{own_root[key]}`" if key in own_root
- else _folder_cell(key, guide))
- pack = release_record.pack_for(key, record)
- no_zip |= bool(record.get("tag") and not (pack and pack.get("assets")))
- lines.append(
- f"| [{display[key]}](platforms/{key}.md) | "
- f"{guide.runs_on or _runs_on(registry[key], guide)} | {folder} | "
- f"{_pack_cell(key, record)} |"
- )
- for setup, _summary, uses, runs_on, folder in ALSO_RUNS.get(group, ()):
- lines.append(
- f"| {setup} | {runs_on} | {folder}, same as "
- f"[{display[uses]}](platforms/{uses}.md) | {_pack_cell(uses, record)} |"
- )
- lines.append("")
- if group == "handheld":
- lines += markup.details("note", "Other handhelds that run RetroArch", [
- "They use the RetroArch files, in the folder below. The command "
- "finds it.",
- "",
- "| System | Runs | BIOS folder |",
- "|--------|------|-------------|",
- *(f"| {name} | [RetroArch](platforms/retroarch.md) | `{folder}` |"
- for name, folder in _detected_systems(registry, "retroarch")),
- ])
- if no_zip:
+ no_zip = any(not (pack and pack.get("assets"))
+ for pack in (release_record.pack_for(key, record) for key in registry))
+ if record.get("tag") and no_zip:
lines += ["Setups added after the last release have no ZIP until the next one.", ""]
lines += [
f"The ZIPs come from the [latest release]({RELEASE_URL}). The command may "
@@ -7529,6 +7669,10 @@ def _platform_nav(coverages: dict, registry: dict) -> list:
for name in sorted(coverages, key=lambda n: coverages[n]["platform"].casefold())
if registry.get(name, {}).get("device_group") == group
]
+ # The handheld guide sits with the group whose tile leads to it.
+ members += [{HANDHELD_GUIDE.title: HANDHELD_GUIDE.path}
+ for tile in GROUP_TILES.get(group, ())
+ if HANDHELD_GUIDE and tile.link == HANDHELD_GUIDE.path]
if members:
nav.append({label: members})
return nav
@@ -7890,6 +8034,8 @@ def _generate(args: argparse.Namespace) -> None:
str(docs / "platforms" / "index.md"),
generate_platform_index(coverages, registry),
)
+ if HANDHELD_GUIDE:
+ write_if_changed(str(docs / HANDHELD_GUIDE.path), generate_handheld_guide(HANDHELD_GUIDE))
for name, cov in coverages.items():
# The emulators this platform runs, not every one of the system.
runs = resolve_platform_cores(cov["config"], profiles)
@@ -8016,6 +8162,10 @@ def _generate(args: argparse.Namespace) -> None:
for src_file in wiki_src.glob("*.md"):
shutil.copy2(src_file, wiki_dest / src_file.name)
_record(wiki_dest / src_file.name)
+ table_page = docs / SETUP_TABLE_PAGE
+ if table_page.is_file():
+ write_if_changed(str(table_page), with_setup_table(
+ table_page.read_text(encoding="utf-8"), coverages, registry))
# data-model.md is generated (contains live DB stats)
write_if_changed(
str(wiki_dest / "data-model.md"), generate_wiki_data_model(db, profiles)
diff --git a/tests/test_doc_surfaces.py b/tests/test_doc_surfaces.py
index 21cfd770..86c51ed9 100644
--- a/tests/test_doc_surfaces.py
+++ b/tests/test_doc_surfaces.py
@@ -169,7 +169,8 @@ class DownloadLinksNameTheFile(unittest.TestCase):
self.assertNotIn("releases/latest", row)
for pack in RECORD["packs"].values():
for asset in pack["assets"]:
- self.assertIn(f"/releases/download/{RECORD['tag']}/{asset})", page)
+ self.assertRegex(page, re.escape(f"/releases/download/{RECORD['tag']}/{asset}")
+ + r"[)\"]")
def test_a_platform_the_release_lacks_links_no_release(self):
"""KNULLI, AmberELEC and EmuELEC were registered after the recorded
diff --git a/tests/test_site_copy.py b/tests/test_site_copy.py
index ebf80951..c4f7bd06 100644
--- a/tests/test_site_copy.py
+++ b/tests/test_site_copy.py
@@ -37,7 +37,10 @@ START_PAGES = {
"wiki/glossary.md": 200,
}
# Level 1: the guide part of each setup page, above its Reference heading.
-SETUP_CEILING = 220
+# 220 until each page also answered three asked questions in about 50 words:
+# which setup version the ZIP was made from (7 % of the community sample),
+# how to get only some consoles, and a file in place but reported missing (14 %).
+SETUP_CEILING = 280
# The setup index is a card per setup, one sentence each.
SETUP_INDEX_CEILING = 300
diff --git a/tests/test_site_funnel.py b/tests/test_site_funnel.py
index 2a58bfa4..68d744bf 100644
--- a/tests/test_site_funnel.py
+++ b/tests/test_site_funnel.py
@@ -134,8 +134,11 @@ class EveryPlatformOnThePicker(unittest.TestCase):
def test_handheld_systems_that_run_retroarch_are_named(self):
"""muOS, ArkOS and the rest are detected as RetroArch by the
installer and were named on no page a user reads."""
- page = _which_pack()
- self.assertIn("| muOS | [RetroArch](platforms/retroarch.md) | `/run/muos/storage/bios` |", page)
+ from siteguides import HANDHELD_GUIDE
+
+ page = generate_site.generate_handheld_guide(HANDHELD_GUIDE)
+ self.assertIn("| muOS |", page)
+ self.assertIn("`MUOS/bios` on the card", page)
self.assertNotIn("| TrimUI |", page)
diff --git a/tests/test_site_r2_entry.py b/tests/test_site_r2_entry.py
index ccc4c1c1..9a01f98f 100644
--- a/tests/test_site_r2_entry.py
+++ b/tests/test_site_r2_entry.py
@@ -115,7 +115,6 @@ class OneSetupCount(unittest.TestCase):
self.assertEqual(siteguides.setup_count(), count)
self.assertIn(f"and {count - 3} more.", _page("index.md"))
self.assertIn(f'{count}', _page("explore.md"))
- self.assertIn(f"{count} supported platforms", _page("platforms/index.md"))
class SetupTiles(unittest.TestCase):
@@ -123,7 +122,8 @@ class SetupTiles(unittest.TestCase):
for name in ("index.md", "which-pack.md"):
text = _page(name)
with self.subTest(page=name):
- self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY))
+ extra = sum(len(tiles) for tiles in siteguides.GROUP_TILES.values())
+ self.assertEqual(text.count('class="rb-card rb-tile"'), len(REGISTRY) + extra)
for key in REGISTRY:
self.assertIn(f'href="/retrobios/platforms/{key}/"', text)
diff --git a/tests/test_site_r3_entry.py b/tests/test_site_r3_entry.py
new file mode 100644
index 00000000..a7fb1aca
--- /dev/null
+++ b/tests/test_site_r3_entry.py
@@ -0,0 +1,273 @@
+"""Entry pages and guides (round 3, package 4).
+
+A handheld owner found no tile for Android, ArkOS, muOS, Onion or spruceOS:
+the systems sat in a closed note with a path seen from inside the device.
+The setup version a pack fits was named only under Reference, the setup page
+offered the full pack only, Get your files listed every setup twice, the Your
+setup index ended on a table in project words, and the home page said a
+Dreamcast needs its BIOS while Flycast boots without one.
+"""
+
+from __future__ import annotations
+
+import re
+import sys
+import unittest
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(REPO_ROOT / "scripts"))
+sys.path.insert(0, str(REPO_ROOT))
+
+import generate_site # noqa: E402
+import release_record # noqa: E402
+import siteguides # noqa: E402
+from common import load_platform_config, load_platform_registry # noqa: E402
+from tests import sitepages # noqa: E402
+
+REGISTRY = load_platform_registry(str(REPO_ROOT / "platforms"))
+GUIDE = siteguides.HANDHELD_GUIDE
+
+
+def _page(name: str) -> str:
+ sitepages.require()
+ return sitepages.page(name).read_text(encoding="utf-8")
+
+
+def _guide_part(text: str) -> str:
+ """The guide part of a setup page, glossary marks taken off."""
+ match = re.search(r"^## Reference\b", text, re.M)
+ text = text[: match.start()] if match else text
+ return re.sub(r"?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", text)
+
+ def test_the_closed_note_is_gone(self):
+ self.assertNotIn("Other handhelds that run RetroArch", _page("which-pack.md"))
+
+ def test_one_row_per_detected_system(self):
+ detected = [name for name, _folder in siteguides.detected_systems(REGISTRY, GUIDE.uses)]
+ self.assertEqual(sorted((s.name for s in GUIDE.systems), key=str.casefold), detected)
+ text = _page(GUIDE.path)
+ for system in GUIDE.systems:
+ with self.subTest(system=system.name):
+ self.assertIn(f"| {system.name} |", text)
+
+ def test_every_folder_and_device_list_is_cited(self):
+ for system in GUIDE.systems:
+ with self.subTest(system=system.name):
+ if system.folder:
+ self.assertTrue(system.folder_source.startswith("https://"))
+ if system.devices:
+ self.assertTrue(system.devices_source.startswith("https://"))
+ for name in ("ArkOS", "muOS", "Onion", "spruceOS"):
+ system = next(s for s in GUIDE.systems if s.name == name)
+ with self.subTest(system=name):
+ self.assertTrue(system.folder and system.devices)
+
+ def test_a_value_without_its_page_is_refused(self):
+ with self.assertRaises(siteguides.GuideError):
+ siteguides._handheld_system({"name": "Onion", "folder": "`BIOS`"}, "x")
+ with self.assertRaises(siteguides.GuideError):
+ siteguides._handheld_system(
+ {"name": "Onion", "devices": ["Miyoo Mini"], "devices_source": "a blog"}, "x")
+
+ def test_the_guide_rejects_a_missing_or_unknown_system(self):
+ data = {"page": "x", "title": "X", "summary": "S", "uses": "retroarch",
+ "systems": [{"name": "Onion"}]}
+ with self.assertRaises(siteguides.GuideError):
+ siteguides._handheld_guide(data, REGISTRY, siteguides.GUIDES, "x")
+
+ def test_devices_are_a_search_field_with_no_text(self):
+ text = _page(GUIDE.path)
+ match = re.search(r'', text)
+ self.assertIsNotNone(match)
+ self.assertIn("R36S", match[1])
+ self.assertIn("Miyoo Mini", match[1])
+ self.assertNotIn(",", match[1])
+ # The names typed most often are also on the page itself.
+ self.assertIn("R36S", text.split(match[0], 1)[1])
+
+ def test_the_guide_is_in_the_handheld_group_of_the_navigation(self):
+ coverages = {key: {"platform": _display(key)} for key in REGISTRY}
+ nav = generate_site.generate_nav(coverages, {}, {}, REGISTRY)
+ setup = next(tab["Your setup"] for tab in nav if "Your setup" in tab)
+ groups = {next(iter(item)): next(iter(item.values()))
+ for item in setup if isinstance(item, dict)}
+ label = siteguides.DEVICE_GROUPS["handheld"][0]
+ self.assertIn({GUIDE.title: GUIDE.path}, groups[label])
+
+ def test_the_guide_fills_the_card_and_never_its_root(self):
+ text = _page(GUIDE.path)
+ self.assertIn("--platform retroarch --dest E:\\BIOS", text)
+ self.assertNotRegex(text, r"--dest E:\\\s")
+
+
+class PackNamesItsVersion(unittest.TestCase):
+ def test_the_line_under_the_release(self):
+ record = release_record.load_record()
+ if not release_record.pack_for("batocera", record):
+ self.skipTest("release.json has no Batocera pack")
+ block = generate_site._pack_buttons("batocera")
+ version = generate_site._pack_version(release_record.pack_name_for("batocera", record))
+ self.assertIn(f"Made from the BIOS list of Batocera {version}. "
+ + generate_site.OTHER_VERSION, block)
+
+ def test_a_version_gap_is_said_once(self):
+ record = {"tag": "v2026.10.10", "packs": {"RetroBat_1.0_BIOS_Pack.zip": {
+ "assets": ["RetroBat_1.0_BIOS_Pack.zip"], "platforms": ["retrobat"]}}}
+ released = generate_site.release_line("retrobat", record)
+ line = generate_site.version_line("retrobat", record, named=len(released) > 1)
+ self.assertEqual(line, generate_site.OTHER_VERSION)
+
+ def test_the_v_prefix_is_dropped(self):
+ self.assertEqual(generate_site.setup_version("retroarch", {}),
+ f"RetroArch {load_platform_config('retroarch', 'platforms')['version'].lstrip('v')}")
+
+ def test_every_setup_page_names_a_version(self):
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ text = _guide_part(_page(f"platforms/{key}.md"))
+ self.assertRegex(text, r"(?:from the BIOS list of|This pack was built for) \S")
+
+
+class SetupPagesOfferPartOfThePack(unittest.TestCase):
+ def test_pick_them_and_press_c(self):
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ text = _guide_part(_page(f"platforms/{key}.md"))
+ self.assertIn(f"/retrobios/which-pack/?platform={key}#only-some-consoles", text)
+ self.assertIn("or press **c** when the command asks.", text)
+
+ def test_the_anchor_exists(self):
+ self.assertIn("## Only some consoles?", _page("which-pack.md"))
+
+ def test_a_file_in_place_but_missing_is_answered(self):
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ self.assertIn("In place but reported missing?",
+ _guide_part(_page(f"platforms/{key}.md")))
+
+
+class OfflineLine(unittest.TestCase):
+ def test_under_from_your_computer(self):
+ text = _page("platforms/batocera.md")
+ start = text.index("## From your computer")
+ line = text.index("No internet on the device?")
+ self.assertLess(start, line)
+ self.assertLess(line, text.index("## On the device"))
+ self.assertIn("/retrobios/wiki/installer/#without-a-network", text)
+ self.assertIn("{ #without-a-network }", (REPO_ROOT / "wiki" / "installer.md").read_text(
+ encoding="utf-8"))
+
+
+class GetYourFilesListsEachSetupOnce(unittest.TestCase):
+ def setUp(self):
+ self.text = _page("which-pack.md")
+
+ def test_the_tables_are_in_one_closed_block(self):
+ block = re.search(r'\s*Every folder and ZIP '
+ r'in one table
(.*?) ', self.text, re.S)
+ self.assertIsNotNone(block)
+ outside = self.text.replace(block[0], "")
+ self.assertNotIn("| Setup | Runs on | BIOS folder | ZIP |", outside)
+ for key in REGISTRY:
+ with self.subTest(platform=key):
+ self.assertEqual(block[1].count(f"| [{_display(key)}](/retrobios/platforms/{key}/) |"), 1)
+
+ def test_single_setup_groups_share_one_table(self):
+ self.assertIn("**Other setups**", self.text)
+ self.assertNotIn("**MiSTer FPGA**", self.text)
+ self.assertNotIn("**Self-hosted game library**", self.text)
+
+ def test_no_unzip_into_without_a_zip(self):
+ record = release_record.load_record()
+ for line in self.text.splitlines():
+ if "Unzip into" in line:
+ key = re.search(r"/retrobios/platforms/([a-z0-9]+)/", line)[1]
+ with self.subTest(platform=key):
+ self.assertTrue(release_record.pack_for(key, record))
+
+ def test_parts_are_buttons_with_the_size_after(self):
+ rows = [line for line in self.text.splitlines() if "Part 1 of" in line]
+ self.assertTrue(rows)
+ for row in rows:
+ self.assertRegex(row, r'Part 1 of \d')
+ self.assertRegex(row, r" \(\d[\d.]* [KMG]B\) \|$")
+
+ def test_the_zip_note(self):
+ for line in ("A big ZIP comes in parts. Download every part.",
+ "Leave the ZIPs inside, like `neogeo.zip`, as they are.",
+ "Their setup's page says where to unzip them."):
+ self.assertIn(line, self.text)
+ self.assertNotIn("**Unzip into** means", self.text)
+
+
+class YourSetupIndexInPlainWords(unittest.TestCase):
+ def test_the_index_links_the_table(self):
+ text = _page("platforms/index.md")
+ self.assertNotIn("| Platform |", text)
+ self.assertNotIn("at a glance", text.split("---", 2)[-1].lower().replace(
+ "every setup at a glance", ""))
+ self.assertIn("/retrobios/wiki/verification-modes/#every-setup-at-a-glance", text)
+
+ def test_the_table_on_verification_modes(self):
+ text = _page("wiki/verification-modes.md")
+ self.assertIn("| Setup | Files on its own list | Files in the ZIP | How it checks |", text)
+ table = text.split("## Every setup at a glance", 1)[1].split("\n## ", 1)[0]
+ self.assertNotIn("existence", table)
+ self.assertIn("| name only |", table)
+ self.assertIn("No list of its own", table)
+ self.assertLess(text.index("## Every setup at a glance"), text.index("## Next steps"))
+
+ def test_insertion_keeps_next_steps_last(self):
+ page = "# T\n\nBody.\n\n## Next steps\n\n- [A](a.md)\n"
+ out = generate_site.with_setup_table(page, {}, {})
+ self.assertTrue(out.rstrip().endswith("- [A](a.md)"))
+ self.assertIn("## Every setup at a glance", out)
+
+
+class HomeCopy(unittest.TestCase):
+ def test_dreamcast_and_the_check_link(self):
+ home = re.sub(r"?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 DownloadLine(unittest.TestCase):
+ def test_checksums_and_signature_read_as_two(self):
+ record = release_record.load_record()
+ if not release_record.pack_for("batocera", record):
+ self.skipTest("release.json has no Batocera pack")
+ line = generate_site.release_line("batocera", record)[0]
+ self.assertRegex(line, r"\[SHA256SUMS\.txt\]\([^)]+\) and its \[signature\]\([^)]+\)\.$")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_which_pack.py b/tests/test_which_pack.py
index 7d214096..fef42ee7 100644
--- a/tests/test_which_pack.py
+++ b/tests/test_which_pack.py
@@ -32,7 +32,7 @@ class WhichPackRows(unittest.TestCase):
record = release_record.load_record()
page = generate_site.generate_which_pack()
row = re.compile(r"^\| \[[^\]]+\]\(platforms/([a-z0-9]+)\.md\) \|(.*)$", re.M)
- asset = re.compile(r"/releases/download/[^/]+/([^)\s]+)\)")
+ asset = re.compile(r"/releases/download/[^/]+/([^)\s\"]+)[)\"]")
checked = 0
for key, rest in row.findall(page):
linked = set(asset.findall(rest))
diff --git a/wiki/testing-guide.md b/wiki/testing-guide.md
index 2e67ef63..117ddcf8 100644
--- a/wiki/testing-guide.md
+++ b/wiki/testing-guide.md
@@ -54,7 +54,7 @@ Every module in `tests/`, by the area it guards.
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_offline`, `test_install_selection` |
| Handheld port | stubbed PortMaster dialogs, fake installer | `test_port` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
-| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
+| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_entry`, `test_site_r3_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
## Test architecture