diff --git a/scripts/exporter/retrodeck_exporter.py b/scripts/exporter/retrodeck_exporter.py index 946a2049..31267ffc 100644 --- a/scripts/exporter/retrodeck_exporter.py +++ b/scripts/exporter/retrodeck_exporter.py @@ -34,7 +34,8 @@ def _systems_of(entry: dict) -> list[str]: def _md5_set(value: object) -> set[str]: - return {m.strip().lower() for m in str(value or "").split(",") if m.strip()} + values = value if isinstance(value, list) else str(value or "").split(",") + return {str(m).strip().lower() for m in values if str(m).strip()} def _paths_key(entry: dict) -> tuple[str, ...]: @@ -136,9 +137,11 @@ class Exporter(BaseExporter): def _entry(fe: NativeFile) -> OrderedDict: entry: OrderedDict[str, object] = OrderedDict() entry["filename"] = fe.name - md5 = ",".join(fe.hashes("md5")) + # Several accepted dumps stay a JSON array, as RetroDECK writes them: + # a comma-joined string reads back as one value that is no md5. + md5 = fe.hashes("md5") if md5: - entry["md5"] = md5 + entry["md5"] = md5 if len(md5) > 1 else md5[0] sha256 = fe.hash("sha256") if sha256: entry["sha256"] = sha256 @@ -241,6 +244,10 @@ class Exporter(BaseExporter): (field, value) for field, value in by_key[keys[0]].items() if field in cls.carries() and not (field == "system" and declared) ) + if _md5_set(entry.get("md5")) == _md5_set(combined.get("md5")): + combined["md5"] = entry.get("md5") + elif isinstance(entry.get("md5"), list) and isinstance(combined.get("md5"), str): + combined["md5"] = [combined["md5"]] merged.append(combined) corrected.update(keys) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index daca4e9d..34f6c6ce 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -627,6 +627,78 @@ class RommExport(unittest.TestCase): self.assertTrue(all(k.startswith("psx:") for k in data)) +class RetrodeckExport(unittest.TestCase): + A, B, C = "a" * 32, "b" * 32, "c" * 32 + + def _render(self, declared, accepted): + """Export one file RetroDECK lists as an md5 array.""" + truth = { + "systems": { + "atari-400-800": { + "files": [ + { + "name": "ATARIOSB.ROM", + "path": "ATARIOSB.ROM", + "md5": accepted, + "required": True, + "_cores": ["atari800"], + } + ] + } + } + } + scraped = { + "systems": { + "atari-400-800": { + "native_id": "atari800", + "files": [ + { + "name": "ATARIOSB.ROM", + "destination": "bios/ATARIOSB.ROM", + "native_system": "atari800", + "md5": ",".join(declared), + "component": "retroarch", + "required": True, + } + ], + } + } + } + original = json.dumps({ + "retroarch": { + "bios": [ + { + "filename": "ATARIOSB.ROM", + "md5": declared, + "system": "atari800", + "required": "Required", + } + ] + } + }) + systems, report = build_native_model(truth, scraped) + exporter = discover_exporters()["retrodeck"]() + produced = exporter.render( + systems, report, {"retroarch/component_manifest.json": original}, None + ) + (entry,) = json.loads(produced["retroarch/component_manifest.json"])[ + "retroarch" + ]["bios"] + return entry["md5"] + + def test_an_unchanged_md5_array_is_left_as_written(self): + """The same dumps in another order are no correction.""" + self.assertEqual( + self._render([self.A, self.B], [self.B, self.A]), [self.A, self.B] + ) + + def test_a_corrected_md5_array_stays_an_array(self): + """A comma-joined string reads back as one value that is no md5.""" + written = self._render([self.A, self.B], [self.A, self.C]) + self.assertIsInstance(written, list) + self.assertEqual(set(written), {self.A, self.B, self.C}) + + class BatoceraExport(unittest.TestCase): def _render(self, originals): systems, report = model()