diff --git a/README.md b/README.md index 50d862c..bb30bbe 100644 --- a/README.md +++ b/README.md @@ -70,20 +70,34 @@ same line when it starts, so it is obvious which build produced a file. Bump it ## Verify - python verify.py [sedona_home] + python verify.py [app.sax|app.sab] [sedona_home] -Without an argument the home comes from `system.properties`. Three checks, run against a -`test_normal.sax` / `test_normal_sedonac.sab` pair in `testprogram/`. That folder is **not** in the -repo (gitignored) — drop the pair in yourself, otherwise verify prints `SKIP` and exits 0: +Point it at any app of yours and it round-trips it, direction by extension: - test_normal.sax -> sab vs test_normal_sedonac.sab - test_normal_sedonac.sab -> sax vs test_normal.sax - test_normal_sedonac.sab -> sax -> sab back to the same bytes + app.sab -> sab -> sax -> sab must come back byte-identical + app.sax -> sax -> sab -> sax -> sab the two sabs must be identical + +The `.sax` case compares the two **binaries**, not the two texts, because going through a `.sab` is +lossy on purpose — runtime prop values and formatting are not in the binary. If the regenerated +`.sax` differs from the original, that is printed as a note rather than a failure. (For a `.sax` +sedonac itself wrote, they do match.) + +With no file argument a chooser opens, listing `.sax` and `.sab`. Cancel it and the built-in +regression runs instead, against a `test_normal.sax` / `test_normal_sedonac.sab` pair in +`testprogram/`. That folder is **not** in the repo (gitignored) — drop the pair in yourself, +otherwise verify prints `SKIP` and exits 0: + + test_normal.sax -> sab vs test_normal_sedonac.sab + test_normal_sedonac.sab -> sax vs test_normal.sax + both round trips as above `check_encode(sax)` / `check_decode(sab)` with no reference argument run `bin/sedonac.exe` on a copy of the input and diff against that instead — a live comparison, if you have a JRE it will start under. +Exit code is 0 on pass, 1 on failure. A malformed file fails with a one-line reason +(`comp 0 app: missing child 254`) rather than a traceback. + ## Files sab2sax.py the CLI: dispatches on the input extension diff --git a/sax.py b/sax.py index c9f49b9..a11f3ab 100644 --- a/sax.py +++ b/sax.py @@ -225,15 +225,20 @@ def read_app(sab_path, home): order.append(cid) # rebuild the tree from the child/sibling id chain + def must(cid, what, owner): + if cid not in by_id: + raise Exception("comp %d %s: missing %s %d" % (owner["id"], owner["name"], what, cid)) + return by_id[cid] + for c in by_id.values(): - c["parent"] = by_id[c["parentId"]] if c["parentId"] != 0xFFFF else None + c["parent"] = must(c["parentId"], "parent", c) if c["parentId"] != 0xFFFF else None if c["childId"] != 0xFFFF: - kid = by_id[c["childId"]] + kid = must(c["childId"], "child", c) while True: c["kids"].append(kid) if kid["siblingId"] == 0xFFFF: break - kid = by_id[kid["siblingId"]] + kid = must(kid["siblingId"], "nextSibling", kid) links = [] while True: diff --git a/verify.py b/verify.py index c7c19f1..38f197b 100644 --- a/verify.py +++ b/verify.py @@ -1,27 +1,38 @@ -"""Regression check: our output must be byte-identical to sedonac.exe's, both ways. +"""Regression checks: our output must be byte-identical to sedonac.exe's, and a +conversion must survive a round trip. - sax -> sab check_encode() - sab -> sax check_decode() - sab -> sax -> sab round trip + sax -> sab check_encode() + sab -> sax check_decode() + sab -> sax -> sab round_trip_sab() bytes must come back identical + sax -> sab -> sax -> sab round_trip_sax() the two sabs must be identical -Usage: python verify.py [sedona_home] +Usage: python verify.py [app.sax|app.sab] [sedona_home] -Without an argument the home comes from system.properties (see config.py), which -pops a folder chooser the first time. +With no file argument a chooser opens; cancel it and the built-in +`testprogram/` regression runs instead (if those files are present). Sedona home +comes from system.properties (see config.py) unless given. """ import os, sys, subprocess, tempfile, shutil import sab, sax, config HERE = os.path.dirname(os.path.abspath(__file__)) -HOME = sys.argv[1] if len(sys.argv) > 1 else config.get_sedona_home() TESTS = os.path.join(HERE, "testprogram") +HOME = None + + +def home(): + """Resolved once, on first use -- so importing this module opens no dialog.""" + global HOME + if HOME is None: + HOME = config.get_sedona_home() + return HOME def sedonac(src, tmp): """Run sedonac on a copy of `src`; return the file it produced (needs a JRE).""" work = os.path.join(tmp, os.path.basename(src)) shutil.copy(src, work) - exe = os.path.join(HOME, "bin", "sedonac.exe") + exe = os.path.join(home(), "bin", "sedonac.exe") r = subprocess.run([exe, work], capture_output=True, text=True) if r.returncode != 0: print(" sedonac failed:\n" + r.stdout + r.stderr) @@ -30,30 +41,32 @@ def sedonac(src, tmp): return os.path.splitext(work)[0] + other -def compare(label, reference, mine, binary): +def compare(label, reference, mine, binary, names=("sedonac", "ours")): a = open(reference, "rb").read() b = open(mine, "rb").read() if a == b: - print(" PASS %-28s %d bytes identical" % (label, len(a))) + print(" PASS %-34s %d bytes identical" % (label, len(a))) return True diffs = [i for i in range(min(len(a), len(b))) if a[i] != b[i]] - print(" FAIL %-28s sedonac=%d ours=%d, %d byte diffs" % (label, len(a), len(b), len(diffs))) + print(" FAIL %-34s %s=%d %s=%d, %d byte diffs" % ( + label, names[0], len(a), names[1], len(b), len(diffs))) if diffs: i = diffs[0] if binary: lo, hi = max(0, i - 12), i + 12 print(" first diff at offset %d" % i) - print(" sedonac %s" % a[lo:hi].hex()) - print(" ours %s" % b[lo:hi].hex()) + print(" %-8s %s" % (names[0], a[lo:hi].hex())) + print(" %-8s %s" % (names[1], b[lo:hi].hex())) else: line = a[:i].count(b"\n") + 1 print(" first diff at line %d" % line) - print(" sedonac %s" % a.splitlines()[line - 1]) - print(" ours %s" % b.splitlines()[line - 1]) + print(" %-8s %s" % (names[0], a.splitlines()[line - 1])) + print(" %-8s %s" % (names[1], b.splitlines()[line - 1])) return False +# ---------- against sedonac ---------- def check_encode(sax_file, reference=None): """sax -> sab, against `reference` or a fresh sedonac run.""" tmp = tempfile.mkdtemp() @@ -62,7 +75,7 @@ def check_encode(sax_file, reference=None): if reference is None: return False mine = os.path.join(tmp, "mine.sab") - sab.encode(sax_file, HOME, mine) + sab.encode(sax_file, home(), mine) return compare(os.path.basename(sax_file) + " -> sab", reference, mine, True) finally: shutil.rmtree(tmp, ignore_errors=True) @@ -76,36 +89,108 @@ def check_decode(sab_file, reference=None): if reference is None: return False mine = os.path.join(tmp, "mine.sax") - sax.decode(sab_file, HOME, mine) + sax.decode(sab_file, home(), mine) return compare(os.path.basename(sab_file) + " -> sax", reference, mine, False) finally: shutil.rmtree(tmp, ignore_errors=True) -def check_round_trip(sab_file): +# ---------- round trips ---------- +def round_trip_sab(sab_file): """sab -> sax -> sab must come back to the same bytes.""" tmp = tempfile.mkdtemp() try: mid = os.path.join(tmp, "mid.sax") back = os.path.join(tmp, "back.sab") - sax.decode(sab_file, HOME, mid) - sab.encode(mid, HOME, back) - return compare(os.path.basename(sab_file) + " round trip", sab_file, back, True) + sax.decode(sab_file, home(), mid) + sab.encode(mid, home(), back) + return compare(os.path.basename(sab_file) + " sab->sax->sab", + sab_file, back, True, ("original", "ours")) finally: shutil.rmtree(tmp, ignore_errors=True) -if __name__ == "__main__": - print("sedona home: %s" % HOME) +def round_trip_sax(sax_file): + """sax -> sab -> sax -> sab: the two sabs must match. + + The sax leg is not compared against the original file, because a .sab holds + no runtime prop values and no formatting -- coming back through it is lossy + on purpose. The binary is the thing that has to be stable, so we compare + that. Any difference from the original sax is printed as a note, not a + failure.""" + tmp = tempfile.mkdtemp() + try: + first = os.path.join(tmp, "first.sab") + mid = os.path.join(tmp, "mid.sax") + second = os.path.join(tmp, "second.sab") + sab.encode(sax_file, home(), first) + sax.decode(first, home(), mid) + sab.encode(mid, home(), second) + + ok = compare(os.path.basename(sax_file) + " sax->sab->sax->sab", + first, second, True, ("1st sab", "2nd sab")) + + a = open(sax_file, "rb").read() + b = open(mid, "rb").read() + if a != b: + al, bl = a.splitlines(), b.splitlines() + print(" note: the regenerated sax differs from the original " + "(%d vs %d lines) -- expected where the original carries runtime " + "props or different formatting" % (len(al), len(bl))) + return ok + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +def check_file(path): + """Round-trip whatever was chosen, by extension.""" + ext = os.path.splitext(path)[1].lower() + if ext not in (".sax", ".sab"): + print(" don't know what to do with %s -- expected .sax or .sab" % path) + return False + try: + return round_trip_sax(path) if ext == ".sax" else round_trip_sab(path) + except Exception as e: # a malformed file is a failure, not a traceback + print(" FAIL %-34s %s" % (os.path.basename(path), e)) + return False + + +def builtin(): + """The checked-in pair, when testprogram/ is populated locally.""" saxf = os.path.join(TESTS, "test_normal.sax") sabf = os.path.join(TESTS, "test_normal_sedonac.sab") missing = [f for f in (saxf, sabf) if not os.path.isfile(f)] if missing: # testprogram/ is deliberately not in the repo -- put the pair back to run this print(" SKIP no test files: %s" % ", ".join(os.path.basename(f) for f in missing)) - sys.exit(0) + return None ok = check_encode(saxf, sabf) ok &= check_decode(sabf, saxf) - ok &= check_round_trip(sabf) - sys.exit(0 if ok else 1) + ok &= round_trip_sab(sabf) + ok &= round_trip_sax(saxf) + return ok + + +def main(argv): + global HOME + print("%s %s" % (config.NAME, config.VERSION)) + path = argv[1] if len(argv) > 1 else config.choose_input_file() + if len(argv) > 2: + HOME = argv[2] + print("sedona home: %s" % home()) + + if not path: + print("no file chosen -- running the built-in checks") + ok = builtin() + return 0 if ok is not False else 1 + + path = os.path.abspath(path) + if not os.path.isfile(path): + print("no such file: " + path) + return 1 + return 0 if check_file(path) else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv))