From 21d34748e5d851d77b9588f90ecd866ecc85082a Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:47:26 +0100 Subject: [PATCH] feat: list unsourceable launcher names apart A launcher name that a reader's profile entry declares unsourceable is no file left to find. Its row carries the reason and both reports print it under its own count, never mixed with the names to acquire. --- scripts/cross_reference.py | 34 +++++++++++++++++++++++++++++++++- scripts/verify.py | 13 +++++++++---- tests/test_launcher_gaps.py | 26 +++++++++++++++++++++++--- 3 files changed, 65 insertions(+), 8 deletions(-) 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: