"""The one-liner narrows to the cores a RetroArch already has. RetroArch keeps its cores in libretro_directory, named the buildbot's way (`mednafen_psx_libretro.so`), while a file in the manifest names the profile that reads it (`beetle_psx`). The manifest maps one to the other and says which cores keep each system, the rule a pack built for a target follows. """ from __future__ import annotations import contextlib import io import json import shlex import shutil import subprocess import sys import tempfile import unittest from pathlib import Path from unittest import mock REPO_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT / "tests")) import install # noqa: E402 from test_install_selection import FakeRepository # noqa: E402 class CoreDirectories(unittest.TestCase): def setUp(self): self.tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) self.addCleanup(lambda: __import__("shutil").rmtree(self.tmp, ignore_errors=True)) def test_the_config_detection_read_names_the_cores(self): cfg = self.tmp / "retroarch.cfg" cfg.write_text( f'system_directory = "{self.tmp / "sys"}"\n' f'libretro_directory = "{self.tmp / "my cores"}"\n' ) system_dir = install._parse_retroarch_system_dir(cfg) self.assertEqual( install.core_dirs("retroarch", system_dir), [self.tmp / "my cores"] ) def test_a_portable_layout_defaults_to_cores_beside_the_config(self): (self.tmp / "retroarch.cfg").write_text('libretro_directory = "default"\n') self.assertEqual( install.core_dirs("retroarch", self.tmp / "system"), [self.tmp / "cores"] ) (self.tmp / "retroarch.cfg").write_text('video_driver = "gl"\n') self.assertEqual( install.core_dirs("retroarch", self.tmp / "system"), [self.tmp / "cores"] ) def test_retropie_and_lakka_keep_their_own_folders(self): self.assertIn( Path("/opt/retropie/libretrocores"), install.core_dirs("retropie", self.tmp / "none"), ) self.assertIn(Path("/storage/cores"), install.core_dirs("lakka", self.tmp / "none")) def test_core_files_of_every_system_and_retropie_folders(self): cores = self.tmp / "cores" (cores / "lr-fceumm").mkdir(parents=True) for name in ( "mednafen_psx_libretro.so", "snes9x_libretro.dll", "mgba_libretro.dylib", "genesis_plus_gx_libretro_android.so", "lr-fceumm/fceumm_libretro.so", "mgba_libretro.info", "readme.txt", ): (cores / name).write_bytes(b"") self.assertEqual( install.installed_cores([cores, self.tmp / "missing"]), ["fceumm", "genesis_plus_gx", "mednafen_psx", "mgba", "snes9x"], ) MANIFEST = { "core_names": {"beetle_psx": ["mednafen_psx"]}, "system_cores": {"sony-playstation": ["beetle_psx", "pcsx_rearmed"], "nintendo-gba": ["mgba"]}, "files": [ {"dest": "scph5501.bin", "cores": None, "systems": ["sony-playstation"]}, {"dest": "gba_bios.bin", "cores": None, "systems": ["nintendo-gba"]}, {"dest": "unlisted.bin", "cores": None, "systems": ["arcade"]}, {"dest": "psx/extra.bin", "cores": ["beetle_psx"], "systems": ["sony-playstation"]}, {"dest": "mgba/extra.bin", "cores": ["mgba"], "systems": ["nintendo-gba"]}, ], "omitted_files": [ {"dest": "gone.bin", "cores": ["mgba"], "systems": ["nintendo-gba"]}, ], } class NarrowToCores(unittest.TestCase): def test_a_buildbot_name_keeps_its_profile_and_its_systems(self): files, omitted = install.narrow_to_cores(MANIFEST, ["mednafen_psx"]) self.assertEqual( [f["dest"] for f in files], ["scph5501.bin", "unlisted.bin", "psx/extra.bin"], ) self.assertEqual(omitted, []) def test_an_older_list_keeps_the_platform_files(self): older = {k: v for k, v in MANIFEST.items() if k in ("files", "omitted_files")} files, _ = install.narrow_to_cores(older, ["mgba"]) self.assertEqual( [f["dest"] for f in files], ["scph5501.bin", "gba_bios.bin", "unlisted.bin", "mgba/extra.bin"], ) class TheBuildersOrder(unittest.TestCase): """RetroPie reads megaplay.zip as advancemame (copy A), mame (copy B), then mame2003 (copy A). Without advancemame the builder ships mame's copy, and merging mame2003 into the first copy gave it A.""" ENTRY = { "dest": "megaplay.zip", "sha1": "a" * 40, "size": 1, "repo_path": "bios/a.zip", "cores": ["advancemame"], "systems": ["arcade"], "alternatives": [ {"sha1": "b" * 40, "sha256": "", "size": 2, "repo_path": "bios/b.zip", "cores": ["mame"]}, {"sha1": "a" * 40, "sha256": "", "size": 1, "repo_path": "bios/a.zip", "cores": ["mame2003"]}, ], } def test_the_first_chosen_claim_wins(self): manifest = {"files": [self.ENTRY], "omitted_files": []} for names, sha in ((["mame", "mame2003"], "b"), (["mame2003"], "a"), (["advancemame", "mame"], "a")): with self.subTest(names=names): files, _ = install.narrow_to_cores(manifest, names) self.assertEqual(files[0]["sha1"], sha * 40) self.assertNotIn("alternatives", files[0] if sha == "b" else {}) def test_the_parity_covers_a_core_set(self): """A real build for mame and mame2003 on RetroPie, the shape --installed-cores produces there.""" db_path = REPO_ROOT / "database.json" if not db_path.exists(): self.skipTest("database.json is not built") sys.path.insert(0, str(REPO_ROOT / "scripts")) import generate_pack as gp from common import load_emulator_profiles db = json.loads(db_path.read_text(encoding="utf-8")) profiles = load_emulator_profiles(str(REPO_ROOT / "emulators")) def manifest(cores): return gp.generate_manifest( "retropie", str(REPO_ROOT / "platforms"), db, str(REPO_ROOT / "bios"), str(REPO_ROOT / "platforms" / "_registry.yml"), emulators_dir=str(REPO_ROOT / "emulators"), emu_profiles=profiles, target_cores=cores, offline=True, ) names = ["mame", "mame2003"] files, _ = install.narrow_to_cores(manifest(None), names) built = manifest(set(names)) self.assertEqual( {(f["dest"], f["sha1"]) for f in files}, {(f["dest"], f["sha1"]) for f in built["files"]}, ) class TheBoundaryBoundsAlternatives(unittest.TestCase): def _manifest(self, alternative: dict) -> dict: entry = {"dest": "a.bin", "sha1": "a" * 40, "size": 1, "repo_path": "bios/a.bin", "cores": ["x"], "alternatives": [alternative]} return {"manifest_version": 2, "platform": "retroarch", "files": [entry]} def test_an_alternative_carries_only_a_copy(self): good = {"sha1": "b" * 40, "size": 1, "repo_path": "bios/b.bin", "cores": ["y"]} install._validate_manifest(self._manifest(good), "retroarch") for extra in ({"dest": "../../evil"}, {"regions": 5}, {"cores": []}, {"repo_path": "../x"}): with self.subTest(extra=extra), self.assertRaises(ValueError): install._validate_manifest(self._manifest({**good, **extra}), "retroarch") def test_the_cap_counts_the_largest_copy(self): big = {"sha1": "b" * 40, "size": install.MAX_DOWNLOAD_SIZE, "repo_path": "bios/b.bin", "cores": ["y"]} manifest = self._manifest(big) manifest["files"] = [ {**manifest["files"][0], "dest": f"f{i}.bin"} for i in range(70) ] with self.assertRaises(ValueError): install._validate_manifest(manifest, "retroarch") class APlatformOmissionKeepsItsClaim(unittest.TestCase): """A file the platform declares and a core reads, absent from the collection, was recorded under the core alone: RetroPie's dsi_nand.bin went to melonds, and a target without melonds lost the omission its pack reports for nintendo-ds.""" OMISSION = { "dest": "dsi_nand.bin", "name": "dsi_nand.bin", "system": "nintendo-ds", "required": True, "reason": "not_found", "cores": None, "systems": ["nintendo-ds", "nintendo-dsi"], "declared_in": ["nintendo-ds"], "alternatives": [{"cores": ["melonds"]}], } MANIFEST = { "files": [], "omitted_files": [OMISSION], "core_names": {"melonds": ["melonds"], "desmume": ["desmume"]}, "system_cores": {"nintendo-ds": ["desmume"], "nintendo-dsi": ["melonds"]}, } def test_the_declaring_system_keeps_it(self): _, omitted = install.narrow_to_cores(self.MANIFEST, ["desmume"]) self.assertEqual(omitted, [self.OMISSION]) def test_a_dropped_system_falls_to_the_claiming_core(self): _, omitted = install.narrow_to_cores(self.MANIFEST, ["melonds"]) self.assertEqual([o["cores"] for o in omitted], [["melonds"]]) _, omitted = install.narrow_to_cores(self.MANIFEST, ["mgba"]) self.assertEqual(omitted, []) def test_the_generator_files_the_core_behind_the_platform(self): sys.path.insert(0, str(REPO_ROOT / "scripts")) import generate_pack as gp held = {"cores": None} self.assertTrue(gp._claim_platform_omission(held, "melonds")) self.assertTrue(gp._claim_platform_omission(held, "melonds")) self.assertEqual(held["alternatives"], [{"cores": ["melonds"]}]) self.assertFalse(gp._claim_platform_omission({"cores": ["x"]}, "melonds")) self.assertFalse(gp._claim_platform_omission(None, "melonds")) def test_the_boundary_takes_only_core_names(self): manifest = {"manifest_version": 2, "platform": "retroarch", "files": [], "omitted_files": [self.OMISSION]} install._validate_manifest(manifest, "retroarch") for claims in ([{"cores": []}], [{"cores": ["x"], "sha1": "a" * 40}], ["melonds"], [5], {"cores": ["x"]}): bad = {**self.OMISSION, "alternatives": claims} with self.subTest(claims=claims), self.assertRaises(ValueError): install._validate_manifest( {**manifest, "omitted_files": [bad]}, "retroarch" ) class APlatformFileSettledByCoresAndSystems(unittest.TestCase): """RetroArch's dsi_bios9.bin ships melonds' copy where melonds runs. On a target without it the pack serves the platform's answer, and the pinned nintendo-dsi declaration gives way to the bare nintendo-ds one once its system drops. The installer kept melonds' copy on rpi4.""" def copy(self, letter: str) -> dict: return {"sha1": letter * 40, "size": 1, "repo_path": f"bios/{letter}.bin"} def manifest(self) -> dict: entry = { "dest": "dsi_bios9.bin", **self.copy("a"), "cores": None, "systems": ["nintendo-ds", "nintendo-dsi"], "slot_choices": [{"cores": ["desmume"]}, {**self.copy("a"), "cores": ["melonds"]}], "declared_choices": [ {**self.copy("c"), "systems": ["nintendo-dsi"]}, {**self.copy("b"), "systems": ["nintendo-ds"]}, ], } return { "manifest_version": 2, "platform": "retroarch", "files": [entry], "omitted_files": [], "core_names": {}, "system_cores": {"nintendo-ds": ["desmume", "melonds", "nds"], "nintendo-dsi": ["melonds", "dsi"]}, } def served(self, names: list[str]) -> str: files, _ = install.narrow_to_cores(self.manifest(), names) self.assertEqual(len(files), 1) self.assertNotIn("declared_choices", files[0]) return files[0]["sha1"][0] def test_the_first_chosen_core_decides(self): self.assertEqual(self.served(["melonds"]), "a") def test_a_core_agreeing_with_the_platform_leaves_its_answer(self): self.assertEqual(self.served(["desmume", "melonds"]), "c") self.assertEqual(self.served(["desmume"]), "b") def test_the_remaining_system_answers_without_the_cores(self): self.assertEqual(self.served(["dsi"]), "c") self.assertEqual(self.served(["nds"]), "b") def test_the_boundary_checks_every_copy(self): install._validate_manifest(self.manifest(), "retroarch") bad_choices = ( [{"cores": []}], [{"cores": ["x"], "repo_path": "../x", "sha1": "a" * 40, "size": 1}], [{"cores": ["x"], "dest": "../evil"}], ) for choices in bad_choices: manifest = self.manifest() manifest["files"][0]["slot_choices"] = choices with self.subTest(choices=choices), self.assertRaises(ValueError): install._validate_manifest(manifest, "retroarch") for declared in ([], [self.copy("c")], [{**self.copy("c"), "systems": []}], [{**self.copy("c"), "systems": ["s"], "cores": ["x"]}]): manifest = self.manifest() manifest["files"][0]["declared_choices"] = declared with self.subTest(declared=declared), self.assertRaises(ValueError): install._validate_manifest(manifest, "retroarch") manifest = self.manifest() del manifest["files"][0]["declared_choices"] with self.assertRaises(ValueError): install._validate_manifest(manifest, "retroarch") class RefusedBeforeAnyInstall(unittest.TestCase): def test_a_platform_shipping_its_cores_stops_the_run_first(self): tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) self.addCleanup(shutil.rmtree, tmp, True) detected = [("retroarch", tmp / "system"), ("batocera", tmp / "bios")] (tmp / "retroarch.cfg").write_text("") (tmp / "cores").mkdir() (tmp / "cores" / "mgba_libretro.so").write_bytes(b"") with mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install, "detect_platforms", return_value=detected), \ mock.patch.object(install, "fetch_manifest") as fetch, \ mock.patch.object(sys, "argv", ["install.py", "--installed-cores", "--no-input"]), \ contextlib.redirect_stdout(io.StringIO()), \ contextlib.redirect_stderr(io.StringIO()) as err: with self.assertRaises(SystemExit) as stop: install.main() self.assertEqual(stop.exception.code, 1) self.assertIn("batocera ships its cores", err.getvalue()) fetch.assert_not_called() class AnUnknownTargetStopsTheRunFirst(unittest.TestCase): """EmuDeck installed 3.2 GB, then steamos was refused for RetroArch and the run ended without a summary.""" def test_nothing_is_fetched_when_one_platform_lacks_the_target(self): tmp = Path(tempfile.mkdtemp(dir=REPO_ROOT / "tmp")) self.addCleanup(shutil.rmtree, tmp, True) detected = [("emudeck", tmp / "bios"), ("retroarch", tmp / "system")] lists = {"emudeck": {"steamos": {"cores": []}}, "retroarch": {"x86_64": {"cores": []}}} with mock.patch.object(install, "detect_os", return_value="linux"), \ mock.patch.object(install, "detect_frontends", return_value=[]), \ mock.patch.object(install, "detect_platforms", return_value=detected), \ mock.patch.object(install, "fetch_targets", side_effect=lists.get), \ mock.patch.object(install, "fetch_manifest") as fetch, \ mock.patch.object(sys, "argv", ["install.py", "--target", "steamos", "--no-input"]), \ contextlib.redirect_stdout(io.StringIO()), \ contextlib.redirect_stderr(io.StringIO()) as err: with self.assertRaises(SystemExit) as stop: install.main() self.assertEqual(stop.exception.code, 1) self.assertIn("unknown target 'steamos' for retroarch", err.getvalue()) self.assertIn("Nothing was installed", err.getvalue()) fetch.assert_not_called() class _CoreRepository(FakeRepository): """The fake repository with the two maps and a portable RetroArch.""" def __enter__(self): super().__enter__() path = self.root / "install" / "retroarch.json" manifest = json.loads(path.read_text()) manifest["core_names"] = {"pcsx_rearmed": ["pcsx_rearmed_neon"]} manifest["system_cores"] = { "sony-playstation": ["pcsx_rearmed"], "nintendo-gba": ["mgba"], } path.write_text(json.dumps(manifest)) (self.root / "retroarch.cfg").write_text('libretro_directory = ":/cores"\n') (self.root / "cores").mkdir() return self def install_core(self, name: str) -> None: (self.root / "cores" / f"{name}_libretro.so").write_bytes(b"") class TheOneLinerReadsInstalledCores(unittest.TestCase): def test_only_what_the_installed_cores_read(self): with _CoreRepository() as repo: repo.install_core("pcsx_rearmed_neon") proc = repo.run("--installed-cores") self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertIn("1 core installed", proc.stdout) self.assertEqual( repo.installed(), ["pcsx/extra.bin", "scph5500.bin", "scph5501.bin"] ) def test_no_core_found_is_an_error_not_everything(self): with _CoreRepository() as repo: proc = repo.run("--installed-cores") self.assertEqual(proc.returncode, 1) self.assertIn("no installed core found", proc.stderr) self.assertEqual(repo.installed(), []) def test_an_older_file_list_is_refused(self): with FakeRepository() as repo: (repo.root / "retroarch.cfg").write_text("") (repo.root / "cores").mkdir() (repo.root / "cores" / "mgba_libretro.so").write_bytes(b"") proc = repo.run("--installed-cores") self.assertEqual(proc.returncode, 1) self.assertIn("older release", proc.stderr) def test_a_platform_that_ships_its_cores_refuses_the_flag(self): with _CoreRepository() as repo: batocera = repo.root / "install" / "batocera.json" manifest = json.loads((repo.root / "install" / "retroarch.json").read_text()) manifest["platform"] = "batocera" batocera.write_text(json.dumps(manifest)) command = repo.command("--installed-cores") command[command.index("retroarch")] = "batocera" proc = subprocess.run( command, env=repo.env(), capture_output=True, text=True, timeout=60, check=False, stdin=subprocess.DEVNULL, ) self.assertEqual(proc.returncode, 1) self.assertIn("ships its cores with the system", proc.stderr) @unittest.skipUnless(shutil.which("script"), "script(1) provides the terminal") def test_the_prompt_offers_the_installed_cores(self): with _CoreRepository() as repo: repo.install_core("mgba") command = " ".join(shlex.quote(part) for part in repo.command()) proc = subprocess.run( ["script", "-qec", command, "/dev/null"], input="i\n", env=repo.env(), capture_output=True, text=True, timeout=60, check=False, ) self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) self.assertIn("i) only what the installed cores read: 2 files", proc.stdout) self.assertIn("--platform retroarch --installed-cores", proc.stdout) self.assertEqual(repo.installed(), ["gba_bios.bin", "mgba/extra.bin"]) if __name__ == "__main__": unittest.main()