diff --git a/scripts/verify.py b/scripts/verify.py index d437282b..4373f7da 100644 --- a/scripts/verify.py +++ b/scripts/verify.py @@ -1378,6 +1378,16 @@ def verify_emulator( if not local_path: result = {"name": name, "status": Status.MISSING, "required": required} + # An entry the profile documents as unsourceable is absent by + # design: a per-user key, a slot the user fills, a dump nobody + # has made. Counting it beside a file somebody could still + # find invites the wrong repair -- dropping the flag, or + # chasing a vendor's whole install tree. + unsourceable = file_entry.get("unsourceable") + if unsourceable: + result["unsourceable"] = ( + unsourceable if isinstance(unsourceable, str) else "" + ) else: # Apply emulator validation check = check_file_validation(local_path, name, validation_index) @@ -1533,11 +1543,19 @@ def print_emulator_result(result: dict, verbose: bool = False) -> None: sc = result.get("status_counts", {}) untested = sc.get(Status.UNTESTED, 0) missing = sc.get(Status.MISSING, 0) + unsourceable_names = { + d["name"] + for d in result["details"] + if d["status"] == Status.MISSING and "unsourceable" in d + } + unsourceable = len(unsourceable_names) parts = [f"{ok_count}/{total} OK"] if untested: parts.append(f"{untested} untested") - if missing: - parts.append(f"{missing} missing") + if missing - unsourceable > 0: + parts.append(f"{missing - unsourceable} missing") + if unsourceable: + parts.append(f"{unsourceable} unsourceable") print(f"{label}: {', '.join(parts)} [{mode}]") seen = set() @@ -1558,7 +1576,7 @@ def print_emulator_result(result: dict, verbose: bool = False) -> None: else: print(f" {_format_ground_truth_aggregate(gt)}") for d in result["details"]: - if d["status"] == Status.MISSING: + if d["status"] == Status.MISSING and "unsourceable" not in d: if d["name"] in seen: continue seen.add(d["name"]) @@ -1572,6 +1590,14 @@ def print_emulator_result(result: dict, verbose: bool = False) -> None: print(f" {line}") else: print(f" {_format_ground_truth_aggregate(gt)}") + for d in result["details"]: + if d["status"] == Status.MISSING and "unsourceable" in d: + if d["name"] in seen: + continue + seen.add(d["name"]) + why = d.get("unsourceable") or "documented as unobtainable" + print(f" UNSOURCEABLE: {d['name']} -{why}") + for d in result["details"]: if d.get("note"): print(f" {d['note']}") diff --git a/tests/test_verify_emulator_evidence.py b/tests/test_verify_emulator_evidence.py index dd672863..b4941b77 100644 --- a/tests/test_verify_emulator_evidence.py +++ b/tests/test_verify_emulator_evidence.py @@ -154,6 +154,93 @@ class HashMismatchIsNotCoverage(unittest.TestCase): self.assertEqual(detail["status"], verify.Status.MISSING) +class UnsourceableIsNotAGap(unittest.TestCase): + """An entry nobody can supply is absent by design, not by omission. + + Counting a per-user key or a slot the user fills beside a file somebody + could still find invites the wrong repair: dropping the flag, deleting the + entry, or chasing a vendor's whole install tree. One profile read + "14 missing" when twelve of those were documented as unobtainable. + """ + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.root = Path(self._tmp.name) + self.emulators = self.root / "emulators" + self.emulators.mkdir() + (self.emulators / "demo.yml").write_text( + "emulator: demo\n" + "type: standalone\n" + "display_name: Demo\n" + "systems: [demo-system]\n" + "files:\n" + " - name: findable.rom\n" + " system: demo-system\n" + " required: true\n" + " - name: paid.so\n" + " system: demo-system\n" + " required: true\n" + " unsourceable: \"ships inside the paid application\"\n" + ) + self.db = {"files": {}, "indexes": { + "by_name": {}, "by_md5": {}, "by_sha256": {}, + "by_crc32": {}, "by_path_suffix": {}}} + from common import _emulator_profiles_cache + + _emulator_profiles_cache.clear() + + def tearDown(self): + from common import _emulator_profiles_cache + + _emulator_profiles_cache.clear() + self._tmp.cleanup() + + def _result(self): + cwd = os.getcwd() + os.chdir(self.root) + try: + return verify.verify_emulator( + ["demo"], str(self.emulators), self.db + ) + finally: + os.chdir(cwd) + + def test_an_unsourceable_entry_carries_its_reason(self): + detail = next(d for d in self._result()["details"] if d["name"] == "paid.so") + self.assertEqual(detail["status"], verify.Status.MISSING) + self.assertIn("unsourceable", detail) + self.assertIn("paid", detail["unsourceable"]) + + def test_a_findable_entry_carries_no_such_marker(self): + detail = next( + d for d in self._result()["details"] if d["name"] == "findable.rom" + ) + self.assertEqual(detail["status"], verify.Status.MISSING) + self.assertNotIn("unsourceable", detail) + + def test_the_summary_counts_the_two_apart(self): + import contextlib + import io + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + verify.print_emulator_result(self._result()) + summary = buffer.getvalue().splitlines()[0] + self.assertIn("1 missing", summary) + self.assertIn("1 unsourceable", summary) + + def test_the_reason_is_printed_rather_than_the_entry_hidden(self): + import contextlib + import io + + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + verify.print_emulator_result(self._result()) + out = buffer.getvalue() + self.assertIn("UNSOURCEABLE: paid.so", out) + self.assertIn("MISSING (required): findable.rom", out) + + class RepositoryWideEvidence(unittest.TestCase): """The real collection, so the fix is measured and not just asserted."""