diff --git a/scripts/exporter/recalbox_exporter.py b/scripts/exporter/recalbox_exporter.py index 58b75a56..da43fc82 100644 --- a/scripts/exporter/recalbox_exporter.py +++ b/scripts/exporter/recalbox_exporter.py @@ -45,10 +45,57 @@ def _stated(text: str) -> dict[str, str]: line breaks inside a value and the case of a digest are not content.""" attrs = {k: " ".join(html.unescape(v).split()) for k, v in _ATTRIBUTE.findall(text)} if "md5" in attrs: - attrs["md5"] = ",".join(h.strip().lower() for h in attrs["md5"].split(",")) + # Recalbox accepts any digest of the list (Bios.cpp:109-130): the + # order is not content, and neogeo.zip's 24, reordered, were + # written again. + attrs["md5"] = ",".join(sorted({h.strip().lower() for h in attrs["md5"].split(",")})) return attrs +_SPACED_ATTRIBUTE = re.compile(r'(\s+)([A-Za-z][A-Za-z0-9]*)(\s*=\s*")([^"]*)(")') + + +def patch_element(kept: str, mine: str) -> str: + """Recalbox's element with only the attributes that changed rewritten. + + Rendered again, a corrected element lost its alignment and the place + of its attributes. A changed value is replaced where it stands, an + attribute ours drops goes with the space before it, and one ours adds + goes before the closing />. + """ + target = dict(_ATTRIBUTE.findall(mine)) + target_stated = _stated(mine) + present: set[str] = set() + + def rewrite(match: re.Match[str]) -> str: + name = match.group(2) + present.add(name) + if name not in target: + return "" + if _stated(f'') == {name: target_stated[name]}: + return match.group(0) + return match.group(1) + name + match.group(3) + target[name] + match.group(5) + + patched = _SPACED_ATTRIBUTE.sub(rewrite, kept) + added = "".join(f' {name}="{value}"' for name, value in target.items() if name not in present) + if added: + close = re.search(r"\s*/>\s*$", patched) + if close is None: + return mine + patched = patched[:close.start()] + added + patched[close.start():] + return patched + + +def _original_openings(original: str) -> list[str]: + """Each system block's opening tag as written, its indentation included: + cdi's is indented four spaces where the others take two (l.604).""" + openings = [] + for block in _SYSTEM_BLOCK.finditer(original): + line_start = original.rfind("\n", 0, block.start()) + 1 + openings.append(original[line_start:block.start()] + block.group(0).split(">", 1)[0] + ">") + return openings + + def _original_elements(original: str) -> list[dict[str, str]]: """Each system block's elements as written, by path, in order.""" blocks = [] @@ -200,10 +247,14 @@ class Exporter(BaseExporter): rendered = " ".join(f"{k}={quoteattr(v)}" for k, v in attrs.items()) return f" " - def emit(platform: str, fullname: str, body: list[str]) -> None: - lines.append( - f" " - ) + def emit(platform: str, fullname: str, body: list[str], opening: str = "") -> None: + stated = _stated(opening) if opening else {} + if stated.get("platform") == platform and stated.get("fullname") == fullname: + lines.append(opening) + else: + lines.append( + f" " + ) lines.extend(body) lines.append(" ") @@ -211,6 +262,7 @@ class Exporter(BaseExporter): # states what Recalbox wrote keeps Recalbox's text: re-rendered, 222 # unchanged elements made 180 lines of diff the report never named. written = _original_elements(original) if original else [] + openings = _original_openings(original) if original else [] blocks: list[tuple[str, str, list[str]]] = [] for index, (platform, fullname, children) in enumerate(theirs): elements = ours.get(platform, (None, {}))[1] @@ -225,6 +277,10 @@ class Exporter(BaseExporter): kept = raw.get(" ".join(path.split())) if kept is not None and (mine is None or _stated(mine[1]) == _stated(kept)): body.append(kept) + elif kept is not None and mine is not None and _stated( + patched := patch_element(kept, mine[1]) + ) == _stated(mine[1]): + body.append(patched) else: body.append(mine[1] if mine else element(child)) blocks.append((platform, fullname, body)) @@ -242,8 +298,8 @@ class Exporter(BaseExporter): key = (platform, str(fe.native("native_name", ""))) blocks[named.get(key, first[platform])][2].append(rendered) - for platform, fullname, body in blocks: - emit(platform, fullname, body) + for index, (platform, fullname, body) in enumerate(blocks): + emit(platform, fullname, body, openings[index] if index < len(openings) else "") for platform in sorted(ours): system, elements = ours[platform] emit(platform, self.display_name(system), [r for _, r in elements.values()]) diff --git a/tests/test_exporters.py b/tests/test_exporters.py index df8aa98f..d0608c2c 100644 --- a/tests/test_exporters.py +++ b/tests/test_exporters.py @@ -1420,6 +1420,42 @@ class BizhawkExport(unittest.TestCase): self.assertFalse(exporter.states(entry, "sha1")) +class RecalboxPatchesAttributesNotElements(unittest.TestCase): + """A changed element was rendered again, losing its alignment and the + place of its attributes, and an element whose md5 list was only + reordered (neogeo.zip, the same 24 digests) was rewritten too.""" + + def test_an_empty_truth_hands_back_the_file(self): + path = UPSTREAM_CACHE / "recalbox" / "es_bios.xml" + if not path.exists(): + self.skipTest("upstream cache not populated") + original = path.read_text(encoding="utf-8") + exporter = discover_exporters()["recalbox"]() + config = native_layer(load_platform_config("recalbox", str(REPO_ROOT / "platforms"))) + systems, report = build_native_model({"systems": {}}, config) + produced = exporter.render(systems, report, {"es_bios.xml": original}, config) + self.assertEqual(produced["es_bios.xml"], original) + + def test_a_reordered_md5_list_states_the_same(self): + from exporter.recalbox_exporter import _stated # noqa: PLC0415 + + self.assertEqual(_stated(''), + _stated('')) + + def test_one_attribute_changes_in_place(self): + from exporter.recalbox_exporter import patch_element # noqa: PLC0415 + + kept = ' ' + mine = ' ' + self.assertEqual(patch_element(kept, mine), + ' ') + dropped = patch_element( + ' ', + ' ') + self.assertEqual(dropped, ' ') + + class RetrobatListComesBackAsWritten(unittest.TestCase): """RetroBat separates each system with a blank line. json.dumps dropped all 100 of them, 100 hunks of noise in a diff meant to show