From 683ba4ef62bef57c2c1d21ace11ce021614bf3cb Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 6 Oct 2026 05:00:02 +0200 Subject: [PATCH] fix: export one file's identity, never two mixed --- scripts/exporter/baseline.py | 37 +++++++++++++++++++++++++++++++++--- tests/test_export_counts.py | 25 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/scripts/exporter/baseline.py b/scripts/exporter/baseline.py index 704752d0..9116f5f9 100644 --- a/scripts/exporter/baseline.py +++ b/scripts/exporter/baseline.py @@ -57,6 +57,24 @@ class NativeFile: return "both" return "platform" if self.platform is not None else "truth" + def _identity_source(self) -> str: + """Which side's content fields describe the file: one side, or both. + + Mixing them field by field wrote the truth's size and crc beside the + platform's sha1, a record no file satisfies. Both sides are merged + only where they agree on some hash; where they contradict, the truth + decides alone; where the truth declares none, the platform does. + """ + truth = {f: set(_hash_values(self.truth or {}, f)) for f in HASH_FIELDS} + plat = {f: set(_hash_values(self.platform or {}, f)) for f in HASH_FIELDS} + if not any(truth.values()): + return "platform" + if not any(plat.values()): + return "truth" + if any(truth[f] & plat[f] for f in HASH_FIELDS): + return "both" + return "truth" + def hashes(self, field_name: str) -> list[str]: """Accepted values for a hash, truth first when it has an opinion. @@ -67,6 +85,11 @@ class NativeFile: """ truth_values = _hash_values(self.truth or {}, field_name) platform_values = _hash_values(self.platform or {}, field_name) + source = self._identity_source() + if source == "platform": + return platform_values + if source == "truth": + return truth_values if truth_values and platform_values and not set(truth_values) & set( platform_values ): @@ -113,8 +136,8 @@ class NativeFile: a name-matched 480-byte fbneo boot.bin turned RomM's Dreamcast boot.bin into size 480 beside its 2 MB md5, which never verifies. """ - truth_hashed = any(_hash_values(self.truth or {}, f) for f in HASH_FIELDS) - order = (self.truth, self.platform) if truth_hashed else (self.platform, self.truth) + source = self._identity_source() + order = (self.platform, self.truth) if source == "platform" else (self.truth, self.platform) for entry in order: if entry and entry.get("size"): return int(entry["size"]) @@ -289,7 +312,15 @@ def build_native_model( def by_name(candidate: NativeFile) -> bool: theirs = _match_key(candidate.platform or {})[1] - return bool(t_name) and theirs == t_name + if not t_name or theirs != t_name: + return False + # A shared name with two declared sizes is two files: fbneo's + # 480-byte boot.bin is not RomM's 2 MB Dreamcast boot.bin. + t_size = truth_entry.get("size") + p_size = (candidate.platform or {}).get("size") + if isinstance(t_size, int) and isinstance(p_size, int) and t_size != p_size: + return False + return True def by_hash(candidate: NativeFile) -> bool: if not t_hashes: diff --git a/tests/test_export_counts.py b/tests/test_export_counts.py index 2238f556..2b05104b 100644 --- a/tests/test_export_counts.py +++ b/tests/test_export_counts.py @@ -135,5 +135,30 @@ class ModelKeepsOneFileOneEntry(unittest.TestCase): self.assertEqual(len(systems["psx"].files), 1) +class OneFileOneIdentity(unittest.TestCase): + def test_a_contradicting_truth_does_not_borrow_platform_fields(self): + fe = NativeFile("boot.bin", "dc/boot.bin", "dc", + platform={"size": 2097152, "md5": A, "sha1": "1" * 40, "crc32": "aaaaaaaa"}, + truth={"size": 480, "crc32": "bbbbbbbb"}) + self.assertEqual(fe.hashes("sha1"), []) + self.assertEqual(fe.hashes("crc32"), ["bbbbbbbb"]) + self.assertEqual(fe.size(), 480) + + def test_agreeing_sides_still_merge(self): + fe = NativeFile("x.bin", "x.bin", "s", + platform={"md5": A, "sha1": "1" * 40}, truth={"md5": A}) + self.assertEqual(fe.hashes("sha1"), ["1" * 40]) + + def test_a_name_with_another_size_is_another_file(self): + scraped = {"systems": {"dc": {"files": [ + {"name": "boot.bin", "destination": "dc/boot.bin", "size": 2097152, "md5": A}, + ]}}} + truth = {"systems": {"dc": {"files": [ + {"name": "boot.bin", "path": "fbneo/boot.bin", "size": 480, "crc32": "bbbbbbbb"}, + ]}}} + systems, _report = build_native_model(truth, scraped) + declared = next(f for f in systems["dc"].files if f.platform is not None) + self.assertIsNone(declared.truth) + if __name__ == "__main__": unittest.main()