mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-10 21:43:23 -05:00
The verifier and the builder each resolved the profiles a run names, in thirty-five lines that differed only in how they failed: one exits, the other returns empty-handed. An alias is the same binary under another name and a launcher only starts an emulator, so neither has requirements of its own, and both refusals have to say the same thing. common raises now and each caller chooses its own ending. Six tests hold the refusals, one of them reading both sources so a copy cannot grow back. The manifest's core-complement phase comes out of generate_manifest in the same pass, 60 to 34. Verified inert: manifests identical entry for entry, and the Handy pack rebuilds to the same bytes.
482 lines
18 KiB
Python
482 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""The native-mode policy verify.py and generate_pack.py must share.
|
|
|
|
verify.py and generate_pack.py must reach the same verdict on the same file:
|
|
a pack and a coverage report that disagree describe different collections.
|
|
The rule used to rest on both files spelling out `mode == "existence"` in
|
|
their own words. Routing both through scripts/nativemode.py single-sources
|
|
it, and these tests make the agreement checkable instead of reviewable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
|
|
|
import nativemode # noqa: E402
|
|
|
|
|
|
class ModeVocabulary(unittest.TestCase):
|
|
def test_modes_match_the_platform_schema(self):
|
|
"""A mode added to one side and not the other is the bug this catches."""
|
|
schema = json.loads(
|
|
(REPO_ROOT / "schemas" / "platform.schema.json").read_text()
|
|
)
|
|
enum = schema["properties"]["verification_mode"]["enum"]
|
|
self.assertEqual(sorted(nativemode.MODES), sorted(enum))
|
|
|
|
def test_schema_default_matches_the_module_default(self):
|
|
schema = json.loads(
|
|
(REPO_ROOT / "schemas" / "platform.schema.json").read_text()
|
|
)
|
|
self.assertEqual(
|
|
schema["properties"]["verification_mode"]["default"],
|
|
nativemode.DEFAULT_MODE,
|
|
)
|
|
|
|
def test_unknown_or_missing_mode_falls_back_to_the_default(self):
|
|
for value in (None, "", "sha256", "MD5"):
|
|
self.assertEqual(nativemode.normalize(value), nativemode.DEFAULT_MODE)
|
|
|
|
def test_every_declared_platform_mode_is_known(self):
|
|
import yaml
|
|
|
|
for path in sorted((REPO_ROOT / "platforms").glob("*.yml")):
|
|
if path.name.startswith("_"):
|
|
continue
|
|
data = yaml.safe_load(path.read_text()) or {}
|
|
mode = data.get("verification_mode")
|
|
if mode is None:
|
|
continue
|
|
self.assertIn(mode, nativemode.MODES, f"{path.name} declares {mode!r}")
|
|
|
|
|
|
class ContentReadingPolicy(unittest.TestCase):
|
|
def test_existence_reads_nothing(self):
|
|
self.assertFalse(nativemode.reads_file_contents("existence"))
|
|
self.assertIsNone(nativemode.digest_algorithm("existence"))
|
|
|
|
def test_digest_modes_read_their_digest(self):
|
|
self.assertEqual(nativemode.digest_algorithm("md5"), "md5")
|
|
self.assertEqual(nativemode.digest_algorithm("sha1"), "sha1")
|
|
self.assertTrue(nativemode.reads_file_contents("md5"))
|
|
self.assertTrue(nativemode.reads_file_contents("sha1"))
|
|
|
|
def test_exclusion_follows_content_reading_for_every_mode(self):
|
|
"""The builder omits exactly what the frontend would reject."""
|
|
for mode in nativemode.MODES:
|
|
self.assertEqual(
|
|
nativemode.hash_mismatch_excludes_file(mode),
|
|
nativemode.reads_file_contents(mode),
|
|
f"mode {mode} disagrees with itself",
|
|
)
|
|
|
|
|
|
class BothConsumersAgree(unittest.TestCase):
|
|
"""verify.py and generate_pack.py must answer the same for every mode."""
|
|
|
|
def test_builder_and_verifier_share_one_predicate(self):
|
|
from generate_pack import _intentional_hash_exclusion
|
|
from verify import compute_severity # noqa: F401 (import proves wiring)
|
|
|
|
for mode in nativemode.MODES:
|
|
# No entries is never an exclusion, whatever the mode.
|
|
self.assertFalse(_intentional_hash_exclusion([], {}, verification_mode=mode))
|
|
|
|
# The builder must refuse to exclude under existence even when asked.
|
|
self.assertFalse(
|
|
_intentional_hash_exclusion(
|
|
[{"name": "x.bin"}], {}, verification_mode="existence"
|
|
)
|
|
)
|
|
|
|
def test_severity_uses_the_shared_predicate(self):
|
|
from verify import Severity, Status, compute_severity
|
|
|
|
# Existence mode: a missing required file is a warning, not critical.
|
|
self.assertEqual(
|
|
compute_severity(Status.MISSING, True, "existence"),
|
|
Severity.WARNING,
|
|
)
|
|
# Digest mode: the same absence is critical.
|
|
self.assertEqual(
|
|
compute_severity(Status.MISSING, True, "md5"),
|
|
Severity.CRITICAL,
|
|
)
|
|
# An unknown mode must fall back to existence, not to the strictest
|
|
# reading: a typo in a platform YAML should not invent CRITICALs.
|
|
self.assertEqual(
|
|
compute_severity(Status.MISSING, True, "sha256"),
|
|
Severity.WARNING,
|
|
)
|
|
|
|
|
|
class ThePolicyIsSpeltInOnePlace(unittest.TestCase):
|
|
"""Consumers ask the module; they do not re-spell the rule.
|
|
|
|
Four sites still compared the mode to a literal after this module existed,
|
|
and one of them dispatched an unknown mode to MD5 verification while
|
|
compute_severity was scoring it as existence.
|
|
"""
|
|
|
|
LITERALS = ('!= "existence"', '== "existence"', 'in ("md5", "sha1")')
|
|
|
|
def test_no_consumer_compares_the_mode_to_a_literal(self):
|
|
scripts = Path(__file__).resolve().parent.parent / "scripts"
|
|
offenders = []
|
|
for path in sorted(scripts.glob("*.py")):
|
|
if path.name == "nativemode.py":
|
|
continue
|
|
for number, line in enumerate(path.read_text().splitlines(), 1):
|
|
if any(literal in line for literal in self.LITERALS):
|
|
offenders.append(f"{path.name}:{number}: {line.strip()}")
|
|
self.assertEqual(
|
|
offenders, [],
|
|
"route these through nativemode: " + "; ".join(offenders),
|
|
)
|
|
|
|
def test_an_unknown_mode_verifies_the_way_it_is_scored(self):
|
|
"""A typo in a platform YAML must not verify one way and score another."""
|
|
import verify
|
|
|
|
self.assertFalse(nativemode.reads_file_contents("sha256"))
|
|
self.assertEqual(
|
|
verify.compute_severity(verify.Status.MISSING, True, "sha256"),
|
|
verify.Severity.WARNING,
|
|
)
|
|
config = {
|
|
"platform": "Typo",
|
|
"verification_mode": "shaa1",
|
|
"systems": {},
|
|
"cores": [],
|
|
}
|
|
result = verify.verify_platform(
|
|
config, {"files": {}, "indexes": {}}, emu_profiles={},
|
|
supplemental_names=set(),
|
|
)
|
|
self.assertEqual(result["verification_mode"], nativemode.DEFAULT_MODE)
|
|
|
|
|
|
class GapAnalysisAgreesWithTheBuilder(unittest.TestCase):
|
|
""""Available" must mean the pack will carry it.
|
|
|
|
find_undeclared_files answered from the name index, so a core extra whose
|
|
local copy contradicts its declared hash counted as held. Under a digest
|
|
mode the builder drops exactly that file, so the coverage report described
|
|
a pack that would not contain it: seven such files across three platforms.
|
|
"""
|
|
|
|
def _fixture(self):
|
|
import hashlib
|
|
import tempfile
|
|
|
|
tmp = tempfile.TemporaryDirectory()
|
|
root = Path(tmp.name)
|
|
(root / "emulators").mkdir()
|
|
rom = root / "collide.rom"
|
|
rom.write_bytes(b"THE BYTES THE COLLECTION HOLDS")
|
|
sha1 = hashlib.sha1(rom.read_bytes()).hexdigest()
|
|
db = {
|
|
"files": {
|
|
sha1: {
|
|
"path": str(rom),
|
|
"name": "collide.rom",
|
|
"size": rom.stat().st_size,
|
|
"sha1": sha1,
|
|
"md5": hashlib.md5(rom.read_bytes()).hexdigest(),
|
|
"sha256": hashlib.sha256(rom.read_bytes()).hexdigest(),
|
|
"crc32": "00000000",
|
|
}
|
|
},
|
|
"indexes": {
|
|
"by_name": {"collide.rom": [sha1]},
|
|
"by_md5": {hashlib.md5(rom.read_bytes()).hexdigest(): sha1},
|
|
"by_sha256": {},
|
|
"by_crc32": {},
|
|
"by_path_suffix": {},
|
|
},
|
|
}
|
|
(root / "emulators" / "demo.yml").write_text(
|
|
"emulator: demo\n"
|
|
"type: libretro\n"
|
|
"display_name: Demo\n"
|
|
"systems: [demo-system]\n"
|
|
"cores: [demo]\n"
|
|
"files:\n"
|
|
" - name: collide.rom\n"
|
|
" system: demo-system\n"
|
|
" required: true\n"
|
|
" md5: \"" + "f" * 32 + "\"\n"
|
|
)
|
|
return tmp, root, db
|
|
|
|
def _in_repo(self, mode: str) -> bool:
|
|
import common
|
|
from verify import find_undeclared_files
|
|
|
|
tmp, root, db = self._fixture()
|
|
try:
|
|
common._emulator_profiles_cache.clear()
|
|
profiles = common.load_emulator_profiles(str(root / "emulators"))
|
|
config = {
|
|
"platform": "Demo",
|
|
"verification_mode": mode,
|
|
"cores": ["demo"],
|
|
"systems": {},
|
|
}
|
|
found = find_undeclared_files(
|
|
config, str(root / "emulators"), db, emu_profiles=profiles
|
|
)
|
|
entry = next(e for e in found if e["name"] == "collide.rom")
|
|
return bool(entry["in_repo"])
|
|
finally:
|
|
common._emulator_profiles_cache.clear()
|
|
tmp.cleanup()
|
|
|
|
def test_a_digest_mode_does_not_call_a_contradicted_copy_available(self):
|
|
self.assertFalse(
|
|
self._in_repo("md5"),
|
|
"the builder drops this file, so the report must not count it",
|
|
)
|
|
|
|
def test_existence_mode_still_counts_it(self):
|
|
"""The frontend never opens the file, so the pack carries it."""
|
|
self.assertTrue(self._in_repo("existence"))
|
|
|
|
def test_the_two_modes_answer_the_way_the_shared_predicate_says(self):
|
|
for mode in nativemode.MODES:
|
|
with self.subTest(mode=mode):
|
|
self.assertEqual(
|
|
self._in_repo(mode),
|
|
not nativemode.hash_mismatch_excludes_file(mode),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|
|
|
|
|
|
class CandidateVerdict(unittest.TestCase):
|
|
"""Whether a profile entry can be a gap, and whether it is settled.
|
|
|
|
The filter chain mixed two kinds of skip. A settled entry is answered for
|
|
-the platform declares it, the profile calls it unsourceable -so the same
|
|
requirement reached from another profile must not be reconsidered. A
|
|
skipped one merely does not apply here: the same file can be libretro-only
|
|
in one profile and standalone-only in another, and recording it from the
|
|
profile that cannot use it would hide it from the one that can.
|
|
"""
|
|
|
|
def verdict(self, entry, *, standalone=False, include_all=False,
|
|
declared=frozenset()):
|
|
from verify import _candidate_verdict
|
|
|
|
return _candidate_verdict(
|
|
entry, entry.get("name", ""), standalone, include_all, set(declared)
|
|
)
|
|
|
|
def test_a_plain_requirement_is_a_candidate(self):
|
|
self.assertEqual(self.verdict({"name": "bios.bin"}), "keep")
|
|
|
|
def test_a_declared_file_is_settled(self):
|
|
self.assertEqual(
|
|
self.verdict({"name": "bios.bin"}, declared={"bios.bin"}), "settled"
|
|
)
|
|
|
|
def test_a_declared_archive_settles_its_entry(self):
|
|
self.assertEqual(
|
|
self.verdict(
|
|
{"name": "rom.bin", "archive": "neogeo.zip"},
|
|
declared={"neogeo.zip"},
|
|
),
|
|
"settled",
|
|
)
|
|
|
|
def test_include_all_ignores_what_the_platform_declares(self):
|
|
"""Ground-truth mode reports the core's needs, not the platform's list."""
|
|
self.assertEqual(
|
|
self.verdict(
|
|
{"name": "bios.bin"}, declared={"bios.bin"}, include_all=True
|
|
),
|
|
"keep",
|
|
)
|
|
|
|
def test_an_unsourceable_entry_is_settled(self):
|
|
self.assertEqual(
|
|
self.verdict({"name": "font.rom", "unsourceable": "in a paid package"}),
|
|
"settled",
|
|
)
|
|
|
|
def test_a_placeholder_names_a_family_not_a_file(self):
|
|
for name in ("<region>.png", "disk*.rom", "<user-selected>.bin"):
|
|
self.assertEqual(self.verdict({"name": name}), "skip", name)
|
|
|
|
def test_a_null_path_means_the_user_imports_it(self):
|
|
self.assertEqual(self.verdict({"name": "key.bin", "path": None}), "skip")
|
|
|
|
def test_mode_mismatches_are_skipped_but_left_open(self):
|
|
"""Not settled: the profile that can use the file must still see it."""
|
|
self.assertEqual(
|
|
self.verdict({"name": "b.bin", "mode": "standalone"}, standalone=False),
|
|
"skip",
|
|
)
|
|
self.assertEqual(
|
|
self.verdict({"name": "b.bin", "mode": "libretro"}, standalone=True),
|
|
"skip",
|
|
)
|
|
self.assertEqual(
|
|
self.verdict({"name": "b.bin", "mode": "standalone"}, standalone=True),
|
|
"keep",
|
|
)
|
|
|
|
def test_a_file_read_from_elsewhere_is_not_a_bios_gap(self):
|
|
self.assertEqual(
|
|
self.verdict({"name": "save.bin", "load_from": "save_dir"}), "skip"
|
|
)
|
|
self.assertEqual(
|
|
self.verdict({"name": "b.bin", "load_from": "system_dir"}), "keep"
|
|
)
|
|
|
|
def test_an_agnostic_entry_is_answered_by_the_builders_scan(self):
|
|
self.assertEqual(
|
|
self.verdict({"name": "any.bin", "agnostic": True}), "skip"
|
|
)
|
|
|
|
|
|
class SkippingIsNotSettling(unittest.TestCase):
|
|
"""A profile that cannot use a file must not answer for one that can.
|
|
|
|
The key recording a settled requirement carries the name, path, system and
|
|
variant, not the emulator, so two profiles can reach the same key. If the
|
|
one where the entry does not apply records it, the entry vanishes from the
|
|
report for the profile that does need it.
|
|
"""
|
|
|
|
def _report(self):
|
|
import hashlib
|
|
import tempfile
|
|
|
|
import common
|
|
from verify import find_undeclared_files
|
|
|
|
tmp = tempfile.TemporaryDirectory()
|
|
root = Path(tmp.name)
|
|
(root / "emulators").mkdir()
|
|
rom = root / "shared.bin"
|
|
rom.write_bytes(b"SHARED REQUIREMENT")
|
|
sha1 = hashlib.sha1(rom.read_bytes()).hexdigest()
|
|
db = {
|
|
"files": {sha1: {"path": str(rom), "name": "shared.bin",
|
|
"size": rom.stat().st_size, "sha1": sha1,
|
|
"md5": hashlib.md5(rom.read_bytes()).hexdigest()}},
|
|
"indexes": {"by_name": {"shared.bin": [sha1]}, "by_md5": {},
|
|
"by_sha256": {}, "by_crc32": {}, "by_path_suffix": {}},
|
|
}
|
|
# "a_" sorts first, so the profile that cannot use the file is seen
|
|
# before the one that can.
|
|
for slug, mode in (("a_standalone_only", "standalone"), ("b_libretro", None)):
|
|
entry = " - name: shared.bin\n system: demo-system\n"
|
|
if mode:
|
|
entry += f" mode: {mode}\n"
|
|
(root / "emulators" / f"{slug}.yml").write_text(
|
|
f"emulator: {slug}\n"
|
|
"type: libretro\n"
|
|
f"display_name: {slug}\n"
|
|
"systems: [demo-system]\n"
|
|
f"cores: [{slug}]\n"
|
|
"files:\n" + entry
|
|
)
|
|
try:
|
|
common._emulator_profiles_cache.clear()
|
|
profiles = common.load_emulator_profiles(str(root / "emulators"))
|
|
config = {
|
|
"platform": "Demo", "verification_mode": "existence",
|
|
"cores": ["a_standalone_only", "b_libretro"], "systems": {},
|
|
}
|
|
found = find_undeclared_files(
|
|
config, str(root / "emulators"), db, profiles, data_names=set()
|
|
)
|
|
return {(u["emulator"], u["name"]) for u in found}
|
|
finally:
|
|
common._emulator_profiles_cache.clear()
|
|
tmp.cleanup()
|
|
|
|
def test_the_profile_that_needs_the_file_still_reports_it(self):
|
|
reported = self._report()
|
|
self.assertIn(
|
|
("b_libretro", "shared.bin"), reported,
|
|
"a standalone-only entry seen first must not settle the requirement",
|
|
)
|
|
|
|
def test_the_profile_that_cannot_use_it_does_not_report_it(self):
|
|
self.assertNotIn(("a_standalone_only", "shared.bin"), self._report())
|
|
|
|
|
|
class OneProfileSelector(unittest.TestCase):
|
|
"""The verifier and the builder refuse the same names for the same reasons.
|
|
|
|
Both resolved the named profiles themselves, in thirty-five lines that
|
|
differed only in how they failed: one exits, the other returns
|
|
empty-handed. Two copies of a refusal drift the way every other pair in
|
|
this repository has.
|
|
"""
|
|
|
|
PROFILES = {
|
|
"real": {"emulator": "Real", "type": "libretro", "files": []},
|
|
"ghost": {"emulator": "Ghost", "type": "alias", "alias_of": "real"},
|
|
"starter": {"emulator": "Starter", "type": "launcher"},
|
|
"lib_only": {"emulator": "LibOnly", "type": "libretro"},
|
|
}
|
|
|
|
def _select(self, names, standalone=False):
|
|
from common import ProfileSelectionError, select_emulator_profiles
|
|
|
|
try:
|
|
return select_emulator_profiles(names, self.PROFILES, standalone), None
|
|
except ProfileSelectionError as exc:
|
|
return None, str(exc)
|
|
|
|
def test_a_real_profile_resolves(self):
|
|
selected, error = self._select(["real"])
|
|
self.assertIsNone(error)
|
|
self.assertEqual([n for n, _p in selected], ["real"])
|
|
|
|
def test_an_alias_names_the_profile_to_ask_instead(self):
|
|
_selected, error = self._select(["ghost"])
|
|
self.assertIn("alias of real", error)
|
|
self.assertIn("--emulator real", error)
|
|
|
|
def test_a_launcher_is_refused(self):
|
|
_selected, error = self._select(["starter"])
|
|
self.assertIn("launcher", error)
|
|
|
|
def test_standalone_is_refused_on_a_libretro_only_profile(self):
|
|
_selected, error = self._select(["lib_only"], standalone=True)
|
|
self.assertIn("does not support --standalone", error)
|
|
self.assertIsNone(self._select(["lib_only"])[1])
|
|
|
|
def test_an_unknown_name_lists_what_exists(self):
|
|
_selected, error = self._select(["absent"])
|
|
self.assertIn("not found", error)
|
|
self.assertIn("real", error)
|
|
self.assertNotIn("ghost", error, "an alias is not something to suggest")
|
|
|
|
def test_both_entry_points_route_through_it(self):
|
|
"""A copy would let one accept what the other refuses."""
|
|
scripts = Path(__file__).resolve().parent.parent / "scripts"
|
|
for name in ("verify.py", "generate_pack.py"):
|
|
text = (scripts / name).read_text()
|
|
self.assertIn(
|
|
"select_emulator_profiles", text,
|
|
f"{name} must ask common, not re-derive the refusal",
|
|
)
|
|
self.assertNotIn(
|
|
'is a launcher -use the emulator it launches', text,
|
|
f"{name} still spells the refusal itself",
|
|
)
|