Py_sax2sab/verify.py
arda.aydin@ontrol.com.tr 1fdfed90dd 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>
2026-08-12 22:30:52 +03:00

197 lines
7.0 KiB
Python

"""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_sab() bytes must come back identical
sax -> sab -> sax -> sab round_trip_sax() the two sabs must be identical
Usage: python verify.py [app.sax|app.sab] [sedona_home]
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__))
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")
r = subprocess.run([exe, work], capture_output=True, text=True)
if r.returncode != 0:
print(" sedonac failed:\n" + r.stdout + r.stderr)
return None
other = ".sax" if work.endswith(".sab") else ".sab"
return os.path.splitext(work)[0] + other
def compare(label, reference, mine, binary, names=("sedonac", "ours")):
a = open(reference, "rb").read()
b = open(mine, "rb").read()
if a == b:
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 %-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(" %-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(" %-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()
try:
reference = reference or sedonac(sax_file, tmp)
if reference is None:
return False
mine = os.path.join(tmp, "mine.sab")
sab.encode(sax_file, home(), mine)
return compare(os.path.basename(sax_file) + " -> sab", reference, mine, True)
finally:
shutil.rmtree(tmp, ignore_errors=True)
def check_decode(sab_file, reference=None):
"""sab -> sax, against `reference` or a fresh sedonac run."""
tmp = tempfile.mkdtemp()
try:
reference = reference or sedonac(sab_file, tmp)
if reference is None:
return False
mine = os.path.join(tmp, "mine.sax")
sax.decode(sab_file, home(), mine)
return compare(os.path.basename(sab_file) + " -> sax", reference, mine, False)
finally:
shutil.rmtree(tmp, ignore_errors=True)
# ---------- 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) + " sab->sax->sab",
sab_file, back, True, ("original", "ours"))
finally:
shutil.rmtree(tmp, ignore_errors=True)
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))
return None
ok = check_encode(saxf, sabf)
ok &= check_decode(sabf, saxf)
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))