mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
_enrich_hashes repeated "ground truth" five times with capitalised insistence for a three-step lookup, and the MAME ZIP rebuild listed three guarantees of which two were the same.
421 lines
18 KiB
Python
421 lines
18 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"
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
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)}
|
|
self.assertEqual(documented, raised | {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)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|