mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
feat: export md5 lists the checker reads
This commit is contained in:
commit
b657cff327
8 files changed
+424
-25
No files matched your search
@@ -483,6 +483,15 @@ class LandedChanges:
|
||||
def requirements(self) -> int:
|
||||
return sum(r["kind"] == "requirement_corrected" for r in self.records)
|
||||
|
||||
@property
|
||||
def lists(self) -> int:
|
||||
"""Entries whose md5 is written as a list of accepted values."""
|
||||
return sum(
|
||||
isinstance(r["new"], list)
|
||||
or (isinstance(r["new"], dict) and isinstance(r["new"].get("md5"), list))
|
||||
for r in self.records
|
||||
)
|
||||
|
||||
|
||||
def _change(
|
||||
kind: str, entry: object, field_name: str | None, old: object, new: object
|
||||
@@ -498,8 +507,18 @@ def _change(
|
||||
}
|
||||
|
||||
|
||||
def _platform_value(entry: object, field_name: str) -> str:
|
||||
return ",".join(_hash_values(entry.platform or {}, field_name))
|
||||
def _platform_value(entry: object, field_name: str) -> str | list[str]:
|
||||
values = _hash_values(entry.platform or {}, field_name)
|
||||
if (entry.platform or {}).get(f"{field_name}_list"):
|
||||
return values
|
||||
return ",".join(values)
|
||||
|
||||
|
||||
def _written_value(exporter: object, entry: object, field_name: str) -> str | list[str]:
|
||||
"""A hash field as the render writes it: a list where the checker reads
|
||||
one, so the report never shows several values as one."""
|
||||
listed = exporter.written_list(entry, field_name)
|
||||
return listed if listed is not None else exporter.written(entry, field_name)
|
||||
|
||||
|
||||
def _added_fields(exporter: object, entry: object) -> dict:
|
||||
@@ -508,7 +527,7 @@ def _added_fields(exporter: object, entry: object) -> dict:
|
||||
written: dict[str, object] = {
|
||||
name: value
|
||||
for name in HASH_FIELDS
|
||||
if name in carried and (value := exporter.written(entry, name))
|
||||
if name in carried and (value := _written_value(exporter, entry, name))
|
||||
}
|
||||
if "size" in carried and entry.size() is not None:
|
||||
written["size"] = entry.size()
|
||||
@@ -560,11 +579,27 @@ def landed_changes(exporter: object, systems: dict) -> LandedChanges:
|
||||
records.append(_change(
|
||||
"hash_corrected", entry, field_name,
|
||||
_platform_value(entry, field_name),
|
||||
exporter.written(entry, field_name),
|
||||
_written_value(exporter, entry, field_name),
|
||||
))
|
||||
# A list the truth extends with values the platform's own lacks:
|
||||
# no contradiction, so no correction in the model, and still a
|
||||
# new value in the file.
|
||||
for field_name in HASH_FIELDS:
|
||||
listed = exporter.written_list(entry, field_name)
|
||||
if (
|
||||
listed is not None
|
||||
and field_name not in entry.corrections
|
||||
and field_name not in entry.filled
|
||||
and listed != _hash_values(entry.platform, field_name)
|
||||
and exporter.states(entry, field_name)
|
||||
):
|
||||
records.append(_change(
|
||||
"hash_corrected", entry, field_name,
|
||||
_platform_value(entry, field_name), listed,
|
||||
))
|
||||
records.extend(
|
||||
_change("hash_filled", entry, field_name, None,
|
||||
exporter.written(entry, field_name))
|
||||
_written_value(exporter, entry, field_name))
|
||||
for field_name in entry.filled
|
||||
if exporter.fills(entry, field_name)
|
||||
)
|
||||
@@ -671,6 +706,7 @@ def export_platform(
|
||||
resized, cleared = changes.resized, changes.cleared
|
||||
unsized = changes.unsized
|
||||
requirements = changes.requirements
|
||||
lists = changes.lists
|
||||
|
||||
landed = 0
|
||||
refused = 0
|
||||
@@ -703,6 +739,7 @@ def export_platform(
|
||||
(len(unsized), "size", "", "removed"),
|
||||
(len(cleared), "hash", "", "removed"),
|
||||
(lost, "entry", "entries", "removed, the format cannot state them"),
|
||||
(lists, "md5 list", "", "written, any listed value passes"),
|
||||
)
|
||||
parts += [
|
||||
f"{_count(number, noun, plural)} {verb}"
|
||||
|
||||
@@ -9,7 +9,6 @@ every build, and an addition is written the same way.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .base_exporter import BaseExporter
|
||||
from .baseline import NativeFile
|
||||
from .batocera_exporter import Exporter as BatoceraExporter
|
||||
|
||||
@@ -24,10 +23,8 @@ class Exporter(BatoceraExporter):
|
||||
|
||||
PATH_PREFIX = ""
|
||||
|
||||
@classmethod
|
||||
def writable(cls, fe: NativeFile, require: str = "") -> bool:
|
||||
# The list gates nothing on a runner, so an addition needs none.
|
||||
return BaseExporter.writable.__func__(cls, fe, require)
|
||||
# The list gates nothing on a runner, so an addition needs none.
|
||||
RUNNER_GATED = False
|
||||
|
||||
def _addition_keys(self, fe: NativeFile) -> list[tuple[str, str | None]] | None:
|
||||
return []
|
||||
|
||||
@@ -171,11 +171,16 @@ class BaseExporter(ABC):
|
||||
hands back a file poorer than the one it corrects. The requirement
|
||||
only gates what we would be adding.
|
||||
"""
|
||||
return cls._carried(fe, require, cls.SINGLE_HASH_FIELD)
|
||||
|
||||
@classmethod
|
||||
def _carried(cls, fe: NativeFile, require: str, single: str | None) -> bool:
|
||||
"""writable(), with the field held to one value named by the caller:
|
||||
a checker that reads a list for an entry holds it to none."""
|
||||
if fe.platform is not None:
|
||||
return True
|
||||
if not cls.can_add() or (cls.keys_by_system() and fe.foreign_system):
|
||||
return False
|
||||
single = cls.SINGLE_HASH_FIELD
|
||||
if single and len(set(fe.hashes(single))) > 1:
|
||||
# One value checked: written with the first, the addition
|
||||
# refuses every other dump the core accepts.
|
||||
@@ -207,6 +212,12 @@ class BaseExporter(ABC):
|
||||
return self.single_value(fe, field_name)
|
||||
return ",".join(fe.hashes(field_name))
|
||||
|
||||
def written_list(self, fe: NativeFile, field_name: str) -> list[str] | None:
|
||||
"""The values of a hash field the render writes as a list, None when
|
||||
it writes one string. The report shows a list as a list: joined, it
|
||||
reads as one value the checker never compares."""
|
||||
return None
|
||||
|
||||
def required_value(self, fe: NativeFile) -> bool:
|
||||
"""The requirement the render writes for an entry.
|
||||
|
||||
|
||||
@@ -58,6 +58,80 @@ def _closing_line(text: str, start: int) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _md5_subscript(node: ast.expr) -> bool:
|
||||
"""`file["md5"]`: an item's md5 as checkBios reads it."""
|
||||
return (
|
||||
isinstance(node, ast.Subscript)
|
||||
and isinstance(node.slice, ast.Constant)
|
||||
and node.slice.value == "md5"
|
||||
)
|
||||
|
||||
|
||||
def _names_member(call: ast.Call) -> bool:
|
||||
"""Whether a call also passes the item's zippedFile."""
|
||||
return any(
|
||||
isinstance(arg, ast.Subscript)
|
||||
and isinstance(arg.slice, ast.Constant)
|
||||
and arg.slice.value == "zippedFile"
|
||||
for arg in call.args
|
||||
)
|
||||
|
||||
|
||||
def _list_parameters(function: ast.FunctionDef) -> set[str]:
|
||||
"""Parameters the function tests with `isinstance(<parameter>, list)`."""
|
||||
names = {arg.arg for arg in function.args.args}
|
||||
return {
|
||||
node.args[0].id
|
||||
for node in ast.walk(function)
|
||||
if isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "isinstance"
|
||||
and len(node.args) == 2
|
||||
and isinstance(node.args[0], ast.Name)
|
||||
and node.args[0].id in names
|
||||
and isinstance(node.args[1], ast.Name)
|
||||
and node.args[1].id == "list"
|
||||
}
|
||||
|
||||
|
||||
def md5_list_support(script: str) -> frozenset[str]:
|
||||
"""The items whose md5 the script's checker reads as a list.
|
||||
|
||||
"file" when checkBios hands a plain file's md5 to a function that takes
|
||||
a list (batocera.linux 6b08b873: `md5Matches(md5, file["md5"])`),
|
||||
"zippedFile" when it does so for a member of an archive
|
||||
(`checkInsideZip(filepath, file["zippedFile"], file["md5"])`). Read from
|
||||
the parsed checker, never executed and never from a version number:
|
||||
a fork can take the change at any revision, or never.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(script)
|
||||
except SyntaxError:
|
||||
return frozenset()
|
||||
functions = {
|
||||
node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
check = functions.get("checkBios")
|
||||
if check is None:
|
||||
return frozenset()
|
||||
support: set[str] = set()
|
||||
for call in ast.walk(check):
|
||||
if not (isinstance(call, ast.Call) and isinstance(call.func, ast.Name)):
|
||||
continue
|
||||
callee = functions.get(call.func.id)
|
||||
if callee is None:
|
||||
continue
|
||||
takes_list = _list_parameters(callee)
|
||||
parameters = [arg.arg for arg in callee.args.args]
|
||||
if any(
|
||||
_md5_subscript(arg) and index < len(parameters)
|
||||
and parameters[index] in takes_list
|
||||
for index, arg in enumerate(call.args)
|
||||
):
|
||||
support.add("zippedFile" if _names_member(call) else "file")
|
||||
return frozenset(support)
|
||||
|
||||
|
||||
def _md5_literal(value: str | list[str], spaced: bool = True) -> str:
|
||||
"""An md5 as the script writes it: a quoted string, or a list of them
|
||||
with Batocera's spacing inside the brackets (`[ "a", "b" ]`)."""
|
||||
@@ -82,9 +156,14 @@ class Exporter(BaseExporter):
|
||||
# checkBios compares one md5 for a plain file. altmd5 is read only for
|
||||
# a zippedFile: retrodream's boot.bin written with the first of four
|
||||
# md5 refused the three other dumps the core accepts. An item the
|
||||
# platform writes with a list of md5 keeps its list (md5_list).
|
||||
# platform writes with a list of md5 keeps its list (md5_list), and a
|
||||
# checker that reads lists (md5_list_support) gets every accepted md5.
|
||||
SINGLE_HASH_FIELD = "md5"
|
||||
|
||||
# Whether an addition must name the emulator and core checkBios gates
|
||||
# it on.
|
||||
RUNNER_GATED = True
|
||||
|
||||
# checkBios reads emulator names from es_systems, and Batocera's
|
||||
# standalone_cores are those names. ROCKNIX's list holds package names
|
||||
# (duckstation-sa), which its checker does not know.
|
||||
@@ -100,6 +179,9 @@ class Exporter(BaseExporter):
|
||||
# The emulator and core each system entry of the original is gated
|
||||
# on, read by render.
|
||||
self._gates: dict[str, tuple[str, str] | None] = {}
|
||||
# The items the pinned checker reads an md5 list for, read by
|
||||
# render from the platform's own file.
|
||||
self._md5_lists: frozenset[str] = frozenset()
|
||||
|
||||
@staticmethod
|
||||
def platform_name() -> str:
|
||||
@@ -121,15 +203,15 @@ class Exporter(BaseExporter):
|
||||
def carries() -> frozenset[str]:
|
||||
return frozenset({"md5"})
|
||||
|
||||
@classmethod
|
||||
def writable(cls, fe: NativeFile, require: str = "") -> bool:
|
||||
def writable(self, fe: NativeFile, require: str = "") -> bool:
|
||||
"""An addition the format can state whole."""
|
||||
if fe.platform is None and not cls._usable_runners(fe):
|
||||
if fe.platform is None and self.RUNNER_GATED and not self._usable_runners(fe):
|
||||
# Written without its core's emulator and core, checkBios asks
|
||||
# every build for it (the defect _runner_keys repairs for the
|
||||
# platform's own items).
|
||||
return False
|
||||
return super().writable(fe, require)
|
||||
single = None if self.lists_accepted(fe) else self.SINGLE_HASH_FIELD
|
||||
return self._carried(fe, require, single)
|
||||
|
||||
@classmethod
|
||||
def _usable_runners(cls, fe: NativeFile) -> list[tuple[bool, str]]:
|
||||
@@ -178,11 +260,33 @@ class Exporter(BaseExporter):
|
||||
return False
|
||||
return fe.platform is not None or self._addition_keys(fe) is not None
|
||||
|
||||
@staticmethod
|
||||
def md5_list(fe: NativeFile) -> bool:
|
||||
"""Whether the platform writes this item's md5 as a list. Its
|
||||
checker then accepts any listed value, so every one is written."""
|
||||
return fe.platform is not None and bool(fe.platform.get("md5_list"))
|
||||
def md5_list(self, fe: NativeFile) -> bool:
|
||||
"""Whether this item's md5 is written as a list: the platform wrote
|
||||
it so, or the truth accepts several and the checker reads a list."""
|
||||
if fe.platform is not None and fe.platform.get("md5_list"):
|
||||
return True
|
||||
return self.lists_accepted(fe)
|
||||
|
||||
def lists_accepted(self, fe: NativeFile) -> bool:
|
||||
"""Whether the pinned checker reads a list for this item and the
|
||||
truth accepts more than one md5 for it.
|
||||
|
||||
A single md5 stays a string, as the maintainers write it. An item
|
||||
with altmd5 stays one string: checkBios hands
|
||||
`[file["md5"], file["altmd5"]]` to checkInsideZip, and a list there
|
||||
nests a list no digest equals.
|
||||
"""
|
||||
kind = "zippedFile" if fe.native("zipped_file", "") else "file"
|
||||
return (
|
||||
kind in self._md5_lists
|
||||
and not fe.native("alt_md5", "")
|
||||
and len(set(fe.hashes("md5"))) > 1
|
||||
)
|
||||
|
||||
def written_list(self, fe: NativeFile, field_name: str) -> list[str] | None:
|
||||
if field_name == "md5" and self.md5_list(fe):
|
||||
return fe.hashes("md5")
|
||||
return None
|
||||
|
||||
def md5_value(self, fe: NativeFile) -> str | list[str]:
|
||||
"""The md5 an item is written with, in the form the platform used."""
|
||||
@@ -525,6 +629,7 @@ class Exporter(BaseExporter):
|
||||
"script, and the rest of it is the checker"
|
||||
)
|
||||
|
||||
self._md5_lists = md5_list_support(original)
|
||||
head, body, tail = self._split_original(original)
|
||||
spans = self._entry_spans(body)
|
||||
for key, (start, end) in spans.items():
|
||||
|
||||
@@ -40,6 +40,9 @@ CHANGE_RECORDS = {
|
||||
|
||||
|
||||
def _value(value: object) -> str:
|
||||
"""One value in code, or a list the checker reads as any of its values."""
|
||||
if isinstance(value, list):
|
||||
return " or ".join(f"`{v}`" for v in value) if value else "`[]`"
|
||||
return f"`{value}`"
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from common import resolve_local_file # noqa: E402
|
||||
from exporter import discover_exporters # noqa: E402
|
||||
from exporter.baseline import build_native_model # noqa: E402
|
||||
from export_native import landed_changes # noqa: E402
|
||||
from exporter.baseline import NativeFile, build_native_model # noqa: E402
|
||||
from exporter.batocera_exporter import md5_list_support # noqa: E402
|
||||
from scraper import rocknix_scraper # noqa: E402
|
||||
from scraper.base_scraper import md5_field, requirement_entry # noqa: E402
|
||||
from scraper.batocera_scraper import ( # noqa: E402
|
||||
@@ -30,6 +32,7 @@ from scraper.batocera_scraper import ( # noqa: E402
|
||||
platform_systems,
|
||||
requirements_from_systems,
|
||||
)
|
||||
from siteexports import describe_change # noqa: E402
|
||||
from verify import Status, verify_entry_md5 # noqa: E402
|
||||
|
||||
A = "dc2b9bf8da62ec93e868cfd29f0d067d"
|
||||
@@ -204,5 +207,235 @@ class VerifyAcceptsAnyListedMd5(unittest.TestCase):
|
||||
self.assertEqual(verify_entry_md5(entry, str(archive))["status"], Status.OK)
|
||||
|
||||
|
||||
# checkBios as batocera.linux 6b08b873 writes it: a plain file's md5 goes
|
||||
# through md5Matches, a member's through checkInsideZip, both reading lists.
|
||||
LIST_CHECKER = """def md5Matches(computed, expected):
|
||||
if isinstance(expected, list):
|
||||
expectedList = expected
|
||||
else:
|
||||
expectedList = [expected]
|
||||
return computed in expectedList
|
||||
|
||||
def checkInsideZip(container, fileName, md5sums):
|
||||
if not isinstance(md5sums, list):
|
||||
md5sums = [md5sums]
|
||||
import zipfile
|
||||
from hashlib import md5
|
||||
with zipfile.ZipFile(container) as archive:
|
||||
for fname in archive.namelist():
|
||||
if fname.casefold() == fileName.casefold():
|
||||
if md5sums == [""] or md5sums == []:
|
||||
return True
|
||||
return md5(archive.read(fname)).hexdigest() in md5sums
|
||||
return False
|
||||
|
||||
def checkBios(systems, prefix, filterROMs):
|
||||
from hashlib import md5
|
||||
from os.path import isfile
|
||||
missingBios = {}
|
||||
for system in systems.keys():
|
||||
for file in systems[system]["biosFiles"]:
|
||||
filepath = prefix + "/" + file["file"]
|
||||
if not isfile(filepath):
|
||||
missingBios.setdefault(system, {})[file["file"]] = "MISSING"
|
||||
elif not "zippedFile" in file:
|
||||
with open(filepath, "rb") as handle:
|
||||
digest = md5(handle.read()).hexdigest()
|
||||
if not md5Matches(digest, file["md5"]) and file["md5"] != "" and file["md5"] != []:
|
||||
missingBios.setdefault(system, {})[file["file"]] = "UNTESTED"
|
||||
elif 'altmd5' in file:
|
||||
if not checkInsideZip(filepath, file["zippedFile"], [file["md5"], file["altmd5"]]):
|
||||
missingBios.setdefault(system, {})[file["file"]] = "UNTESTED"
|
||||
elif not checkInsideZip(filepath, file["zippedFile"], file["md5"]):
|
||||
missingBios.setdefault(system, {})[file["file"]] = "UNTESTED"
|
||||
return missingBios
|
||||
"""
|
||||
|
||||
# batocera-43.1's checker: one md5, compared with `!=`.
|
||||
STRING_CHECKER = """def checkInsideZip(container, fileName, md5sum):
|
||||
return md5sum == ""
|
||||
|
||||
def checkBios(systems, prefix, filterROMs):
|
||||
missingBios = {}
|
||||
for system in systems.keys():
|
||||
for file in systems[system]["biosFiles"]:
|
||||
if "zippedFile" in file:
|
||||
checkInsideZip(prefix, file["zippedFile"], file["md5"])
|
||||
elif "" != file["md5"]:
|
||||
missingBios[system] = file["file"]
|
||||
return missingBios
|
||||
"""
|
||||
|
||||
DATA = SCRIPT.split("def checkBios")[0]
|
||||
LISTING = DATA + LIST_CHECKER
|
||||
STRINGS = DATA + STRING_CHECKER
|
||||
D = "e" * 32
|
||||
F = "f" * 32
|
||||
SECOND = hashlib.md5(b"second").hexdigest()
|
||||
|
||||
|
||||
class ListSupportIsReadFromTheChecker(unittest.TestCase):
|
||||
def test_the_list_checker_reads_lists_for_both_kinds(self):
|
||||
self.assertEqual(md5_list_support(LISTING), {"file", "zippedFile"})
|
||||
|
||||
def test_the_string_checker_reads_none(self):
|
||||
self.assertEqual(md5_list_support(STRINGS), frozenset())
|
||||
self.assertEqual(md5_list_support(SCRIPT), frozenset())
|
||||
|
||||
def test_a_list_test_on_another_parameter_is_not_support(self):
|
||||
checker = LIST_CHECKER.replace(
|
||||
"isinstance(expected, list)", "isinstance(computed, list)"
|
||||
)
|
||||
self.assertEqual(md5_list_support(DATA + checker), {"zippedFile"})
|
||||
|
||||
def test_a_script_that_does_not_parse_reads_none(self):
|
||||
self.assertEqual(md5_list_support(LISTING + "\ndef (\n"), frozenset())
|
||||
|
||||
def test_the_cached_originals(self):
|
||||
"""The pins cached today (batocera-43.1 and the forks) read one md5."""
|
||||
root = REPO_ROOT / ".cache" / "upstream-native"
|
||||
scripts = [
|
||||
root / name / filename
|
||||
for name, filename in (
|
||||
("batocera", "batocera-systems"), ("rocknix", "rocknix-systems"),
|
||||
("amberelec", "batocera-systems"), ("emuelec", "batocera-systems"),
|
||||
("knulli", "knulli-systems"),
|
||||
)
|
||||
if (root / name / filename).is_file()
|
||||
]
|
||||
if not scripts:
|
||||
self.skipTest(".cache/upstream-native/ is not filled")
|
||||
for path in scripts:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
expected = frozenset() if "md5Matches" not in text else {"file"}
|
||||
with self.subTest(path.parent.name):
|
||||
self.assertLessEqual(expected, md5_list_support(text))
|
||||
|
||||
|
||||
class SeveralAcceptedMd5BecomeAList(unittest.TestCase):
|
||||
"""Every md5 the truth accepts, once the pinned checker reads a list."""
|
||||
|
||||
CORRECTED = {
|
||||
"name": "scph5500.bin", "path": "scph5500.bin", "md5": [C, D],
|
||||
"_cores": ["pcsx_rearmed"],
|
||||
}
|
||||
ADDED = {
|
||||
"name": "scph7001.bin", "path": "scph7001.bin", "md5": [D, F],
|
||||
"_cores": ["pcsx_rearmed"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _export(truth_files: list[dict], script: str):
|
||||
scraped = _scraped(script)
|
||||
scraped["cores"] = ["pcsx_rearmed"]
|
||||
systems, report = build_native_model(
|
||||
{"systems": {"sony-playstation": {"files": truth_files}}}, scraped
|
||||
)
|
||||
exporter = discover_exporters()["batocera"]()
|
||||
produced = exporter.render(
|
||||
systems, report, {"batocera-systems": script}, scraped
|
||||
)["batocera-systems"]
|
||||
files = {fe.name: fe for system in systems.values() for fe in system.files}
|
||||
return produced, landed_changes(exporter, systems), exporter, files
|
||||
|
||||
def test_a_string_item_with_several_accepted_becomes_a_list(self):
|
||||
produced, changes, _, _ = self._export([self.CORRECTED], LISTING)
|
||||
self.assertEqual(
|
||||
produced, LISTING.replace(f'"md5": "{C}"', f'"md5": [ "{C}", "{D}" ]')
|
||||
)
|
||||
record = next(r for r in changes.records if r["kind"] == "hash_corrected")
|
||||
self.assertEqual((record["old"], record["new"]), (C, [C, D]))
|
||||
self.assertEqual(changes.lists, 1)
|
||||
|
||||
def test_an_addition_with_several_accepted_is_written_as_a_list(self):
|
||||
produced, changes, exporter, files = self._export([self.ADDED], LISTING)
|
||||
self.assertTrue(exporter.lands(files["scph7001.bin"]))
|
||||
self.assertIn(
|
||||
f'{{ "md5": [ "{D}", "{F}" ], "file": "bios/scph7001.bin", '
|
||||
'"emulator": "libretro", "core": "pcsx_rearmed" }',
|
||||
produced,
|
||||
)
|
||||
record = next(r for r in changes.records if r["kind"] == "added")
|
||||
self.assertEqual(record["new"]["md5"], [D, F])
|
||||
self.assertEqual(changes.lists, 1)
|
||||
|
||||
def test_an_empty_md5_is_filled_with_the_list(self):
|
||||
"""Left empty while one value was all the item could hold."""
|
||||
produced, changes, _, _ = self._export([self.CORRECTED], LISTING.replace(C, ""))
|
||||
self.assertEqual(
|
||||
produced, LISTING.replace(f'"md5": "{C}"', f'"md5": [ "{C}", "{D}" ]')
|
||||
)
|
||||
record = next(r for r in changes.records if r["kind"] == "hash_filled")
|
||||
self.assertEqual(record["new"], [C, D])
|
||||
strings = STRINGS.replace(C, "")
|
||||
self.assertEqual(self._export([self.CORRECTED], strings)[0], strings)
|
||||
|
||||
def test_one_accepted_md5_stays_a_string(self):
|
||||
produced, changes, _, _ = self._export([dict(self.CORRECTED, md5=D)], LISTING)
|
||||
self.assertEqual(produced, LISTING.replace(C, D))
|
||||
self.assertEqual(changes.lists, 0)
|
||||
|
||||
def test_without_the_checker_the_export_is_unchanged(self):
|
||||
"""The string checker keeps the first md5 and refuses the addition,
|
||||
as every export did before the checker read lists."""
|
||||
produced, changes, exporter, files = self._export(
|
||||
[self.CORRECTED, self.ADDED], STRINGS
|
||||
)
|
||||
self.assertEqual(produced, STRINGS)
|
||||
self.assertFalse(exporter.lands(files["scph7001.bin"]))
|
||||
self.assertEqual(changes.lists, 0)
|
||||
|
||||
def test_the_written_list_passes_the_checker(self):
|
||||
"""Run the produced script: either listed md5 passes, another fails."""
|
||||
produced, _, _, _ = self._export([dict(self.CORRECTED, md5=[C, SECOND])], LISTING)
|
||||
namespace: dict = {"__name__": "batocera_systems"}
|
||||
exec(compile(produced, "batocera-systems", "exec"), namespace) # noqa: S102
|
||||
item = namespace["systems"]["psx"]["biosFiles"][0]
|
||||
self.assertEqual(item["md5"], [C, SECOND])
|
||||
scratch = REPO_ROOT / "tmp"
|
||||
with tempfile.TemporaryDirectory(dir=scratch if scratch.is_dir() else None) as tmp:
|
||||
(Path(tmp) / "bios").mkdir()
|
||||
systems = {"psx": {"name": "PSX", "biosFiles": [item]}}
|
||||
for payload, missing in (
|
||||
(b"second", {}),
|
||||
(b"other", {"psx": {"bios/scph5500.bin": "UNTESTED"}}),
|
||||
):
|
||||
(Path(tmp) / "bios" / "scph5500.bin").write_bytes(payload)
|
||||
with self.subTest(payload):
|
||||
self.assertEqual(namespace["checkBios"](systems, tmp, False), missing)
|
||||
|
||||
def test_an_item_with_altmd5_stays_a_string(self):
|
||||
"""checkBios nests [md5, altmd5]: a list there matches no digest."""
|
||||
exporter = discover_exporters()["batocera"]()
|
||||
exporter._md5_lists = md5_list_support(LISTING)
|
||||
platform = {"name": "pack.zip", "md5": A, "alt_md5": B, "zipped_file": "rom.bin"}
|
||||
fe = NativeFile("pack.zip", "pack.zip", "psx", platform=platform,
|
||||
truth={"md5": [A, B, D]})
|
||||
self.assertFalse(exporter.md5_list(fe))
|
||||
self.assertTrue(exporter.md5_list(
|
||||
NativeFile("rom.zip", "rom.zip", "psx", truth={"md5": [A, B]},
|
||||
platform={"name": "rom.zip", "md5": A, "zipped_file": "rom.bin"})
|
||||
))
|
||||
|
||||
def test_the_formats_that_share_the_script_read_the_same_checker(self):
|
||||
for name in ("rocknix", "amberelec", "emuelec", "knulli"):
|
||||
fe = NativeFile("boot.bin", "boot.bin", "dc", truth={"md5": [A, B]})
|
||||
with self.subTest(name):
|
||||
exporter = discover_exporters()[name]()
|
||||
self.assertFalse(exporter.md5_list(fe))
|
||||
exporter._md5_lists = md5_list_support(LISTING)
|
||||
self.assertTrue(exporter.md5_list(fe))
|
||||
self.assertEqual(exporter.written_list(fe, "md5"), [A, B])
|
||||
|
||||
|
||||
class SitePageShowsAList(unittest.TestCase):
|
||||
def test_a_list_reads_as_any_of_its_values(self):
|
||||
line = describe_change({
|
||||
"kind": "hash_corrected", "system": "psx", "name": "scph5500.bin",
|
||||
"field": "md5", "old": C, "new": [C, D],
|
||||
})
|
||||
self.assertEqual(line, f"`psx/scph5500.bin` md5 `{C}` becomes `{C}` or `{D}`")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -417,11 +417,13 @@ class OneMd5FormatsAddWhole(unittest.TestCase):
|
||||
def test_an_addition_with_several_md5_is_refused(self):
|
||||
from exporter.batocera_exporter import Exporter as Batocera # noqa: PLC0415
|
||||
|
||||
several = NativeFile("boot.bin", "boot.bin", "dc", truth={"md5": [A, B, C]})
|
||||
several = NativeFile("boot.bin", "boot.bin", "dc", truth={"md5": [A, B, C]},
|
||||
runners=[(False, "flycast")])
|
||||
single = NativeFile("boot2.bin", "boot2.bin", "dc", truth={"md5": A},
|
||||
runners=[(False, "flycast")])
|
||||
self.assertFalse(Batocera.writable(several))
|
||||
self.assertTrue(Batocera.writable(single))
|
||||
# A checker that compares one md5, as every cached pin does.
|
||||
self.assertFalse(Batocera().writable(several))
|
||||
self.assertTrue(Batocera().writable(single))
|
||||
|
||||
def test_retrobat_refuses_it_too(self):
|
||||
# RetroBat's batocera-systems.json holds one md5 per entry: the
|
||||
|
||||
@@ -1024,3 +1024,14 @@ Located in `scripts/exporter/`. Each inherits `BaseExporter`, defined in
|
||||
| `bizhawk_exporter` | `FirmwareDatabase.cs`, BizHawk's own C# |
|
||||
| `misterfpga_exporter` | hash and size in MiSTer's BiosDB (`bios_db.json`) |
|
||||
| `retropie_exporter` | the `rp_module_help` prose of each scriptmodule, by adding a file name the package's emulator reads |
|
||||
|
||||
The five script formats (Batocera, ROCKNIX, KNULLI, AmberELEC, EmuELEC) write
|
||||
an md5 as one string or, where the checker reads one, as a list. The export
|
||||
reads the checker of the pinned file, never a version number: when `checkBios`
|
||||
hands an item's md5 to a function that tests it with `isinstance(..., list)`
|
||||
(`md5Matches` since batocera.linux `6b08b873`), an entry whose emulator code
|
||||
accepts several dumps is written with all of them. Without that code, an entry
|
||||
keeps the one md5 it can hold, and an addition that would need several is left
|
||||
out and counted. An item with `altmd5` keeps a string, since the checker wraps
|
||||
it in a list of its own. The report gives a list as a list, and the summary
|
||||
counts the lists written.
|
||||
Reference in new issue
Block a user