mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-12 22:43:23 -05:00
feat: make the native export round-trip a platform file
This commit is contained in:
1 parent
7b93285e1d
commit
691ccbfca7
65 files changed
+18417
-2115
No files matched your search
@@ -1219,6 +1219,27 @@ class FreshnessGuardMechanics(unittest.TestCase):
|
||||
self.assertIn("Generated on B", handle.read())
|
||||
|
||||
|
||||
|
||||
class PipelineReportsWhatItDid(unittest.TestCase):
|
||||
"""A step that did not run must not be summarised as OK.
|
||||
|
||||
--offline skips the network steps and --with-export gates three more.
|
||||
Reporting them as OK claims work nobody did, and a run whose export
|
||||
never happened read exactly like one where it had.
|
||||
"""
|
||||
|
||||
def test_a_skipped_step_is_not_reported_as_done(self):
|
||||
import pipeline
|
||||
|
||||
self.assertTrue(bool(pipeline.SKIPPED), "a skip must not fail the run")
|
||||
self.assertEqual(repr(pipeline.SKIPPED), "SKIPPED")
|
||||
self.assertIsNot(pipeline.SKIPPED, True)
|
||||
|
||||
def test_every_skip_branch_uses_the_sentinel(self):
|
||||
source = (ROOT / "scripts" / "pipeline.py").read_text()
|
||||
stale = re.findall(r'results\["(\w+)"\] = True', source)
|
||||
self.assertEqual(stale, [], f"steps still claiming OK when skipped: {stale}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
+40
-26
@@ -3996,19 +3996,19 @@ class TestE2E(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
out_path = os.path.join(self.root, "System.dat")
|
||||
exporter = SystemDatExporter()
|
||||
exporter.export(truth, out_path, scraped_data=scraped)
|
||||
from exporter.baseline import build_native_model
|
||||
|
||||
with open(out_path) as fh:
|
||||
content = fh.read()
|
||||
systems, report = build_native_model(truth, scraped)
|
||||
exporter = SystemDatExporter()
|
||||
produced = exporter.render(systems, report, {}, scraped)
|
||||
content = produced["System.dat"]
|
||||
self.assertIn("Sony - PlayStation", content)
|
||||
self.assertIn("scph5501.bin", content)
|
||||
self.assertIn("b056ee5a4d65937e1a3a17e1e78f3258ea49c38e", content)
|
||||
self.assertIn('name "System"', content)
|
||||
self.assertIn("71AF80B4", content) # CRC uppercase
|
||||
|
||||
issues = exporter.validate(truth, out_path)
|
||||
issues = exporter.validate(systems, produced)
|
||||
self.assertEqual(issues, [])
|
||||
|
||||
# Discovery finds the systemdat exporter
|
||||
@@ -4037,12 +4037,11 @@ class TestE2E(unittest.TestCase):
|
||||
},
|
||||
}
|
||||
|
||||
out_path = os.path.join(self.root, "System_numeric.dat")
|
||||
SystemDatExporter().export(truth, out_path, scraped_data={"systems": {}})
|
||||
from exporter.baseline import build_native_model
|
||||
|
||||
with open(out_path) as fh:
|
||||
content = fh.read()
|
||||
self.assertIn("crc 70295038", content)
|
||||
systems, report = build_native_model(truth, {"systems": {}})
|
||||
produced = SystemDatExporter().render(systems, report, {}, {"systems": {}})
|
||||
self.assertIn("crc 70295038", produced["System.dat"])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Full truth + diff integration test
|
||||
@@ -4417,15 +4416,28 @@ class TestE2E(unittest.TestCase):
|
||||
"sony-playstation": {"native_id": "psx", "files": []},
|
||||
}
|
||||
}
|
||||
out = os.path.join(self.root, "batocera-systems")
|
||||
exp = Exporter()
|
||||
exp.export(truth, out, scraped_data=scraped)
|
||||
from exporter.baseline import build_native_model
|
||||
|
||||
content = Path(out).read_text(encoding="utf-8")
|
||||
# The systems dict is one block inside the checker Batocera runs, so
|
||||
# the exporter is handed the script it patches.
|
||||
skeleton = (
|
||||
"#!/usr/bin/env python\n"
|
||||
"systems = {\n"
|
||||
"}\n"
|
||||
"def checkBios(systems, prefix, filterROMs):\n"
|
||||
" return {}\n"
|
||||
)
|
||||
systems, report = build_native_model(truth, scraped)
|
||||
exp = Exporter()
|
||||
produced = exp.render(
|
||||
systems, report, {"batocera-systems": skeleton}, scraped
|
||||
)
|
||||
content = produced["batocera-systems"]
|
||||
self.assertIn('"psx"', content)
|
||||
self.assertIn("scph5501.bin", content)
|
||||
self.assertIn("b" * 32, content)
|
||||
self.assertEqual(exp.validate(truth, out), [])
|
||||
self.assertIn("def checkBios(", content)
|
||||
self.assertEqual(exp.validate(systems, produced), [])
|
||||
|
||||
def test_180_recalbox_exporter_round_trip(self):
|
||||
"""Recalbox exporter produces valid es_bios.xml."""
|
||||
@@ -4453,11 +4465,12 @@ class TestE2E(unittest.TestCase):
|
||||
"sony-playstation": {"native_id": "psx", "files": []},
|
||||
}
|
||||
}
|
||||
out = os.path.join(self.root, "es_bios.xml")
|
||||
exp = Exporter()
|
||||
exp.export(truth, out, scraped_data=scraped)
|
||||
from exporter.baseline import build_native_model
|
||||
|
||||
content = Path(out).read_text(encoding="utf-8")
|
||||
systems, report = build_native_model(truth, scraped)
|
||||
exp = Exporter()
|
||||
produced = exp.render(systems, report, {}, scraped)
|
||||
content = produced["es_bios.xml"]
|
||||
self.assertIn("<biosList", content)
|
||||
self.assertIn('platform="psx"', content)
|
||||
self.assertIn("fullname=", content)
|
||||
@@ -4465,7 +4478,7 @@ class TestE2E(unittest.TestCase):
|
||||
# mandatory="true" is the default, not emitted (matching Recalbox format)
|
||||
self.assertNotIn('mandatory="false"', content)
|
||||
self.assertIn('core="libretro/c"', content)
|
||||
self.assertEqual(exp.validate(truth, out), [])
|
||||
self.assertEqual(exp.validate(systems, produced), [])
|
||||
|
||||
def test_181_retrobat_exporter_round_trip(self):
|
||||
"""RetroBat exporter produces valid JSON."""
|
||||
@@ -4495,16 +4508,17 @@ class TestE2E(unittest.TestCase):
|
||||
"sony-playstation": {"native_id": "psx", "files": []},
|
||||
}
|
||||
}
|
||||
out = os.path.join(self.root, "batocera-systems.json")
|
||||
exp = Exporter()
|
||||
exp.export(truth, out, scraped_data=scraped)
|
||||
from exporter.baseline import build_native_model
|
||||
|
||||
data = _json.loads(Path(out).read_text(encoding="utf-8"))
|
||||
systems, report = build_native_model(truth, scraped)
|
||||
exp = Exporter()
|
||||
produced = exp.render(systems, report, {}, scraped)
|
||||
data = _json.loads(produced["batocera-systems.json"])
|
||||
self.assertIn("psx", data)
|
||||
self.assertTrue(
|
||||
any("scph5501" in bf["file"] for bf in data["psx"]["biosFiles"])
|
||||
)
|
||||
self.assertEqual(exp.validate(truth, out), [])
|
||||
self.assertEqual(exp.validate(systems, produced), [])
|
||||
|
||||
def test_182_exporter_discovery(self):
|
||||
"""All exporters are discovered by the plugin system."""
|
||||
|
||||
+786
-129
@@ -1,14 +1,22 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Native-format exporters.
|
||||
|
||||
export_native.py turns the profile data back into each platform's own file:
|
||||
Recalbox's es_bios.xml, Batocera's systems list, RetroDECK's manifests. The
|
||||
point is that a platform maintainer can take the output and use it, so the
|
||||
format has to be theirs, not an approximation of it. None of it was covered.
|
||||
export_native.py rewrites each platform's own file, corrected: Recalbox's
|
||||
es_bios.xml, Batocera's systems list, RetroDECK's manifests. The point is
|
||||
that a maintainer can take the output and use it, so what matters is
|
||||
whether the platform would still read it, and whether anything it already
|
||||
declared went missing on the way through.
|
||||
|
||||
The fidelity cases read the platforms' real files from the upstream cache
|
||||
and skip when it has not been populated (python scripts/export_native.py
|
||||
--all --fetch), because a test that needs the network is not a test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -18,8 +26,20 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from common import ( # noqa: E402
|
||||
list_registered_platforms,
|
||||
load_platform_config,
|
||||
parse_untrusted_xml,
|
||||
)
|
||||
from exporter import discover_exporters # noqa: E402
|
||||
from exporter.base_exporter import BaseExporter # noqa: E402
|
||||
from export_native import pinned_base # noqa: E402
|
||||
from exporter.baseline import ( # noqa: E402
|
||||
NativeFile,
|
||||
build_native_model,
|
||||
)
|
||||
|
||||
UPSTREAM_CACHE = REPO_ROOT / ".cache" / "upstream-native"
|
||||
|
||||
TRUTH = {
|
||||
"systems": {
|
||||
@@ -29,13 +49,17 @@ TRUTH = {
|
||||
"name": "scph5501.bin",
|
||||
"path": "psx/scph5501.bin",
|
||||
"md5": "490f666e1afb15b7362b406ed1cea246",
|
||||
"size": 524288,
|
||||
"required": True,
|
||||
"_cores": ["duckstation"],
|
||||
},
|
||||
{
|
||||
"name": "scph7001.bin",
|
||||
"path": "psx/scph7001.bin",
|
||||
"md5": "1e68c231d0896b7eadcad1d7d8e76129",
|
||||
"size": 524288,
|
||||
"required": False,
|
||||
"_cores": ["duckstation"],
|
||||
},
|
||||
# Carries an md5 on purpose: without one it would be dropped
|
||||
# for being incomplete, and the placeholder guard would never
|
||||
@@ -44,7 +68,37 @@ TRUTH = {
|
||||
"name": "<user-selected>.bin",
|
||||
"path": "psx/PLACEHOLDER-MUST-NOT-SHIP.bin",
|
||||
"md5": "00000000000000000000000000000000",
|
||||
"size": 1,
|
||||
"required": False,
|
||||
"_cores": ["duckstation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
SCRAPED = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"native_id": "psx",
|
||||
"files": [
|
||||
{
|
||||
"name": "scph5501.bin",
|
||||
"destination": "psx/scph5501.bin",
|
||||
"native_system": "psx",
|
||||
"native_name": "Sony PlayStation",
|
||||
"md5": "deadbeefdeadbeefdeadbeefdeadbeef",
|
||||
"core": "libretro/duckstation",
|
||||
"required": True,
|
||||
},
|
||||
{
|
||||
"name": "unknown-to-truth.bin",
|
||||
"destination": "psx/unknown-to-truth.bin",
|
||||
"native_system": "psx",
|
||||
"native_name": "Sony PlayStation",
|
||||
"md5": "11111111111111111111111111111111",
|
||||
"core": "libretro/duckstation",
|
||||
"required": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -52,161 +106,764 @@ TRUTH = {
|
||||
}
|
||||
|
||||
|
||||
def model(truth: dict = TRUTH, scraped: dict | None = SCRAPED):
|
||||
return build_native_model(truth, scraped)
|
||||
|
||||
|
||||
class Reconciliation(unittest.TestCase):
|
||||
"""The platform's file corrected, not replaced by ours."""
|
||||
|
||||
def test_a_file_the_truth_says_nothing_about_is_kept(self):
|
||||
systems, _ = model()
|
||||
names = {fe.name for fe in systems["psx"].files}
|
||||
self.assertIn("unknown-to-truth.bin", names)
|
||||
|
||||
def test_the_truth_overrides_a_hash_the_platform_got_wrong(self):
|
||||
systems, report = model()
|
||||
entry = next(fe for fe in systems["psx"].files if fe.name == "scph5501.bin")
|
||||
self.assertEqual(entry.hash("md5"), "490f666e1afb15b7362b406ed1cea246")
|
||||
self.assertTrue(any("scph5501" in c for c in report.hashes_corrected))
|
||||
|
||||
def test_a_file_only_the_truth_knows_is_added(self):
|
||||
systems, report = model()
|
||||
names = {fe.name for fe in systems["psx"].files}
|
||||
self.assertIn("scph7001.bin", names)
|
||||
self.assertEqual(report.files_added, 1)
|
||||
|
||||
def test_a_placeholder_is_never_added(self):
|
||||
systems, _ = model()
|
||||
names = {fe.name for fe in systems["psx"].files}
|
||||
self.assertNotIn("<user-selected>.bin", names)
|
||||
|
||||
def test_systems_are_keyed_by_the_name_the_platform_uses(self):
|
||||
systems, _ = model()
|
||||
self.assertEqual(sorted(systems), ["psx"])
|
||||
|
||||
def test_the_platform_own_grouping_survives_a_collapsed_slug(self):
|
||||
"""Several native systems can share one of our slugs.
|
||||
|
||||
Recalbox files pcengine, pcenginecd and supergrafx under one slug.
|
||||
Grouping on the slug would publish one of the three and drop the
|
||||
rest, which is how eleven Recalbox platforms went missing.
|
||||
"""
|
||||
scraped = {
|
||||
"systems": {
|
||||
"nec-pc-engine": {
|
||||
"native_id": "pcengine",
|
||||
"files": [
|
||||
{
|
||||
"name": "syscard3.pce",
|
||||
"destination": "pcengine/syscard3.pce",
|
||||
"native_system": "pcengine",
|
||||
"md5": "38179df8f4ac870017db21ebcbf53114",
|
||||
},
|
||||
{
|
||||
"name": "syscard3.pce",
|
||||
"destination": "supergrafx/syscard3.pce",
|
||||
"native_system": "supergrafx",
|
||||
"md5": "38179df8f4ac870017db21ebcbf53114",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
systems, _ = build_native_model({"systems": {}}, scraped)
|
||||
self.assertEqual(sorted(systems), ["pcengine", "supergrafx"])
|
||||
|
||||
def test_an_entry_the_platform_declares_without_a_hash_is_still_written(self):
|
||||
"""Batocera and RetroBat both ship entries with an empty md5."""
|
||||
scraped = {
|
||||
"systems": {
|
||||
"nintendo-ds": {
|
||||
"native_id": "nds",
|
||||
"files": [
|
||||
{
|
||||
"name": "firmware.bin",
|
||||
"destination": "firmware.bin",
|
||||
"native_system": "nds",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
systems, _ = build_native_model({"systems": {}}, scraped)
|
||||
entry = systems["nds"].files[0]
|
||||
self.assertTrue(BaseExporter.writable(entry, require="md5"))
|
||||
|
||||
def test_no_format_drops_what_the_platform_already_declares(self):
|
||||
"""An export poorer than the file it corrects is not usable."""
|
||||
systems, _ = model()
|
||||
for name, cls in discover_exporters().items():
|
||||
for system in systems.values():
|
||||
for entry in system.files:
|
||||
if entry.platform is None:
|
||||
continue
|
||||
with self.subTest(platform=name, file=entry.name):
|
||||
self.assertTrue(cls.writable(entry))
|
||||
|
||||
def test_an_addition_the_format_cannot_express_is_refused(self):
|
||||
truth = {
|
||||
"systems": {
|
||||
"nintendo-ds": {"files": [{"name": "no-hash.bin", "required": True}]}
|
||||
}
|
||||
}
|
||||
systems, _ = build_native_model(truth, None)
|
||||
entry = systems["nintendo-ds"].files[0]
|
||||
self.assertFalse(BaseExporter.writable(entry, require="md5"))
|
||||
|
||||
|
||||
class ExporterRegistry(unittest.TestCase):
|
||||
def test_every_exporter_declares_a_platform_and_the_contract(self):
|
||||
exporters = discover_exporters()
|
||||
self.assertTrue(exporters, "no exporters discovered")
|
||||
for platform, cls in exporters.items():
|
||||
with self.subTest(platform=platform):
|
||||
"""Every registered platform has an exporter, and it declares itself."""
|
||||
|
||||
def test_every_exporter_declares_a_platform_and_a_filename(self):
|
||||
for name, cls in discover_exporters().items():
|
||||
with self.subTest(platform=name):
|
||||
self.assertTrue(cls.platform_name())
|
||||
self.assertTrue(cls.native_filename())
|
||||
self.assertTrue(issubclass(cls, BaseExporter))
|
||||
self.assertEqual(cls.platform_name(), platform)
|
||||
instance = cls()
|
||||
self.assertTrue(callable(instance.export))
|
||||
self.assertTrue(callable(instance.validate))
|
||||
|
||||
def test_the_registry_covers_the_platforms_it_claims(self):
|
||||
for expected in ("recalbox", "batocera", "retrodeck", "romm"):
|
||||
self.assertIn(expected, discover_exporters())
|
||||
def test_every_registered_platform_can_be_exported(self):
|
||||
"""A platform with no exporter is a platform we cannot hand back.
|
||||
|
||||
|
||||
class SharedHelpers(unittest.TestCase):
|
||||
def test_placeholder_names_are_recognised(self):
|
||||
for name in ("<user-selected>.bin", "rom*.bin", "a<b>.rom"):
|
||||
self.assertTrue(BaseExporter._is_pattern(name), name)
|
||||
for name in ("scph5501.bin", "bios_CD_E.bin"):
|
||||
self.assertFalse(BaseExporter._is_pattern(name), name)
|
||||
|
||||
def test_destination_prefers_path_then_destination_then_name(self):
|
||||
self.assertEqual(
|
||||
BaseExporter._dest({"path": "a", "destination": "b", "name": "c"}), "a"
|
||||
Archived included: the export is a correction we offer, not a
|
||||
release artefact, and RetroPie's users are not fewer for it.
|
||||
"""
|
||||
exporters = discover_exporters()
|
||||
registered = set(
|
||||
list_registered_platforms(
|
||||
str(REPO_ROOT / "platforms"), include_archived=True
|
||||
)
|
||||
)
|
||||
self.assertEqual(BaseExporter._dest({"destination": "b", "name": "c"}), "b")
|
||||
self.assertEqual(BaseExporter._dest({"name": "c"}), "c")
|
||||
self.assertEqual(BaseExporter._dest({}), "")
|
||||
self.assertEqual(registered - set(exporters), set())
|
||||
|
||||
def test_a_scraped_display_name_wins_over_the_slug(self):
|
||||
self.assertEqual(
|
||||
BaseExporter._display_name("sony-playstation", {"name": "PlayStation"}),
|
||||
"PlayStation",
|
||||
def test_no_exporter_is_registered_for_an_unregistered_platform(self):
|
||||
"""A dead exporter is one nobody will notice has stopped working."""
|
||||
exporters = set(discover_exporters())
|
||||
registered = set(
|
||||
list_registered_platforms(
|
||||
str(REPO_ROOT / "platforms"), include_archived=True
|
||||
)
|
||||
)
|
||||
self.assertEqual(exporters - registered, set())
|
||||
|
||||
def test_every_exporter_says_what_its_format_can_state(self):
|
||||
"""A correction to a field the file has no place for is not delivered."""
|
||||
for name, cls in discover_exporters().items():
|
||||
with self.subTest(platform=name):
|
||||
carried = cls.carries()
|
||||
self.assertTrue(carried)
|
||||
self.assertTrue(
|
||||
carried
|
||||
<= {"size", "crc32", "md5", "sha1", "sha256", "required", "name"},
|
||||
f"{name} claims a field that is not one of ours: {carried}",
|
||||
)
|
||||
|
||||
def test_only_a_format_with_the_field_claims_to_carry_it(self):
|
||||
"""Recalbox writes mandatory; RetroBat's entries are md5 and file."""
|
||||
exporters = discover_exporters()
|
||||
self.assertIn("required", exporters["recalbox"].carries())
|
||||
self.assertNotIn("required", exporters["retrobat"].carries())
|
||||
self.assertNotIn("required", exporters["retroarch"].carries())
|
||||
|
||||
def test_an_exporter_that_patches_code_says_it_needs_the_original(self):
|
||||
"""Regenerating a script from data alone drops the code around it."""
|
||||
for name in ("batocera", "rocknix", "emudeck", "retrodeck", "bizhawk",
|
||||
"misterfpga"):
|
||||
with self.subTest(platform=name):
|
||||
self.assertTrue(discover_exporters()[name].needs_original())
|
||||
|
||||
|
||||
class SearchOrder(unittest.TestCase):
|
||||
"""A list is read in the order the code looks, best first."""
|
||||
|
||||
@staticmethod
|
||||
def _entry(name, priority=None, position=0):
|
||||
truth = {"name": name, "required": True, "md5": f"{position:032x}"}
|
||||
if priority is not None:
|
||||
truth["priority"] = priority
|
||||
return NativeFile(
|
||||
name=name, destination=name, native_system="psx", truth=truth
|
||||
)
|
||||
|
||||
def test_acronyms_stay_upper_case_in_a_derived_name(self):
|
||||
derived = BaseExporter._display_name("nes-and-snes")
|
||||
self.assertIn("NES", derived)
|
||||
self.assertIn("SNES", derived)
|
||||
def test_the_declared_rank_orders_the_names(self):
|
||||
exporter = discover_exporters()["retropie"]()
|
||||
candidates = [
|
||||
self._entry("scph5502.bin", 5, 0),
|
||||
self._entry("scph5501.bin", 2, 1),
|
||||
self._entry("scph7001.bin", 10, 2),
|
||||
]
|
||||
self.assertEqual(
|
||||
exporter._in_search_order(candidates),
|
||||
["scph5501.bin", "scph5502.bin", "scph7001.bin"],
|
||||
)
|
||||
|
||||
def test_without_a_rank_the_order_the_code_walks_is_kept(self):
|
||||
"""An undeclared order is the profile's own, not the alphabet."""
|
||||
exporter = discover_exporters()["retropie"]()
|
||||
candidates = [
|
||||
self._entry("zzz.bin", None, 0),
|
||||
self._entry("aaa.bin", None, 1),
|
||||
]
|
||||
self.assertEqual(
|
||||
exporter._in_search_order(candidates), ["zzz.bin", "aaa.bin"]
|
||||
)
|
||||
|
||||
def test_a_ranked_file_is_read_before_an_unranked_one(self):
|
||||
exporter = discover_exporters()["retropie"]()
|
||||
candidates = [
|
||||
self._entry("unranked.bin", None, 0),
|
||||
self._entry("ranked.bin", 9, 1),
|
||||
]
|
||||
self.assertEqual(
|
||||
exporter._in_search_order(candidates), ["ranked.bin", "unranked.bin"]
|
||||
)
|
||||
|
||||
def test_cores_that_disagree_keep_the_best_rank(self):
|
||||
"""Nothing is dropped on the rank here, so the best one wins.
|
||||
|
||||
slot.py voids the choice when cores disagree because it keeps one
|
||||
file; ordering keeps them all, and a file that is some core's first
|
||||
choice belongs early.
|
||||
"""
|
||||
merged = {"name": "scph5501.bin", "priority": 2, "priority_conflict": True}
|
||||
entry = NativeFile(
|
||||
name="scph5501.bin",
|
||||
destination="scph5501.bin",
|
||||
native_system="psx",
|
||||
truth=merged,
|
||||
)
|
||||
self.assertEqual(entry.priority, 2)
|
||||
|
||||
|
||||
class _ExportFixture(unittest.TestCase):
|
||||
platform = ""
|
||||
class AdditionOrder(unittest.TestCase):
|
||||
"""What we add is listed the way the code looks for it."""
|
||||
|
||||
def setUp(self):
|
||||
if not self.platform:
|
||||
self.skipTest("base fixture")
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.out = Path(self._tmp.name) / "out"
|
||||
self.exporter = discover_exporters()[self.platform]()
|
||||
TRUTH = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"files": [
|
||||
{
|
||||
"name": "third.bin",
|
||||
"md5": "c" * 32,
|
||||
"required": True,
|
||||
"priority": 10,
|
||||
},
|
||||
{
|
||||
"name": "first.bin",
|
||||
"md5": "a" * 32,
|
||||
"required": True,
|
||||
"priority": 1,
|
||||
},
|
||||
{"name": "unranked.bin", "md5": "b" * 32, "required": True},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
if hasattr(self, "_tmp"):
|
||||
self._tmp.cleanup()
|
||||
def test_a_ranked_addition_is_named_before_a_later_one(self):
|
||||
systems, _ = build_native_model(self.TRUTH, None)
|
||||
names = [fe.name for fe in systems["sony-playstation"].files]
|
||||
self.assertEqual(names, ["first.bin", "third.bin", "unranked.bin"])
|
||||
|
||||
def _export(self) -> str:
|
||||
self.exporter.export(TRUTH, str(self.out))
|
||||
self.assertTrue(self.out.exists(), "exporter wrote nothing")
|
||||
return self.out.read_text(encoding="utf-8")
|
||||
def test_what_the_platform_wrote_keeps_its_place(self):
|
||||
"""Reordering their own entries would be churn, not a correction."""
|
||||
scraped = {
|
||||
"systems": {
|
||||
"sony-playstation": {
|
||||
"native_id": "psx",
|
||||
"files": [
|
||||
{
|
||||
"name": "theirs-b.bin",
|
||||
"destination": "theirs-b.bin",
|
||||
"native_system": "psx",
|
||||
"md5": "d" * 32,
|
||||
},
|
||||
{
|
||||
"name": "theirs-a.bin",
|
||||
"destination": "theirs-a.bin",
|
||||
"native_system": "psx",
|
||||
"md5": "e" * 32,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
systems, _ = build_native_model(self.TRUTH, scraped)
|
||||
names = [fe.name for fe in systems["psx"].files]
|
||||
self.assertEqual(names[:2], ["theirs-b.bin", "theirs-a.bin"])
|
||||
self.assertEqual(names[2], "first.bin")
|
||||
|
||||
|
||||
class RecalboxExport(_ExportFixture):
|
||||
platform = "recalbox"
|
||||
class PinnedRevision(unittest.TestCase):
|
||||
"""The file we patch is the revision our transcription came from."""
|
||||
|
||||
def test_the_tag_the_scraper_pinned_wins_over_the_branch_tip(self):
|
||||
wanted = {"batocera-systems": "https://example.invalid/master/batocera-systems"}
|
||||
scraped = {
|
||||
"source": "https://raw.githubusercontent.com/batocera-linux"
|
||||
"/batocera.linux/batocera-43.1/package/batocera-systems"
|
||||
}
|
||||
self.assertEqual(
|
||||
pinned_base(wanted, scraped),
|
||||
"https://raw.githubusercontent.com/batocera-linux/batocera.linux"
|
||||
"/batocera-43.1/package/",
|
||||
)
|
||||
|
||||
def test_a_blob_page_is_read_as_raw(self):
|
||||
"""github.com/.../blob/... names a revision but serves HTML."""
|
||||
wanted = {"System.dat": "https://example.invalid/System.dat"}
|
||||
scraped = {
|
||||
"source": "https://github.com/libretro/libretro-database"
|
||||
"/blob/master/dat/System.dat"
|
||||
}
|
||||
self.assertEqual(
|
||||
pinned_base(wanted, scraped),
|
||||
"https://raw.githubusercontent.com/libretro/libretro-database"
|
||||
"/master/dat/",
|
||||
)
|
||||
|
||||
def test_a_source_that_names_no_wanted_file_changes_nothing(self):
|
||||
wanted = {"component_manifest.json": "https://example.invalid/x.json"}
|
||||
scraped = {"source": "https://github.com/RetroDECK/components"}
|
||||
self.assertEqual(pinned_base(wanted, scraped), "")
|
||||
|
||||
def test_every_platform_that_records_its_source_pins_the_export(self):
|
||||
"""A scraped platform states the revision; the export follows it."""
|
||||
exporters = discover_exporters()
|
||||
for name in ("batocera", "recalbox", "bizhawk", "romm", "retroarch"):
|
||||
with self.subTest(platform=name):
|
||||
config = load_platform_config(name, str(REPO_ROOT / "platforms"))
|
||||
base = pinned_base(dict(exporters[name].native_sources()), config)
|
||||
self.assertTrue(base.startswith("https://"), base)
|
||||
|
||||
|
||||
class RecalboxExport(unittest.TestCase):
|
||||
def _render(self):
|
||||
systems, report = model()
|
||||
exporter = discover_exporters()["recalbox"]()
|
||||
return exporter, systems, exporter.render(systems, report, {}, None)
|
||||
|
||||
def test_output_is_well_formed_xml_in_recalbox_shape(self):
|
||||
root = ET.fromstring(self._export())
|
||||
_, _, produced = self._render()
|
||||
root = ET.fromstring(produced["es_bios.xml"])
|
||||
self.assertEqual(root.tag, "biosList")
|
||||
systems = root.findall("system")
|
||||
self.assertTrue(systems, "no <system> emitted")
|
||||
self.assertTrue(list(root.iter("system")))
|
||||
|
||||
def test_each_declared_file_becomes_a_bios_element_with_its_md5(self):
|
||||
root = ET.fromstring(self._export())
|
||||
by_path = {
|
||||
b.get("path"): b for s in root.findall("system") for b in s.findall("bios")
|
||||
}
|
||||
self.assertIn("psx/scph5501.bin", by_path)
|
||||
self.assertEqual(
|
||||
by_path["psx/scph5501.bin"].get("md5"),
|
||||
"490f666e1afb15b7362b406ed1cea246",
|
||||
)
|
||||
|
||||
def test_a_placeholder_entry_is_not_exported_as_a_file(self):
|
||||
"""Recalbox would otherwise look for a file nobody can supply.
|
||||
|
||||
The check is on the destination path, not the name: the exporter
|
||||
writes path=, so asserting on the name passes whatever the filter
|
||||
does.
|
||||
"""
|
||||
exported = self._export()
|
||||
self.assertNotIn("PLACEHOLDER-MUST-NOT-SHIP", exported)
|
||||
self.assertNotIn("<user-selected>", exported)
|
||||
def test_every_element_carries_what_the_schema_requires(self):
|
||||
"""es_bios.xsd makes path, md5 and core required on every bios."""
|
||||
_, _, produced = self._render()
|
||||
root = ET.fromstring(produced["es_bios.xml"])
|
||||
for element in root.iter("bios"):
|
||||
for attribute in ("path", "md5", "core"):
|
||||
self.assertTrue(element.get(attribute), attribute)
|
||||
|
||||
def test_mandatory_is_only_stated_when_it_is_false(self):
|
||||
"""Recalbox treats a missing attribute as mandatory."""
|
||||
root = ET.fromstring(self._export())
|
||||
by_path = {
|
||||
b.get("path"): b for s in root.findall("system") for b in s.findall("bios")
|
||||
}
|
||||
_, _, produced = self._render()
|
||||
root = ET.fromstring(produced["es_bios.xml"])
|
||||
by_path = {e.get("path"): e for e in root.iter("bios")}
|
||||
self.assertIsNone(by_path["psx/scph5501.bin"].get("mandatory"))
|
||||
self.assertEqual(by_path["psx/scph7001.bin"].get("mandatory"), "false")
|
||||
|
||||
def test_the_export_round_trips_through_its_own_validator(self):
|
||||
self.exporter.export(TRUTH, str(self.out))
|
||||
self.assertEqual(self.exporter.validate(TRUTH, str(self.out)), [])
|
||||
|
||||
def test_a_truncated_export_is_reported_by_the_validator(self):
|
||||
self.exporter.export(TRUTH, str(self.out))
|
||||
self.out.write_text(
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n<biosList></biosList>\n',
|
||||
encoding="utf-8",
|
||||
def test_hash_match_mandatory_does_not_follow_mandatory(self):
|
||||
"""They are separate axes: Recalbox uses all four combinations."""
|
||||
_, _, produced = self._render()
|
||||
root = ET.fromstring(produced["es_bios.xml"])
|
||||
optional = next(
|
||||
e for e in root.iter("bios") if e.get("mandatory") == "false"
|
||||
)
|
||||
self.assertTrue(
|
||||
self.exporter.validate(TRUTH, str(self.out)),
|
||||
"a file missing every entry validated clean",
|
||||
self.assertIsNone(optional.get("hashMatchMandatory"))
|
||||
|
||||
def test_the_export_passes_its_own_validator(self):
|
||||
exporter, systems, produced = self._render()
|
||||
self.assertEqual(exporter.validate(systems, produced), [])
|
||||
|
||||
def test_a_truncated_export_is_reported(self):
|
||||
exporter, systems, _ = self._render()
|
||||
empty = {
|
||||
"es_bios.xml": '<?xml version="1.0"?>\n<biosList></biosList>\n',
|
||||
}
|
||||
self.assertTrue(exporter.validate(systems, empty))
|
||||
|
||||
|
||||
class RommExport(unittest.TestCase):
|
||||
def _render(self):
|
||||
systems, report = model()
|
||||
exporter = discover_exporters()["romm"]()
|
||||
return exporter, systems, exporter.render(systems, report, {}, None)
|
||||
|
||||
def test_every_entry_can_actually_verify(self):
|
||||
"""RomM matches on size and then on one hash; without both, never."""
|
||||
_, _, produced = self._render()
|
||||
data = json.loads(produced["known_bios_files.json"])
|
||||
self.assertTrue(data)
|
||||
for key, entry in data.items():
|
||||
with self.subTest(key=key):
|
||||
self.assertTrue(entry.get("size"))
|
||||
self.assertTrue(
|
||||
any(entry.get(h) for h in ("md5", "sha1", "crc"))
|
||||
)
|
||||
|
||||
def test_size_is_written_as_the_fixture_writes_it(self):
|
||||
_, _, produced = self._render()
|
||||
data = json.loads(produced["known_bios_files.json"])
|
||||
self.assertIsInstance(next(iter(data.values()))["size"], str)
|
||||
|
||||
def test_keys_are_the_platform_slug_the_file_belongs_to(self):
|
||||
_, _, produced = self._render()
|
||||
data = json.loads(produced["known_bios_files.json"])
|
||||
self.assertTrue(all(k.startswith("psx:") for k in data))
|
||||
|
||||
|
||||
class BatoceraExport(unittest.TestCase):
|
||||
def _render(self, originals):
|
||||
systems, report = model()
|
||||
exporter = discover_exporters()["batocera"]()
|
||||
return exporter, systems, exporter.render(systems, report, originals, None)
|
||||
|
||||
SKELETON = (
|
||||
"#!/usr/bin/env python\n"
|
||||
"systems = {\n"
|
||||
"}\n"
|
||||
"def checkBios(systems, prefix, filterROMs):\n"
|
||||
" return {}\n"
|
||||
)
|
||||
|
||||
def test_an_unhashed_entry_is_written_with_an_empty_md5(self):
|
||||
scraped = {
|
||||
"systems": {
|
||||
"nintendo-ds": {
|
||||
"native_id": "nds",
|
||||
"files": [
|
||||
{
|
||||
"name": "firmware.bin",
|
||||
"destination": "firmware.bin",
|
||||
"native_system": "nds",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
systems, report = build_native_model({"systems": {}}, scraped)
|
||||
exporter = discover_exporters()["batocera"]()
|
||||
produced = exporter.render(
|
||||
systems, report, {"batocera-systems": self.SKELETON}, None
|
||||
)
|
||||
self.assertIn('"md5": ""', produced["batocera-systems"])
|
||||
|
||||
def test_writing_without_the_platform_file_is_refused(self):
|
||||
"""A systems dict alone is not the script Batocera runs."""
|
||||
systems, report = model()
|
||||
exporter = discover_exporters()["batocera"]()
|
||||
with self.assertRaises(ValueError):
|
||||
exporter.render(systems, report, {}, None)
|
||||
|
||||
def test_the_code_around_the_data_survives(self):
|
||||
original = (
|
||||
"#!/usr/bin/env python\n"
|
||||
"systems = {\n"
|
||||
' "psx": { "name": "PS1", "biosFiles": [ '
|
||||
'{ "md5": "deadbeefdeadbeefdeadbeefdeadbeef", '
|
||||
'"file": "bios/psx/scph5501.bin" } ] },\n'
|
||||
"}\n"
|
||||
"def checkBios(systems, prefix, filterROMs):\n"
|
||||
" return {}\n"
|
||||
)
|
||||
_, _, produced = self._render({"batocera-systems": original})
|
||||
output = produced["batocera-systems"]
|
||||
self.assertIn("def checkBios(", output)
|
||||
self.assertIn("#!/usr/bin/env python", output)
|
||||
|
||||
def test_a_note_beside_a_file_is_not_lost_when_the_entry_changes(self):
|
||||
original = (
|
||||
"systems = {\n"
|
||||
' "psx": { "name": "PS1", "biosFiles": [\n'
|
||||
' # ideally the other revision\n'
|
||||
' { "md5": "deadbeefdeadbeefdeadbeefdeadbeef", '
|
||||
'"file": "bios/psx/scph5501.bin" } ] },\n'
|
||||
"}\n"
|
||||
"def checkBios(systems, prefix, filterROMs):\n"
|
||||
" return {}\n"
|
||||
)
|
||||
_, _, produced = self._render({"batocera-systems": original})
|
||||
self.assertIn("# ideally the other revision", produced["batocera-systems"])
|
||||
|
||||
|
||||
class UpstreamFidelity(unittest.TestCase):
|
||||
"""The real files, compared against what we hand back.
|
||||
|
||||
Nothing a platform declares today may be missing from the export: an
|
||||
export that loses systems is one no maintainer can apply.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not UPSTREAM_CACHE.is_dir():
|
||||
raise unittest.SkipTest(
|
||||
"no upstream cache; run scripts/export_native.py --all --fetch"
|
||||
)
|
||||
|
||||
def _export(self, platform: str, truth: dict | None = None) -> dict[str, str]:
|
||||
cache = UPSTREAM_CACHE / platform
|
||||
if not cache.is_dir():
|
||||
self.skipTest(f"no cached original for {platform}")
|
||||
scraped = load_platform_config(platform, str(REPO_ROOT / "platforms"))
|
||||
systems, report = build_native_model(truth or {"systems": {}}, scraped)
|
||||
exporter = discover_exporters()[platform]()
|
||||
originals: dict[str, str] = {}
|
||||
for path in cache.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = str(path.relative_to(cache))
|
||||
if relative.endswith((".zip", ".tar.gz")) and hasattr(exporter, "unpack"):
|
||||
originals.update(exporter.unpack(path.read_bytes()))
|
||||
else:
|
||||
originals[relative] = path.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
produced = exporter.render(systems, report, originals, scraped)
|
||||
self.assertEqual(exporter.validate(systems, produced), [])
|
||||
return produced, originals
|
||||
|
||||
def test_recalbox_keeps_every_system_and_every_path(self):
|
||||
produced, originals = self._export("recalbox")
|
||||
before = parse_untrusted_xml(originals["es_bios.xml"], "es_bios.xml")
|
||||
after = ET.fromstring(produced["es_bios.xml"])
|
||||
self.assertEqual(
|
||||
{s.get("platform") for s in before.iter("system")}
|
||||
- {s.get("platform") for s in after.iter("system")},
|
||||
set(),
|
||||
)
|
||||
self.assertEqual(
|
||||
{b.get("path") for b in before.iter("bios")}
|
||||
- {b.get("path") for b in after.iter("bios")},
|
||||
set(),
|
||||
)
|
||||
|
||||
def test_the_recalbox_export_satisfies_the_schema_recalbox_ships(self):
|
||||
schema_path = UPSTREAM_CACHE / "recalbox" / "es_bios.xsd"
|
||||
if not schema_path.exists():
|
||||
self.skipTest("es_bios.xsd not cached")
|
||||
required = self._required_attributes(schema_path.read_text())
|
||||
produced, _ = self._export("recalbox")
|
||||
root = ET.fromstring(produced["es_bios.xml"])
|
||||
for tag, attributes in required.items():
|
||||
for element in root.iter(tag):
|
||||
for attribute in attributes:
|
||||
with self.subTest(tag=tag, attribute=attribute):
|
||||
self.assertTrue(element.get(attribute))
|
||||
|
||||
class EveryExporterProducesSomething(unittest.TestCase):
|
||||
"""Whatever the format, an exporter must write a non-empty file it accepts."""
|
||||
@staticmethod
|
||||
def _required_attributes(schema: str) -> dict[str, set[str]]:
|
||||
"""Read the required attributes straight out of the platform's XSD.
|
||||
|
||||
def test_each_exporter_writes_and_validates_its_own_output(self):
|
||||
for platform, cls in sorted(discover_exporters().items()):
|
||||
with self.subTest(platform=platform):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out = Path(directory) / "out"
|
||||
exporter = cls()
|
||||
try:
|
||||
exporter.export(TRUTH, str(out))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
self.fail(f"{platform} export raised {exc!r}")
|
||||
self.assertTrue(out.exists(), f"{platform} wrote nothing")
|
||||
self.assertTrue(
|
||||
out.stat().st_size > 0, f"{platform} wrote an empty file"
|
||||
)
|
||||
issues = exporter.validate(TRUTH, str(out))
|
||||
self.assertEqual(
|
||||
issues, [], f"{platform} rejects its own output: {issues[:2]}"
|
||||
)
|
||||
Scoped to each element's own type: a nested declaration belongs to
|
||||
the child, and folding it into the parent would demand md5 on
|
||||
<system>.
|
||||
"""
|
||||
namespace = "{http://www.w3.org/2001/XMLSchema}"
|
||||
root = parse_untrusted_xml(schema, "es_bios.xsd")
|
||||
required: dict[str, set[str]] = {}
|
||||
|
||||
def test_no_exporter_emits_a_placeholder_name(self):
|
||||
for platform, cls in sorted(discover_exporters().items()):
|
||||
with self.subTest(platform=platform):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out = Path(directory) / "out"
|
||||
cls().export(TRUTH, str(out))
|
||||
exported = out.read_text(encoding="utf-8", errors="replace")
|
||||
self.assertNotIn(
|
||||
"PLACEHOLDER-MUST-NOT-SHIP",
|
||||
exported,
|
||||
f"{platform} exported a placeholder as a real file",
|
||||
)
|
||||
self.assertNotIn("<user-selected>", exported)
|
||||
def own_attributes(node: ET.Element) -> set[str]:
|
||||
found: set[str] = set()
|
||||
for child in node:
|
||||
if child.tag == f"{namespace}element":
|
||||
continue
|
||||
if child.tag == f"{namespace}attribute":
|
||||
if child.get("use") == "required" and child.get("name"):
|
||||
found.add(child.get("name"))
|
||||
continue
|
||||
found |= own_attributes(child)
|
||||
return found
|
||||
|
||||
for element in root.iter(f"{namespace}element"):
|
||||
name = element.get("name")
|
||||
if not name:
|
||||
continue
|
||||
names = own_attributes(element)
|
||||
if names:
|
||||
required[name] = names
|
||||
return required
|
||||
|
||||
def test_batocera_keeps_every_system_and_still_runs(self):
|
||||
produced, originals = self._export("batocera")
|
||||
before = set(
|
||||
re.findall(r'^\s{4}"([^"]+)":\s*\{\s*"name"', originals["batocera-systems"],
|
||||
re.M)
|
||||
)
|
||||
after = set(
|
||||
re.findall(r'^\s{4}"([^"]+)":\s*\{\s*"name"',
|
||||
produced["batocera-systems"], re.M)
|
||||
)
|
||||
self.assertEqual(before - after, set())
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
script = Path(tmp) / "batocera-systems"
|
||||
script.write_text(produced["batocera-systems"], encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--createReadme"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(result.stdout.strip())
|
||||
|
||||
def test_batocera_keeps_the_zip_member_checks(self):
|
||||
"""checkInsideZip verifies a ROM inside the archive, not the archive."""
|
||||
produced, originals = self._export("batocera")
|
||||
self.assertEqual(
|
||||
produced["batocera-systems"].count("zippedFile"),
|
||||
originals["batocera-systems"].count("zippedFile"),
|
||||
)
|
||||
|
||||
def test_rocknix_still_runs(self):
|
||||
produced, _ = self._export("rocknix")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
script = Path(tmp) / "rocknix-systems"
|
||||
script.write_text(produced["rocknix-systems"], encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(script), "--createReadme"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_emudeck_keeps_every_check_and_still_parses(self):
|
||||
produced, originals = self._export("emudeck")
|
||||
pattern = re.compile(r"^(\w+)\(\)\{", re.M)
|
||||
before = set(pattern.findall(originals["checkBIOS.sh"]))
|
||||
after = set(pattern.findall(produced["checkBIOS.sh"]))
|
||||
self.assertEqual(before - after, set())
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
script = Path(tmp) / "checkBIOS.sh"
|
||||
script.write_text(produced["checkBIOS.sh"], encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
["bash", "-n", str(script)], capture_output=True, text=True, timeout=60
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_retrobat_keeps_every_system_and_every_file(self):
|
||||
produced, originals = self._export("retrobat")
|
||||
before = json.loads(originals["batocera-systems.json"])
|
||||
after = json.loads(produced["batocera-systems.json"])
|
||||
self.assertEqual(set(before) - set(after), set())
|
||||
self.assertEqual(
|
||||
{(s, f["file"]) for s, d in before.items() for f in d["biosFiles"]}
|
||||
- {(s, f["file"]) for s, d in after.items() for f in d["biosFiles"]},
|
||||
set(),
|
||||
)
|
||||
|
||||
def test_retroarch_keeps_every_rom_name_the_dat_declares(self):
|
||||
produced, originals = self._export("retroarch")
|
||||
pattern = re.compile(r"^\trom \( name (\"[^\"]+\"|\S+)", re.M)
|
||||
before = {n.strip('"') for n in pattern.findall(originals["System.dat"])}
|
||||
after = {n.strip('"') for n in pattern.findall(produced["System.dat"])}
|
||||
self.assertEqual(before - after, set())
|
||||
|
||||
def test_the_dat_keeps_the_system_names_libretro_writes(self):
|
||||
"""The comment is the name the DAT spells, separator included."""
|
||||
produced, originals = self._export("retroarch")
|
||||
comments = re.compile(r'^\tcomment "([^"]+)"', re.M)
|
||||
before = set(comments.findall(originals["System.dat"]))
|
||||
after = set(comments.findall(produced["System.dat"]))
|
||||
self.assertEqual(before - after, set())
|
||||
|
||||
def test_the_dat_never_states_a_rom_it_cannot_hash(self):
|
||||
produced, _ = self._export("retroarch")
|
||||
for line in re.findall(r"^\trom \( (.*) \)$", produced["System.dat"], re.M):
|
||||
with self.subTest(line=line[:60]):
|
||||
self.assertTrue(
|
||||
any(f" {h} " in line for h in ("crc", "md5", "sha1"))
|
||||
)
|
||||
|
||||
def test_misterfpga_keeps_the_download_urls_it_installs_from(self):
|
||||
produced, _ = self._export("misterfpga")
|
||||
database = json.loads(produced["bios_db.json"])
|
||||
self.assertTrue(database.get("db_id"))
|
||||
for path, entry in database["files"].items():
|
||||
with self.subTest(path=path):
|
||||
self.assertTrue(entry.get("url"))
|
||||
|
||||
def test_bizhawk_stays_compilable(self):
|
||||
produced, originals = self._export("bizhawk")
|
||||
source = produced["FirmwareDatabase.cs"]
|
||||
self.assertEqual(source.count("{"), source.count("}"))
|
||||
self.assertEqual(
|
||||
source.count("FirmwareAndOption("),
|
||||
originals["FirmwareDatabase.cs"].count("FirmwareAndOption("),
|
||||
)
|
||||
|
||||
# A core RetroPie packages, with a file its help does not yet name.
|
||||
RETROPIE_TRUTH = {
|
||||
"systems": {
|
||||
"magnavox-odyssey2": {
|
||||
"files": [
|
||||
{
|
||||
"name": "c52.bin",
|
||||
"required": True,
|
||||
"md5": "f1071cdb0b6b10dde94d3bc8a6146387",
|
||||
"_cores": ["o2em"],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def test_retropie_keeps_every_name_its_packages_already_list(self):
|
||||
"""RetroPie's BIOS list is prose; a name it writes is never removed."""
|
||||
produced, originals = self._export("retropie", self.RETROPIE_TRUTH)
|
||||
exporter = discover_exporters()["retropie"]()
|
||||
help_of = re.compile(r'rp_module_help="((?:[^"\\]|\\.)*)"')
|
||||
for relative, text in produced.items():
|
||||
with self.subTest(module=relative):
|
||||
before = help_of.search(originals[relative])
|
||||
after = help_of.search(text)
|
||||
self.assertIsNotNone(after, "the help string is not closed")
|
||||
self.assertEqual(
|
||||
exporter._listed(before.group(1))
|
||||
- exporter._listed(after.group(1)),
|
||||
set(),
|
||||
)
|
||||
self.assertIn("BIOS", after.group(1))
|
||||
|
||||
def test_a_patched_scriptmodule_still_parses_as_bash(self):
|
||||
produced, _ = self._export("retropie", self.RETROPIE_TRUTH)
|
||||
self.assertTrue(produced)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for relative, text in produced.items():
|
||||
script = Path(tmp) / Path(relative).name
|
||||
script.write_text(text, encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
["bash", "-n", str(script)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
with self.subTest(module=relative):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_retropie_never_drafts_a_sentence_nobody_wrote(self):
|
||||
"""A package with no BIOS list is a docs change, not a correction."""
|
||||
produced, originals = self._export("retropie", self.RETROPIE_TRUTH)
|
||||
for relative in produced:
|
||||
with self.subTest(module=relative):
|
||||
self.assertIn("BIOS", originals[relative])
|
||||
|
||||
def test_retrodeck_writes_a_manifest_per_component(self):
|
||||
produced, _ = self._export("retrodeck")
|
||||
self.assertTrue(produced)
|
||||
for path, content in produced.items():
|
||||
with self.subTest(path=path):
|
||||
self.assertTrue(path.endswith("component_manifest.json"))
|
||||
manifest = json.loads(content)
|
||||
component = next(iter(manifest.values()))
|
||||
self.assertTrue(
|
||||
component.get("name") or component.get("system"),
|
||||
"the component lost its identity",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -116,5 +116,77 @@ class TestConnection(unittest.TestCase):
|
||||
_StubScraper(error=KeyError("bug")).test_connection()
|
||||
|
||||
|
||||
class RetroPiePackageList(unittest.TestCase):
|
||||
"""RetroPie states its packages, and nothing else states them for it."""
|
||||
|
||||
@staticmethod
|
||||
def _archive(modules: dict[str, str]) -> bytes:
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
||||
for path, body in modules.items():
|
||||
payload = body.encode()
|
||||
info = tarfile.TarInfo(f"RetroPie-Setup-master/{path}")
|
||||
info.size = len(payload)
|
||||
archive.addfile(info, io.BytesIO(payload))
|
||||
return buffer.getvalue()
|
||||
|
||||
def _scraper(self, modules: dict[str, str]):
|
||||
from scraper.retropie_scraper import Scraper
|
||||
|
||||
scraper = Scraper()
|
||||
scraper.module_ids(self._archive(modules))
|
||||
return scraper
|
||||
|
||||
def test_a_package_is_read_from_the_id_its_script_declares(self):
|
||||
scraper = self._scraper(
|
||||
{
|
||||
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
|
||||
"scriptmodules/emulators/openmsx.sh": 'rp_module_id="openmsx"\n',
|
||||
}
|
||||
)
|
||||
self.assertEqual(scraper.module_ids(), ["lr-mgba", "openmsx"])
|
||||
|
||||
def test_a_script_outside_the_package_directories_is_not_a_package(self):
|
||||
"""Setup helpers, themes and drivers carry no core."""
|
||||
scraper = self._scraper(
|
||||
{
|
||||
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
|
||||
"scriptmodules/supplementary/autostart.sh": (
|
||||
'rp_module_id="autostart"\n'
|
||||
),
|
||||
"scriptmodules/admin/setup.sh": 'rp_module_id="setup"\n',
|
||||
}
|
||||
)
|
||||
self.assertEqual(scraper.module_ids(), ["lr-mgba"])
|
||||
|
||||
def test_the_core_list_drops_the_libretro_prefix(self):
|
||||
scraper = self._scraper(
|
||||
{
|
||||
"scriptmodules/libretrocores/lr-mgba.sh": 'rp_module_id="lr-mgba"\n',
|
||||
"scriptmodules/emulators/openmsx.sh": 'rp_module_id="openmsx"\n',
|
||||
}
|
||||
)
|
||||
config = scraper.generate_platform_yaml()
|
||||
self.assertEqual(config["cores"], ["mgba", "openmsx"])
|
||||
self.assertEqual(config["inherits"], "retroarch")
|
||||
self.assertEqual(config["base_destination"], "BIOS")
|
||||
|
||||
def test_no_requirement_is_invented_from_prose(self):
|
||||
"""RetroPie names BIOS in prose, without a hash to transcribe."""
|
||||
scraper = self._scraper(
|
||||
{
|
||||
"scriptmodules/libretrocores/lr-mgba.sh": (
|
||||
'rp_module_id="lr-mgba"\n'
|
||||
'rp_module_help="Copy the required BIOS file gba_bios.bin'
|
||||
' to $biosdir"\n'
|
||||
),
|
||||
}
|
||||
)
|
||||
self.assertEqual(scraper.fetch_requirements(), [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user