Files
libretro/tests/test_doc_pages.py
T
Abdessamad Derraz 84646d00a6 fix: read a typed folder and quit with 130
A folder typed in quotes at the BIOS folder question was used with its
quotes, and its missing parents were created without the question
--dest asks. The quotes go, ~ and variables expand on every system, and
a default folder that does not exist is named as such, not as found.
q at a menu now leaves with 130 like Ctrl+C, and Ctrl+C before anything
started no longer offers to carry on.
2026-10-23 13:50:16 +02:00

777 lines
35 KiB
Python

"""What the wiki and the generated README state holds against the code.
Each class reads a page and the code it describes. A page that quotes a
function's return shape, a resolution order or a field list drifts when the
code moves, and nothing else notices.
"""
from __future__ import annotations
import ast
import hashlib
import inspect
import io
import json
import re
import sys
import unittest
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WIKI = ROOT / "wiki"
sys.path.insert(0, str(ROOT / "scripts"))
from tests.test_prose_style import _python_prose, _sentence_case # noqa: E402
FENCED = re.compile(r"^```.*?^```", re.S | re.M)
PUBLISHED = sorted(WIKI.glob("*.md")) + [ROOT / "README.md", ROOT / "CONTRIBUTING.md"]
def _page(name: str) -> str:
return (WIKI / name).read_text(encoding="utf-8")
def _section(text: str, heading: str) -> str:
"""Body of a markdown section, up to the next heading of any level."""
start = re.search(rf"(?m)^#+ {re.escape(heading)}\n", text)
if start is None:
raise AssertionError(f"no section {heading!r}")
rest = text[start.end():]
blanked = FENCED.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), rest)
end = re.search(r"(?m)^#+ ", blanked)
return rest[: end.start()] if end else rest
def _table_rows(text: str) -> list[list[str]]:
rows = []
for line in text.splitlines():
if line.startswith("|") and not re.match(r"\|\s*-", line):
rows.append([cell.strip() for cell in line.strip().strip("|").split(" | ")])
return rows[1:]
class TestSkeletonReadsTheResultShape(unittest.TestCase):
"""The skeleton indexed verify_platform's result as a list, and a test
copied from it stopped on KeyError: 0 before any assertion."""
def test_every_key_it_reads_is_one_verify_platform_returns(self):
from verify import verify_platform
result = verify_platform(
{"platform": "x", "verification_mode": "existence", "systems": {}},
{"files": {}, "indexes": {}},
emu_profiles={},
)
text = _page("testing-guide.md").split("Example skeleton:", 1)[1]
code = re.search(r"```python\n(.*?)```", text, re.S).group(1)
read = [
node.slice
for node in ast.walk(ast.parse(code))
if isinstance(node, ast.Subscript)
and isinstance(node.value, ast.Name)
and node.value.id == "result"
]
self.assertTrue(read, "the skeleton no longer reads the result")
for key in read:
self.assertIsInstance(key, ast.Constant)
self.assertIn(key.value, result)
class InstallerOutputExample(unittest.TestCase):
"""The page showed a run with no `Not installable:` line while the
RetroArch manifest left eleven entries out, and a count that the menu
example below it no longer shared."""
def test_run_and_menu_show_one_dated_run_of_the_manifest(self):
import install
text = _page("installer.md")
intro, run = re.search(r"(?s)\n([^\n]*)\n\n```\n(Fetching file index.*?)```", text).groups()
self.assertRegex(intro, r"\d{4}-\d{2}-\d{2}", "an output example says when it was taken")
manifest = json.loads((ROOT / "install" / "retroarch.json").read_text(encoding="utf-8"))
omitted = re.search(r"Not installable: \d+ files? \(\d+ required, (.*)\)\.", run)
if manifest.get("omitted_files"):
self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it")
if omitted:
for part in omitted.group(1).split(", "):
self.assertIn(part.rsplit(":", 1)[0], install.OMISSION_REASONS.values())
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", run).groups()
self.assertIn(f"0/{count} present", run)
menu = re.search(r"RetroArch: (\d+) files, ([\d.]+ [KMG]B)\.", text)
self.assertEqual(menu.groups(), (count, size))
class OneResolutionChain(unittest.TestCase):
"""architecture.md drew path_suffix as the first step, before SHA1, while
the code and verification-modes.md try it after every hash."""
ORDER = ("SHA1", "SHA256", "CRC32", "MD5", "Path suffix")
def test_the_table_follows_the_code(self):
import common
source = inspect.getsource(common.resolve_local_file)
steps = re.findall(r"# \d+[a-z]?\. (SHA1|SHA256|CRC32|MD5|Path suffix)", source)
self.assertEqual(tuple(dict.fromkeys(steps)), self.ORDER)
table = _table_rows(_section(_page("verification-modes.md"), "File resolution chain"))
methods = [row[1] for row in table[: len(self.ORDER)]]
self.assertEqual(
[next(m for m in self.ORDER if method.startswith(m)) for method in methods],
list(self.ORDER),
)
def test_architecture_links_instead_of_drawing_a_second_one(self):
section = _section(_page("architecture.md"), "File resolution chain")
self.assertNotIn("```mermaid", section)
self.assertIn("verification-modes.md#file-resolution-chain", section)
class ReleaseRefPromise(unittest.TestCase):
"""RETROBIOS_REF was said to pin a reproducible file set, while every
release deletes the previous tags and large files come from one fixed
release whatever the ref."""
def test_the_page_says_what_a_tag_does_not_pin(self):
import install
self.assertIn("/large-files/", install.RELEASE_URL)
self.assertIn("--cleanup-tag", _page("release-process.md"))
row = next(
line for line in _page("installer.md").splitlines()
if line.startswith("| `RETROBIOS_REF`")
)
self.assertIn("`large-files`", row)
self.assertIn("next release", row)
for page in PUBLISHED:
for paragraph in page.read_text(encoding="utf-8").split("\n\n"):
if "RETROBIOS_REF" in paragraph:
self.assertNotIn("reproducible", paragraph, page.name)
class RequirementFieldsTable(unittest.TestCase):
"""The table stopped at native_id: native_path, mirror_of, alt_md5 and
the native dict, the fields that make a platform YAML reversible, were
named nowhere a scraper author reads first."""
def test_every_field_and_native_key_is_listed(self):
from dataclasses import fields
from scraper.base_scraper import BiosRequirement
section = _section(_page("adding-a-platform.md"), "BiosRequirement fields")
listed = {row[0].strip("`") for row in _table_rows(section)}
self.assertEqual(listed, {f.name for f in fields(BiosRequirement)})
native_row = next(row for row in _table_rows(section) if row[0] == "`native`")[2]
keys: set[str] = set()
for path in (ROOT / "scripts" / "scraper").glob("*_scraper.py"):
source = path.read_text(encoding="utf-8")
keys |= set(re.findall(r'native\["(\w+)"\]', source))
keys |= set(re.findall(r'native(?:: dict\[str, object\])?\s*=\s*\{"(\w+)"', source))
self.assertTrue(keys)
for key in keys:
self.assertIn(f"`{key}`", native_row)
class ReleaseCommitCarriesTheManifests(unittest.TestCase):
"""The documented release commit left out install/*.json, which the
release pipeline regenerates: v2026.10.10 added three by hand."""
def test_the_commit_step_adds_what_the_pipeline_rewrites(self):
pipeline = (ROOT / "scripts" / "pipeline.py").read_text(encoding="utf-8")
self.assertIn('"--manifest"', pipeline)
block = _section(_page("release-process.md"), "Cutting a release")
add = next(line for line in block.splitlines() if line.startswith("git add "))
self.assertIn("install/*.json", add.split())
class PublishedHeadings(unittest.TestCase):
"""README.md kept '## Quick Install', the one Title Case heading left,
because the casing test read wiki/ only."""
def test_readme_and_contributing_use_sentence_case(self):
offenders = []
for page in (ROOT / "README.md", ROOT / "CONTRIBUTING.md"):
text = FENCED.sub("", page.read_text(encoding="utf-8"))
for title in re.findall(r"(?m)^#{1,4} (.+)$", text):
if _sentence_case(title) != title:
offenders.append(f"{page.name}: {title}")
self.assertEqual(offenders, [], "\n".join(offenders))
class FilesColumnParagraph(unittest.TestCase):
"""'Files is how many the pack holds' was glued to the paragraph above
it, two paragraphs before the table whose column it described."""
def test_it_names_the_column_in_its_own_paragraph_before_the_table(self):
import generate_readme
source = inspect.getsource(generate_readme)
self.assertNotIn("Files is how many", source)
readme = (ROOT / "README.md").read_text(encoding="utf-8")
paragraph = next(p for p in readme.split("\n\n") if "The Files column" in p)
self.assertNotIn("\n", paragraph.strip())
after = readme.split(paragraph, 1)[1].lstrip("\n")
self.assertTrue(after.startswith("| Platform | Files |"))
class PageOpenings(unittest.TestCase):
"""Four pages opened by announcing what they cover."""
META = re.compile(r"(?i)\bthis (page|guide) (covers|documents|explains|describes|picks)\b|^Diagnosis guide")
def test_no_page_announces_itself(self):
offenders = [
f"{page.name}: {line[:80]}"
for page in PUBLISHED
for line in FENCED.sub("", page.read_text(encoding="utf-8")).splitlines()
if self.META.search(line)
]
self.assertEqual(offenders, [], "\n".join(offenders))
class NoHedgedRules(unittest.TestCase):
"""The profiling guide and the verification discipline turned rules into
wishes ("it's worth", "Ideally", "something to look into")."""
FILLER = re.compile(
r"(?i)\bit'?s worth\b|\bworth noting\b|\bit helps to\b|^Ideally\b"
r"|\bsomething to look into\b|\busually indicate\b"
)
def test_published_pages(self):
offenders = [
f"{page.name}: {line[:80]}"
for page in PUBLISHED
for line in FENCED.sub("", page.read_text(encoding="utf-8")).splitlines()
if self.FILLER.search(line)
]
self.assertEqual(offenders, [], "\n".join(offenders))
class DocstringsWithoutInsistence(unittest.TestCase):
"""truth.py shouted ONE and NEVER in an eighteen-line docstring, and
generate_pack.py listed three guarantees under 'This ensures:' of which
two said the same thing."""
TICS = re.compile(r"\b(NEVER|ALWAYS|ONE|MUST)\b|This ensures:")
def test_scripts_and_installer(self):
offenders = []
paths = sorted((ROOT / "scripts").rglob("*.py")) + [ROOT / "install.py"]
for path in paths:
for line in _python_prose(path.read_text(encoding="utf-8")).splitlines():
if self.TICS.search(line):
offenders.append(f"{path.relative_to(ROOT)}: {line.strip()[:80]}")
self.assertEqual(offenders, [], "\n".join(offenders))
class PeerToneTowardPlatforms(unittest.TestCase):
"""Platform lists were called wrong, stale or corrected on public pages
that their maintainers read."""
WORDS = re.compile(
r"(?i)\bgets wrong\b|\bstale (hash|entries|entry)\b|<their file>|\bpatch their\b"
r"|, corrected\b|\bcorrected in place\b|\bdeclare incorrectly\b|\blist error\b"
r"|\btheir syntax\b|\bthe platform lacks\b|\bthe odd one\b|\bmay not match reality\b"
r"|\ball their code does\b|\bcan drift from\b"
)
def test_published_pages(self):
offenders = [
f"{page.name}: {line[:80]}"
for page in PUBLISHED
for line in page.read_text(encoding="utf-8").splitlines()
if self.WORDS.search(line)
]
self.assertEqual(offenders, [], "\n".join(offenders))
class RecalboxSeverity(unittest.TestCase):
"""The table gave verify.py a mapping from hashMatchMandatory that the
code never reads, and called a missing optional file GREEN."""
def test_the_verify_column_is_what_compute_severity_returns(self):
from verify import Status, compute_severity
section = _section(_page("verification-modes.md"), "Mandatory levels (Recalbox)")
rows = _table_rows(section)
self.assertTrue(rows)
status = {"hash matches": Status.OK, "missing": Status.MISSING, "hash differs": Status.UNTESTED}
for state, mandatory, _hash_match, _colour, expected in rows:
for required in ((True, False) if mandatory == "(any)" else (mandatory == "true",)):
self.assertEqual(
compute_severity(status[state], required, "md5").upper(), expected,
f"{state} mandatory={mandatory}",
)
self.assertNotIn("severity levels", _page("tools.md"))
class Md5CompositeDefinition(unittest.TestCase):
"""The page described the MD5 of the members' MD5s, the code hashes the
members' contents in name order."""
def test_page_and_code_agree(self):
from hashing import md5_composite_of
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as zf:
zf.writestr("b.rom", b"BBBB")
zf.writestr("a.rom", b"AAAA")
buffer.seek(0)
self.assertEqual(md5_composite_of(buffer), hashlib.md5(b"AAAABBBB").hexdigest())
section = _section(_page("verification-modes.md"), "md5_composite (Recalbox ZIP verification)")
self.assertIn("contents of every member", section)
self.assertNotIn("inner file MD5s", section)
def test_composite_only_is_read_by_both_checks(self):
"""The page said the direct MD5 is tried before the composite, which
Recalbox never does for a .zip, and the pack check did the same."""
import common
import validation
section = _section(_page("verification-modes.md"), "md5_composite (Recalbox ZIP verification)")
self.assertIn("zip_digest: composite", section)
self.assertNotIn("is tried before reporting a mismatch", section)
for function in (common.resolve_local_file, validation.digest_verdict):
self.assertIn('get("zip_digest") == "composite"', inspect.getsource(function))
class ValidationIndexScope(unittest.TestCase):
"""The page said the index reads every profile. verify and the builder
pass it only the profiles the platform resolves."""
def test_page_names_the_scope_the_code_uses(self):
import verify
source = inspect.getsource(verify.verify_platform)
self.assertRegex(source, r"plat_cores = resolve_platform_cores\(config, profiles\)")
self.assertIn("_build_validation_index(platform_profiles)", source)
section = _section(_page("verification-modes.md"), "Validation index")
self.assertIn("resolve_platform_cores", section)
self.assertNotIn("reads all emulator profiles", section)
class CoreResolutionWrittenOnce(unittest.TestCase):
"""architecture.md and adding-a-platform.md each stated the core
resolution rule, and only one knew that a list naming retroarch takes
every libretro profile. Neither said built_by profiles stay out."""
def test_one_statement_matching_the_platforms_and_the_code(self):
import yaml
import common
self.assertNotIn("cores: [list]", _page("architecture.md"))
source = inspect.getsource(common.resolve_platform_cores)
self.assertIn('not p.get("built_by")', source)
self.assertIn('"retroarch" in core_set', source)
naming = set()
for path in (ROOT / "platforms").glob("*.yml"):
config = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
cores = config.get("cores")
if isinstance(cores, list) and "retroarch" in map(str, cores):
naming.add(str(config.get("platform") or path.stem).lower())
section = _section(_page("adding-a-platform.md"), "Required fields")
bullet = next(p for p in section.split("\n- ") if p.startswith("**Explicit list**"))
used_by = re.search(r"Used by ([^.]+)\.", bullet.replace("\n", " ")).group(1)
names = [n.strip().lower() for n in used_by.split(",")]
self.assertIn("the last\n four", bullet)
self.assertEqual(set(names[-4:]), naming)
libretro = next(p for p in section.split("\n- ") if p.startswith("**all_libretro**"))
self.assertIn("built_by", libretro)
class ExitStatusTable(unittest.TestCase):
"""Code 2 was documented as 'every file failed' only, while argparse
exits 2 on every refused option, and the 141 example named an option
that does not exist."""
def test_codes_and_options_are_the_installers(self):
source = (ROOT / "install.py").read_text(encoding="utf-8")
section = _section(_page("installer.md"), "Exit status")
documented = {int(row[0].strip("`")) for row in _table_rows(section)}
raised = {int(code) for code in re.findall(r"sys\.exit\((\d+)\)", source)}
# 0 is a run that returns, 2 the code argparse leaves with.
self.assertEqual(documented, raised | {0, 2})
self.assertIn("install.py: error:", section)
for option in set(re.findall(r"(--[a-z][a-z-]+)", section)):
self.assertIn(f'"{option}"', source, option)
class ProfileFieldsRequired(unittest.TestCase):
"""The table marked source, profiled_date, core_version and systems as
required and called the schema authoritative, which requires none."""
def test_yes_matches_the_schema(self):
schema = json.loads((ROOT / "schemas" / "emulator.schema.json").read_text(encoding="utf-8"))
required = set(schema["required"])
for rule in schema.get("allOf", []):
for branch in ("then", "else"):
required |= set(rule.get(branch, {}).get("required", []))
section = _section(_page("profiling.md"), "Profile fields")
marked = {
row[0].strip("`") for row in _table_rows(section)
if row[1].startswith(("yes", "for `type"))
}
self.assertEqual(marked, required)
class PythonFloorOnRetroPie(unittest.TestCase):
"""The page offered `apt install python3` to a RetroPie 4.8 image, where
python3 is 3.7.3 and apt has nothing newer."""
def test_both_pages_name_the_case(self):
install_sh = (ROOT / "install.sh").read_text(encoding="utf-8")
self.assertIn("(3, 8)", install_sh)
first = _section(_page("first-install.md"), "Error: Python 3.8 or newer is required.")
self.assertIn("RetroPie 4.8", first)
self.assertIn("3.7.3", first)
changing = _section(_page("installer.md"), "Changing the installer")
self.assertIn("3.7.3", changing)
class ToolsRunOnTheStatedFloor(unittest.TestCase):
"""tools.md and testing-guide.md state Python 3.9 for every script, and
check_release_assets.py built `int | None` at import: a TypeError on 3.9
before any argument was read, in check_release.py too. CI runs 3.12 only,
so this test reads the source for what 3.9 cannot evaluate."""
TYPES = {"int", "str", "float", "bytes", "bool", "dict", "list", "tuple", "set", "type"}
def _type_like(self, node: ast.AST) -> bool:
if isinstance(node, ast.Constant) and node.value is None:
return True
if isinstance(node, ast.Subscript):
node = node.value
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
return self._type_like(node.left) or self._type_like(node.right)
return isinstance(node, ast.Name) and node.id in self.TYPES
def _runtime_unions(self, tree: ast.AST) -> list[int]:
annotations = set()
for node in ast.walk(tree):
for field in ("annotation", "returns"):
value = getattr(node, field, None)
if value is not None:
annotations.update(id(n) for n in ast.walk(value))
return [
node.lineno for node in ast.walk(tree)
if id(node) not in annotations
and (isinstance(node, ast.Match)
or isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr)
and (self._type_like(node.left) or self._type_like(node.right)))
]
def test_pages_state_the_floor(self):
self.assertIn("Python 3.9 or newer", _page("tools.md"))
self.assertIn("Python 3.9 or newer", _page("testing-guide.md"))
def test_no_script_evaluates_310_syntax(self):
offenders = [
f"{path.relative_to(ROOT)}:{line}"
for path in sorted((ROOT / "scripts").rglob("*.py"))
for line in self._runtime_unions(ast.parse(path.read_text(encoding="utf-8")))
]
self.assertEqual(offenders, [], "\n".join(offenders))
def test_the_check_catches_the_alias(self):
self.assertTrue(self._runtime_unions(ast.parse("F = tuple[str, int | None]")))
self.assertFalse(self._runtime_unions(ast.parse("def f(x: int | None) -> str | None: ...")))
self.assertFalse(self._runtime_unions(ast.parse("mask = flags | other")))
class DeriveFiguresLiveWithTheTool(unittest.TestCase):
"""tools.md gave --derive about 90 s and 67 entries while its --help said
about 35 s: two hand-kept figures for one run, measured at 33 s."""
def test_the_page_carries_no_duration_or_count(self):
paragraph = next(
p for p in _page("tools.md").split("\n\n") if "`--derive` reads" in p
)
self.assertNotRegex(paragraph, r"\d+ ?s\b|\d+ entries")
self.assertIn("derivable.json", paragraph)
class EmulatorVerboseSample(unittest.TestCase):
"""advanced-usage.md showed `handy validates size=512 crc32=0x0d973c9d
[src/handy/system.h:45]`, a format verify.py does not print and a
reference handy.yml does not cite."""
def test_every_sample_line_is_printed(self):
import subprocess
section = _section(_page("advanced-usage.md"), "Per-emulator checks")
sample = [body for lang, body in re.findall(r"(?ms)^```(\w*)\n(.*?)^```", section) if not lang][-1]
printed = subprocess.run(
[sys.executable, "scripts/verify.py", "--emulator", "handy", "--verbose"],
cwd=ROOT, capture_output=True, text=True, check=True,
).stdout.splitlines()
for line in sample.splitlines():
with self.subTest(line=line):
self.assertIn(line, printed)
class ShortMd5Handling(unittest.TestCase):
"""Three pages said some Batocera entries carry 29-character MD5s and show
as untested. No platform YAML holds one, since the Batocera scraper
completes them, and adding-a-scraper.md said to store them as-is."""
def test_no_page_claims_short_md5_entries(self):
offenders = [
page.name for page in PUBLISHED
if re.search(r"(?i)29[- ]char|truncated md5 hashes in some entries|store the hash as-is",
page.read_text(encoding="utf-8"))
]
self.assertEqual(offenders, [])
def test_the_page_names_what_the_code_does(self):
import common
from scraper import batocera_scraper
scraper = inspect.getsource(batocera_scraper)
self.assertIn("_resolve_truncated_md5(value, md5_index) for value in values", scraper)
resolver = inspect.getsource(common.resolve_local_file)
self.assertIn("if len(md5_candidate) < 32:", resolver)
self.assertIn("_md5_name_ok(path)", resolver)
section = _section(_page("verification-modes.md"), "MD5 shorter than 32 characters (Batocera)")
self.assertIn("_resolve_truncated_md5", section)
class RegistryKeysDocumented(unittest.TestCase):
"""zip_digest, firmware_folder_flat and extract_to were read from the
registry by the resolver, the pack and the README, and the page that
adds a platform named none of them while saying runtime behaviour lives
in the platform YAML only."""
def test_every_registry_key_is_on_the_page(self):
import yaml
registry = yaml.safe_load((ROOT / "platforms" / "_registry.yml").read_text(encoding="utf-8"))
keys = {key for entry in registry["platforms"].values() for key in entry}
page = _page("adding-a-platform.md")
self.assertEqual(sorted(k for k in keys if not re.search(rf"\b{k}\b", page)), [])
def test_the_page_says_the_registry_reaches_the_config(self):
import common
source = inspect.getsource(common.load_platform_config)
self.assertIn("config[key] = val", source)
section = _section(_page("adding-a-platform.md"), "Step 2: Register the platform")
self.assertIn("copies every registry key", section)
self.assertNotIn("belongs to the platform\nYAML", section)
class ResolutionSkipsNamesForUnheldFiles(unittest.TestCase):
"""The resolution table promised name_exact to any entry without a hash,
while an unsourceable entry, or one whose indexed path is absent from
disk, skips every step that answers by name and returns not_found."""
def test_the_page_names_both_cases_the_code_guards(self):
import common
source = inspect.getsource(common.resolve_local_file)
self.assertIn('unsourceable = bool(file_entry.get("unsourceable")) or own_file_absent', source)
for guard in (
"if not has_strong_hash and not unsourceable:",
"elif not unsourceable:",
"if _depth < 3 and not has_strong_hash and not unsourceable:",
"if data_dir_registry and (has_strong_hash or not unsourceable):",
):
self.assertIn(guard, source)
section = _section(_page("verification-modes.md"), "File resolution chain")
self.assertIn("`unsourceable:`", section)
self.assertIn("restore_large_files.py", section)
class CiTriggersPointToTheirLists(unittest.TestCase):
"""The CI table listed six directories for validate.yml, which also runs
on the installer, wiki/ and every other site input: a contributor
touching only wiki/ read that no check would run."""
def test_the_table_does_not_copy_a_path_list(self):
import yaml
section = _section(_page("architecture.md"), "CI workflows")
self.assertNotRegex(section, r"on bios/, platforms/|\(platforms, emulators, wiki, scripts\)")
self.assertIn("`paths:`", section)
self.assertIn("test_workflow_paths.py", section)
workflow = yaml.safe_load((ROOT / ".github/workflows/validate.yml").read_text(encoding="utf-8"))
paths = workflow.get("on", workflow.get(True))["pull_request"]["paths"]
for named in ("wiki/**", "install.sh"):
self.assertIn(named, paths)
self.assertIn(f"`{named.rstrip('*')}`", section)
class ScratchFilesGoToRepoTmp(unittest.TestCase):
"""adding-a-scraper.md wrote its comparison to /tmp while two other
pages say /tmp is a RAM disk and send scratch files to the repo's tmp/."""
@staticmethod
def _code_lines(text: str) -> list[str]:
lines, inside = [], False
for line in text.splitlines():
if line.strip().startswith("```"):
inside = not inside
elif inside:
lines.append(line)
return lines
def test_no_command_writes_to_slash_tmp(self):
offenders = [
f"{page.name}: {line.strip()}"
for page in PUBLISHED
for line in self._code_lines(page.read_text(encoding="utf-8"))
if re.search(r"(?<![\w.])/tmp/", line)
]
self.assertEqual(offenders, [])
old = " ```bash\n python x.py --output /tmp/test.yml\n ```\n"
self.assertEqual(self._code_lines(old), [" python x.py --output /tmp/test.yml"])
self.assertIn("tmp/", (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines())
class ReadmeInstallerOptions(unittest.TestCase):
"""The README named --system, --region and --check but showed options
only after `sh -s --`. Appended to `irm ... | iex`, PowerShell rejects
them, and the form that works was on the installer page alone, which the
README never linked."""
def test_quick_install_gives_the_powershell_form_and_the_page(self):
import generate_readme
source = inspect.getsource(generate_readme.generate_readme)
self.assertIn("&([scriptblock]::Create((irm https://raw.githubusercontent.com/Abdess/retrobios/main/install.ps1)))", source)
self.assertIn("wiki/installer/#passing-options", source)
self.assertIn("## Passing options", _page("installer.md"))
readme = (ROOT / "README.md").read_text(encoding="utf-8")
quick = _section(readme, "Quick install")
self.assertIn("[scriptblock]::Create", quick)
self.assertIn("wiki/installer/#passing-options", quick)
self.assertNotRegex(readme, r"(?m)^irm \S+ \| iex \S")
class PublishedNumbersShareOneFormat(unittest.TestCase):
"""The README gave the collection as `16011 MB` beside `19,526 files`,
the site home as 15.6 GB, and the emulators page `10095 files`."""
def test_generators_format_counts_and_sizes_alike(self):
import generate_readme
import generate_site
readme_source = inspect.getsource(generate_readme.generate_readme)
self.assertNotIn("size_mb", readme_source)
self.assertIn("format_size(total_size)", readme_source)
self.assertIn("{total_files:,}", inspect.getsource(generate_site))
self.assertEqual(generate_readme.format_size(16011 * 1024 * 1024), "15.6 GB")
def test_the_readme_has_no_bare_large_count(self):
readme = (ROOT / "README.md").read_text(encoding="utf-8")
self.assertEqual(re.findall(r"\*\*\d{4,}\b[^*]*\*\*", readme), [])
class FirstInstallWalkthrough(unittest.TestCase):
"""First install showed a Batocera run of 12893 files with no omission
line and said any other ending meant work left undone, while every
Batocera run printed 399 files not installed. Its disk sizes (Batocera
4.0 GB) contradicted its own example (8.6 GB), and its no-platform case
said a piped command cannot ask, where install.sh hands it the terminal
and the installer shows a menu. The annotated run now lives in the
installer page, under Reading a run: First install keeps the steps."""
def setUp(self):
self.text = _page("first-install.md")
self.run = _page("installer.md")
def test_no_hand_kept_size_before_the_run(self):
import install
section = _section(self.run, "Before the run")
# The confirmation threshold is a constant of the installer, not a pack size.
self.assertEqual(install.CONFIRM_FROM_BYTES, 1024 ** 3)
self.assertIn("reaches 1 GB", section)
self.assertNotRegex(section.replace("reaches 1 GB", ""), r"\d+(\.\d+)? ?[KMG]B\b")
def test_the_run_is_dated_and_consistent_with_the_manifest(self):
import install
self.assertRegex(_section(self.run, "Reading a run { #reading-a-run }"),
r"published on\s+\d{4}-\d{2}-\d{2}")
step3 = _section(self.run, "The file index and the download")
step4 = _section(self.run, "The end of the run")
step5 = _section(self.run, "A later check")
# First install names the three endings a run prints, and no count:
# a figure from one setup read as the size of every install.
source = (ROOT / "install.py").read_text(encoding="utf-8")
for ending in ("Done.", "Finished with errors.", "Nothing was installed."):
with self.subTest(ending=ending):
self.assertIn(f"`{ending}`", _section(self.text, "5. Read the last line"))
self.assertIn(f'"\\n{ending} ', source)
self.assertNotRegex(self.text, r"Done\. \d+ files")
count, size = re.search(r"\n (\d+) files \(([\d.]+ [KMG]B)\)", step3).groups()
self.assertIn(f"0/{count} present", step3)
self.assertIn(f"Downloading {count} files ({size})", step3)
self.assertIn(f"Done. {count} files installed", step4)
self.assertIn(f"{count}/{count} present ({count} verified", step5)
manifest = json.loads((ROOT / "install" / "batocera.json").read_text(encoding="utf-8"))
omitted = re.search(r"Not installable: (\d+) files \((\d+) required", step3)
if manifest.get("omitted_files"):
self.assertIsNotNone(omitted, "the manifest omits files and the run shows no line for it")
if omitted:
total, required = map(int, omitted.groups())
first, needed = install._omission_lines(
total, [{"required": True, "dest": str(i)} for i in range(required)]
)[:2]
self.assertIn(first, step4)
self.assertIn(needed.split(":")[0], step4)
self.assertIn(f"all {required} belong", step4)
self.assertIn(f"other {total - required} are optional", step4)
def test_no_platform_detected_reads_like_the_installer(self):
source = (ROOT / "install.py").read_text(encoding="utf-8")
branch = source.split('print(" No supported platform detected.")', 1)[1][:1600]
self.assertIn("if not interactive or listing:", branch)
self.assertIn("_prompt_manual_platform(os_type)", branch)
self.assertIn('"$INSTALLER" "$@" </dev/tty', (ROOT / "install.sh").read_text(encoding="utf-8"))
section = _section(self.text, "No supported platform detected.")
self.assertIn("a menu follows", section)
self.assertIn("--no-input", section)
self.assertNotIn("cannot stop and ask", section)
class RetroArchWindowsSystemFolder(unittest.TestCase):
"""Getting started gave %APPDATA%\\RetroArch\\system for the Windows
installer. RetroArch's default is `:\\system`, the folder of
retroarch.exe (platform_win32.c). %APPDATA% is where a retroarch.cfg
can live, and install.py reads it there. The RetroArch page of the site
states the folder now, from the guide data."""
def test_page_and_installer_agree(self):
import install
source = inspect.getsource(install)
self.assertIn('ra_dir or exe_dir / "system"', source)
self.assertTrue(str(install._os_default_dests("windows")["retroarch"]).replace("\\", "/").endswith("/system"))
from siteguides import GUIDES
guide = GUIDES["retroarch"]
section = "\n".join([*(path for _label, path in guide.folders), *guide.notes])
self.assertNotIn("%APPDATA%\\RetroArch\\system", section)
self.assertIn("`system\\` next to `retroarch.exe`", section)
def test_the_macos_folder_is_the_installers(self):
"""The site gave Application Support/RetroArch/system for macOS. A
RetroArch left at its defaults reads ~/Documents/RetroArch/system
(platform_darwin.m), Application Support only for a portable or
Steam build, and install.py looks there in that order."""
import install
from siteguides import GUIDES
source = inspect.getsource(install._macos_retroarch_base)
self.assertIn('Path.home() / "Documents" / "RetroArch"', source)
folders = dict(GUIDES["retroarch"].folders)
self.assertEqual(folders["macOS"], "`~/Documents/RetroArch/system/`")
if __name__ == "__main__":
unittest.main()