mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: round 3 entry pages
This commit is contained in:
commit
703f05657f
10 files changed
+865
-177
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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>', ""]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user