Files
libretro/tests/test_slots.py
T

651 lines
26 KiB
Python

"""Slot arbitration: who decides what goes at a destination."""
from __future__ import annotations
import os
import re
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
import slots # noqa: E402
from slots import Claim, Conflict, arbitrate, format_decision # noqa: E402
def _db(entries: dict[str, dict]) -> dict:
"""Minimal database shaped like the generated one, keyed by sha1.
The resolver reads the disk before it answers, so the fixture writes the
files it indexes and the caller runs from the directory holding them.
"""
files, by_md5, by_name, by_path_suffix = {}, {}, {}, {}
for sha1, info in entries.items():
path = info["path"]
name = path.rsplit("/", 1)[-1]
files[sha1] = {
"path": path,
"md5": info.get("md5", ""),
"name": name,
"crc32": info.get("crc32", ""),
"size": info.get("size", 0),
"sha1": sha1,
}
if info.get("md5"):
by_md5[info["md5"]] = sha1
by_name.setdefault(name, []).append(sha1)
parts = path.split("/")
for depth in range(1, len(parts)):
by_path_suffix.setdefault("/".join(parts[-depth:]), []).append(sha1)
return {
"files": files,
"indexes": {
"by_md5": by_md5,
"by_name": by_name,
"by_crc32": {},
"by_path_suffix": by_path_suffix,
},
}
_ROOT = Path(tempfile.mkdtemp(prefix="slots-"))
_ENTRIES = {
"a" * 40: {"path": "bios/Console/GC/USA/IPL.bin", "md5": "m" * 32, "size": 3},
"b" * 40: {"path": "bios/Console/GC/JAP/IPL.bin", "md5": "n" * 32, "size": 3},
}
for _info in _ENTRIES.values():
_target = _ROOT / _info["path"]
_target.parent.mkdir(parents=True, exist_ok=True)
_target.write_bytes(b"rom")
REGIONS_DB = _db(_ENTRIES)
def setUpModule():
global _PREVIOUS_CWD
_PREVIOUS_CWD = os.getcwd()
os.chdir(_ROOT)
def tearDownModule():
os.chdir(_PREVIOUS_CWD)
shutil.rmtree(_ROOT, ignore_errors=True)
class TestClaimIndex(unittest.TestCase):
"""Keyed by destination and by name, the name carrying the union."""
def _claims(self):
return [
slots.Claim("profile", "GC/USA/IPL.bin", "IPL.bin", emulator="dolphin"),
slots.Claim("profile", "GC/JAP/IPL.bin", "IPL.bin", emulator="dolphin"),
]
def test_destination_key_separates_same_named_entries(self):
index = slots.build_claim_index(self._claims())
self.assertEqual(len(index["gc/usa/ipl.bin"]), 1)
self.assertEqual(len(index["gc/jap/ipl.bin"]), 1)
def test_bare_name_answers_with_the_union(self):
index = slots.build_claim_index(self._claims())
self.assertEqual(len(index["ipl.bin"]), 2)
def test_lookup_ignores_case_and_leading_separator(self):
index = slots.build_claim_index(
[slots.Claim("platform", "/BIOS/Disk.rom", "Disk.rom")]
)
self.assertIn("bios/disk.rom", index)
class TestConflicts(unittest.TestCase):
"""A proven profile claim contradicting what the baseline would ship."""
def _config(self, md5: str) -> dict:
return {
"systems": {
"console": {
"files": [
{
"name": "IPL.bin",
"destination": "GC/JAP/IPL.bin",
"md5": md5,
}
]
}
}
}
def _profile(self, *paths: str) -> dict:
return {
"dolphin": {
"files": [{"name": "IPL.bin", "path": p} for p in paths]
}
}
def test_a_core_claim_sits_where_the_pack_puts_it(self):
"""RetroDECK writes bios/ into every destination and leaves its base
empty; core files go under bios/ too. Claimed at the root, they never
met the platform's and four contradictions went unreported."""
config = {"base_destination": "", "systems": {"console": {"files": [
{"name": "IPL.bin", "destination": "bios/GC/JAP/IPL.bin", "md5": "m" * 32},
{"name": "other.bin", "destination": "bios/other.bin"},
]}}}
conflicts = slots.find_conflicts(config, self._profile("GC/JAP/IPL.bin"), REGIONS_DB)
self.assertEqual([c.destination for c in conflicts], ["bios/GC/JAP/IPL.bin"])
def test_wrong_region_under_a_region_slot_is_reported(self):
conflicts = slots.find_conflicts(
self._config("m" * 32), self._profile("GC/JAP/IPL.bin"), REGIONS_DB
)
self.assertEqual(len(conflicts), 1)
self.assertEqual(conflicts[0].destination, "GC/JAP/IPL.bin")
self.assertTrue(conflicts[0].platform_claim.local_path.endswith("USA/IPL.bin"))
self.assertTrue(
conflicts[0].profile_claims[0].local_path.endswith("JAP/IPL.bin")
)
self.assertEqual(conflicts[0].emulators, ["dolphin"])
def test_agreement_is_not_a_conflict(self):
conflicts = slots.find_conflicts(
self._config("n" * 32), self._profile("GC/JAP/IPL.bin"), REGIONS_DB
)
self.assertEqual(conflicts, [])
def test_one_matching_revision_among_several_settles_the_slot(self):
# A profile listing every acceptable revision agrees with the platform
# as soon as one of them is what ships.
profile = {
"dosbox": {
"files": [
{"name": "IPL.bin", "path": "GC/JAP/IPL.bin", "md5": "n" * 32},
{"name": "IPL.bin", "path": "GC/JAP/IPL.bin", "md5": "m" * 32},
]
}
}
conflicts = slots.find_conflicts(self._config("m" * 32), profile, REGIONS_DB)
self.assertEqual(conflicts, [])
def test_a_platform_list_naming_the_profile_file_is_agreement(self):
"""Recalbox names both scph1001.bin revisions; the resolver stops on
the first, the profile pins the other, and --strict reported a
contradiction nobody has to correct."""
conflicts = slots.find_conflicts(
self._config(f"{'m' * 32},{'n' * 32}"),
self._profile("GC/JAP/IPL.bin"),
REGIONS_DB,
)
self.assertEqual(conflicts, [])
def test_a_profile_list_naming_the_shipped_file_is_agreement(self):
"""mesence accepts four BS-X dumps, one of which System.dat pins;
the slot read as contested and the pack swapped the catalogued
dump for an uncatalogued one."""
profile = {
"mesence": {
"files": [
{"name": "IPL.bin", "path": "GC/JAP/IPL.bin",
"md5": [ "n" * 32, "m" * 32 ]},
]
}
}
conflicts = slots.find_conflicts(self._config("m" * 32), profile, REGIONS_DB)
self.assertEqual(conflicts, [])
def test_an_entry_the_pack_never_carries_claims_nothing(self):
"""A file read from the save directory, declared with the sha1 of
another dump, arbitrated the platform's file off its own path."""
profile = {
"core": {
"files": [
{"name": "IPL.bin", "path": "GC/JAP/IPL.bin", "md5": "n" * 32,
"load_from": "save_dir"},
]
}
}
conflicts = slots.find_conflicts(self._config("m" * 32), profile, REGIONS_DB)
self.assertEqual(conflicts, [])
def test_a_name_only_resolution_asserts_nothing(self):
# No hash and no path that the repository carries: the entry is
# answered by filename alone and cannot contradict anything.
config = {
"systems": {
"console": {
"files": [{"name": "IPL.bin", "destination": "slot/IPL.bin"}]
}
}
}
profile = {"dolphin": {"files": [{"name": "IPL.bin", "path": "slot/IPL.bin"}]}}
self.assertEqual(slots.find_conflicts(config, profile, REGIONS_DB), [])
def test_a_bare_sibling_met_first_does_not_hide_the_contradiction(self):
"""RetroArch declares dsi_bios7.bin bare under one system and with a
hash under another; the pack ships the hashed one, so the slot is
judged on it, whichever system is read first."""
config = {
"systems": {
"a-console": {"files": [{"name": "IPL.bin", "destination": "GC/JAP/IPL.bin"}]},
"b-console": {"files": [
{"name": "IPL.bin", "destination": "GC/JAP/IPL.bin", "md5": "m" * 32}]},
}
}
conflicts = slots.find_conflicts(config, self._profile("GC/JAP/IPL.bin"), REGIONS_DB)
self.assertEqual([c.destination for c in conflicts], ["GC/JAP/IPL.bin"])
def test_launchers_and_aliases_never_claim(self):
profile = {
"launcher": {
"type": "launcher",
"files": [{"name": "IPL.bin", "path": "GC/JAP/IPL.bin"}],
}
}
self.assertEqual(
slots.find_conflicts(self._config("m" * 32), profile, REGIONS_DB), []
)
def test_the_arbitration_reads_the_same_evidence_as_the_builder(self):
"""A verdict decided on less evidence than its consumers apply.
slots resolved without the ZIP index and without the data-directory
registry, while generate_pack and verify pass both, so an entry only
a ZIP member or a data directory can satisfy looked unproven here and
proven there.
"""
import inspect
import generate_pack
import verify
for function in (
slots.platform_claims,
slots.profile_claims,
slots.find_conflicts,
slots.find_collisions,
):
parameters = inspect.signature(function).parameters
with self.subTest(function=function.__name__):
self.assertIn("zip_contents", parameters)
self.assertIn("data_dir_registry", parameters)
parameters = inspect.signature(slots.pack_overrides).parameters
self.assertIn("zip_contents", parameters)
self.assertIn("data_dir_registry", parameters)
# Every consumer reads the one decision and hands its indexes over:
# the ZIP builder, the install manifest and the report. The manifest
# deciding nothing gave the installer other bytes than the ZIP.
for module, calls in ((generate_pack, 2), (verify, 1)):
source = inspect.getsource(module)
with self.subTest(module=module.__name__):
self.assertNotIn("slots.arbitrate(", source)
self.assertNotIn("slots.find_conflicts(", source)
self.assertEqual(source.count("slots.pack_overrides("), calls)
for start in range(len(source)):
start = source.find("slots.pack_overrides(", start)
if start < 0:
break
self.assertIn("zip_contents", source[start:start + 200])
start += 1
def test_a_rom_inside_a_romset_claims_nothing_of_its_own(self):
"""The archive occupies the destination, not the ROM it holds.
FBNeo declares msx.rom and kanji.rom with archive: msx.zip and no
path, so the member was claimed at the platform's BIOS root and
contradicted whatever really lives there.
"""
profile = {
"fbneo": {
"type": "libretro",
"files": [
{"name": "IPL.bin", "archive": "romset.zip"},
],
}
}
self.assertEqual(
slots.find_conflicts(self._config("m" * 32), profile, REGIONS_DB), []
)
self.assertEqual(slots.profile_claims(profile, REGIONS_DB), [])
def test_base_destination_prefixes_both_sides(self):
conflicts = slots.find_conflicts(
self._config("m" * 32),
self._profile("GC/JAP/IPL.bin"),
REGIONS_DB,
base_dest="bios",
)
self.assertEqual(conflicts[0].destination, "bios/GC/JAP/IPL.bin")
def test_report_line_names_both_answers(self):
conflicts = slots.find_conflicts(
self._config("m" * 32), self._profile("GC/JAP/IPL.bin"), REGIONS_DB
)
line = slots.format_conflict(conflicts[0])
self.assertIn("USA/IPL.bin", line)
self.assertIn("JAP/IPL.bin", line)
self.assertIn("dolphin", line)
class TestArbitration(unittest.TestCase):
"""A pack answers to whoever asked for it, and serves both when it can."""
def _conflict(self):
platform = slots.Claim(
"platform", "GC/JAP/IPL.bin", "IPL.bin",
local_path="bios/Console/GC/USA/IPL.bin", status="md5_exact",
)
profile = slots.Claim(
"profile", "GC/JAP/IPL.bin", "IPL.bin", emulator="dolphin",
local_path="bios/Console/GC/JAP/IPL.bin", status="path_exact",
)
return slots.Conflict("GC/JAP/IPL.bin", platform, [profile])
def test_existence_mode_serves_both(self):
decision = slots.arbitrate(self._conflict(), "existence")
self.assertEqual(decision.winner.origin, "profile")
self.assertTrue(decision.serves_both)
def test_md5_mode_keeps_the_frontend_green(self):
decision = slots.arbitrate(self._conflict(), "md5")
self.assertEqual(decision.winner.origin, "platform")
self.assertFalse(decision.serves_both)
def test_sha1_mode_is_content_checking_too(self):
decision = slots.arbitrate(self._conflict(), "sha1")
self.assertEqual(decision.winner.origin, "platform")
def test_an_emulator_pack_answers_to_the_emulator(self):
decision = slots.arbitrate(self._conflict(), "md5", addressee="emulator")
self.assertEqual(decision.winner.origin, "profile")
self.assertFalse(decision.serves_both)
def test_the_reported_line_gives_the_ground_for_the_decision(self):
kept = slots.format_decision(slots.arbitrate(self._conflict(), "md5"))
self.assertIn("verifies content", kept)
served = slots.format_decision(slots.arbitrate(self._conflict(), "existence"))
self.assertIn("both are satisfied", served)
class TestBuilderAndVerifierAgree(unittest.TestCase):
"""The two tools must settle a contested destination the same way."""
def test_both_read_the_same_decision_function(self):
# The rule lives in one place. A second copy would let the pack and the
# report describe different files, which the project forbids outright.
builder = Path(__file__).resolve().parents[1] / "scripts" / "generate_pack.py"
verifier = Path(__file__).resolve().parents[1] / "scripts" / "verify.py"
for source in (builder, verifier):
text = source.read_text(encoding="utf-8")
self.assertIn("slots.pack_overrides(", text, source.name)
self.assertNotIn("decision.serves_both", text, source.name)
self.assertIn(
"decision.serves_both",
(builder.parent / "slots.py").read_text(encoding="utf-8"),
)
def test_neither_reimplements_the_mode_test(self):
# A local "if mode == md5" beside the override would drift from the
# arbitration rule the moment either side is edited. The anchor is the
# override lookup both sides must perform; the old anchor was a string
# the test above forbids, so the window was the whole file.
mode_test = re.compile(r"""[=!]=\s*["'](?:md5|sha1|existence)["']""")
for name in ("generate_pack.py", "verify.py"):
source = Path(__file__).resolve().parents[1] / "scripts" / name
body = source.read_text(encoding="utf-8")
anchors = [m.start() for m in re.finditer(r"slot_overrides\.get\(", body)]
self.assertTrue(anchors, name)
for at in anchors:
window = body[max(0, at - 600):at + 600]
self.assertIsNone(mode_test.search(window), name)
class TestSelfContradictingDestinations(unittest.TestCase):
"""One path holds one file, whatever the upstream list says."""
def _config(self, *entries: dict) -> dict:
return {"systems": {"console": {"files": list(entries)}}}
def test_two_declarations_landing_on_two_files_are_reported(self):
config = self._config(
{"name": "IPL.bin", "destination": "disk.rom", "md5": "m" * 32},
{"name": "IPL.bin", "destination": "disk.rom", "md5": "n" * 32},
)
collisions = slots.find_collisions(config, REGIONS_DB)
self.assertEqual(len(collisions), 1)
self.assertEqual(len(collisions[0].resolved), 2)
def test_one_archive_named_by_its_inner_roms_is_not_a_collision(self):
# The documented zipped_file pattern: several md5s, one archive.
config = self._config(
{"name": "IPL.bin", "destination": "a.zip", "md5": "m" * 32,
"zipped_file": "one.bin"},
{"name": "IPL.bin", "destination": "a.zip", "md5": "m" * 32,
"zipped_file": "two.bin"},
)
self.assertEqual(slots.find_collisions(config, REGIONS_DB), [])
def test_a_file_both_declarations_accept_settles_the_path(self):
"""RetroDECK: ATARIOSB.ROM under one system with one md5, under
another with a list naming that same md5; the shipped file passes
both, so --strict has nothing to correct there."""
config = self._config(
{"name": "IPL.bin", "destination": "disk.rom", "md5": "m" * 32},
{"name": "IPL.bin", "destination": "disk.rom",
"md5": f"{'n' * 32},{'m' * 32}"},
)
self.assertEqual(slots.find_collisions(config, REGIONS_DB), [])
def test_a_primary_and_its_pinned_variant_are_one_family(self):
self.assertTrue(
slots._same_file_family(
["bios/M/C/rom.zip", "bios/M/C/.variants/rom.zip.abcd1234"]
)
)
def test_two_machines_are_not_one_family(self):
self.assertFalse(
slots._same_file_family(
["bios/Microsoft/MSX/DISK.ROM", "bios/Tandy/CoCo/disk.rom"]
)
)
def test_a_destination_declared_once_is_never_a_collision(self):
config = self._config(
{"name": "IPL.bin", "destination": "disk.rom", "md5": "m" * 32}
)
self.assertEqual(slots.find_collisions(config, REGIONS_DB), [])
def test_the_line_names_every_file_claiming_the_path(self):
config = self._config(
{"name": "IPL.bin", "destination": "disk.rom", "md5": "m" * 32},
{"name": "IPL.bin", "destination": "disk.rom", "md5": "n" * 32},
)
line = slots.format_collision(slots.find_collisions(config, REGIONS_DB)[0])
self.assertIn("USA/IPL.bin", line)
self.assertIn("JAP/IPL.bin", line)
class TestBuildModeDecidesWhoClaims(unittest.TestCase):
"""A platform runs some emulators as cores and others standalone."""
def _config(self, standalone=None) -> dict:
config = {
"systems": {
"console": {
"files": [
{"name": "IPL.bin", "destination": "GC/JAP/IPL.bin",
"md5": "m" * 32}
]
}
}
}
if standalone:
config["standalone_cores"] = standalone
return config
def _profile(self, mode=None, standalone_path=None) -> dict:
entry = {"name": "IPL.bin", "path": "GC/JAP/IPL.bin"}
if mode:
entry["mode"] = mode
if standalone_path:
entry["standalone_path"] = standalone_path
return {"ares": {"type": "standalone + libretro", "cores": ["ares"],
"files": [entry]}}
def test_a_standalone_only_file_does_not_claim_a_core_slot(self):
conflicts = slots.find_conflicts(
self._config(), self._profile(mode="standalone"), REGIONS_DB
)
self.assertEqual(conflicts, [])
def test_it_claims_when_the_platform_runs_that_emulator_standalone(self):
conflicts = slots.find_conflicts(
self._config(standalone=["ares"]),
self._profile(mode="standalone"),
REGIONS_DB,
standalone_cores={"ares"},
)
self.assertEqual(len(conflicts), 1)
def test_a_libretro_only_file_is_silent_in_standalone_mode(self):
conflicts = slots.find_conflicts(
self._config(standalone=["ares"]),
self._profile(mode="libretro"),
REGIONS_DB,
standalone_cores={"ares"},
)
self.assertEqual(conflicts, [])
def test_standalone_mode_reads_the_standalone_destination(self):
claims = slots.profile_claims(
self._profile(standalone_path="elsewhere/IPL.bin"),
REGIONS_DB,
standalone_cores={"ares"},
)
self.assertEqual(claims[0].destination, "elsewhere/IPL.bin")
def test_the_mode_follows_a_core_alias_not_only_the_profile_name(self):
profile = {"ares_core": {"type": "standalone + libretro",
"cores": ["ares"],
"files": [{"name": "IPL.bin",
"path": "GC/JAP/IPL.bin",
"mode": "standalone"}]}}
conflicts = slots.find_conflicts(
self._config(standalone=["ares"]), profile, REGIONS_DB,
standalone_cores={"ares"},
)
self.assertEqual(len(conflicts), 1)
def test_a_profile_without_a_standalone_build_keeps_its_core_layout(self):
profile = {"ares": {"type": "libretro", "cores": ["ares"],
"files": [{"name": "IPL.bin",
"path": "GC/JAP/IPL.bin",
"standalone_path": "elsewhere/IPL.bin"}]}}
claims = slots.profile_claims(
profile, REGIONS_DB, standalone_cores={"ares"}
)
self.assertEqual(claims[0].destination, "GC/JAP/IPL.bin")
class TestProvenEvidence(unittest.TestCase):
"""What counts as proof that a claim is about content, not about a name."""
def test_hash_evidence_is_proof(self):
self.assertTrue(slots.Claim("profile", "d", "n", status="sha1_exact").is_proven)
self.assertTrue(slots.Claim("profile", "d", "n", status="md5_exact").is_proven)
def test_path_evidence_is_proof(self):
# Dolphin declares no checksum for the GameCube boot ROM because its
# source declares none; the path is then the whole assertion.
self.assertTrue(slots.Claim("profile", "d", "n", status="path_exact").is_proven)
def test_a_name_is_not_proof(self):
self.assertFalse(slots.Claim("profile", "d", "n", status="name_exact").is_proven)
self.assertFalse(
slots.Claim("profile", "d", "n", status="hash_mismatch").is_proven
)
class ManifestFollowsTheArbitration(unittest.TestCase):
"""The installer is handed the file the ZIP carries on a contested slot."""
def test_retroarch_manifest_serves_every_override(self):
repo = Path(__file__).resolve().parents[1]
if not (repo / "database.json").is_file():
self.skipTest("no database.json")
# The database names files relative to the repository root, and this
# module's fixtures move the working directory elsewhere.
previous = os.getcwd()
os.chdir(repo)
self.addCleanup(os.chdir, previous)
import generate_pack
from common import (
build_zip_contents_index,
load_data_dir_registry,
load_database,
load_emulator_profiles,
load_platform_config,
)
db = load_database(str(repo / "database.json"))
config = load_platform_config("retroarch", str(repo / "platforms"))
profiles = load_emulator_profiles(str(repo / "emulators"))
overrides = slots.pack_overrides(
config, profiles, db, build_zip_contents_index(db),
load_data_dir_registry(str(repo / "platforms")),
)
if not overrides:
self.skipTest("no contested slot the pack settles on its own")
manifest = generate_pack.generate_manifest(
"retroarch", str(repo / "platforms"), db, str(repo / "bios"),
str(repo / "platforms" / "_registry.yml"),
emulators_dir=str(repo / "emulators"), emu_profiles=profiles, offline=True,
)
base = config.get("base_destination", "")
by_dest = {f["dest"]: f for f in manifest["files"]}
for destination, path in overrides.items():
dest = destination[len(base) + 1:] if base else destination
with self.subTest(destination=destination):
self.assertEqual(by_dest[dest]["repo_path"], path)
class StrongestProfileClaimWins(unittest.TestCase):
"""yaps2 pins GameIndex.yaml by sha1; armsx2 matched a path only."""
def test_hash_proof_beats_path_proof(self):
def claim(emu, path, status):
return Claim("profile", "pcsx2/resources/GameIndex.yaml", "GameIndex.yaml",
emulator=emu, local_path=path, status=status)
conflict = Conflict(
"pcsx2/resources/GameIndex.yaml",
Claim("platform", "pcsx2/resources/GameIndex.yaml", "GameIndex.yaml",
local_path="p", status="path_exact"),
[claim("armsx2", "a", "path_exact"), claim("yaps2", "y", "sha1_exact")],
)
decision = arbitrate(conflict, "existence")
self.assertEqual(decision.winner.emulator, "yaps2")
# armsx2 names no content: the file at the path answers it too.
line = format_decision(decision)
self.assertIn("(armsx2, yaps2)", line)
self.assertNotIn("expected another file", line)
pinned = Conflict(
conflict.destination,
conflict.platform_claim,
[claim("armsx2", "a", "md5_exact"), claim("yaps2", "y", "sha1_exact")],
)
# Both pin content: the tie goes to the first, the other is named.
self.assertIn(
"yaps2 expected another file",
format_decision(arbitrate(pinned, "existence")),
)
if __name__ == "__main__":
unittest.main()