mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 21:43:23 -05:00
651 lines
26 KiB
Python
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()
|