verify.py: round-trip any app you pick, with a file chooser

Takes a file now -- python verify.py [app.sax|app.sab] [sedona_home] -- and
opens the same chooser as the converter when none is given. Direction follows
the extension:

  .sab  sab -> sax -> sab            must come back byte-identical
  .sax  sax -> sab -> sax -> sab     the two sabs must be identical

The sax case compares the binaries, not the two texts: a sab carries no
runtime prop values and no formatting, so a regenerated sax may legitimately
differ from a hand-written original. That is printed as a note, not a failure.

Cancelling the chooser falls back to the built-in testprogram/ regression,
now four checks (both sedonac comparisons plus both round trips).

Malformed input fails with a one-line reason and exit 1 instead of a
traceback, and sax.py reports a broken parent/child/sibling id as
"comp 0 app: missing child 254" rather than a bare KeyError. Sedona home is
resolved lazily so importing verify opens no dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
arda.aydin@ontrol.com.tr 2026-08-12 22:30:52 +03:00
parent af906b31dc
commit 1fdfed90dd
3 changed files with 141 additions and 37 deletions

View File

@ -70,20 +70,34 @@ same line when it starts, so it is obvious which build produced a file. Bump it
## Verify ## 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 Point it at any app of yours and it round-trips it, direction by extension:
`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: 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.sax -> sab vs test_normal_sedonac.sab
test_normal_sedonac.sab -> sax vs test_normal.sax test_normal_sedonac.sab -> sax vs test_normal.sax
test_normal_sedonac.sab -> sax -> sab back to the same bytes both round trips as above
`check_encode(sax)` / `check_decode(sab)` with no reference argument run `bin/sedonac.exe` on a copy `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 of the input and diff against that instead — a live comparison, if you have a JRE it will start
under. 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 ## Files
sab2sax.py the CLI: dispatches on the input extension sab2sax.py the CLI: dispatches on the input extension

11
sax.py
View File

@ -225,15 +225,20 @@ def read_app(sab_path, home):
order.append(cid) order.append(cid)
# rebuild the tree from the child/sibling id chain # 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(): 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: if c["childId"] != 0xFFFF:
kid = by_id[c["childId"]] kid = must(c["childId"], "child", c)
while True: while True:
c["kids"].append(kid) c["kids"].append(kid)
if kid["siblingId"] == 0xFFFF: if kid["siblingId"] == 0xFFFF:
break break
kid = by_id[kid["siblingId"]] kid = must(kid["siblingId"], "nextSibling", kid)
links = [] links = []
while True: while True:

135
verify.py
View File

@ -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() sax -> sab check_encode()
sab -> sax check_decode() sab -> sax check_decode()
sab -> sax -> sab round trip 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 With no file argument a chooser opens; cancel it and the built-in
pops a folder chooser the first time. `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 os, sys, subprocess, tempfile, shutil
import sab, sax, config import sab, sax, config
HERE = os.path.dirname(os.path.abspath(__file__)) 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") 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): def sedonac(src, tmp):
"""Run sedonac on a copy of `src`; return the file it produced (needs a JRE).""" """Run sedonac on a copy of `src`; return the file it produced (needs a JRE)."""
work = os.path.join(tmp, os.path.basename(src)) work = os.path.join(tmp, os.path.basename(src))
shutil.copy(src, work) 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) r = subprocess.run([exe, work], capture_output=True, text=True)
if r.returncode != 0: if r.returncode != 0:
print(" sedonac failed:\n" + r.stdout + r.stderr) print(" sedonac failed:\n" + r.stdout + r.stderr)
@ -30,30 +41,32 @@ def sedonac(src, tmp):
return os.path.splitext(work)[0] + other 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() a = open(reference, "rb").read()
b = open(mine, "rb").read() b = open(mine, "rb").read()
if a == b: if a == b:
print(" PASS %-28s %d bytes identical" % (label, len(a))) print(" PASS %-34s %d bytes identical" % (label, len(a)))
return True return True
diffs = [i for i in range(min(len(a), len(b))) if a[i] != b[i]] 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: if diffs:
i = diffs[0] i = diffs[0]
if binary: if binary:
lo, hi = max(0, i - 12), i + 12 lo, hi = max(0, i - 12), i + 12
print(" first diff at offset %d" % i) print(" first diff at offset %d" % i)
print(" sedonac %s" % a[lo:hi].hex()) print(" %-8s %s" % (names[0], a[lo:hi].hex()))
print(" ours %s" % b[lo:hi].hex()) print(" %-8s %s" % (names[1], b[lo:hi].hex()))
else: else:
line = a[:i].count(b"\n") + 1 line = a[:i].count(b"\n") + 1
print(" first diff at line %d" % line) print(" first diff at line %d" % line)
print(" sedonac %s" % a.splitlines()[line - 1]) print(" %-8s %s" % (names[0], a.splitlines()[line - 1]))
print(" ours %s" % b.splitlines()[line - 1]) print(" %-8s %s" % (names[1], b.splitlines()[line - 1]))
return False return False
# ---------- against sedonac ----------
def check_encode(sax_file, reference=None): def check_encode(sax_file, reference=None):
"""sax -> sab, against `reference` or a fresh sedonac run.""" """sax -> sab, against `reference` or a fresh sedonac run."""
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
@ -62,7 +75,7 @@ def check_encode(sax_file, reference=None):
if reference is None: if reference is None:
return False return False
mine = os.path.join(tmp, "mine.sab") 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) return compare(os.path.basename(sax_file) + " -> sab", reference, mine, True)
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
@ -76,36 +89,108 @@ def check_decode(sab_file, reference=None):
if reference is None: if reference is None:
return False return False
mine = os.path.join(tmp, "mine.sax") 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) return compare(os.path.basename(sab_file) + " -> sax", reference, mine, False)
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) 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.""" """sab -> sax -> sab must come back to the same bytes."""
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
try: try:
mid = os.path.join(tmp, "mid.sax") mid = os.path.join(tmp, "mid.sax")
back = os.path.join(tmp, "back.sab") back = os.path.join(tmp, "back.sab")
sax.decode(sab_file, HOME, mid) sax.decode(sab_file, home(), mid)
sab.encode(mid, HOME, back) sab.encode(mid, home(), back)
return compare(os.path.basename(sab_file) + " round trip", sab_file, back, True) return compare(os.path.basename(sab_file) + " sab->sax->sab",
sab_file, back, True, ("original", "ours"))
finally: finally:
shutil.rmtree(tmp, ignore_errors=True) shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__": def round_trip_sax(sax_file):
print("sedona home: %s" % HOME) """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") saxf = os.path.join(TESTS, "test_normal.sax")
sabf = os.path.join(TESTS, "test_normal_sedonac.sab") sabf = os.path.join(TESTS, "test_normal_sedonac.sab")
missing = [f for f in (saxf, sabf) if not os.path.isfile(f)] missing = [f for f in (saxf, sabf) if not os.path.isfile(f)]
if missing: if missing:
# testprogram/ is deliberately not in the repo -- put the pair back to run this # 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)) 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_encode(saxf, sabf)
ok &= check_decode(sabf, saxf) ok &= check_decode(sabf, saxf)
ok &= check_round_trip(sabf) ok &= round_trip_sab(sabf)
sys.exit(0 if ok else 1) 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))