mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-18 17:33:30 -05:00
356 lines
17 KiB
Python
356 lines
17 KiB
Python
"""One system, two spellings, one pack.
|
|
|
|
stella2014 and stella2023 write atari_2600 where every other profile writes
|
|
atari-2600. --system matched the spelling exactly and named the ZIP from
|
|
it, so the two spellings built two different packs under one file name,
|
|
the second replacing the first; --from-md5 --emulator X --standalone
|
|
accepted a libretro-only core and named a standalone pack that was the
|
|
libretro pack.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import common # noqa: E402
|
|
import generate_pack as builder # noqa: E402
|
|
|
|
|
|
class SpellingsAgree(unittest.TestCase):
|
|
def test_both_spellings_select_the_same_profiles(self):
|
|
profiles = common.load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
|
dashed = common.profiles_for_systems(profiles, ["atari-2600"])
|
|
underscored = common.profiles_for_systems(profiles, ["atari_2600"])
|
|
self.assertEqual(dashed, underscored)
|
|
self.assertIn("stella2014", dashed)
|
|
self.assertIn("gopher2600", dashed)
|
|
|
|
def test_the_pack_name_normalizes_the_spelling(self):
|
|
self.assertEqual(
|
|
builder._system_display_name("atari_2600"), builder._system_display_name("atari-2600")
|
|
)
|
|
self.assertEqual(builder._system_display_name("msxturboR"), builder._system_display_name("msxturbor"))
|
|
|
|
|
|
class TheListingCountsLikeTheSelection(unittest.TestCase):
|
|
def test_one_system_is_listed_once_with_every_profile(self):
|
|
import contextlib # noqa: PLC0415
|
|
import io # noqa: PLC0415
|
|
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
common.list_system_ids(str(REPO_ROOT / "emulators"))
|
|
lines = {line.split()[0]: line for line in out.getvalue().splitlines() if line.strip()}
|
|
profiles = common.load_emulator_profiles(str(REPO_ROOT / "emulators"))
|
|
for spellings in (("3do", "panasonic-3do"), ("atari-2600", "atari_2600")):
|
|
listed = [s for s in spellings if s in lines]
|
|
self.assertEqual(len(listed), 1, f"{spellings}: one line per system")
|
|
count = len(common.profiles_for_systems(profiles, [spellings[0]]))
|
|
self.assertIn(f"({count} emulator", lines[listed[0]])
|
|
|
|
|
|
class StandaloneNeedsAStandaloneBuild(unittest.TestCase):
|
|
def test_a_custom_pack_refuses_it_for_a_libretro_core(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
Path(tmp, "emulators").mkdir()
|
|
Path(tmp, "emulators", "handy.yml").write_text(
|
|
"emulator: Handy\ntype: libretro\nsystems: [atari-lynx]\nfiles:\n - name: lynxboot.img\n"
|
|
)
|
|
common._emulator_profiles_cache.clear()
|
|
self.addCleanup(common._emulator_profiles_cache.clear)
|
|
db = {"files": {}, "indexes": {"by_md5": {}, "by_crc32": {}, "by_name": {}}}
|
|
result = builder.generate_md5_pack(
|
|
[("md5", "d8f1206299c48946e6ec5ef96d014eaa")], db, tmp, tmp,
|
|
emulator_name="handy", emulators_dir=str(Path(tmp, "emulators")), standalone=True,
|
|
)
|
|
self.assertIsNone(result)
|
|
self.assertEqual([p for p in os.listdir(tmp) if p.endswith(".zip")], [])
|
|
|
|
|
|
class ADeclinedBuildIsAFailure(unittest.TestCase):
|
|
def test_a_target_that_removes_every_requested_system_exits_nonzero(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
proc = subprocess.run(
|
|
[sys.executable, "scripts/generate_pack.py", "--platform", "retroarch",
|
|
"--system", "sony-playstation-2", "--target", "switch", "--offline",
|
|
"--output-dir", tmp],
|
|
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600, check=False,
|
|
)
|
|
self.assertNotEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertNotIn("Traceback", proc.stderr, proc.stderr)
|
|
self.assertEqual([p for p in os.listdir(tmp) if p.endswith(".zip")], [])
|
|
|
|
|
|
|
|
class AMistypedPlatformIsNamed(unittest.TestCase):
|
|
"""verify and cross_reference ended a typo on a traceback; generate_pack
|
|
ran its whole build first and then said "no pack for batocra"."""
|
|
|
|
# Every script taking --platform: slots ended on a traceback, and
|
|
# generate_truth, refresh_data_dirs and auto_fetch exited 0 having done
|
|
# nothing (or, for refresh_data_dirs, refreshed every unscoped entry).
|
|
CLIS = {
|
|
"verify.py": [],
|
|
"cross_reference.py": [],
|
|
"slots.py": [],
|
|
"diff_truth.py": [],
|
|
"auto_fetch.py": ["--dry-run"],
|
|
"refresh_data_dirs.py": ["--dry-run"],
|
|
}
|
|
|
|
def test_every_cli_names_the_platform_it_meant(self):
|
|
scratch = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp")
|
|
self.addCleanup(scratch.cleanup)
|
|
out = Path(scratch.name) / "packs"
|
|
writers = {
|
|
"generate_pack.py": ["--offline", "--output-dir", str(out)],
|
|
"generate_truth.py": ["--output-dir", str(out)],
|
|
"export_native.py": ["--output-dir", str(out)],
|
|
}
|
|
for script, extra in {**self.CLIS, **writers}.items():
|
|
with self.subTest(script=script):
|
|
proc = subprocess.run(
|
|
[sys.executable, f"scripts/{script}", "--platform", "batocra", *extra],
|
|
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=300,
|
|
check=False,
|
|
)
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertNotIn("Traceback", proc.stderr, proc.stderr)
|
|
self.assertIn("Did you mean 'batocera'?", proc.stderr)
|
|
self.assertFalse(out.exists())
|
|
|
|
def test_the_list_holds_every_script_taking_a_platform(self):
|
|
taking = {
|
|
path.name for path in (REPO_ROOT / "scripts").glob("*.py")
|
|
if re.search(r'add_argument\(\s*"--platform"', path.read_text(encoding="utf-8"))
|
|
}
|
|
listed = set(self.CLIS) | {"generate_pack.py", "generate_truth.py", "export_native.py"}
|
|
self.assertEqual(taking - listed, set())
|
|
|
|
|
|
class CrossReferenceAnswersForTheEmulatorAsked(unittest.TestCase):
|
|
"""`--emulator dolhpin` printed "No emulator profiles found" with exit 0,
|
|
and `--emulator dolphin` showed only a header and a total "across all
|
|
emulators": an emulator with nothing undeclared was skipped."""
|
|
|
|
def _run(self, *argv: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(
|
|
[sys.executable, "scripts/cross_reference.py", *argv],
|
|
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
|
|
check=False,
|
|
)
|
|
|
|
def test_a_mistyped_emulator_fails_and_names_the_one_meant(self):
|
|
proc = self._run("--emulator", "dolhpin")
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertNotIn("Traceback", proc.stderr)
|
|
self.assertIn("Did you mean: dolphin", proc.stderr)
|
|
listing = self._run("--list-emulators")
|
|
self.assertEqual(listing.returncode, 0)
|
|
self.assertIn("dolphin", listing.stdout.split())
|
|
|
|
def test_the_named_emulator_is_reported_even_without_gaps(self):
|
|
proc = self._run("--emulator", "dolphin")
|
|
self.assertEqual(proc.returncode, 0, proc.stderr)
|
|
self.assertIn("files in profile", proc.stdout)
|
|
self.assertIn("across dolphin", proc.stdout)
|
|
self.assertNotIn("all emulators", proc.stdout)
|
|
|
|
def test_an_emulator_the_platform_does_not_run_is_refused(self):
|
|
proc = self._run("--emulator", "dolphin", "--platform", "misterfpga")
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertIn("not run by misterfpga", proc.stderr)
|
|
|
|
|
|
class AMissingDatabaseIsNamed(unittest.TestCase):
|
|
"""`slots.py --db nosuch.json` and `verify.py --all --db nosuch.json`
|
|
ended on a FileNotFoundError at the bottom of a stack trace;
|
|
generate_truth and validate_pr carried on without one."""
|
|
|
|
EXTRA = {
|
|
"auto_fetch.py": ["--all", "--dry-run"],
|
|
"generate_truth.py": ["--all", "--output-dir", "{out}"],
|
|
"generate_site.py": ["--docs-dir", "{out}"],
|
|
"generate_pack.py": ["--platform", "retroarch", "--offline", "--output-dir", "{out}"],
|
|
"validate_pr.py": ["bios/Sony/PlayStation/scph5501.bin"],
|
|
"verify.py": ["--all"],
|
|
}
|
|
|
|
def _taking_db(self) -> list[str]:
|
|
return sorted(
|
|
path.name for path in (REPO_ROOT / "scripts").glob("*.py")
|
|
if re.search(r'add_argument\(\s*"--db"', path.read_text(encoding="utf-8"))
|
|
)
|
|
|
|
def test_every_script_names_the_missing_file(self):
|
|
scratch = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp")
|
|
self.addCleanup(scratch.cleanup)
|
|
out = str(Path(scratch.name) / "out")
|
|
for script in self._taking_db():
|
|
extra = [arg.format(out=out) for arg in self.EXTRA.get(script, [])]
|
|
with self.subTest(script=script):
|
|
proc = subprocess.run(
|
|
[sys.executable, f"scripts/{script}", "--db", "nosuch.json", *extra],
|
|
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
|
|
check=False,
|
|
)
|
|
self.assertNotEqual(proc.returncode, 0, proc.stdout[-500:])
|
|
self.assertNotIn("Traceback", proc.stderr, proc.stderr[-1500:])
|
|
self.assertIn("nosuch.json", proc.stderr)
|
|
|
|
|
|
class AMissingBiosDirIsRefused(unittest.TestCase):
|
|
"""identity --strict passed on a mistyped --bios-dir with "0 contents",
|
|
dedup saved 0 MB, and generate_pack wrote a pack of nothing: a folder
|
|
that does not exist read as an empty collection."""
|
|
|
|
EXTRA = {
|
|
"auto_fetch.py": ["--platform", "batocera", "--dry-run"],
|
|
"generate_pack.py": ["--platform", "batocera", "--offline", "--output-dir", "{out}"],
|
|
"dedup.py": ["--dry-run"],
|
|
"identity.py": ["--strict"],
|
|
}
|
|
|
|
def test_every_script_taking_a_bios_dir_refuses_a_missing_one(self):
|
|
scratch = tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp")
|
|
self.addCleanup(scratch.cleanup)
|
|
out = str(Path(scratch.name) / "out")
|
|
taking = sorted(
|
|
path.name for path in (REPO_ROOT / "scripts").glob("*.py")
|
|
if re.search(r'add_argument\(\s*"--bios-dir"', path.read_text(encoding="utf-8"))
|
|
and path.name != "generate_db.py"
|
|
)
|
|
self.assertGreater(len(taking), 4)
|
|
for script in taking:
|
|
extra = [arg.format(out=out) for arg in self.EXTRA.get(script, [])]
|
|
with self.subTest(script=script):
|
|
proc = subprocess.run(
|
|
[sys.executable, f"scripts/{script}", "--bios-dir", "nosuch", *extra],
|
|
capture_output=True, text=True, cwd=str(REPO_ROOT), timeout=600,
|
|
check=False,
|
|
)
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertIn("--bios-dir nosuch is not a directory", proc.stderr)
|
|
self.assertFalse(Path(out).exists())
|
|
|
|
|
|
class StrictVerifyFailsOnACriticalMiss(unittest.TestCase):
|
|
"""verify exited 0 with a required file missing on an md5 platform, so
|
|
the pipeline printed "verify OK" over a pack the frontend rejects."""
|
|
|
|
def test_strict_exits_nonzero_and_plain_does_not(self):
|
|
with tempfile.TemporaryDirectory(dir=REPO_ROOT / "tmp") as tmp:
|
|
root = Path(tmp)
|
|
(root / "plat").mkdir()
|
|
(root / "emu").mkdir()
|
|
(root / "plat" / "fx.yml").write_text(
|
|
"platform: Fx\nverification_mode: md5\nbase_destination: bios\n"
|
|
"systems:\n sony-playstation:\n files:\n - name: absent.bin\n"
|
|
" destination: absent.bin\n required: true\n"
|
|
" md5: 00000000000000000000000000000000\n"
|
|
)
|
|
(root / "db.json").write_text(
|
|
'{"files": {}, "indexes": {"by_md5": {}, "by_name": {}, '
|
|
'"by_crc32": {}, "by_path_suffix": {}}}'
|
|
)
|
|
base = [sys.executable, "scripts/verify.py", "--platform", "fx",
|
|
"--platforms-dir", str(root / "plat"), "--emulators-dir",
|
|
str(root / "emu"), "--db", str(root / "db.json")]
|
|
plain = subprocess.run(base, capture_output=True, text=True,
|
|
cwd=str(REPO_ROOT), timeout=300, check=False)
|
|
strict = subprocess.run([*base, "--strict"], capture_output=True, text=True,
|
|
cwd=str(REPO_ROOT), timeout=300, check=False)
|
|
self.assertEqual(plain.returncode, 0, plain.stderr)
|
|
self.assertEqual(strict.returncode, 1, strict.stderr)
|
|
self.assertIn("1 required file(s) missing", strict.stderr)
|
|
|
|
def test_the_pipeline_asks_for_it(self):
|
|
source = (REPO_ROOT / "scripts" / "pipeline.py").read_text()
|
|
self.assertIn('"scripts/verify.py", "--all", "--strict"', source)
|
|
|
|
|
|
class ExclusionNotesListedOnAsk(unittest.TestCase):
|
|
"""verify --all repeated every core's exclusion note under every
|
|
platform: 985 of its 1114 lines, the platform results lost among them."""
|
|
|
|
def test_counted_by_default_listed_with_verbose(self):
|
|
if not (REPO_ROOT / "database.json").exists():
|
|
self.skipTest("database.json is not generated")
|
|
cmd = [sys.executable, "scripts/verify.py", "--platform", "retroarch"]
|
|
plain = subprocess.run(cmd, capture_output=True, text=True, cwd=str(REPO_ROOT),
|
|
timeout=900, check=True).stdout
|
|
verbose = subprocess.run([*cmd, "--verbose"], capture_output=True, text=True,
|
|
cwd=str(REPO_ROOT), timeout=900, check=True).stdout
|
|
self.assertRegex(plain, r"No external files: \d+ cores \(--verbose lists them\)")
|
|
self.assertRegex(verbose, r"No external files \(\d+\):")
|
|
self.assertLess(len(plain.splitlines()), len(verbose.splitlines()))
|
|
|
|
|
|
class ATypoGetsTheNearestNames(unittest.TestCase):
|
|
"""The hint listed the first names of the alphabet behind "...":
|
|
sony-playstaton was answered with 3do to apple-iie."""
|
|
|
|
def test_suggestions_follow_the_typed_name(self):
|
|
hint = common.suggest_names(["sony-playstaton"],
|
|
["3do", "apple-ii", "sony-playstation"], "--list-systems")
|
|
self.assertEqual(hint, "Did you mean: sony-playstation? --list-systems lists all 3.")
|
|
self.assertEqual(common.suggest_names(["zzz"], ["3do"], "--list-systems"),
|
|
"Known: 3do.")
|
|
many = [f"system-{i}" for i in range(40)]
|
|
self.assertEqual(common.suggest_names(["zzz"], many, "--list-systems"),
|
|
"--list-systems lists all 40.")
|
|
|
|
|
|
class EmulatorVerifyReadsNoArchiveItDoesNotNeed(unittest.TestCase):
|
|
"""verify --emulator decompressed every archive of the collection to
|
|
index ZIP members, for zipped_file entries no profile declares: 23 of
|
|
the 24 seconds of a run."""
|
|
|
|
def test_the_member_index_is_not_built(self):
|
|
from unittest import mock # noqa: PLC0415
|
|
|
|
import verify # noqa: PLC0415
|
|
|
|
if not (REPO_ROOT / "database.json").exists():
|
|
self.skipTest("database.json is not generated")
|
|
db = common.load_database(str(REPO_ROOT / "database.json"))
|
|
with mock.patch.object(verify, "build_zip_contents_index",
|
|
side_effect=AssertionError("index built")):
|
|
result = verify.verify_emulator(["handy"], str(REPO_ROOT / "emulators"), db)
|
|
self.assertTrue(result)
|
|
|
|
|
|
class CoverageWithoutProfilesMatchesTheCli(unittest.TestCase):
|
|
"""verify_platform built the slot overrides from the profiles the caller
|
|
passed, before loading them: the README's coverage passes none and listed
|
|
a neocdz.zip discrepancy the pack and the CLI do not have."""
|
|
|
|
def test_omitting_profiles_gives_the_same_report(self):
|
|
import verify # noqa: PLC0415
|
|
|
|
if not (REPO_ROOT / "database.json").exists():
|
|
self.skipTest("database.json is not generated")
|
|
db = common.load_database(str(REPO_ROOT / "database.json"))
|
|
config = common.load_platform_config("retroarch", str(REPO_ROOT / "platforms"))
|
|
emulators = str(REPO_ROOT / "emulators")
|
|
bare = verify.verify_platform(config, db, emulators)
|
|
full = verify.verify_platform(config, db, emulators,
|
|
common.load_emulator_profiles(emulators))
|
|
|
|
def discrepancies(result):
|
|
return sorted(d["name"] for d in result["details"] if d.get("discrepancy"))
|
|
|
|
self.assertEqual(discrepancies(bare), discrepancies(full))
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|