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:
parent
af906b31dc
commit
1fdfed90dd
28
README.md
28
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
|
||||
|
||||
11
sax.py
11
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:
|
||||
|
||||
139
verify.py
139
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))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user