mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-11 14:03:23 -05:00
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),
|
|
)
|
|
|
|
|
|
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",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|