Files
scanengine-3/tests/gen_goldens.py
T
Thomas Ales d185676130 Phase 0: test scaffolding + golden fixtures
- ruff config, offscreen smoke tests for all 7 GUI apps/panels
- golden v6 .sras fixtures (complete + 4 truncation variants) generated
  by the pre-refactor writer, with expected header/frontier JSON
- golden geometry fixtures from the pre-refactor _build_scan_params
- consistency tests proving current code reproduces the goldens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:01:14 -05:00

174 lines
6.6 KiB
Python

"""Generate golden fixtures capturing PRE-REFACTOR behavior (Phase 0).
Run once against the un-refactored sc3_aui_app.py; the outputs are committed
under tests/golden/. After the refactor, tests compare the new
implementations against these committed files — do NOT regenerate them
against refactored code, that would defeat the purpose.
Usage: .venv/bin/python tests/gen_goldens.py
"""
import json
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import conftest # noqa: F401 (pyueye stub + offscreen)
import sc3_aui_app as app
GOLDEN = Path(__file__).parent / "golden"
# ── Geometry fixtures ────────────────────────────────────────────────────────
GEOMETRY_CASES = [
# label, XS, YS, XD, YD, num_angles, row_spacing
("single_angle_square", "10.0", "10.0", "10.0", "10.0", "1", "0.25"),
("single_angle_rect", "10.0", "10.0", "80.0", "50.0", "1", "0.25"),
("three_angles", "5.0", "5.0", "20.0", "12.0", "3", "0.1"),
("five_angles", "0.0", "0.0", "30.0", "30.0", "5", "0.5"),
("seven_angles_asym", "12.5", "7.5", "42.0", "17.0", "7", "0.05"),
("two_angles_tiny", "1.0", "1.0", "0.02", "0.02", "2", "0.01"),
("flat_line_ydelta_zero", "10.0", "10.0", "5.0", "0.0", "1", "0.25"),
("fine_spacing", "3.0", "4.0", "2.5", "1.5", "4", "0.025"),
]
GEOMETRY_ERROR_CASES = [
("xd_zero", "10.0", "10.0", "0.0", "10.0", "1", "0.25"),
("xd_negative", "10.0", "10.0", "-5.0", "10.0", "1", "0.25"),
("spacing_zero", "10.0", "10.0", "10.0", "10.0", "1", "0.0"),
("angles_zero", "10.0", "10.0", "10.0", "10.0", "0", "0.25"),
("xs_not_number", "abc", "10.0", "10.0", "10.0", "1", "0.25"),
]
class _W:
"""Stands in for a QLineEdit: _build_scan_params only calls .text()."""
def __init__(self, text):
self._text = text
def text(self):
return self._text
def _fake_window(xs, ys, xd, yd, na, rs):
fs = type("FakeMainWindow", (), {})()
fs.x_start_edit = _W(xs)
fs.y_start_edit = _W(ys)
fs.x_delta_edit = _W(xd)
fs.y_delta_edit = _W(yd)
fs.num_angles_edit = _W(na)
fs.row_spacing_edit = _W(rs)
fs.scan_prefix_edit = _W("golden")
fs.scan_save_dir_edit = _W("/tmp/golden-scans")
return fs
def build_geometry_fixtures():
out = {"constants": {
"LASER_FREQ_HZ": app.LASER_FREQ_HZ,
"SCAN_VELOCITY_MM_S": app.SCAN_VELOCITY_MM_S,
"GR_ROTATION_SIGN": app.GR_ROTATION_SIGN,
}, "cases": {}, "error_cases": {}}
for label, *inputs in GEOMETRY_CASES:
params = app.MainWindow._build_scan_params(_fake_window(*inputs))
out["cases"][label] = {
"inputs": dict(zip(("XS", "YS", "XD", "YD", "num_angles", "row_spacing"), inputs)),
"params": params,
}
for label, *inputs in GEOMETRY_ERROR_CASES:
try:
app.MainWindow._build_scan_params(_fake_window(*inputs))
raise AssertionError(f"error case {label} did not raise")
except ValueError as e:
out["error_cases"][label] = {
"inputs": dict(zip(("XS", "YS", "XD", "YD", "num_angles", "row_spacing"), inputs)),
"message": str(e),
}
with open(GOLDEN / "geometry.json", "w") as f:
json.dump(out, f, indent=2)
print(f"geometry.json: {len(out['cases'])} cases, {len(out['error_cases'])} error cases")
# ── SRAS v6 file fixtures ────────────────────────────────────────────────────
SPF = 8 # samples per frame (synthetic, tiny)
SAMPLE_RATE = 6.25e9
PREAMBLES = [f"WFMOUTPRE:CH{ch};SYNTHETIC;PT_FMT Y;XINCR 1.6E-10" for ch in app.SCAN_CHANNELS]
BACKGROUND = bytes(range(SPF))
def _synthetic_frame(ai, ri, ci, fi):
return bytes((ai * 7 + ri * 5 + ci * 3 + fi + s) % 256 for s in range(SPF))
def build_sras_fixtures():
# Geometry via the real pre-refactor code path: 2 angles, 3 rows, 4 frames.
params = app.MainWindow._build_scan_params(
_fake_window("1.0", "1.0", "0.02", "0.02", "2", "0.01"))
per_angle = params["per_angle"]
angles = [pa["angle"] for pa in per_angle]
complete = GOLDEN / "complete.sras"
f = app._open_scan_file(
complete, angles, per_angle,
params["x_start_nominal"], params["y_start_nominal"],
params["x_delta_nominal"], params["y_delta_nominal"],
params["row_spacing"],
app.SCAN_VELOCITY_MM_S, app.LASER_FREQ_HZ,
SPF, SAMPLE_RATE, PREAMBLES, BACKGROUND,
)
try:
data_start = f.tell()
for ai, pa in enumerate(per_angle):
for ri in range(pa["n_rows"]):
for ci in range(len(app.SCAN_CHANNELS)):
for fi in range(pa["n_frames"]):
f.write(_synthetic_frame(ai, ri, ci, fi))
finally:
f.close()
info = app._read_sras_header(complete)
assert info["data_start_offset"] == data_start
row_bytes = info["n_channels"] * per_angle[0]["n_frames"] * SPF * info["bytes_per_sample"]
angle_bytes = [row_bytes * pa["n_rows"] for pa in per_angle]
def _truncate(name, size):
dst = GOLDEN / name
shutil.copyfile(complete, dst)
with open(dst, "r+b") as g:
g.truncate(data_start + size)
return dst
variants = {
"complete.sras": complete,
"trunc_midrow_a1.sras": _truncate(
"trunc_midrow_a1.sras", angle_bytes[0] + row_bytes + row_bytes // 2),
"trunc_rowboundary_a1.sras": _truncate(
"trunc_rowboundary_a1.sras", angle_bytes[0] + 2 * row_bytes),
"trunc_angleboundary.sras": _truncate(
"trunc_angleboundary.sras", angle_bytes[0]),
"trunc_midrow_a0.sras": _truncate(
"trunc_midrow_a0.sras", row_bytes + row_bytes // 2),
"header_only.sras": _truncate("header_only.sras", 0),
}
expected = {"header": info, "statuses": {}}
for name, path in variants.items():
expected["statuses"][name] = app._compute_angle_status(path, info)
with open(GOLDEN / "sras_expected.json", "w") as f_json:
json.dump(expected, f_json, indent=2)
print(f"sras fixtures: {len(variants)} files, "
f"data block {sum(angle_bytes)} bytes, row_bytes={row_bytes}")
if __name__ == "__main__":
GOLDEN.mkdir(exist_ok=True)
build_geometry_fixtures()
build_sras_fixtures()
print("done")