mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
chore: merge round 2 fixes (tests)
# Conflicts: # install.ps1 # install.py # install.sh
This commit is contained in:
commit
ce1f165d2f
35 files changed
+618
-163
No files matched your search
@@ -4,10 +4,19 @@ The CI job sets RETROBIOS_OFFLINE, but a local run of the documented command
|
||||
did not, and e2e fixtures queried the large-files release and wrote its
|
||||
answers into the shared .cache/large. A test that serves its own assets
|
||||
clears the variable for itself.
|
||||
|
||||
Scratch files go under the repository's tmp/, which git does not track: a
|
||||
fresh clone has none, and a module run alone raised FileNotFoundError on a
|
||||
temporary directory instead of naming the missing parent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("RETROBIOS_OFFLINE", "1")
|
||||
|
||||
(Path(__file__).resolve().parent.parent / "tmp" / "tests").mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
@@ -110,11 +110,12 @@ class ReadmeRegressions(unittest.TestCase):
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("needs Python 3.9", proc.stderr)
|
||||
|
||||
def test_the_floor_interpreter_compiles_every_script(self):
|
||||
"""The floor said 3.12 for a reason the code no longer carried: every
|
||||
script compiled under 3.9, and only the guard refused it. Checked
|
||||
with a real interpreter of the floor version, as the installer's 3.8
|
||||
floor is."""
|
||||
def test_the_floor_interpreter_imports_every_script(self):
|
||||
"""Every module under scripts/ is loaded by a real interpreter of the
|
||||
floor version, as the installer's 3.8 floor is proven by running it.
|
||||
Compiling only checks the grammar: a module-level alias such as
|
||||
`tuple[int, int | None]` compiles under 3.9 and raises TypeError
|
||||
when the module loads."""
|
||||
import shutil # noqa: PLC0415
|
||||
|
||||
floor = re.search(
|
||||
@@ -128,21 +129,62 @@ class ReadmeRegressions(unittest.TestCase):
|
||||
self.fail(f"CI must provide {name}")
|
||||
self.skipTest(f"no {name} on PATH")
|
||||
check = (
|
||||
"import pathlib, sys\n"
|
||||
"bad = []\n"
|
||||
"import importlib, json, pathlib, sys\n"
|
||||
"sys.path.insert(0, '.')\n"
|
||||
"failed = {}\n"
|
||||
"for p in sorted(pathlib.Path('scripts').rglob('*.py')):\n"
|
||||
" if '__pycache__' in p.parts:\n"
|
||||
" continue\n"
|
||||
" parts = p.with_suffix('').parts\n"
|
||||
" if parts[-1] == '__init__':\n"
|
||||
" parts = parts[:-1]\n"
|
||||
" try:\n"
|
||||
" compile(p.read_text(encoding='utf-8'), str(p), 'exec')\n"
|
||||
" except SyntaxError as e:\n"
|
||||
" bad.append(f'{p}:{e.lineno}')\n"
|
||||
"print(bad)\n"
|
||||
"sys.exit(1 if bad else 0)\n"
|
||||
" importlib.import_module('.'.join(parts))\n"
|
||||
" except BaseException as e:\n"
|
||||
" failed[str(p)] = [type(e).__name__, getattr(e, 'name', None), str(e)]\n"
|
||||
"print(json.dumps(failed))\n"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[interpreter, "-I", "-c", check], cwd=ROOT, capture_output=True,
|
||||
text=True, timeout=300, check=False,
|
||||
text=True, timeout=600, check=False,
|
||||
env={**os.environ, "RETROBIOS_OFFLINE": "1"},
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
failed = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
# pyyaml for every build script, jsonschema for validate_schemas:
|
||||
# without them the floor interpreter cannot load the modules, which
|
||||
# proves nothing either way.
|
||||
absent = sorted({
|
||||
err[1] for err in failed.values()
|
||||
if err[0] == "ModuleNotFoundError" and err[1] in ("yaml", "jsonschema")
|
||||
})
|
||||
if absent:
|
||||
message = f"{name} lacks {', '.join(absent)}"
|
||||
if os.environ.get("CI"):
|
||||
self.fail(message)
|
||||
self.skipTest(message)
|
||||
self.assertEqual(failed, {})
|
||||
|
||||
|
||||
class AModuleRunAloneHasItsScratchDirectory(unittest.TestCase):
|
||||
"""About forty modules write under tmp/, which git does not track. Only
|
||||
three created it, as an import side effect, so a module run alone on a
|
||||
fresh clone raised FileNotFoundError. The test package creates it."""
|
||||
|
||||
def test_importing_the_package_creates_tmp(self):
|
||||
with tempfile.TemporaryDirectory(dir=TMP_ROOT) as directory:
|
||||
clone = Path(directory)
|
||||
(clone / "tests").mkdir()
|
||||
(clone / "tests" / "__init__.py").write_bytes(
|
||||
(ROOT / "tests" / "__init__.py").read_bytes()
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-I", "-c",
|
||||
"import sys; sys.path.insert(0, '.'); import tests"],
|
||||
cwd=clone, capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertTrue((clone / "tmp" / "tests").is_dir())
|
||||
|
||||
|
||||
class ReleaseSigningRegressions(unittest.TestCase):
|
||||
@@ -4864,6 +4906,8 @@ class NullReadsAsNone(unittest.TestCase):
|
||||
def test_the_rendered_site_has_no_python_none(self):
|
||||
docs = ROOT / "docs"
|
||||
if not docs.is_dir():
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI generates docs/ before the suite")
|
||||
self.skipTest("docs/ not generated")
|
||||
pattern = re.compile(r"\| None \||: None$|: None \|", re.M)
|
||||
from scripts.generate_site import LOCAL_NOTE_DIRS
|
||||
@@ -6398,9 +6442,16 @@ class CommentsSayWhy(unittest.TestCase):
|
||||
REASON = re.compile(r"\b(for|only|so|because|if|when|unless|before|after)\b")
|
||||
|
||||
def test_no_label_comment_restates_the_next_line(self):
|
||||
"""Three scripts were read, and 22 labels stood in 13 others."""
|
||||
tracked = subprocess.run(
|
||||
["git", "ls-files", "--", "scripts/*.py", "scripts/**/*.py", "install.py"],
|
||||
cwd=ROOT, capture_output=True, text=True, check=True,
|
||||
).stdout.split()
|
||||
self.assertIn("install.py", tracked)
|
||||
self.assertIn("scripts/scraper/mame_parser.py", tracked)
|
||||
found = []
|
||||
for name in ("verify.py", "generate_site.py", "generate_truth.py"):
|
||||
lines = (ROOT / "scripts" / name).read_text(encoding="utf-8").splitlines()
|
||||
for name in sorted(set(tracked)):
|
||||
lines = (ROOT / name).read_text(encoding="utf-8").splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
if not self.LABEL.match(line) or self.REASON.search(line):
|
||||
continue
|
||||
|
||||
@@ -632,5 +632,52 @@ class AProgressBarOnlyOnATerminal(unittest.TestCase):
|
||||
self.assertEqual(out.getvalue().count("\n"), 1)
|
||||
|
||||
|
||||
class TheShellDownloaderSendsTheToken(unittest.TestCase):
|
||||
"""download.py sent GITHUB_TOKEN to the API and download.sh did not, so
|
||||
past the anonymous quota the shell twin failed with 403 where the Python
|
||||
one succeeded. A curl stand-in records what each call carries."""
|
||||
|
||||
def _calls(self, **env: str) -> str:
|
||||
scratch = Path(_tempdir(self))
|
||||
log = scratch / "calls.log"
|
||||
fake = scratch / "curl"
|
||||
fake.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f'printf "%s\\n" "$*" >> "{log}"\n'
|
||||
'case "$*" in *"-H @-"*) cat >> "' + str(log) + '" ;; esac\n'
|
||||
"printf '{}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake.chmod(0o755)
|
||||
base = {k: v for k, v in os.environ.items()
|
||||
if k not in ("GITHUB_TOKEN", "RETROBIOS_API")}
|
||||
proc = subprocess.run(
|
||||
["bash", str(SHELL), "--list"],
|
||||
env={**base, "PATH": f"{scratch}{os.pathsep}{os.environ['PATH']}", **env},
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
return log.read_text(encoding="utf-8")
|
||||
|
||||
def test_the_api_call_carries_the_token(self):
|
||||
calls = self._calls(GITHUB_TOKEN="sekrit")
|
||||
self.assertIn("Authorization: Bearer sekrit", calls)
|
||||
self.assertIn("api.github.com/repos/", calls)
|
||||
|
||||
def test_the_token_stays_out_of_the_command_line(self):
|
||||
calls = self._calls(GITHUB_TOKEN="sekrit")
|
||||
command = calls.splitlines()[0]
|
||||
self.assertNotIn("sekrit", command)
|
||||
|
||||
def test_another_host_never_receives_it(self):
|
||||
calls = self._calls(
|
||||
GITHUB_TOKEN="sekrit", RETROBIOS_API="https://mirror.example"
|
||||
)
|
||||
self.assertNotIn("sekrit", calls)
|
||||
|
||||
def test_no_token_sends_no_header(self):
|
||||
self.assertNotIn("Authorization", self._calls())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -87,34 +87,60 @@ class ReadersFollowTheDataTrees(unittest.TestCase):
|
||||
"""Dolphin names codehandler.bin without a hash. The pack takes it from
|
||||
data/dolphin-sys, and the site credited Dolphin and Triforce with the
|
||||
Dolphin-MMJR copy, another revision, because the consumers resolved
|
||||
without the data trees. Reads the repository."""
|
||||
without the data trees."""
|
||||
|
||||
def test_dolphin_does_not_read_the_mmjr_copy(self):
|
||||
def setUp(self):
|
||||
import os
|
||||
|
||||
from common import load_database, load_emulator_profiles
|
||||
self._previous = os.getcwd()
|
||||
self._tree = tempfile.TemporaryDirectory()
|
||||
root = Path(self._tree.name)
|
||||
fork = root / "bios" / "Other" / "dolphin-mmjr" / "codehandler.bin"
|
||||
fork.parent.mkdir(parents=True)
|
||||
fork.write_bytes(b"dolphin-mmjr codehandler")
|
||||
own = root / "data" / "dolphin-sys" / "codehandler.bin"
|
||||
own.parent.mkdir(parents=True)
|
||||
own.write_bytes(b"dolphin codehandler")
|
||||
self.fork_sha1 = hashlib.sha1(fork.read_bytes()).hexdigest()
|
||||
self.db = {
|
||||
"files": {self.fork_sha1: {
|
||||
"name": "codehandler.bin", "sha1": self.fork_sha1,
|
||||
"path": "bios/Other/dolphin-mmjr/codehandler.bin",
|
||||
"md5": hashlib.md5(fork.read_bytes()).hexdigest(),
|
||||
"size": fork.stat().st_size,
|
||||
}},
|
||||
"indexes": {
|
||||
"by_name": {"codehandler.bin": [self.fork_sha1]},
|
||||
"by_md5": {}, "by_path_suffix": {},
|
||||
},
|
||||
}
|
||||
self.registry = {"dolphin-sys": {"local_cache": str(own.parent)}}
|
||||
self.profiles = {"dolphin": {"type": "standalone + libretro", "files": [
|
||||
{"name": "codehandler.bin", "path": None, "mode": "libretro"},
|
||||
]}}
|
||||
os.chdir(root)
|
||||
|
||||
if not (REPO_ROOT / "database.json").is_file() or not (
|
||||
REPO_ROOT / "data" / "dolphin-sys" / "codehandler.bin"
|
||||
).is_file():
|
||||
self.skipTest("database.json or data/dolphin-sys is absent")
|
||||
previous = os.getcwd()
|
||||
os.chdir(REPO_ROOT)
|
||||
try:
|
||||
db = load_database("database.json")
|
||||
profiles = load_emulator_profiles("emulators")
|
||||
consumers = FileConsumers(db)
|
||||
consumers.add_emulators({"dolphin": profiles["dolphin"]})
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
mmjr = next(
|
||||
(sha1 for sha1, entry in db["files"].items()
|
||||
if entry["path"] == "bios/Other/dolphin-mmjr/codehandler.bin"),
|
||||
None,
|
||||
)
|
||||
if mmjr is None:
|
||||
self.skipTest("the Dolphin-MMJR copy is not collected")
|
||||
self.assertNotIn("dolphin", consumers.emulators.get(mmjr, set()))
|
||||
def tearDown(self):
|
||||
import os
|
||||
|
||||
from common import forget_data_dir_index
|
||||
|
||||
os.chdir(self._previous)
|
||||
forget_data_dir_index()
|
||||
self._tree.cleanup()
|
||||
|
||||
def _readers(self, registry: dict) -> set[str]:
|
||||
consumers = FileConsumers(self.db, data_dir_registry=registry)
|
||||
consumers.add_emulators(self.profiles)
|
||||
return consumers.emulators.get(self.fork_sha1, set())
|
||||
|
||||
def test_dolphin_does_not_read_the_mmjr_copy(self):
|
||||
self.assertNotIn("dolphin", self._readers(self.registry))
|
||||
|
||||
def test_without_the_data_trees_the_name_step_takes_the_fork_copy(self):
|
||||
"""The case above has to reach the data trees to pass: resolved
|
||||
without them, the name alone hands Dolphin the fork's copy."""
|
||||
self.assertIn("dolphin", self._readers({}))
|
||||
|
||||
|
||||
def tearDownModule():
|
||||
|
||||
@@ -9,7 +9,10 @@ armsx2's pack ships without a reader, and MAME's neogeo.zip without MAME.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -30,7 +33,7 @@ class ReadersNameTheOwner(unittest.TestCase):
|
||||
def setUpClass(cls):
|
||||
db_path = REPO_ROOT / "database.json"
|
||||
if not db_path.exists():
|
||||
raise unittest.SkipTest("database.json is not built")
|
||||
raise AssertionError("database.json is committed and missing")
|
||||
cls.db = load_database(str(db_path))
|
||||
cls.profiles = load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
||||
cls.sha1_by_path = {r["path"]: s for s, r in cls.db["files"].items()}
|
||||
@@ -40,15 +43,9 @@ class ReadersNameTheOwner(unittest.TestCase):
|
||||
self.db, build_zip_contents_index(self.db)
|
||||
)
|
||||
consumers.add_emulators(self.profiles)
|
||||
for path, owner in (
|
||||
("bios/Other/armsx2/game_controller_db.txt", "armsx2"),
|
||||
("bios/Arcade/MAME/neogeo.zip", "mame"),
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
sha1 = self.sha1_by_path.get(path)
|
||||
if sha1 is None:
|
||||
self.skipTest(f"{path} is not collected")
|
||||
self.assertIn(owner, consumers.emulators.get(sha1, set()))
|
||||
sha1 = self.sha1_by_path.get("bios/Arcade/MAME/neogeo.zip")
|
||||
self.assertIsNotNone(sha1, "MAME's neogeo.zip is not collected")
|
||||
self.assertIn("mame", consumers.emulators.get(sha1, set()))
|
||||
|
||||
def test_slot_claims_resolve_the_owners_tree(self):
|
||||
claims = slots.profile_claims({"pcem": self.profiles["pcem"]}, self.db)
|
||||
@@ -57,12 +54,60 @@ class ReadersNameTheOwner(unittest.TestCase):
|
||||
}
|
||||
# pcem declares ide_xt.bin and the collection holds it: an unclaimed
|
||||
# file here is a claiming defect, not missing data.
|
||||
if not any(e.get("path", "").endswith("/ide_xt.bin") for e in self.db["files"].values()):
|
||||
self.skipTest("ide_xt.bin is not collected")
|
||||
self.assertTrue(
|
||||
any(e.get("path", "").endswith("/ide_xt.bin")
|
||||
for e in self.db["files"].values()),
|
||||
"ide_xt.bin is not collected",
|
||||
)
|
||||
local = held.get("ide_xt.bin")
|
||||
self.assertIsNotNone(local, "pcem's ide_xt.bin is not claimed")
|
||||
self.assertTrue(local.startswith("bios/Other/pcem/"), local)
|
||||
|
||||
|
||||
class EachForkReadsItsOwnCopy(unittest.TestCase):
|
||||
"""Two emulators ship a file under one name in their own trees. Without
|
||||
the owner, the site credited aethersx2's game_controller_db.txt to
|
||||
armsx2 and left the copy armsx2's pack ships without a reader."""
|
||||
|
||||
NAME = "game_controller_db.txt"
|
||||
|
||||
def setUp(self):
|
||||
self._previous = os.getcwd()
|
||||
self._tree = tempfile.TemporaryDirectory()
|
||||
root = Path(self._tree.name)
|
||||
files: dict[str, dict] = {}
|
||||
self.sha1_of: dict[str, str] = {}
|
||||
for owner in ("aethersx2", "armsx2"):
|
||||
rel = f"bios/Other/{owner}/{self.NAME}"
|
||||
payload = f"{owner} mappings".encode()
|
||||
(root / rel).parent.mkdir(parents=True)
|
||||
(root / rel).write_bytes(payload)
|
||||
sha1 = hashlib.sha1(payload).hexdigest()
|
||||
files[sha1] = {
|
||||
"name": self.NAME, "sha1": sha1, "path": rel,
|
||||
"md5": hashlib.md5(payload).hexdigest(), "size": len(payload),
|
||||
}
|
||||
self.sha1_of[owner] = sha1
|
||||
self.db = {"files": files, "indexes": {
|
||||
"by_name": {self.NAME: sorted(files)},
|
||||
"by_md5": {}, "by_path_suffix": {},
|
||||
}}
|
||||
os.chdir(root)
|
||||
|
||||
def tearDown(self):
|
||||
os.chdir(self._previous)
|
||||
self._tree.cleanup()
|
||||
|
||||
def test_the_site_credits_each_owner_with_its_copy(self):
|
||||
consumers = cross_reference.FileConsumers(self.db, data_dir_registry={})
|
||||
consumers.add_emulators({
|
||||
owner: {"type": "standalone", "files": [{"name": self.NAME}]}
|
||||
for owner in self.sha1_of
|
||||
})
|
||||
for owner, sha1 in self.sha1_of.items():
|
||||
with self.subTest(owner=owner):
|
||||
self.assertEqual(consumers.emulators.get(sha1), {owner})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+47
-21
@@ -2,14 +2,14 @@
|
||||
|
||||
Em-dashes, clause-joining semicolons, unicode arrows, the ellipsis character,
|
||||
box-drawing banners and check-mark emoji had spread through profiles, docs,
|
||||
comments and command output. None of them is wrong, but together they gave the
|
||||
project a generated look its maintainer did not want. This module names every
|
||||
place one comes back.
|
||||
comments and command output. None of them is wrong, but the maintainer does
|
||||
not write them. This module names every place one comes back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tokenize
|
||||
@@ -174,38 +174,58 @@ class NoMarketingVocabulary(unittest.TestCase):
|
||||
self.assertEqual(offenders, [], "\n".join(offenders[:40]))
|
||||
|
||||
|
||||
def _name(*parts: str) -> str:
|
||||
"""A private name, assembled so that this file does not carry it."""
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
_NOTES = _name("cla", "ude")
|
||||
_VENDOR = _name("anth", "ropic")
|
||||
_MD_STEMS = (_name("pro", "mpt"), _name("age", "nts"), _name("au", "dit"))
|
||||
_DOT_DIRS = (
|
||||
_NOTES, _name("co", "dex"), _name("cur", "sor"), _name("conti", "nue"),
|
||||
_name("copi", "lot"), _name("gem", "ini"), _name("wind", "surf"),
|
||||
_name("ser", "ena"), _name("playwright", "-mcp"),
|
||||
)
|
||||
_AIDER = _name("ai", "der")
|
||||
|
||||
|
||||
class NoToolingNames(unittest.TestCase):
|
||||
"""Nothing committed names the private notes or the agent tooling the
|
||||
maintainer works with. Two test docstrings did, and history keeps them."""
|
||||
"""Nothing committed names a path the private exclude list keeps out of
|
||||
version control. Two test docstrings did, and history keeps them. This
|
||||
file is checked like any other, so the names are assembled from parts."""
|
||||
|
||||
# The names the repository keeps out of version control through its
|
||||
# private exclude list, plus the assistant itself. `claude\w*` covers
|
||||
# claudedocs/.
|
||||
NAMES = re.compile(
|
||||
r"(?i)\b(claude\w*|anthropic)\b|\b(prompt|agents|audit)\.md\b"
|
||||
r"|\.(claude|codex|cursor|continue|copilot|gemini|windsurf|serena|playwright-mcp)/"
|
||||
r"|\.aider"
|
||||
rf"(?i)\b({_NOTES}\w*|{_VENDOR})\b|\b({'|'.join(_MD_STEMS)})\.md\b"
|
||||
rf"|\.({'|'.join(map(re.escape, _DOT_DIRS))})/|\.{_AIDER}"
|
||||
)
|
||||
|
||||
def test_the_pattern_covers_every_private_name(self):
|
||||
"""The pattern caught CLAUDE.md and missed AGENTS.md, AUDIT.md and
|
||||
claudedocs/, which the same rule keeps out of the repository."""
|
||||
"""The pattern caught the notes file and missed the other two .md
|
||||
files and the notes directory the same exclude list holds."""
|
||||
for text in (
|
||||
"see CLAUDE.md", "rules live in AGENTS.md", "AUDIT.md angle G",
|
||||
"notes in claudedocs/launchers", "prompt.md", "under .claude/hooks",
|
||||
"a .cursor/ rule", "the .serena/ cache", ".aider.conf.yml",
|
||||
f"see {_NOTES.upper()}.md",
|
||||
f"rules live in {_MD_STEMS[1].upper()}.md",
|
||||
f"{_MD_STEMS[2].upper()}.md angle G",
|
||||
f"notes in {_NOTES}docs/launchers",
|
||||
f"{_MD_STEMS[0]}.md",
|
||||
f"under .{_NOTES}/hooks",
|
||||
f"a .{_DOT_DIRS[2]}/ rule",
|
||||
f"the .{_DOT_DIRS[7]}/ cache",
|
||||
f".{_AIDER}.conf.yml",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
self.assertRegex(text, self.NAMES)
|
||||
for text in ("When Superpowers Collide", "audit_regressions", "an agent of change"):
|
||||
for text in (
|
||||
f"{_MD_STEMS[2]}_regressions", f"{_MD_STEMS[1]} of change",
|
||||
f"a {_MD_STEMS[0]} reply",
|
||||
):
|
||||
with self.subTest(text=text):
|
||||
self.assertNotRegex(text, self.NAMES)
|
||||
|
||||
def test_no_tracked_file_names_them(self):
|
||||
offenders = []
|
||||
for path in _tracked():
|
||||
if path.name == "test_prose_style.py":
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
for number, line in enumerate(text.splitlines(), 1):
|
||||
if self.NAMES.search(line):
|
||||
@@ -331,13 +351,19 @@ class GeneratedSiteProse(unittest.TestCase):
|
||||
"""The site pages carry strings no tracked file shows verbatim."""
|
||||
|
||||
def test_generated_pages(self):
|
||||
import sys # noqa: PLC0415
|
||||
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from generate_site import LOCAL_NOTE_DIRS # noqa: PLC0415
|
||||
|
||||
docs = ROOT / "docs"
|
||||
pages = [
|
||||
p for p in docs.rglob("*.md")
|
||||
if "superpowers" not in p.parts and "plans" not in p.parts
|
||||
and "specs" not in p.parts
|
||||
if not set(p.relative_to(docs).parts[:-1]) & set(LOCAL_NOTE_DIRS)
|
||||
] if docs.is_dir() else []
|
||||
if not pages:
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI generates docs/ before the suite")
|
||||
self.skipTest("docs/ not generated (python scripts/generate_site.py)")
|
||||
offenders = []
|
||||
for page in pages:
|
||||
|
||||
@@ -30,8 +30,15 @@ class GitThatCannotAnswer(unittest.TestCase):
|
||||
# TMPDIR may lie inside this repository (the audit gate puts it in
|
||||
# tmp/): git must not find the enclosing work tree from there.
|
||||
env = dict(os.environ, GIT_CEILING_DIRECTORIES=str(Path(tmp).parent))
|
||||
# The script moves to its own checkout first: kept here, it stands
|
||||
# for a copy of scripts/ that no repository holds.
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "scripts" / "validate_pr.py"), "--changed"],
|
||||
[sys.executable, "-c", (
|
||||
f"import sys; sys.path.insert(0, {str(REPO_ROOT / 'scripts')!r}); "
|
||||
"import verify; verify.work_from_repo_root = lambda *a: None; "
|
||||
"import validate_pr; sys.argv = ['validate_pr.py', '--changed']; "
|
||||
"validate_pr.main()"
|
||||
)],
|
||||
cwd=tmp, env=env, capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 2, proc.stdout + proc.stderr)
|
||||
@@ -80,6 +87,51 @@ class ChangesNotYetCommittedAreRead(unittest.TestCase):
|
||||
self.assertIn("bios/new.bin", proc.stdout)
|
||||
|
||||
|
||||
class AForkPushedToItsOwnMain(unittest.TestCase):
|
||||
"""A fork's clone has origin alone. Once the commit was pushed to the
|
||||
fork's main, origin/main was HEAD, the diff was empty, and --changed
|
||||
said "No changed BIOS files detected" with rc 0."""
|
||||
|
||||
GIT = ["git", "-c", "user.email=a@b", "-c", "user.name=a",
|
||||
"-c", "init.defaultBranch=main"]
|
||||
|
||||
def _git(self, *args: str, cwd: str | None = None) -> None:
|
||||
subprocess.run([*self.GIT, *args], cwd=cwd, check=True, capture_output=True)
|
||||
|
||||
def _changed(self, clone: str, project: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", (
|
||||
f"import sys; sys.path.insert(0, {str(REPO_ROOT / 'scripts')!r}); "
|
||||
"import validate_pr; "
|
||||
f"validate_pr.PROJECT_URLS = ({project!r},); "
|
||||
"print(validate_pr.get_changed_files())"
|
||||
)],
|
||||
cwd=clone, capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
|
||||
def test_the_project_main_is_fetched_and_compared(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
project, fork, clone = (str(Path(tmp, n)) for n in ("project", "fork", "clone"))
|
||||
self._git("init", "-q", project)
|
||||
Path(project, "README").write_text("x")
|
||||
self._git("add", "README", cwd=project)
|
||||
self._git("commit", "-qm", "init", cwd=project)
|
||||
self._git("clone", "-q", "--bare", project, fork)
|
||||
self._git("clone", "-q", fork, clone)
|
||||
Path(clone, "bios").mkdir()
|
||||
Path(clone, "bios", "new.bin").write_bytes(b"x")
|
||||
self._git("add", "bios/new.bin", cwd=clone)
|
||||
self._git("commit", "-qm", "add", cwd=clone)
|
||||
self._git("push", "-q", "origin", "main", cwd=clone)
|
||||
proc = self._changed(clone, project)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
self.assertIn("bios/new.bin", proc.stdout)
|
||||
# A project that cannot be reached is an error, never "nothing".
|
||||
missing = self._changed(clone, str(Path(tmp, "absent")))
|
||||
self.assertNotEqual(missing.returncode, 0)
|
||||
self.assertIn("fetching its main failed", missing.stderr)
|
||||
|
||||
|
||||
class RefreshFailuresAreFailures(unittest.TestCase):
|
||||
def test_a_failed_refresh_is_returned_and_exits_nonzero(self):
|
||||
report = {"entries": [{"status": "UPDATED", "key": "dolphin-sys"},
|
||||
|
||||
@@ -657,10 +657,11 @@ class EveryGitHubApiCallerSendsTheToken(unittest.TestCase):
|
||||
refresh_stale had put a token in their environment."""
|
||||
|
||||
def test_each_module_naming_the_api_reads_the_token(self):
|
||||
# download.sh is download.py's twin and called the API bare.
|
||||
scripts = Path(__file__).resolve().parent.parent / "scripts"
|
||||
silent = sorted(
|
||||
str(path.relative_to(scripts))
|
||||
for path in scripts.rglob("*.py")
|
||||
for path in [*scripts.rglob("*.py"), *scripts.rglob("*.sh")]
|
||||
if "api.github.com" in (source := path.read_text(encoding="utf-8"))
|
||||
and "GITHUB_TOKEN" not in source
|
||||
and "github_headers(" not in source
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -216,8 +217,10 @@ class EveryTestDependencyIsInstalledInCi(unittest.TestCase):
|
||||
}
|
||||
workflow = (root / ".github" / "workflows" / "validate.yml").read_text()
|
||||
job = workflow[workflow.index("run-tests:"):]
|
||||
# The suite runs on the default python: a line that installs into
|
||||
# another interpreter (python3.9 -m pip) does not count.
|
||||
install_line = next(
|
||||
line for line in job.splitlines() if "pip install" in line
|
||||
line for line in job.splitlines() if "run: pip install" in line
|
||||
)
|
||||
missing = sorted(
|
||||
m for m in third_party if self.PACKAGES.get(m, m) not in install_line
|
||||
@@ -289,6 +292,8 @@ class PlatformPagesPrintTheDeclaredCrc32(unittest.TestCase):
|
||||
def test_the_rendered_page(self):
|
||||
page = ROOT / "docs" / "platforms" / "retroarch.md"
|
||||
if not page.is_file():
|
||||
if os.environ.get("CI"):
|
||||
self.fail("CI generates docs/ before the suite")
|
||||
self.skipTest("site not generated")
|
||||
self.assertIn("CRC32: `91e93107`", page.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
+15
-20
@@ -153,8 +153,7 @@ class TestRepoProfiles(unittest.TestCase):
|
||||
os.path.join(os.path.dirname(__file__), "..", "emulators", "*.yml")
|
||||
)
|
||||
)
|
||||
if not self.paths:
|
||||
self.skipTest("emulators/ not present")
|
||||
self.assertTrue(self.paths, "emulators/ holds no profile")
|
||||
self.profiles = {}
|
||||
for path in self.paths:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
@@ -176,19 +175,19 @@ class TestRepoProfiles(unittest.TestCase):
|
||||
number, so on its own scale the US slot resolves to it. The merged
|
||||
index is a different story: pcsx1 disagrees, and the conflict test
|
||||
below asserts that neither order wins there."""
|
||||
index = slot.build_slot_index(
|
||||
{"duckstation": self.profiles["duckstation"]}
|
||||
)
|
||||
ds = self.profiles.get("duckstation")
|
||||
if not ds:
|
||||
self.skipTest("duckstation profile not present")
|
||||
ds = self.profiles["duckstation"]
|
||||
index = slot.build_slot_index({"duckstation": ds})
|
||||
# Membership, not equality: an image DuckStation selects for several
|
||||
# territories lists north-america beside the others.
|
||||
members = [
|
||||
(f["name"], f["name"])
|
||||
for f in ds["files"]
|
||||
if f.get("region") == ["north-america"] and f.get("priority") is not None
|
||||
if "north-america" in (f.get("region") or [])
|
||||
and f.get("priority") is not None
|
||||
]
|
||||
if len(members) < 2:
|
||||
self.skipTest("no US candidate set to decide")
|
||||
self.assertGreaterEqual(
|
||||
len(members), 2, "DuckStation ranks no US candidates any more"
|
||||
)
|
||||
drops, undecidable = slot.resolve_slot_drops(
|
||||
{"sony-playstation": members}, index
|
||||
)
|
||||
@@ -201,9 +200,7 @@ class TestRepoProfiles(unittest.TestCase):
|
||||
serves both cores, so neither order may drop the other's first pick."""
|
||||
index = slot.build_slot_index(self.profiles)
|
||||
for name in ("scph1001.bin", "scph5501.bin"):
|
||||
entry = index.get(name)
|
||||
if entry is None:
|
||||
self.skipTest(f"{name} not in any profile")
|
||||
self.assertIn(name, index, f"{name} is in no profile")
|
||||
# pcsx1 and DuckStation both rank these and disagree: losing the mark
|
||||
# is the failure this test exists for, not a reason to skip it.
|
||||
self.assertTrue(index["scph1001.bin"].get("conflict"))
|
||||
@@ -234,8 +231,7 @@ class TestRepoProfiles(unittest.TestCase):
|
||||
"""numero is alone on ti-83 and ranks all three ROMs, so this is the
|
||||
one slot the repo can currently decide end to end."""
|
||||
index = slot.build_slot_index(self.profiles)
|
||||
if "ti83se.rom" not in index:
|
||||
self.skipTest("numero profile not present")
|
||||
self.assertIn("ti83se.rom", index, "numero no longer ranks ti83se.rom")
|
||||
drops, undecidable = slot.resolve_slot_drops(
|
||||
{"ti-83": _pairs("ti83se.rom", "ti83plus.rom", "ti83.rom")}, index
|
||||
)
|
||||
@@ -245,14 +241,13 @@ class TestRepoProfiles(unittest.TestCase):
|
||||
def test_ties_leave_the_group_untouched(self):
|
||||
"""Japanese PlayStation ties at 5, so nothing may be dropped there."""
|
||||
index = slot.build_slot_index(self.profiles)
|
||||
ds = self.profiles.get("duckstation")
|
||||
if not ds:
|
||||
self.skipTest("duckstation profile not present")
|
||||
ds = self.profiles["duckstation"]
|
||||
members = [
|
||||
(f["name"], f["name"])
|
||||
for f in ds["files"]
|
||||
if f.get("region") == ["japan"] and f.get("priority") is not None
|
||||
if "japan" in (f.get("region") or []) and f.get("priority") is not None
|
||||
]
|
||||
self.assertGreaterEqual(len(members), 2)
|
||||
drops, undecidable = slot.resolve_slot_drops(
|
||||
{"sony-playstation": members}, index
|
||||
)
|
||||
|
||||
@@ -99,6 +99,35 @@ class AFileOnlyACoreReadsIsKnown(_Fixture):
|
||||
result.checks)
|
||||
self.assertFalse(any("not referenced" in message for _s, message in result.checks))
|
||||
|
||||
def test_a_missing_file_is_told_the_folder_of_its_system(self):
|
||||
"""The folder came from platform lists only: a file only a profile
|
||||
declares, absent from the collection, got the generic advice."""
|
||||
import hashlib # noqa: PLC0415
|
||||
|
||||
held, missing = b"held rom", b"missing rom"
|
||||
emulators = self.tmp / "emulators"
|
||||
emulators.mkdir()
|
||||
(emulators / "engine.yml").write_text(
|
||||
"emulator: Engine\ntype: standalone\nsystems: [x, y]\nfiles:\n"
|
||||
f" - name: \"a.bin\"\n system: x\n sha1: \"{hashlib.sha1(held).hexdigest()}\"\n"
|
||||
f" - name: \"b.bin\"\n system: x\n md5: \"{hashlib.md5(missing).hexdigest()}\"\n"
|
||||
)
|
||||
db = {"files": {hashlib.sha1(held).hexdigest(): {"path": "bios/Maker/Console/a.bin"}},
|
||||
"indexes": {"by_md5": {}, "by_sha256": {}}}
|
||||
known = validate_pr.load_profile_hashes(str(emulators), db)
|
||||
result = validate_pr.validate_file(
|
||||
self._bios_file("Downloads/b.bin", missing), db, EMPTY_HASHES, known,
|
||||
)
|
||||
self.assertIn(
|
||||
("WARN", "File is not under bios/ yet. Its system's files are in "
|
||||
"`bios/Maker/Console/`"),
|
||||
result.checks,
|
||||
)
|
||||
placed = validate_pr.validate_file(
|
||||
self._bios_file("bios/Other/Place/b.bin", missing), db, EMPTY_HASHES, known,
|
||||
)
|
||||
self.assertTrue(any("Placed under bios/Other/Place/" in m for _s, m in placed.checks))
|
||||
|
||||
|
||||
class ASizeEveryDeclarationRulesOutIsNotAVariant(_Fixture):
|
||||
"""1024 random bytes named scph5501.bin, a name every declaration sizes
|
||||
|
||||
@@ -7,7 +7,9 @@ cannot hold has to be an action the contributor can take.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -119,5 +121,30 @@ class OversizedFileAdvice(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class RunFromAnyDirectory(unittest.TestCase):
|
||||
"""database.json, platforms/ and emulators/ were read from the current
|
||||
directory. Run from elsewhere, scph5501.bin came back referenced by no
|
||||
platform and not under bios/, with rc 0."""
|
||||
|
||||
def test_the_report_is_the_one_the_root_gives(self):
|
||||
path = REPO_ROOT / "bios" / "Sony" / "PlayStation" / "scph5501.bin"
|
||||
self.assertTrue(path.is_file(), path)
|
||||
reports = {}
|
||||
with tempfile.TemporaryDirectory() as elsewhere:
|
||||
for cwd, arg in ((REPO_ROOT, "bios/Sony/PlayStation/scph5501.bin"),
|
||||
(Path(elsewhere), str(path))):
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(REPO_ROOT / "scripts" / "validate_pr.py"),
|
||||
"--json", arg],
|
||||
cwd=cwd, capture_output=True, text=True, timeout=120, check=False,
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, proc.stderr)
|
||||
reports[cwd == REPO_ROOT] = json.loads(proc.stdout)[0]["checks"]
|
||||
self.assertEqual(reports[False], reports[True])
|
||||
messages = [c["message"] for c in reports[False]]
|
||||
self.assertIn("SHA1 matches known platform requirement", messages)
|
||||
self.assertIn("Already in the database at this path", messages)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -133,6 +133,18 @@ class ThePullRequestBuildsTheSite(unittest.TestCase):
|
||||
with self.subTest(package=package):
|
||||
self.assertIn(package, runs)
|
||||
|
||||
def test_the_pages_exist_before_the_suite_reads_them(self):
|
||||
"""docs/ is gitignored. Built after the suite, the tests that read
|
||||
the rendered pages found none and skipped on every CI run."""
|
||||
steps = self._workflow("validate.yml")["jobs"]["run-tests"]["steps"]
|
||||
runs = [str(step.get("run", "")) for step in steps]
|
||||
generate = next(
|
||||
i for i, run in enumerate(runs) if "scripts/generate_site.py" in run
|
||||
)
|
||||
suite = next(i for i, run in enumerate(runs) if "unittest discover" in run)
|
||||
self.assertLess(generate, suite)
|
||||
self.assertEqual(steps[suite].get("if"), "${{ !cancelled() }}")
|
||||
|
||||
def test_every_site_input_triggers_validation(self):
|
||||
deploy = self._workflow("deploy-site.yml")
|
||||
validate = self._workflow("validate.yml")
|
||||
|
||||
Reference in new issue
Block a user