mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
The README ran 211 lines and 2,000 words: five commands with options, an 18-row download table, coverage figures and the build commands, all before the help. It now says what the project does, gives the line that installs for Windows and for the rest, shows it, lists the six ZIPs and the setups that install by command, and leads to the site, where the figures and every option live. The build commands sit in a closed block. No personal pronoun is left in its text.
641 lines
27 KiB
Python
641 lines
27 KiB
Python
"""What the README, the site and the wiki tell an end user.
|
|
|
|
Someone with a game that does not boot runs the one-line installer, not a
|
|
clone of this repository: `scripts/verify.py` reads the collection, never
|
|
their BIOS folder. A download link names the file to fetch, parts included,
|
|
from the release the table describes. Output shown on a page is output the
|
|
tool prints, and a size is the one the release record holds.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
|
|
|
|
import ast
|
|
import contextlib
|
|
import re
|
|
import sys
|
|
import unittest
|
|
from functools import cache
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import release_record # noqa: E402
|
|
from generate_readme import ( # noqa: E402
|
|
SITE_URL,
|
|
asset_links,
|
|
contributing_text,
|
|
download_links,
|
|
download_table,
|
|
format_size,
|
|
)
|
|
|
|
RECORD = release_record.load_record(REPO_ROOT / "release.json")
|
|
REPO = "https://github.com/Abdess/retrobios"
|
|
|
|
SPLIT_RECORD = {
|
|
"tag": "v2000.01.01",
|
|
"packs": {
|
|
"Batocera_1_BIOS_Pack.zip": {
|
|
"files": 3,
|
|
"extracted_size": 3 * 1024**3,
|
|
"download_size": 2 * 1024**3,
|
|
"assets": [
|
|
"Batocera_1_BIOS_Pack.part1of2.zip",
|
|
"Batocera_1_BIOS_Pack.part2of2.zip",
|
|
],
|
|
"platforms": ["batocera"],
|
|
},
|
|
"RomM_1_BIOS_Pack.zip": {
|
|
"files": 1,
|
|
"extracted_size": 1024**2,
|
|
"download_size": 1024**2,
|
|
"assets": ["RomM_1_BIOS_Pack.zip"],
|
|
"platforms": ["romm"],
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
def _read(relative: str) -> str:
|
|
return (REPO_ROOT / relative).read_text(encoding="utf-8")
|
|
|
|
|
|
def _section(text: str, heading: str) -> str:
|
|
"""The text under a markdown heading, up to the next heading of its level."""
|
|
level = len(heading) - len(heading.lstrip("#"))
|
|
start = text.index(heading + "\n")
|
|
rest = text[start + len(heading) + 1:]
|
|
stop = re.search(rf"^#{{1,{level}}} ", rest, re.MULTILINE)
|
|
return rest[: stop.start()] if stop else rest
|
|
|
|
|
|
def _which_pack() -> str:
|
|
import generate_site
|
|
|
|
with contextlib.chdir(REPO_ROOT):
|
|
return generate_site.generate_which_pack()
|
|
|
|
|
|
class TheDownloadTablesFollowTheRegistry(unittest.TestCase):
|
|
"""EmuDeck has a Windows edition the installer detects, and the Windows
|
|
table of the download page left it out. The page is grouped by device
|
|
now: the row of each platform names every system it is detected on."""
|
|
|
|
def test_every_detected_system_lists_the_platform(self):
|
|
import yaml # noqa: PLC0415
|
|
|
|
from common import load_platform_config # noqa: PLC0415
|
|
|
|
registry = yaml.safe_load(_read("platforms/_registry.yml"))
|
|
page = _which_pack()
|
|
for os_name, label in (("windows", "Windows"), ("darwin", "macOS")):
|
|
for platform, entry in registry["platforms"].items():
|
|
systems = {rule["os"] for rule in (entry.get("install") or {}).get("detect", [])}
|
|
if os_name in systems:
|
|
name = load_platform_config(platform, "platforms")["platform"]
|
|
row = next(line for line in page.splitlines()
|
|
if line.startswith(f"| [{name}]("))
|
|
with self.subTest(os=os_name, platform=platform):
|
|
self.assertIn(label, row.split(" | ")[1])
|
|
|
|
|
|
class AssetLinksNameTheFile(unittest.TestCase):
|
|
"""The get/ page of a platform links the files of its pack."""
|
|
|
|
def test_a_whole_pack_links_its_asset(self):
|
|
self.assertEqual(
|
|
asset_links("romm", SPLIT_RECORD, "../..", "Download"),
|
|
"[Download](../../releases/download/v2000.01.01/RomM_1_BIOS_Pack.zip)",
|
|
)
|
|
|
|
def test_a_split_pack_links_every_part(self):
|
|
cell = asset_links("batocera", SPLIT_RECORD, "../..", "Download")
|
|
for part in ("part1of2", "part2of2"):
|
|
self.assertIn(
|
|
f"releases/download/v2000.01.01/Batocera_1_BIOS_Pack.{part}.zip", cell
|
|
)
|
|
self.assertIn("part 1 of 2", cell)
|
|
|
|
def test_a_split_pack_says_how_many_files_to_download(self):
|
|
"""One link taken for the pack left half of it out of the folder."""
|
|
self.assertIn(
|
|
"Download, 2 files, all needed: ",
|
|
asset_links("batocera", SPLIT_RECORD, "../..", "Download"),
|
|
)
|
|
buttons = asset_links("batocera", SPLIT_RECORD, REPO, "Pack", " { .x }")
|
|
self.assertTrue(buttons.startswith("2 files to download, all needed: "), buttons)
|
|
|
|
def test_buttons_carry_their_attributes_on_each_part(self):
|
|
cell = asset_links("batocera", SPLIT_RECORD, REPO, "Pack", " { .x }")
|
|
self.assertEqual(cell.count("{ .x }"), 2)
|
|
|
|
def test_without_a_recorded_release_the_link_is_the_release_page(self):
|
|
self.assertEqual(
|
|
asset_links("romm", {}, "../..", "Download"),
|
|
"[Download](../../releases/latest)",
|
|
)
|
|
|
|
|
|
class DownloadLinksKeepTheirAddress(unittest.TestCase):
|
|
"""Every release deletes the previous tag and its asset URLs: six of the
|
|
eight links a community wiki had to our packs answered 404. A download
|
|
cell links the platform's get/ page, which outlives the release."""
|
|
|
|
def test_a_released_pack_links_its_get_page(self):
|
|
self.assertEqual(
|
|
download_links("romm", SPLIT_RECORD, "Download", site=SITE_URL),
|
|
f"[Download]({SITE_URL}get/romm/)",
|
|
)
|
|
self.assertEqual(
|
|
download_links("batocera", SPLIT_RECORD, "Download", site=SITE_URL),
|
|
f"[Download]({SITE_URL}get/batocera/)",
|
|
)
|
|
|
|
def test_a_setup_without_a_release_pack_reads_use_the_command(self):
|
|
self.assertEqual(
|
|
download_links("bizhawk", SPLIT_RECORD, "Download", site=SITE_URL),
|
|
f"[Use the command]({SITE_URL}get/bizhawk/)",
|
|
)
|
|
|
|
def test_a_marked_setup_the_release_lacks_has_no_zip_yet(self):
|
|
self.assertEqual(
|
|
download_links("retroarch", SPLIT_RECORD, "Download", site=SITE_URL),
|
|
f"No ZIP yet: [use the command]({SITE_URL}get/retroarch/)",
|
|
)
|
|
|
|
def test_without_a_recorded_release_the_get_page_answers(self):
|
|
self.assertEqual(
|
|
download_links("romm", {}, "Download", site=SITE_URL),
|
|
f"[Download]({SITE_URL}get/romm/)",
|
|
)
|
|
|
|
def test_the_readme_table_links_get_pages(self):
|
|
coverages = {
|
|
"batocera": {"platform": "Batocera"},
|
|
"bizhawk": {"platform": "BizHawk"},
|
|
"romm": {"platform": "RomM"},
|
|
}
|
|
rows = download_table(coverages, {}, SPLIT_RECORD)[2:]
|
|
self.assertEqual(len(rows), 3)
|
|
for row in rows:
|
|
self.assertNotIn("/releases/", row)
|
|
self.assertIn(f"{SITE_URL}get/", row)
|
|
self.assertIn("[Use the command]", next(r for r in rows if "BizHawk" in r))
|
|
|
|
def test_the_generated_readme_links_every_get_page(self):
|
|
if not RECORD.get("packs"):
|
|
self.skipTest("release.json is not written")
|
|
from common import list_registered_platforms, list_release_pack_platforms
|
|
|
|
readme = _read("README.md")
|
|
table = _section(readme, "## Or download a ZIP")
|
|
self.assertNotIn("/releases/download/", table)
|
|
released = set(list_release_pack_platforms("platforms"))
|
|
rows = [line for line in table.splitlines() if line.startswith("| ")]
|
|
for key in list_registered_platforms("platforms"):
|
|
with self.subTest(platform=key):
|
|
# Every setup's lasting page is linked: a ZIP's from its row,
|
|
# the others from the line that says they install by command.
|
|
self.assertIn(f"]({SITE_URL}get/{key}/)", table)
|
|
in_rows = any(f"get/{key}/)" in row for row in rows)
|
|
self.assertEqual(in_rows, key in released)
|
|
|
|
def test_the_download_page_rows_link_get_pages(self):
|
|
page = _which_pack()
|
|
rows = [line for line in page.splitlines() if line.startswith("| ")]
|
|
self.assertTrue(rows)
|
|
for row in rows:
|
|
self.assertNotIn("/releases/", row)
|
|
|
|
def test_a_platform_without_a_zip_offers_the_command(self):
|
|
"""KNULLI, AmberELEC and EmuELEC were registered after the recorded
|
|
release, and the platforms without release_pack never get a ZIP: a
|
|
row for them offers the command."""
|
|
from common import list_registered_platforms, load_platform_config
|
|
import generate_site
|
|
|
|
record = release_record.load_advertised()
|
|
page = _which_pack()
|
|
for key in list_registered_platforms("platforms"):
|
|
if release_record.pack_for(key, record) is not None:
|
|
continue
|
|
label = load_platform_config(key, "platforms").get("platform", key)
|
|
row = next(line for line in page.splitlines() if line.startswith(f"| [{label}]("))
|
|
state = "No ZIP yet." if generate_site.has_release_pack(key) else "No ZIP."
|
|
with self.subTest(platform=key):
|
|
self.assertNotIn("/releases/", row)
|
|
self.assertIn(f"{state} [Use the command](platforms/{key}.md#install).", row)
|
|
|
|
|
|
class SlicesLeaveWithTheirRelease(unittest.TestCase):
|
|
"""Parts named `.zip.001` were cut by v2026.09.04, deleted since. A page
|
|
explaining a layout no release serves buries the one the user has."""
|
|
|
|
PAGES = ("README.md", "wiki/troubleshooting.md")
|
|
|
|
def setUp(self):
|
|
assets = [a for p in RECORD.get("packs", {}).values() for a in p["assets"]]
|
|
if any(re.search(r"\.zip\.\d{3}$", a) for a in assets):
|
|
self.skipTest("the recorded release is published in slices")
|
|
|
|
def test_user_pages_name_one_layout(self):
|
|
for page in self.PAGES:
|
|
with self.subTest(page=page):
|
|
self.assertNotIn(".zip.001", _read(page))
|
|
self.assertNotIn("copy /b", _read(page))
|
|
|
|
def test_the_download_page_names_one_layout(self):
|
|
self.assertNotIn(".zip.001", _which_pack())
|
|
|
|
|
|
class TheReadmeLeadsBeginnersToTheirPages(unittest.TestCase):
|
|
"""The README linked the installer reference and the FAQ's no-ZIP anchor
|
|
only: a user stopped by an install error or a BIOS-not-found message had
|
|
no link to the first install or troubleshooting page."""
|
|
|
|
def test_quick_install_links_the_start_pages(self):
|
|
# The code block holds `#` comment lines, so the section runs to
|
|
# the next level-two heading.
|
|
section = _read("README.md").split("## Install in one line", 1)[1].split("\n## ", 1)[0]
|
|
for page in ("first-install", "troubleshooting", "faq"):
|
|
with self.subTest(page=page):
|
|
self.assertIn(f"wiki/{page}/)", section)
|
|
self.assertTrue((REPO_ROOT / "wiki" / f"{page}.md").is_file())
|
|
|
|
|
|
class EndUsersCheckTheirOwnFolder(unittest.TestCase):
|
|
"""verify.py reads database.json and bios/. Advice about a user's own
|
|
files points at the installer's --check, which hashes their folder."""
|
|
|
|
USER_SECTIONS = (
|
|
("wiki/troubleshooting.md", "## 1. Check my BIOS folder { #checking-what-you-installed }"),
|
|
("wiki/troubleshooting.md", "## 2. Fix the files"),
|
|
("wiki/faq.md", "## My game shows a black screen"),
|
|
("wiki/faq.md", "## How do I know which BIOS I need?"),
|
|
)
|
|
|
|
def test_user_sections_never_send_to_verify(self):
|
|
for page, heading in self.USER_SECTIONS:
|
|
with self.subTest(page=page, heading=heading):
|
|
section = _section(_read(page), heading)
|
|
self.assertNotIn("verify.py", section)
|
|
self.assertNotIn("python install.py --check", section)
|
|
|
|
def test_the_download_page_gives_the_one_liner_check(self):
|
|
page = _which_pack()
|
|
self.assertNotIn("from a checkout", page)
|
|
self.assertIn("/i | sh -s -- --check", page)
|
|
|
|
def test_the_readme_does_not_call_verify_a_check_of_your_bios(self):
|
|
self.assertNotIn("Verify your BIOS collection", _read("README.md"))
|
|
|
|
|
|
class DestIsTheBiosFolder(unittest.TestCase):
|
|
"""--dest is used as the BIOS folder as given (install.py), so the root
|
|
of a card puts every file beside the folder the frontend reads."""
|
|
|
|
PAGES = (
|
|
"README.md",
|
|
"wiki/installer.md",
|
|
"wiki/faq.md",
|
|
"wiki/first-install.md",
|
|
)
|
|
|
|
def test_no_example_installs_to_a_card_root(self):
|
|
for page in self.PAGES:
|
|
for dest in re.findall(r"--dest\s+(\S+)", _read(page)):
|
|
with self.subTest(page=page, dest=dest):
|
|
self.assertFalse(dest.rstrip("/").endswith("sdcard"), dest)
|
|
|
|
|
|
class ShownOutputIsPrintedOutput(unittest.TestCase):
|
|
"""Every line in the troubleshooting page's verify samples has a shape
|
|
verify.py prints, and the shapes are read from its source."""
|
|
|
|
SHAPES = (
|
|
(r"^\S.*: \d+/\d+ (OK|present)\b.*\[(md5|sha1|existence)\]$", "}: {', '.join(parts)} [{mode}]"),
|
|
(r"^ (OK|UNTESTED|MISSING) \((required|optional)[^)]*\): \S+/\S+", '" UNTESTED ({req}{hle}): {key}: {reason}"'),
|
|
(r"^ DISCREPANCY: \S+/\S+: ", '" DISCREPANCY: {key}: {disc}"'),
|
|
# A count of optional files missing is followed by where to list them:
|
|
# the sample once showed "1 optional missing" with no such suffix.
|
|
(r"^ Core files: \d+ in pack, \d+ required missing, "
|
|
r"(0 optional missing|\d+ optional missing \(--verbose lists them\))$",
|
|
'" Core files: {core_in_pack} in pack'),
|
|
(r"^ Core files held but not packed: \d+ \(.+\)$", '" Core files held but not packed: '),
|
|
(r"^ Bundled with their emulator, not collected: \d+( \(.+\))?$",
|
|
'" Bundled with their emulator, not collected: {len(bundled)}"'),
|
|
(r"^ Game data: \d+ in pack, (0 missing|\d+ missing \(--verbose lists them\))$",
|
|
'" Game data: {gd_packed} in pack'),
|
|
(r"^ Game data held but not packed: \d+ \(.+\)$", '" Game data held but not packed: '),
|
|
(r"^ No external files: \d+ cores \(--verbose lists them\)$",
|
|
'" No external files: {len(exclusions)} cores (--verbose lists them)"'),
|
|
(r"^ Ground truth: \d+/\d+ files have emulator validation \(\d+%\)$",
|
|
'" Ground truth: {gt_cov[\'with_validation\']}/{gt_cov[\'total\']} files have emulator validation'),
|
|
(r"^ \d+ platform-only \(no emulator profile\)$", '" {no_profile} platform-only (no emulator profile)"'),
|
|
(r"^ \S", None),
|
|
)
|
|
|
|
def test_the_shapes_are_verify_output(self):
|
|
source = _read("scripts/verify.py")
|
|
for _pattern, literal in self.SHAPES:
|
|
if literal:
|
|
with self.subTest(literal=literal):
|
|
self.assertIn(literal, source)
|
|
|
|
def test_samples_match_a_shape(self):
|
|
section = _section(_read("wiki/reading-reports.md"), "### Reading the output")
|
|
blocks = re.findall(r"```\n(.*?)```", section, re.DOTALL)
|
|
self.assertTrue(blocks)
|
|
for block in blocks:
|
|
for line in block.splitlines():
|
|
with self.subTest(line=line):
|
|
self.assertTrue(
|
|
any(re.match(pattern, line) for pattern, _ in self.SHAPES), line
|
|
)
|
|
|
|
def test_an_optional_count_without_its_suffix_is_refused(self):
|
|
line = " Core files: 1495 in pack, 0 required missing, 1 optional missing"
|
|
self.assertFalse(any(re.match(pattern, line) for pattern, _ in self.SHAPES))
|
|
|
|
def test_no_severity_column(self):
|
|
self.assertNotIn("[CRITICAL]", _read("wiki/troubleshooting.md"))
|
|
|
|
|
|
class SizesComeFromTheRecord(unittest.TestCase):
|
|
def test_the_quoted_pack_sizes_are_the_recorded_ones(self):
|
|
text = _read("wiki/release-process.md")
|
|
quoted = re.search(
|
|
r"in (v[\d.]+),\s+(\w+) downloaded as ([\d.]+ GB) and extracted to"
|
|
r"\s+([\d.]+ GB)",
|
|
text,
|
|
)
|
|
self.assertIsNotNone(quoted, "the sizes are quoted with their release tag")
|
|
tag, platform, download, extracted = quoted.groups()
|
|
if tag != RECORD.get("tag"):
|
|
self.skipTest(f"release.json records {RECORD.get('tag')}, not {tag}")
|
|
pack = release_record.pack_for(platform.lower(), RECORD)
|
|
self.assertEqual(download, format_size(pack["download_size"]))
|
|
self.assertEqual(extracted, format_size(pack["extracted_size"]))
|
|
|
|
|
|
class ContributorChecksMatchCi(unittest.TestCase):
|
|
"""validate.yml fetches the data directories before the suite: the tests
|
|
that build packs exit non-zero without them."""
|
|
|
|
def test_the_pr_checklist_fetches_data_directories_before_the_tests(self):
|
|
for text in (
|
|
contributing_text(lambda page: page, "# C", "here"),
|
|
_read("CONTRIBUTING.md"),
|
|
):
|
|
block = _section(text, "## Before opening a PR")
|
|
self.assertIn("python scripts/refresh_data_dirs.py", block)
|
|
self.assertLess(
|
|
block.index("refresh_data_dirs.py"), block.index("unittest discover")
|
|
)
|
|
|
|
def test_ci_does_the_same(self):
|
|
workflow = _read(".github/workflows/validate.yml")
|
|
self.assertLess(
|
|
workflow.index("python scripts/refresh_data_dirs.py"),
|
|
workflow.index("python -m unittest discover"),
|
|
)
|
|
|
|
def test_the_pr_checklist_generates_the_site_before_the_tests(self):
|
|
# validate.yml generates the pages before the suite. Without them
|
|
# the 228 tests that read the gitignored pages fail on a clone.
|
|
workflow = _read(".github/workflows/validate.yml")
|
|
self.assertLess(
|
|
workflow.index("run: python scripts/generate_site.py"),
|
|
workflow.index("python -m unittest discover"),
|
|
)
|
|
for text in (
|
|
contributing_text(lambda page: page, "# C", "here"),
|
|
_read("CONTRIBUTING.md"),
|
|
):
|
|
block = _section(text, "## Before opening a PR")
|
|
self.assertIn("python scripts/generate_site.py", block)
|
|
self.assertLess(
|
|
block.index("generate_site.py"), block.index("unittest discover")
|
|
)
|
|
|
|
|
|
class PagesOpenOnTheirContent(unittest.TestCase):
|
|
"""The download page said "This page picks the right pack for a setup"
|
|
before saying anything about a pack."""
|
|
|
|
def test_no_paragraph_announces_its_page(self):
|
|
import generate_site # noqa: PLC0415
|
|
|
|
text = generate_site.generate_which_pack()
|
|
announcing = [
|
|
line for line in text.splitlines()
|
|
if re.match(r"(This|The) (page|section) ", line)
|
|
]
|
|
self.assertEqual(announcing, [])
|
|
|
|
|
|
class CoreCountsReadFromTheData(unittest.TestCase):
|
|
"""The cores page said "623 cores across 18 platforms": that was every
|
|
profile, and 91 of them no platform runs."""
|
|
|
|
def test_the_run_count_is_what_the_platforms_resolve(self):
|
|
import yaml
|
|
from common import load_emulator_profiles, load_platform_config, resolve_platform_cores
|
|
|
|
from tests import sitepages
|
|
|
|
sitepages.require()
|
|
text = sitepages.page("cross-reference.md").read_text(encoding="utf-8")
|
|
profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
|
unique = {k: v for k, v in profiles.items() if v.get("type") not in ("alias", "test")}
|
|
registry = yaml.safe_load((REPO_ROOT / "platforms" / "_registry.yml").read_text(
|
|
encoding="utf-8"))["platforms"]
|
|
run = set()
|
|
for name in registry:
|
|
config = load_platform_config(name, str(REPO_ROOT / "platforms"))
|
|
run |= set(resolve_platform_cores(config, unique))
|
|
said = re.search(r"([\d,]+) cores are profiled, ([\d,]+) of them run by the (\d+) platforms",
|
|
text)
|
|
self.assertIsNotNone(said, "the page says how many profiled cores the platforms run")
|
|
self.assertEqual(int(said[1].replace(",", "")), len(unique))
|
|
self.assertEqual(int(said[2].replace(",", "")), len(run))
|
|
self.assertEqual(int(said[3]), len(registry))
|
|
|
|
|
|
# What the generators write must not bring back what the project retired.
|
|
# The generated pages, README.md, CONTRIBUTING.md and the site's components
|
|
# are read for a retired command, address or feature term, each listed with
|
|
# the reason it was retired, and for every `--option` a generated command
|
|
# passes: the script it names must declare it in its parser. The pages copied
|
|
# from wiki/ are left to their own tests: the installer page keeps the raw
|
|
# one-liner as the documented fallback.
|
|
|
|
|
|
def _sitepages():
|
|
from tests import sitepages
|
|
|
|
return sitepages
|
|
|
|
# (string, why it is retired). Compared case-sensitively against the text.
|
|
RETIRED = (
|
|
("raw.githubusercontent.com/Abdess/retrobios/main/install.sh",
|
|
"the site serves the one-liner at https://abdess.github.io/retrobios/i "
|
|
"since 2026-10-28, pinned to the commit the site was built from"),
|
|
("raw.githubusercontent.com/Abdess/retrobios/main/install.ps1",
|
|
"served at https://abdess.github.io/retrobios/i.ps1 since 2026-10-28"),
|
|
("Abdess/retroarch_system",
|
|
"the repository was renamed retrobios in March 2026"),
|
|
("mkdocs serve", "the MkDocs site was replaced by Astro Starlight on 2026-10-22"),
|
|
("mkdocs build", "the MkDocs site was replaced by Astro Starlight on 2026-10-22"),
|
|
("pip install mkdocs", "the site is built by Node from website/package-lock.json"),
|
|
('class="rb-tested', "the Tested blocks were removed on 2026-10-28: "
|
|
"only Proof lines and the Measurements page remain"),
|
|
("rb-region-runs", "region outcomes left the system pages on 2026-10-28"),
|
|
("## Campaign", "the campaign table left the Measurements page on 2026-10-28"),
|
|
("| Tested by |", "the coverage table left the Measurements page on 2026-10-28"),
|
|
("ms per frame", "benchmark figures stay in the records and SQLite files, "
|
|
"never on a page, since 2026-10-28"),
|
|
("with this BIOS folder as", "the Tested block of platform pages, removed on 2026-10-28"),
|
|
("regenerated in its native format", "the export patches each platform's own "
|
|
"file and never regenerates it (scripts/exporter/base_exporter.py needs_original)"),
|
|
)
|
|
|
|
COMMAND = re.compile(
|
|
r"(?:python3? (?P<script>scripts/[\w/]+\.py|install\.py)"
|
|
r"|python3? -m (?P<module>scripts(?:\.\w+)+)"
|
|
r"|(?P<oneliner>retrobios/i \| sh -s --|retrobios/i\.ps1\)\)\)))"
|
|
r"(?P<args>[^\n`<|]*)"
|
|
)
|
|
OPTION = re.compile(r"(?<![\w-])(--[a-z][a-z0-9-]*)")
|
|
|
|
|
|
def retired_hits(text: str) -> list[str]:
|
|
return [term for term, _why in RETIRED if term in text]
|
|
|
|
|
|
@cache
|
|
def parser_options(script: str) -> frozenset[str]:
|
|
"""The long options *script* declares through add_argument, with the
|
|
shared ones of scraper_cli for a scraper that hands its parser over."""
|
|
source = (REPO_ROOT / script).read_text(encoding="utf-8")
|
|
tree = ast.parse(source)
|
|
found = set()
|
|
for node in ast.walk(tree):
|
|
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "add_argument"):
|
|
found.update(arg.value for arg in node.args
|
|
if isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
|
and arg.value.startswith("--"))
|
|
if "scraper_cli(" in source and script != "scripts/scraper/base_scraper.py":
|
|
found |= parser_options("scripts/scraper/base_scraper.py")
|
|
return frozenset(found)
|
|
|
|
|
|
def unknown_options(text: str) -> list[str]:
|
|
"""Each `script --option` of a command whose script does not declare it."""
|
|
wrong = []
|
|
for match in COMMAND.finditer(text):
|
|
if match["oneliner"]:
|
|
script = "install.py"
|
|
elif match["module"]:
|
|
script = match["module"].replace(".", "/") + ".py"
|
|
else:
|
|
script = match["script"]
|
|
if not (REPO_ROOT / script).is_file():
|
|
wrong.append(f"{script}: no such script")
|
|
continue
|
|
known = parser_options(script)
|
|
wrong += [f"{script} {option}" for option in OPTION.findall(match["args"])
|
|
if option not in known]
|
|
return wrong
|
|
|
|
|
|
def _surfaces() -> dict[str, str]:
|
|
content = _sitepages().require()
|
|
texts = {
|
|
str(path.relative_to(REPO_ROOT)): path.read_text(encoding="utf-8")
|
|
for path in sorted(content.rglob("*.md*"))
|
|
if "wiki" not in path.relative_to(content).parts
|
|
}
|
|
for name in ("README.md", "CONTRIBUTING.md"):
|
|
texts[name] = (REPO_ROOT / name).read_text(encoding="utf-8")
|
|
for path in sorted((REPO_ROOT / "website" / "src").glob("*/*")):
|
|
if path.suffix in (".astro", ".ts", ".css", ".mjs"):
|
|
texts[str(path.relative_to(REPO_ROOT))] = path.read_text(encoding="utf-8")
|
|
return texts
|
|
|
|
|
|
class RetiredTermsStayRetired(unittest.TestCase):
|
|
def test_each_retired_term_says_why(self):
|
|
for term, why in RETIRED:
|
|
with self.subTest(term=term):
|
|
self.assertTrue(term.strip())
|
|
self.assertGreater(len(why.split()), 4)
|
|
|
|
def test_the_check_finds_what_the_old_output_wrote(self):
|
|
old = (
|
|
"curl -fsSL https://raw.githubusercontent.com/Abdess/retrobios/main/install.sh | sh\n"
|
|
'## Tested\n\n<div class="rb-tested">\n'
|
|
"The same comparison drives `scripts/exporter/`: each platform's own "
|
|
"file can be regenerated in its native format with these differences.\n"
|
|
)
|
|
self.assertEqual(retired_hits(old), [
|
|
"raw.githubusercontent.com/Abdess/retrobios/main/install.sh",
|
|
'class="rb-tested', "regenerated in its native format",
|
|
])
|
|
|
|
def test_no_surface_brings_one_back(self):
|
|
found = {name: hits for name, text in _surfaces().items()
|
|
if (hits := retired_hits(text))}
|
|
self.assertEqual(found, {})
|
|
|
|
|
|
class EveryOptionExists(unittest.TestCase):
|
|
def test_the_check_reads_each_form_of_command(self):
|
|
text = (
|
|
"python scripts/generate_pack.py --platform retroarch --zip-all\n"
|
|
"curl -fsSL https://abdess.github.io/retrobios/i | sh -s -- --check --frobnicate\n"
|
|
"&([scriptblock]::Create((irm https://abdess.github.io/retrobios/i.ps1))) --dry-run\n"
|
|
"python -m scripts.scraper.libretro_scraper --dry-run\n"
|
|
)
|
|
self.assertEqual(unknown_options(text), [
|
|
"scripts/generate_pack.py --zip-all", "install.py --frobnicate",
|
|
])
|
|
|
|
def test_every_generated_command_names_options_its_script_has(self):
|
|
wrong = {name: bad for name, text in _surfaces().items()
|
|
if (bad := unknown_options(text))}
|
|
self.assertEqual(wrong, {})
|
|
|
|
|
|
class ContributorChecksFetchThePlatformsFiles(unittest.TestCase):
|
|
"""validate.yml fetches each platform's own file before the suite. The
|
|
checklist left it out, so the exporter fidelity cases skipped on a
|
|
contributor's clone and first ran, red, in CI."""
|
|
|
|
def test_the_pr_checklist_fetches_them_before_the_tests(self):
|
|
workflow = _read(".github/workflows/validate.yml")
|
|
self.assertLess(
|
|
workflow.index("python scripts/export_native.py --all --fetch"),
|
|
workflow.index("python -m unittest discover"),
|
|
)
|
|
for text in (
|
|
contributing_text(lambda page: page, "# C", "here"),
|
|
_read("CONTRIBUTING.md"),
|
|
):
|
|
block = _section(text, "## Before opening a PR")
|
|
self.assertIn("python scripts/export_native.py --all --fetch", block)
|
|
self.assertLess(
|
|
block.index("export_native.py"), block.index("unittest discover")
|
|
)
|
|
|
|
def test_the_testing_guide_names_the_command(self):
|
|
self.assertIn("export_native.py --all --fetch", _read("wiki/testing-guide.md"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|