#!/usr/bin/env python3 """Tests for state_checksum.py. Synthetic tests always run. Real-save tests are skipped cleanly when no saves are available: they look in $SOTS_SAVES_DIR first, then at the in-repo verify/results/saves/. No save is ever written into either repo by this file. uv run python3 -m unittest discover -s verify/state-checksum -v """ from __future__ import annotations import gzip import json import math import os import struct import sys import tempfile import unittest _HERE = os.path.dirname(os.path.abspath(__file__)) _VERIFY = os.path.dirname(_HERE) for _p in (_HERE, os.path.join(_VERIFY, "save-reader")): if _p not in sys.path: sys.path.insert(0, _p) import save_reader as sr # noqa: E402 import save_writer_stub as sw # noqa: E402 import state_checksum as sc # noqa: E402 # --- helpers ------------------------------------------------------------------ def tiny(status_a=4, status_b=4, checksum=-1000, extra_ids=(), fval_a=1.5, fval_b=2.5) -> bytes: """A miniature save with the shapes the tool special-cases. The `Sim` prefix fields (`KeyPath`..`NMnx`) are emitted because the reader types `PlayerIDs` positionally off the Sim shape; without them the tag is guessed and a 4-byte int is indistinguishable from a 2-byte string at the same item size (SAVE_FORMAT.md section 2). """ w = sw.SaveWriter("joint") w.begin("Summary") w.string("GameName", "T") w.int("Turn", 2) w.int("Checksum", checksum) w.end() w.begin("Sim") w.string("KeyPath", "") w.int("NMSz", 16) w.int("NMLc", 0) w.int("NMnx", 109) ids = (16, 32) + tuple(extra_ids) w.int("PlayerIDs", len(ids)) for i in ids: w.int(".", i) w.int("NumPlrs", 2) for pid, st, fv in ((16, status_a, fval_a), (32, status_b, fval_b)): w.int("PlayerID", pid) w.begin("Player") w.string("PlryName", f"p{pid}") w.int("Status", st) w.float("IdealSuit", fv) w.end() w.end() return w.bytes() def find_saves() -> list[str]: d = os.environ.get("SOTS_SAVES_DIR") or os.path.join(_VERIFY, "results", "saves") if not os.path.isdir(d): return [] return sorted(os.path.join(d, f) for f in os.listdir(d) if f.endswith(".sav")) REAL = find_saves() needs_saves = unittest.skipUnless(REAL, "no saves in $SOTS_SAVES_DIR or verify/results/saves") _CACHE: dict = {} def real_ck(path: str, **kw): """Memoised checksum_save -- a real save costs ~6 s to parse.""" key = (path, tuple(sorted(kw.items()))) if key not in _CACHE: _CACHE[key] = sc.checksum_save(path, **kw) return _CACHE[key] # --- coverage: the property that makes the digest evidence -------------------- class CoverageTest(unittest.TestCase): def test_reconstruction_is_exact_on_a_synthetic_save(self): data = tiny() ck = sc.checksum_bytes(data) self.assertTrue(ck.coverage["ok"]) self.assertEqual(ck.coverage["rebuiltBytes"], len(data)) def test_reconstruction_is_exact_on_the_schema_fixture(self): data, _ = sw.build_fixture() ck = sc.checksum_bytes(data) self.assertTrue(ck.coverage["ok"], f"first diff at {ck.coverage['firstDiff']}") def test_reconstruction_handles_gzip_and_bare_streams_alike(self): data = tiny() a = sc.checksum_bytes(data) b = sc.checksum_bytes(gzip.compress(data, mtime=0)) self.assertEqual(a.digest, b.digest) self.assertTrue(b.coverage["ok"]) def test_a_broken_reconstruction_is_reported_not_swallowed(self): res = sr.read_bytes(tiny()) # corrupt the parse, not the file: a leaf whose raw no longer matches for n in res.tree.children[0].children: if n.name == "Turn": n.raw = b"\xff\xff\xff\xff" cov = sc.audit_coverage(res.tree, res.inflated) self.assertFalse(cov["ok"]) self.assertIsNotNone(cov["firstDiff"]) # --- determinism and sensitivity --------------------------------------------- class DigestTest(unittest.TestCase): def test_same_bytes_same_digest(self): data = tiny() self.assertEqual(sc.checksum_bytes(data).digest, sc.checksum_bytes(data).digest) def test_every_value_leaf_is_load_bearing(self): """Flip each leaf in turn; the root must move every single time. This is the anti-'empty region set' test: a leaf the digest does not actually consume would pass silently under any other check. """ base = sc.checksum_bytes(tiny()) seen = 0 for name, kw in (("status", dict(status_a=5)), ("checksum", dict(checksum=-1001)), ("float", dict(fval_a=1.5000001))): with self.subTest(name): other = sc.checksum_bytes(tiny(**kw)) self.assertNotEqual(base.digest, other.digest) seen += 1 self.assertEqual(seen, 3) def test_a_one_bit_float_change_moves_the_root(self): a = sc.checksum_bytes(tiny(fval_a=1.5)) (bits,) = struct.unpack(" str: f = tempfile.NamedTemporaryFile(suffix=".sav", delete=False) f.write(gzip.compress(data, mtime=0)) f.close() self.addCleanup(os.unlink, f.name) return f.name def test_record_then_verify_matches(self): files = [self._write(tiny(checksum=-1000)), self._write(tiny(checksum=-2000))] chain = sc.build_chain(files) self.assertTrue(all(t["coverage"] for t in chain["turns"])) ok, msgs = sc.verify_chain(chain, files) self.assertTrue(ok, msgs) def test_verify_stops_at_the_first_divergent_turn(self): files = [self._write(tiny(checksum=-1000)), self._write(tiny(checksum=-2000))] chain = sc.build_chain(files) bad = [files[0], self._write(tiny(checksum=-2001))] ok, msgs = sc.verify_chain(chain, bad) self.assertFalse(ok) self.assertIn("MATCH", msgs[0]) self.assertIn("DIVERGE", msgs[1]) self.assertTrue(any("/Summary" in m for m in msgs)) def test_chain_records_the_policy_it_was_built_under(self): files = [self._write(tiny())] chain = sc.build_chain(files, floats="canonical", mask="resave") self.assertEqual(chain["policy"]["floats"], "canonical") self.assertEqual(chain["policy"]["mask"], "resave") self.assertEqual(chain["policy"]["readerFingerprint"], sc.READER_FINGERPRINT) json.dumps(chain) # must stay serialisable def test_verifying_under_a_different_policy_is_refused(self): files = [self._write(tiny())] chain = sc.build_chain(files, floats="bits") ok, msgs = sc.verify_chain(chain, files, floats="canonical") self.assertFalse(ok) self.assertTrue(any("not comparable" in m for m in msgs), msgs) def test_a_stale_reader_fingerprint_is_called_out(self): files = [self._write(tiny())] chain = sc.build_chain(files) chain["policy"]["readerFingerprint"] = "0" * 16 _, msgs = sc.verify_chain(chain, files) self.assertTrue(any("save_reader" in m for m in msgs), msgs) # --- real saves --------------------------------------------------------------- @needs_saves class RealSaveTest(unittest.TestCase): def test_coverage_is_proved_on_every_available_save(self): for p in REAL: with self.subTest(os.path.basename(p)): ck = real_ck(p) self.assertTrue(ck.coverage["ok"], f"reconstruction diverged at {ck.coverage['firstDiff']}") self.assertEqual(ck.coverage["rebuiltBytes"], ck.coverage["inflatedBytes"]) def test_identical_bytes_give_identical_digests(self): by_hash: dict = {} for p in REAL: with open(p, "rb") as f: data = f.read() by_hash.setdefault(data, []).append(p) for data, paths in by_hash.items(): if len(paths) < 2: continue digs = {real_ck(p).digest for p in paths} self.assertEqual(len(digs), 1, paths) def test_repeated_runs_are_stable(self): p = REAL[0] self.assertEqual(real_ck(p).digest, real_ck(p).digest) def test_reader_is_clean_on_every_save(self): for p in REAL: with self.subTest(os.path.basename(p)): ck = real_ck(p, audit=False) self.assertEqual(ck.res.count("error"), 0) self.assertEqual(ck.res.count("warn"), 0) def test_canonical_float_policy_is_a_noop_on_this_corpus(self): """No save holds a NaN, a -0.0 or an infinity, so 'canonical' changes nothing below the root. Recorded so a future save that breaks it fails here rather than silently.""" for p in REAL: with self.subTest(os.path.basename(p)): a = real_ck(p, audit=False, floats="bits") b = real_ck(p, audit=False, floats="canonical") self.assertEqual(sc.diff(a.root, b.root), []) def test_known_resave_delta_localises_to_five_named_leaves(self): """determinism-oracle.md: loading a post-turn autosave and re-saving changes exactly Player.Status (4 -> 0) on the four turn-participating players, plus the derived Summary.Checksum. The tree must name them.""" pairs = [] for a in REAL: for b in REAL: if a >= b: continue ca, cb = real_ck(a, audit=False), real_ck(b, audit=False) d = sc.diff(ca.root, cb.root) if d and all(e.kind == "value" for e in d) and \ {e.path.rsplit("/", 1)[-1] for e in d} == {"Status", "Checksum"}: pairs.append((a, b, d)) if not pairs: self.skipTest("no re-save pair among the available saves") for a, b, d in pairs: self.assertEqual(len(d), 5, [repr(e) for e in d]) statuses = [e for e in d if e.path.endswith("/Status")] self.assertEqual(len(statuses), 4) for e in statuses: self.assertIn("/Sim/players/Player[", e.path) self.assertEqual((e.a, e.b), (4, 0)) chk = [e for e in d if e.path == "/Summary/Checksum"] self.assertEqual(len(chk), 1) self.assertEqual(chk[0].b - chk[0].a, -16) # 4 x (4 -> 0) def test_resave_mask_makes_that_pair_identical(self): found = False for a in REAL: for b in REAL: if a >= b: continue if real_ck(a, audit=False, mask="resave").digest == \ real_ck(b, audit=False, mask="resave").digest and \ real_ck(a, audit=False).digest != \ real_ck(b, audit=False).digest: found = True if not found: self.skipTest("no re-save pair among the available saves") self.assertTrue(found) def test_a_real_turn_transition_localises_to_named_objects(self): by_turn = {} for p in REAL: ck = real_ck(p, audit=False) t = (ck.res.typed.get("summary") or {}).get("Turn") by_turn.setdefault(t, ck) if not {2, 3} <= set(by_turn): self.skipTest("need a turn-2 and a turn-3 save") d = sc.diff(by_turn[2].root, by_turn[3].root, limit=500) self.assertGreater(len(d), 10) self.assertTrue(any(e.path == "/Summary/Turn" for e in d)) # every difference must be attributed to a path, never to a bare hash for e in d: self.assertTrue(e.path.startswith("/"), repr(e)) named = [e for e in d if "/Sim/players/Player[" in e.path] self.assertTrue(named, "a turn transition must move at least one player") if __name__ == "__main__": unittest.main(verbosity=2)