diff --git a/scripts/cross_reference.py b/scripts/cross_reference.py index a147f8309..8d9d69e22 100644 --- a/scripts/cross_reference.py +++ b/scripts/cross_reference.py @@ -1204,6 +1204,16 @@ def launcher_reads_not_held( "source_ref": str(record.get("source_ref", "")), "profiles": readers, } + # A name a reader's entry declares unsourceable is no gap: its + # row carries the reason and is counted apart. + unsourceable = { + str(n).rsplit("/", 1)[-1]: str(entry["unsourceable"]) + for key in readers + for entry in profiles[key].get("files") or [] + if entry.get("unsourceable") + for n in (entry.get("name"), *(entry.get("aliases") or [])) + if n + } for name in names: found = holder.held(name) if found and (found[1] or folds): @@ -1219,6 +1229,9 @@ def launcher_reads_not_held( } if found: row["held_as"] = found[0] + reason = unsourceable.get(name.rsplit("/", 1)[-1]) + if reason and "unsourceable" not in row: + row["unsourceable"] = reason if reader["emulator"] not in row["emulators"]: row["emulators"].append(reader["emulator"]) if reader not in row["readers"]: @@ -1228,7 +1241,13 @@ def launcher_reads_not_held( def print_launcher_reads(rows: list[dict]) -> None: - """The launcher reads gap category, after the profile gaps.""" + """The launcher reads gap category, after the profile gaps. + + A name its reader declares unsourceable is listed apart, with the + reason: it is no file left to find. + """ + unsourceable = [r for r in rows if r.get("unsourceable")] + rows = [r for r in rows if not r.get("unsourceable")] platforms = sorted({r["platform"] for r in rows}) noun = "platform" if len(platforms) == 1 else "platforms" print(f"\nLauncher reads, not held: {len(rows)} names across {len(platforms)} {noun}") @@ -1242,6 +1261,19 @@ def print_launcher_reads(rows: list[dict]) -> None: f" {row['name']} [{', '.join(row['emulators'])}, {first['kind']}, " f"{first['source_ref']}]{more}{casing}" ) + if not unsourceable: + return + platforms = sorted({r["platform"] for r in unsourceable}) + noun = "platform" if len(platforms) == 1 else "platforms" + print( + f"\nLauncher reads, unsourceable: {len(unsourceable)} names " + f"across {len(platforms)} {noun}" + ) + for row in unsourceable: + print( + f" {row['platform']}: {row['name']} [{', '.join(row['emulators'])}, " + f"{row['readers'][0]['source_ref']}]: {row['unsourceable']}" + ) def cross_reference( diff --git a/scripts/verify.py b/scripts/verify.py index 437077555..fa43dad47 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1966,13 +1966,18 @@ def _print_undeclared_section(result: dict, verbose: bool) -> None: def _print_launcher_reads(result: dict, verbose: bool) -> None: """Names the platform's launch code reads that the collection lacks.""" - rows = result.get("launcher_reads_not_held") or [] - if not rows: - return + every = result.get("launcher_reads_not_held") or [] + rows = [r for r in every if not r.get("unsourceable")] + unsourceable = [r for r in every if r.get("unsourceable")] listed = "" if verbose else " (--verbose lists them)" - print(f" Launcher reads, not held: {len(rows)}{listed}") + if rows: + print(f" Launcher reads, not held: {len(rows)}{listed}") + if unsourceable: + print(f" Launcher reads, unsourceable: {len(unsourceable)}{listed}") if not verbose: return + for row in unsourceable: + print(f" {row['name']} [{', '.join(row['emulators'])}]: {row['unsourceable']}") for row in rows: kinds = ", ".join(sorted({r["kind"] for r in row["readers"]})) casing = f", held as {row['held_as']}" if row.get("held_as") else "" diff --git a/tests/test_launcher_gaps.py b/tests/test_launcher_gaps.py index d1947201a..245654938 100644 --- a/tests/test_launcher_gaps.py +++ b/tests/test_launcher_gaps.py @@ -74,6 +74,7 @@ LAUNCHERS = [ "names": ["written.ini"], "source_ref": "pkg.sh:2"}, _record("setemu", "fixed_name", ["setname.bin"], passes_to="command line -romslot"), _record("aliasemu", "fixed_name", ["alias.bin.rom"]), + _record("setemu", "copies", ["user.sf2"]), _record("caseemu", "fixed_name", ["mixed.rom"]), _record("foldemu", "fixed_name", ["MIXED.rom"], case_insensitive=True), _record("labelemu", "core_option", ["labelled.rom (CDZ)"]), @@ -136,7 +137,9 @@ class LauncherGapTest(unittest.TestCase): }, "setemu": { "emulator": "SetEmu", "type": "standalone", "systems": ["test-sys"], - "files": [{"name": "core.bin", "config_key": "romslot", "required": True}], + "files": [{"name": "core.bin", "config_key": "romslot", "required": True}, + {"name": "user.sf2", "required": False, + "unsourceable": "user supplied, no canonical file"}], }, "missemu": { "emulator": "MissEmu", "type": "standalone", "systems": ["test-sys"], @@ -191,6 +194,22 @@ class LauncherGapTest(unittest.TestCase): """ROCKNIX's kick34005.A500.rom is Amiberry's kick34005.A500.""" self.assertNotIn("alias.bin.rom", {name for _, name in self._rows()}) + def test_a_name_its_reader_declares_unsourceable_is_counted_apart(self) -> None: + """RetroBat copies any SoundFont saved as Roland_SC-88.sf2: not a + file to find, yet never dropped silently.""" + row = self._rows()[("gaptest", "user.sf2")] + self.assertEqual(row["unsourceable"], "user supplied, no canonical file") + out = io.StringIO() + with contextlib.redirect_stdout(out): + print_launcher_reads(list(self._rows().values())) + text = out.getvalue() + self.assertIn("Launcher reads, not held: 3 names", text) + self.assertIn("Launcher reads, unsourceable: 1 names across 1 platform", text) + self.assertIn("gaptest: user.sf2 [setemu, gen/setemu.py:1]: user supplied, no canonical file", + text) + held_part = text.split("Launcher reads, unsourceable")[0] + self.assertNotIn("user.sf2", held_part) + def test_a_name_handed_to_a_held_entry_setting_is_held(self) -> None: self.assertNotIn("setname.bin", {name for _, name in self._rows()}) @@ -250,7 +269,7 @@ class LauncherGapTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) report = json.loads(result.stdout) names = {r["name"] for r in report[LAUNCHER_READS_KEY]} - self.assertEqual(names, {"absent.bin", "mixed.rom", "labelled.rom"}) + self.assertEqual(names, {"absent.bin", "mixed.rom", "labelled.rom", "user.sf2"}) # The emulator rows keep their own shape and counts. self.assertEqual(set(report) - {LAUNCHER_READS_KEY}, {"aliasemu", "missemu", "setemu"}) @@ -266,7 +285,8 @@ class LauncherGapTest(unittest.TestCase): ) rows = with_rows.pop("launcher_reads_not_held") without.pop("launcher_reads_not_held") - self.assertEqual({r["name"] for r in rows}, {"absent.bin", "mixed.rom", "labelled.rom"}) + self.assertEqual({r["name"] for r in rows}, + {"absent.bin", "mixed.rom", "labelled.rom", "user.sf2"}) self.assertEqual(with_rows, without) def exit_code(result: dict) -> int: