diff --git a/scripts/generate_site.py b/scripts/generate_site.py index 21325f7e..ab49e477 100644 --- a/scripts/generate_site.py +++ b/scripts/generate_site.py @@ -3849,6 +3849,48 @@ def _held_accepted(discrepancy: str, db: dict | None) -> str: return "" +_PROBLEM_HASHES = ("sha1", "md5", "sha256", "crc32") + + +def _declared_hashes(config: dict, detail: dict) -> dict[str, list[str]]: + """The hashes the platform declares for a reported file, by algorithm. + + The untested table said "declared hash contradicted by the local file" + and named neither: the reader could not look for the dump RetroDECK asks + for, nor report the difference. + """ + from hashing import parse_hash_list # noqa: PLC0415 + + system = (config.get("systems") or {}).get(detail.get("system", "")) or {} + for entry in system.get("files") or []: + if isinstance(entry, dict) and entry.get("name") == detail.get("name"): + return { + algo: parse_hash_list(entry.get(algo)) + for algo in _PROBLEM_HASHES if parse_hash_list(entry.get(algo)) + } + return {} + + +def _hash_cell(hashes: dict[str, list[str]]) -> str: + return "
".join( + f"{algo} " + ", ".join(_code(v) for v in values) for algo, values in hashes.items() + ) + + +def _copy_cell(path: str, algos: list[str], db: dict | None) -> str: + """The file a path holds, with its hashes in the algorithms declared.""" + if not path: + return "-" + record = next( + (rec | {"sha1": sha1} for sha1, rec in (db or {}).get("files", {}).items() + if rec.get("path") == path), + {}, + ) + hashes = {algo: [str(record[algo]).lower()] for algo in algos if record.get(algo)} + shown = f"[{_code(path)}]({_fetch_url(path)})" + return f"{shown}
{_hash_cell(hashes)}" if hashes else shown + + def _render_problem_files( platform_problems: list, settled: list | None = None, db: dict | None = None ) -> list[str]: @@ -3877,9 +3919,14 @@ def _render_problem_files( if p["required"]: entry["required"] = True elif p["status"] == "untested": - entry = untested_files.setdefault(fname, { + # Keyed by what is declared and what is held: two platforms + # asking two dumps under one name are two rows. + declared = p.get("declared") or {} + key = f"{fname}\0{sorted(declared.items())}\0{p.get('path', '')}" + entry = untested_files.setdefault(key, { "name": fname, "required": p["required"], "platforms": [], "reason": p["reason"], + "declared": declared, "path": p.get("path", ""), }) entry["platforms"].append(p["platform"]) if p.get("discrepancy"): @@ -3920,15 +3967,21 @@ def _render_problem_files( f'### Untested ' f"{_count(len(untested_files), 'file')}", "", - "Present but hash not verified.", + "Present but hash not verified. Declared is what the platform " + "asks for, the collection copy is what the path holds.", "", - "| File | Platforms | Reason |", - "|------|----------|--------|", + "| File | Platforms | Reason | Declared | Collection copy |", + "|------|----------|--------|----------|-----------------|", ]) - for fname in sorted(untested_files): - f = untested_files[fname] + for key in sorted(untested_files): + f = untested_files[key] plats = ", ".join(sorted(set(f["platforms"]))) - lines.append(f"| `{fname}` | {plats} | {f['reason']} |") + declared = f["declared"] + lines.append( + f"| {_code(f['name'])} | {plats} | {f['reason']} " + f"| {_hash_cell(declared) or '-'} " + f"| {_copy_cell(f['path'], list(declared) or ['md5'], db)} |" + ) lines.append("") if mismatch_files: @@ -4026,6 +4079,8 @@ def generate_gap_analysis( "reason": d.get("reason", ""), "discrepancy": d.get("discrepancy", ""), "system": d.get("system", ""), + "declared": _declared_hashes(cov.get("config") or {}, d), + "path": d.get("path") or "", }) pct_verified = ( diff --git a/tests/test_site_platform_hashes.py b/tests/test_site_platform_hashes.py index 672e8fe8..27b52a8a 100644 --- a/tests/test_site_platform_hashes.py +++ b/tests/test_site_platform_hashes.py @@ -44,5 +44,31 @@ class EachRowItsDeclaration(unittest.TestCase): self.assertNotIn(A, saturn) +class AnUntestedRowNamesBothHashes(unittest.TestCase): + """The gaps page said "declared hash contradicted by the local file" for + RetroDECK's dsifirmware.bin and named neither the dump RetroDECK asks for + nor the one the collection holds.""" + + def test_declared_and_held(self): + import generate_site as gs + + path = "bios/Nintendo/Nintendo DS/dsifirmware.bin" + declared, held = "1" * 64, "2" * 64 + config = {"systems": {"nintendo-ds": {"files": [ + {"name": "dsifirmware.bin", "sha256": declared}]}}} + detail = {"name": "dsifirmware.bin", "system": "nintendo-ds"} + problem = { + "platform": "RetroDECK", "name": "dsifirmware.bin", "status": "untested", + "required": True, "reason": "declared hash contradicted by the local file", + "declared": gs._declared_hashes(config, detail), "path": path, + } + db = {"files": {"f" * 40: {"path": path, "sha256": held}}} + table = "\n".join(gs._render_problem_files([problem], db=db)) + row = next(line for line in table.splitlines() if "dsifirmware" in line) + self.assertIn(f"sha256 `{declared}`", row) + self.assertIn(f"sha256 `{held}`", row) + self.assertIn(f"`{path}`", row) + + if __name__ == "__main__": unittest.main()