From fac86858196b87a951cee76113c1093b35afd2b1 Mon Sep 17 00:00:00 2001
From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:06:52 +0200
Subject: [PATCH] fix: give both hashes in the untested table
A row said the declared hash was contradicted by the local file and
named neither. The table now prints the hashes the platform declares
and the path and hashes of the copy the collection holds.
---
scripts/generate_site.py | 69 +++++++++++++++++++++++++++---
tests/test_site_platform_hashes.py | 26 +++++++++++
2 files changed, 88 insertions(+), 7 deletions(-)
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()