#!/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() #: (id, name, LocID, FPlan destination or None, [ship ids]) PRE_FLEETS = [(258, "Alpha Fleet", 100, None, [16]), (274, "Beta Fleet", 0, 700, [32, 48])] def fleets_save(fleets, sys_flt=None, extra=None) -> bytes: """A miniature save carrying a master fleet table. Shaped like the real one where this tool looks: `FleetIDs[]`/`ShipIDs[]` inline master lists, `FltID`/`Flt` object pairs, `FtName`, `LocID`, an `FPlan` with a `pnd` destination, and `ShipID`/`Ship` pairs whose `Ship` frame carries the `FltID` back-reference. """ w = sw.SaveWriter("joint") w.begin("Summary") w.string("GameName", "T") w.int("Turn", 28) w.int("Checksum", sum(f[0] for f in fleets)) w.end() w.begin("Sim") w.string("KeyPath", "") w.int("NMSz", 16) w.int("NMLc", 0) w.int("NMnx", 100) w.int("PlayerIDs", 1) w.int(".", 32) w.int("FleetIDs", len(fleets)) for f in fleets: w.int(".", f[0]) ships = [s for f in fleets for s in f[4]] w.int("ShipIDs", len(ships)) for s in ships: w.int(".", s) if extra is not None: w.int(extra[0], extra[1]) if sys_flt is not None: w.int("Flt", sys_flt) w.int("NumFlts", len(fleets)) for fid, name, loc, dest, fships in fleets: w.int("FltID", fid) w.begin("Flt") w.string("FtName", name) w.int("LocID", loc) if dest is not None: w.begin("FPlan") w.int("pnd", dest) w.end() w.int("NumShips", len(fships)) for s in fships: w.int("ShipID", s) w.begin("Ship") w.int("FltID", fid) w.end() 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("1986}") ca, cb, d = self.compare(ares, bres, rl) self.assertEqual(d, [], [repr(e) for e in d]) self.assertEqual(ca.digest, cb.digest) def test_the_ship_to_fleet_back_reference_is_rewritten(self): p, a, b, ares, bres = self.trees(self.A_NEW, self.B_NEW) rl = sc.apply_relabelling(ares.tree, sc.build_relabelling(p, a, b)) self.assertEqual(rl.rewrites.get("FltID"), 2) self.assertEqual(rl.moves, 2) def test_a_system_s_fleet_reference_is_rewritten(self): p, a, b, ares, bres = self.trees(self.A_NEW, self.B_NEW) # the same physical group is at system 384 in both, so the system's # fleet reference names 1970 in A and 1986 in B _, a2, _, ares2, _ = self.trees(self.A_NEW, self.B_NEW, sys_flt=1970) _, _, b2, _, bres2 = self.trees(self.A_NEW, self.B_NEW, sys_flt=1986) rl = sc.build_relabelling(p, a2, b2) ca, cb, d = self.compare(ares2, bres2, rl) self.assertEqual(rl.rewrites.get("Flt"), 1) self.assertEqual(d, [], [repr(e) for e in d]) def test_the_identity_case_leaves_everything_alone(self): p, a, b, ares, bres = self.trees(self.A_NEW, self.A_NEW) rl = sc.build_relabelling(p, a, b) self.assertEqual(rl.moved, {}) self.assertFalse(bool(rl)) ca, cb, d = self.compare(ares, bres, rl) self.assertEqual(d, []) def test_pi_is_printed_as_a_cycle_when_it_is_not_a_transposition(self): self.assertEqual(sc.format_permutation({1: 2, 2: 3, 3: 1}), "{1->2->3->1}") self.assertIn("identity", sc.format_permutation({1970: 1970})) def test_a_relabelled_root_is_domain_separated_from_a_plain_one(self): """A 'modulo pi' root must never be mistaken for a strict one.""" data = fleets_save(PRE_FLEETS) plain = sc.checksum_bytes(data) loose = sc.checksum_bytes(data, extra_masks=sc.RELABEL_MASK, id_lists_as_sets=True) self.assertNotEqual(plain.digest, loose.digest) def test_the_default_path_is_untouched_by_the_new_parameters(self): data = fleets_save(PRE_FLEETS) self.assertEqual(sc.checksum_bytes(data).digest, sc.checksum_bytes(data, extra_masks=(), id_lists_as_sets=False).digest) # -- G1: nothing present in the pre-turn save may be relabelled ---------- def test_ids_present_in_the_pre_turn_save_are_never_in_pi(self): p, a, b, _, _ = self.trees(self.A_NEW, self.B_NEW) rl = sc.build_relabelling(p, a, b) pre_ids = {n.value for n, _ in sc._fleet_slots(p)} self.assertTrue(pre_ids) self.assertFalse(pre_ids & (set(rl.pi) | set(rl.pi.values()))) def test_two_pre_turn_fleets_that_swapped_contents_are_NOT_absorbed(self): """The strongest form of G1: the very shape this mode absorbs, but on ids that already existed. That would be a real divergence.""" swapped = [(258, "Alpha Fleet", 0, 700, [32, 48]), (274, "Beta Fleet", 100, None, [16])] p = sr.read_bytes(fleets_save(PRE_FLEETS)).tree a = sr.read_bytes(fleets_save(PRE_FLEETS)).tree b = sr.read_bytes(fleets_save(swapped)).tree rl = sc.build_relabelling(p, a, b) self.assertEqual(rl.pi, {}) ares = sr.read_bytes(fleets_save(PRE_FLEETS)) bres = sr.read_bytes(fleets_save(swapped)) _, _, d = self.compare(ares, bres, rl) self.assertTrue(d, "a swap among pre-existing fleets must still report") # -- G2: only client-minted (non-zero node nibble) ids ------------------ def test_server_minted_ids_are_never_relabelled(self): """Node nibble 0 is the server's space (id-allocation.md section 1).""" a_new = [(7120, "Phi Fleet", 384, None, [6976]), (7136, "Chi Fleet", 0, 80, [5264])] b_new = [(7120, "Phi Fleet", 0, 80, [5264]), (7136, "Chi Fleet", 384, None, [6976])] p, a, b, ares, bres = self.trees(a_new, b_new) rl = sc.build_relabelling(p, a, b) self.assertEqual(rl.pi, {}) self.assertEqual(sc.id_node(7120), 0) _, _, d = self.compare(ares, bres, rl) self.assertTrue(d, "a node-0 swap is not this residue and must report") # -- G3: pi must permute one set of new ids ----------------------------- def test_different_new_id_sets_are_refused(self): """Lane AD's unpinned pair in miniature: one run minted a third fleet.""" b_new = self.B_NEW + [(2002, "Upsilon Fleet", 0, 384, [6992])] p, a, b, _, _ = self.trees(self.A_NEW, b_new) with self.assertRaises(sc.RelabelRefused) as cm: sc.build_relabelling(p, a, b) self.assertIn("only in B [2002]", str(cm.exception)) def test_a_refusal_leaves_both_trees_untouched(self): b_new = self.B_NEW + [(2002, "Upsilon Fleet", 0, 384, [6992])] p, a, b, ares, bres = self.trees(self.A_NEW, b_new) before = sc.checksum_result(ares, "A", {"ok": None}).digest with self.assertRaises(sc.RelabelRefused): sc.build_relabelling(p, a, b) self.assertEqual(sc.checksum_result(ares, "A", {"ok": None}).digest, before) # -- G4: the content match must be a one-to-one correspondence ---------- def test_new_fleets_whose_contents_differ_are_refused(self): b_new = [(1970, "Sigma Fleet", 0, 80, [5264]), (1986, "Tau Fleet", 384, None, [9999])] # a different ship p, a, b, _, _ = self.trees(self.A_NEW, b_new) with self.assertRaises(sc.RelabelRefused) as cm: sc.build_relabelling(p, a, b) self.assertIn("do not correspond by content", str(cm.exception)) def test_an_ambiguous_content_key_is_refused(self): dup = [(1970, "Sigma Fleet", 384, None, [6976]), (1986, "Tau Fleet", 384, None, [6976])] p, a, b, _, _ = self.trees(dup, dup) with self.assertRaises(sc.RelabelRefused) as cm: sc.build_relabelling(p, a, b) self.assertIn("ambiguous", str(cm.exception)) def test_a_fleet_at_a_system_never_keys_the_same_as_one_bound_for_it(self): at = sr.read_bytes(fleets_save([(1970, "S", 384, None, [1])])).tree to = sr.read_bytes(fleets_save([(1970, "S", 0, 384, [1])])).tree ka = sc._fleet_content_key(sc._fleet_slots(at)[0][1]) kb = sc._fleet_content_key(sc._fleet_slots(to)[0][1]) self.assertNotEqual(ka, kb) # -- the name guard ------------------------------------------------------ def test_a_name_that_does_not_track_the_id_is_refused(self): """`FtName` is minted from the same per-fleet ordinal as the id, so it is an id-attached label -- but only while it demonstrably tracks the id in both saves. If it does not, something real differs.""" b_new = [(1970, "Tau Fleet", 0, 80, [5264]), (1986, "Sigma Fleet", 384, None, [6976])] p, a, b, _, _ = self.trees(self.A_NEW, b_new) with self.assertRaises(sc.RelabelRefused) as cm: sc.build_relabelling(p, a, b) self.assertIn("does not track the id", str(cm.exception)) def test_the_name_stays_with_the_slot_not_with_the_body(self): p, a, b, ares, bres = self.trees(self.A_NEW, self.B_NEW) rl = sc.apply_relabelling(ares.tree, sc.build_relabelling(p, a, b)) got = {n.value: sc._fleet_name(f) for n, f in sc._fleet_slots(ares.tree)} self.assertEqual(got[1970], "Sigma Fleet") self.assertEqual(got[1986], "Tau Fleet") # -- G5: the rewrite must be complete ----------------------------------- def test_an_unmodelled_reference_site_is_refused(self): """A leaf holding a permuted id at a tag this tool does not model would keep the old label through the rewrite, so two different states could compare equal. That is a refusal, not a warning.""" p, a, b, _, _ = self.trees(self.A_NEW, self.B_NEW, extra=("SomeOtherRef", 1970)) with self.assertRaises(sc.RelabelRefused) as cm: sc.build_relabelling(p, a, b) self.assertIn("does not model", str(cm.exception)) self.assertIn("SomeOtherRef", str(cm.exception)) def test_the_completeness_scan_matches_raw_bytes_not_the_typed_value(self): """The digest is a function of (bytes, reader schema); a leaf the schema types as a float still carries the id. A value-based scan would miss it and the guard would be silently empty (rule 1).""" node = sr.Node("Odd", "float", 2.76e-42, 0, 12, raw=struct.pack(" 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) # `pairs` is enumerated over sorted filenames, so the re-saved file # can be either side; the claim is symmetric, so assert it that way # rather than assuming which name sorts first (2026-09-09: adding # `cb-turn2to3-endturn.sav` to the corpus put one pair the other # way round and this test failed on a corpus fact, not a defect). direction = (statuses[0].a, statuses[0].b) self.assertIn(direction, ((4, 0), (0, 4))) for e in statuses: self.assertIn("/Sim/players/Player[", e.path) self.assertEqual((e.a, e.b), direction) 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 if direction == (4, 0) else 16) 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) # -- --relabel-new-ids acceptance, both halves ------------------------- def _saves(self, *names): d = os.path.dirname(REAL[0]) paths = [os.path.join(d, n) for n in names] if not all(os.path.exists(p) for p in paths): self.skipTest(f"need {names}") return paths def _relabelled(self, pre, a, b): return sc._relabelled_pair(pre, a, b, floats="bits", mask="none", audit=False) def test_POSITIVE_bp_pinned_pair_is_identical_modulo_one_transposition(self): """Lane BP's two processes with identical pinned AI seeds differ in 35 of 61,147 leaves; all 35 are fleets 1970 and 1986 exchanging contents. Under pi they must read IDENTICAL, and pi must be exactly that swap.""" pre, a, b = self._saves("bp-turn28-pre.sav", "bp-pinA-turn28.sav", "bp-pinB-turn28.sav") ca, cb, rl, refusal = self._relabelled(pre, a, b) self.assertIsNone(refusal) self.assertEqual(rl.moved, {1970: 1986, 1986: 1970}) self.assertEqual(rl.new_ids, [1970, 1986, 2002]) self.assertEqual(rl.nodes, [2]) self.assertEqual(sc.diff(ca.root, cb.root), []) self.assertEqual(ca.digest, cb.digest) def test_POSITIVE_the_same_pair_still_diverges_without_the_flag(self): pre, a, b = self._saves("bp-turn28-pre.sav", "bp-pinA-turn28.sav", "bp-pinB-turn28.sav") d = sc.diff(real_ck(a, audit=False).root, real_ck(b, audit=False).root) self.assertEqual(len(d), 35, [repr(e) for e in d]) def test_NEGATIVE_ad_unpinned_pair_is_refused_and_still_diverges(self): """Lane AD's pair differs because the seeds differ -- real decisions. A tool that made this look identical would be worthless.""" pre, a, b = self._saves("ad-turn27-two-raiders.sav", "ad-oracle-A-post.sav", "ad-oracle-B-post.sav") ca, cb, rl, refusal = self._relabelled(pre, a, b) self.assertIsNone(rl, "AD's pair must not be relabelled") self.assertIn("2002", refusal) self.assertNotEqual(ca.digest, cb.digest) d = sc.diff(ca.root, cb.root, limit=500) self.assertEqual(len(d), 94, [repr(e) for e in d]) def test_NEGATIVE_even_forcing_a_partial_relabelling_cannot_hide_it(self): """Belt and braces: hand the applier the transposition it *would* have found if G3 were absent. The pair must still diverge -- the guard is not the only thing standing between AD's pair and a green verdict.""" pre, a, b = self._saves("ad-turn27-two-raiders.sav", "ad-oracle-A-post.sav", "ad-oracle-B-post.sav") with open(a, "rb") as f: ares = sr.read_bytes(f.read()) with open(b, "rb") as f: bres = sr.read_bytes(f.read()) forced = sc.Relabelling({1970: 1986, 1986: 1970}, [2], [1970, 1986], {1970: None, 1986: None}) sc.apply_relabelling(ares.tree, forced) ca = sc.checksum_result(ares, a, {"ok": None}, extra_masks=sc.RELABEL_MASK, id_lists_as_sets=True) cb = sc.checksum_result(bres, b, {"ok": None}, extra_masks=sc.RELABEL_MASK, id_lists_as_sets=True) self.assertNotEqual(ca.digest, cb.digest) 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)