Files
sras-viewer/tests/test_gui.py
T
Thomas Ales d5914b5793 Stop discarding precomputed FFT data on view switches
The display path hardcoded row_avg_n=0 when checking a file's stored FFT
cache, since the main window has no row-averaging control (only the
batch-only RowAverageFftOptionsDialog). Once a file was batch-computed with
row-averaging, every view switch saw a phantom provenance mismatch and
silently launched a full raw recompute, discarding the precomputed data.

The display now asks for the stored image at the settings it was actually
computed under (bg-sub/pad/row-averaging), rather than the window's live
controls, so a stored or already-computed image is always shown once
present. Those controls now only affect a first-time compute for an
uncached angle or an explicit batch recompute -- never what's already on
screen. DC threshold is unaffected: it stays live, since re-masking a
cached image is free.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 18:21:34 -05:00

698 lines
28 KiB
Python

"""Headless GUI test: drives SrasViewerWindow through the real Qt widgets,
signals and worker threads under the offscreen platform plugin.
Covers the interactions a manual smoke test would: load, switch angles and
channels, background DC precompute, lazy FFT compute, threshold and bg-sub
changes, the alignment wizard end to end (pre-rotation, correlation, manual
nudging, crop, export), aligned view, ROI draw/move, and CSV export.
NOTE: this module is one ordered integration sequence over a single shared
window — the tests build on each other's state and must run in definition
order (pytest's default within a module). Run the whole module, not single
tests.
"""
import json
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pytest
from PyQt6.QtCore import QEventLoop, Qt, QTimer
from PyQt6.QtTest import QTest
from PyQt6.QtWidgets import QApplication, QMessageBox
import sras_compute as compute
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
from sras_viewer import RoiQuad, SrasViewerWindow, VELOCITY_MODE_IDX
import tools.make_test_sras as gen
def pump(ms: int = 250):
"""Run the event loop for a while so queued signals and worker threads
make progress."""
loop = QEventLoop()
QTimer.singleShot(ms, loop.quit)
loop.exec()
def wait_until(pred, timeout_ms: int = 20000, step: int = 100) -> bool:
waited = 0
while waited < timeout_ms:
if pred():
return True
pump(step)
waited += step
return pred()
@pytest.fixture(scope="module")
def ctx(tmp_path_factory):
"""The shared window, test file, and cross-test state for the sequence."""
app = QApplication.instance() or QApplication([])
tmpdir = tmp_path_factory.mktemp("sras_gui")
path = tmpdir / "gui.sras"
gen.write(path, n_angles=4, seed=11, samples_per_frame=256)
win = SrasViewerWindow()
win.show()
errors: list[str] = []
# Capture anything the app reports as an error via the status bar.
win.statusBar().messageChanged.connect(
lambda m: errors.append(m) if m and "error" in m.lower() else None)
c = SimpleNamespace(app=app, win=win, path=path, tmpdir=tmpdir,
errors=errors, s=None)
yield c
if win.isVisible():
win.close()
pump(400)
def test_load(ctx):
win = ctx.win
win._load_file(str(ctx.path))
assert wait_until(lambda: win._sras is not None), "file loaded"
ctx.s = s = win._sras
assert s.version == 6, f"v{s.version}"
assert win.combo_channel.currentIndex() == CH4_IDX, "defaults to CH4"
assert win._current_image is not None, "image displayed"
assert win.spin_angle.maximum() == s.n_angles - 1, \
"angle spinbox ranges over all angles"
assert win._info["Angles"].text() == f"Angles: {s.n_angles}", \
win._info["Angles"].text()
def test_dc_precompute_all_angles(ctx):
win, s = ctx.win, ctx.s
ok = wait_until(lambda: all((a, CH4_IDX) in win._dc_cache
and (a, CH3_IDX) in win._dc_cache
for a in range(s.n_angles)))
assert ok, f"every angle cached for CH3 and CH4 ({len(win._dc_cache)} entries)"
assert "ready for all angles" in win.lbl_dc_precompute.text(), \
win.lbl_dc_precompute.text()
def test_angle_switching_from_cache(ctx):
win, s = ctx.win, ctx.s
for a in range(s.n_angles):
win.spin_angle.setValue(a)
win._on_view_changed()
pump(60)
expected = win._sras.image_shape(a)
assert win._current_image.shape == expected, \
f"angle {a} shows its own geometry {expected}, got {win._current_image.shape}"
assert not win._job_running("compute"), \
"no compute job needed for cached DC angles"
def test_stepping_the_angle_spinbox_redraws(ctx):
"""Clicking the angle spinbox's arrows (or pressing Up/Down in it) must
move the display, not just the number.
This is the ordinary way to walk a scan, and it used to do nothing: the
spinbox was wired on editingFinished, which QAbstractSpinBox emits only
on Return or focus-out — never on a step. Every other test in this module
called _on_view_changed() by hand and so could not have caught it.
"""
win, s = ctx.win, ctx.s
assert s.n_angles >= 3, "need room to step in both directions"
win.spin_angle.setValue(0)
assert wait_until(lambda: win._current_angle == 0), "settled on angle 0"
for expected in range(1, s.n_angles):
win.spin_angle.stepUp()
assert wait_until(lambda e=expected: win._current_angle == e), \
f"stepping up to angle {expected} redrew the display"
win.spin_angle.stepDown()
assert wait_until(lambda: win._current_angle == s.n_angles - 2), \
"stepping down redraws too"
# Keyboard stepping goes through the same signal, so it must work as well.
QTest.keyClick(win.spin_angle, Qt.Key.Key_Down)
assert wait_until(lambda: win._current_angle == s.n_angles - 3), \
"Key_Down redraws"
win.spin_angle.setValue(0)
assert wait_until(lambda: win._current_angle == 0), "back to angle 0"
def test_typing_an_angle_does_not_compute_intermediate_angles(ctx):
"""Keyboard tracking must stay off: with it on, valueChanged fires per
keystroke, so typing "12" would dispatch a compute for angle 1 first —
on a real scan, a whole wasted FFT for an angle the user never asked for.
"""
win, s = ctx.win, ctx.s
assert not win.spin_angle.keyboardTracking(), \
"keyboard tracking off is what makes valueChanged safe to connect"
target = s.n_angles - 1
assert target >= 2, "need a multi-digit-ish range to make the point"
win.spin_angle.setValue(0)
wait_until(lambda: win._current_angle == 0)
seen = []
win.spin_angle.valueChanged.connect(seen.append)
try:
win.spin_angle.lineEdit().selectAll()
QTest.keyClicks(win.spin_angle, str(target))
pump(60)
assert seen == [], f"no signal while typing, got {seen}"
QTest.keyClick(win.spin_angle, Qt.Key.Key_Return)
pump(60)
assert seen == [target], f"one signal on commit, got {seen}"
finally:
win.spin_angle.valueChanged.disconnect(seen.append)
assert wait_until(lambda: win._current_angle == target), "committed angle shown"
win.spin_angle.setValue(0)
assert wait_until(lambda: win._current_angle == 0), "back to angle 0"
def test_channel_switching(ctx):
win = ctx.win
win.spin_angle.setValue(0)
win._on_view_changed()
pump(60)
win.combo_channel.setCurrentIndex(CH3_IDX)
assert wait_until(lambda: win._current_ch == CH3_IDX), "CH3 displayed"
win.combo_channel.setCurrentIndex(CH1_IDX)
assert wait_until(
lambda: win._current_ch == CH1_IDX and not win._job_running("compute")), \
"CH1 (FFT) computed"
assert len(win._fft_cache) > 0, "FFT result cached"
ctx.rf_img = win._current_image
assert len(np.unique(ctx.rf_img)) > 1, \
f"FFT image is degenerate ({len(np.unique(ctx.rf_img))} unique values)"
def test_velocity_mode(ctx):
"""Velocity mode is a pure post-multiply, no recompute."""
win = ctx.win
ctx.n_fft_before = len(win._fft_cache)
win.combo_channel.setCurrentIndex(VELOCITY_MODE_IDX)
assert wait_until(
lambda: win._current_ch == VELOCITY_MODE_IDX
and not win._job_running("compute")), "velocity displayed"
grating = win.spin_grating_um.value()
assert np.allclose(win._current_image, ctx.rf_img * grating, atol=1e-3), \
"velocity == freq x grating"
assert len(win._fft_cache) == ctx.n_fft_before, \
f"velocity reused the cached FFT ({ctx.n_fft_before} -> {len(win._fft_cache)})"
assert win.grp_velocity.isVisible(), "grating spinbox visible in velocity mode"
def test_threshold_change_recomputes(ctx):
"""A threshold change is a genuine cache-key change."""
win = ctx.win
win.combo_channel.setCurrentIndex(CH1_IDX)
wait_until(lambda: not win._job_running("compute"))
dc4 = win._dc_cache[(0, CH4_IDX)]
win.spin_threshold_mv.setValue(float(np.median(dc4)))
win._on_threshold_changed()
assert wait_until(
lambda: not win._job_running("compute")
and len(win._fft_cache) > ctx.n_fft_before), "recomputed at new threshold"
n_zero = int((win._current_image == 0).sum())
assert n_zero > 0, \
f"masking zeroed some pixels ({n_zero} of {win._current_image.size})"
def test_bg_sub_toggle(ctx):
"""bg-sub no longer gates the display: it only affects a future live
compute for an angle with nothing cached yet, or an explicit batch
recompute. Toggling it on an angle that already has an FFT image must
leave that image on screen, untouched."""
win = ctx.win
n_before = len(win._fft_cache)
img_before = win._current_image
win.chk_bg_sub.setChecked(False)
pump(200)
assert not win._job_running("compute"), \
"toggling bg-sub alone must not dispatch a recompute"
assert len(win._fft_cache) == n_before, "no new cache entry from the toggle"
assert np.array_equal(win._current_image, img_before), \
"displayed image unchanged by the bg-sub toggle"
win.chk_bg_sub.setChecked(True)
pump(200)
assert not win._job_running("compute")
assert len(win._fft_cache) == n_before
assert np.array_equal(win._current_image, img_before)
def test_roi_and_csv_export(ctx):
win, s = ctx.win, ctx.s
x = s.x_axis_mm(0)
y = s.y_positions_mm(0)
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]),
float(x[-2]), float(y[-2]))
win.image_canvas.set_roi(roi)
pump(120)
assert win.image_canvas.get_roi() is not None, "ROI registered"
assert ("pixels inside" in win.lbl_roi_npix.text()
and win.lbl_roi_npix.text() != "pixels inside: —"), \
win.lbl_roi_npix.text()
npix = int(win.lbl_roi_npix.text().split(":")[1])
assert 0 < npix <= win._current_image.size, f"{npix}"
assert win.btn_export_roi.isEnabled(), "Export ROI enabled"
csv_path = ctx.tmpdir / "roi.csv"
with patch("sras_viewer.main_window.QFileDialog.getSaveFileName",
return_value=(str(csv_path), "")):
win._on_export_roi_csv()
assert csv_path.exists(), "ROI CSV written"
body = [l for l in csv_path.read_text().splitlines() if not l.startswith("#")]
assert len(body) == npix + 1, \
f"ROI CSV has {len(body)} lines for {npix} pixels (want header + one per pixel)"
img_csv = ctx.tmpdir / "img.csv"
with patch("sras_viewer.main_window.QFileDialog.getSaveFileName",
return_value=(str(img_csv), "")):
win._on_export_csv()
assert img_csv.exists(), "image CSV written"
arr = np.loadtxt(img_csv, delimiter=",")
assert (arr.shape == win._current_image.shape
and np.allclose(arr, win._current_image, rtol=1e-5, atol=1e-4)), \
"image CSV round-trips the displayed image"
def test_roi_survives_switches(ctx):
win = ctx.win
win.spin_angle.setValue(1)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
assert win.image_canvas.get_roi() is not None, \
"ROI still present after angle switch"
win.combo_channel.setCurrentIndex(CH4_IDX)
wait_until(lambda: win._current_ch == CH4_IDX)
assert win.image_canvas.get_roi() is not None, \
"ROI still present after channel switch"
def test_alignment_geometry_is_stage_independent(ctx):
"""Local mm is anchored on each angle's array center, not its stage
position: that is what makes a scan's placement independent of where its
window happened to sit. (Registration accuracy itself is covered by
tests/test_alignment.py, which has a synthetic sample to register.)"""
win, s = ctx.win, ctx.s
win.spin_angle.setValue(0)
win._on_view_changed()
wait_until(lambda: not win._job_running("compute"))
assert win._wizard_act.isEnabled(), "alignment wizard action enabled"
n_rows, n_frames = s.image_shape(0)
assert np.allclose(compute._center_idx(s, 0),
[(n_rows - 1) / 2, (n_frames - 1) / 2]), \
"array center is the geometric center of the pixel grid"
dx0, dy0 = compute.pixel_pitch_mm(s, 0)
assert np.allclose(compute._local_half_extent_mm(s, 0),
[(n_frames - 1) / 2 * abs(dx0), (n_rows - 1) / 2 * abs(dy0)]), \
"local half-extent is derived from shape and pitch alone"
identity = {a: compute.ManualAngleParams() for a in range(s.n_angles)}
origin_a, shape_a = compute.canvas_for_params(s, 0, (dx0, dy0), identity)
moved = SrasFile(str(ctx.path))
for a in range(1, moved.n_angles):
moved.x_start_mm[a] += 7.5
moved.y_pos_per_angle[a] = moved.y_pos_per_angle[a] + 3.25
origin_b, shape_b = compute.canvas_for_params(moved, 0, (dx0, dy0), identity)
assert shape_a == shape_b and np.allclose(origin_a, origin_b), \
("moving every non-reference angle's scan window must leave the canvas "
f"unchanged: {origin_a} {shape_a} vs {origin_b} {shape_b}")
# Both signs of the stage's reported angle are searched by default.
cands = compute._rotation_candidates(30.0, 6.0, 2.0)
assert min(cands) < -29.0 and max(cands) > 29.0, f"{min(cands)}..{max(cands)}"
# Whole-pixel translation must not wrap content around the edge.
arr = np.zeros((6, 6), dtype=np.float32)
arr[0, 0] = 1.0
assert compute._shift_into(arr, -1, -1).sum() == 0.0, \
"_shift_into zero-fills rather than wrapping"
assert compute._shift_into(arr, 2, 3)[2, 3] == 1.0, \
"_shift_into moves content by exactly the requested offset"
def test_wizard_opens_prerotated(ctx):
"""The wizard shows a mask stack before any correlation has run, built from
the stage angles in the file — the "pre-rotate" step. Nothing may seed from
the still-live automatic result; only a saved sidecar."""
win, s = ctx.win, ctx.s
win._on_alignment_wizard()
assert win._align_wizard is not None, "wizard opened"
ctx.wiz = wiz = win._align_wizard
ctx.p1 = p1 = wiz.page(wiz.PAGE_CORRELATE)
assert not win._job_running("align_masks"), \
"mask prep needed no background worker (already DC-cached)"
assert wait_until(lambda: p1.isComplete()), "masks ready, Next enabled"
assert not win._wizard_act.isEnabled(), \
"wizard action disabled while a wizard is open"
st = wiz.state
assert st.result is not None, "an AlignmentResult exists from pre-rotation alone"
assert st.counts is not None and st.counts.shape == st.preview_shape
assert 0 <= st.counts.max() <= s.n_angles
assert not st.fits, "no fits before a correlation has run"
for a in range(s.n_angles):
nominal = compute.nominal_delta_deg(s, a, st.ref_angle_idx)
expected = 0.0 if a == st.ref_angle_idx else nominal
assert abs(st.params[a].rotation_deg - expected) < 1e-9, \
f"angle {a} not pre-rotated to its stage angle"
assert st.params[a].shift_mm == (0.0, 0.0), \
"pre-rotation must not invent a translation"
def test_wizard_reference_angle_is_locked(ctx):
p1, wiz = ctx.p1, ctx.wiz
p1.combo_active.setCurrentIndex(wiz.state.ref_angle_idx)
pump(30)
before = wiz.state.params[wiz.state.ref_angle_idx]
p1._on_nudge_translate(1, 0, False)
p1._on_nudge_rotate(1, False)
assert wiz.state.params[wiz.state.ref_angle_idx] == before, \
"reference angle untouched by nudge attempts"
def test_wizard_nudges(ctx):
"""Manual correction, which the wizard absorbed from the old dialog."""
p1, wiz, s = ctx.p1, ctx.wiz, ctx.s
ctx.active = active = 1 if s.n_angles > 1 else 0
p1.combo_active.setCurrentIndex(active)
pump(30)
before = wiz.state.params[active].shift_mm
p1._on_nudge_translate(1, 0, False)
fine = p1.spin_step_translate.value()
assert abs(wiz.state.params[active].shift_mm[0] - (before[0] + fine)) < 1e-9, \
"fine translate nudge moved shift_x by exactly one fine step"
before = wiz.state.params[active].shift_mm
p1._on_nudge_translate(0, -1, True)
coarse = fine * p1.spin_step_mult.value()
assert abs(wiz.state.params[active].shift_mm[1] - (before[1] - coarse)) < 1e-9, \
"coarse translate nudge uses the multiplier"
before_rot = wiz.state.params[active].rotation_deg
p1._on_nudge_rotate(1, False)
assert wiz.state.params[active].rotation_deg != before_rot
assert len(wiz.state.layers) == s.n_angles, \
"stack rebuilt for every angle after a rotation nudge"
# A nudge only reprojects the angle that moved, patching the overlap counts
# in place. That shortcut is only sound if it lands on exactly what a full
# rebuild would have produced.
incremental = wiz.state.counts.copy()
wiz.rebuild_stack()
assert np.array_equal(wiz.state.counts, incremental), \
"incremental nudge update matches a full stack rebuild"
# Real key-event wiring (keyPressEvent -> signal -> slot).
before = wiz.state.params[active].shift_mm
QTest.keyClick(p1.canvas, Qt.Key.Key_Right)
assert wiz.state.params[active].shift_mm[0] > before[0], \
"a real Right-arrow key event nudged shift_x"
# Both views render from the same reprojected layers.
p1.combo_view.setCurrentIndex(1)
pump(50)
p1.combo_view.setCurrentIndex(0)
pump(50)
def test_wizard_correlate(ctx):
"""Cross-correlation, for every source option, retryable."""
win, p1, wiz, s = ctx.win, ctx.p1, ctx.wiz, ctx.s
from sras_viewer.align_wizard import _CORRELATE_SOURCES
for idx, (label, _sources) in enumerate(_CORRELATE_SOURCES):
p1.combo_source.setCurrentIndex(idx)
p1.btn_correlate.click()
assert not p1.isComplete(), \
f"Next must be disabled while correlating ({label})"
assert wait_until(lambda: not win._job_running("align_correlate"),
timeout_ms=60000), f"correlation finished ({label})"
assert p1.isComplete(), f"Next re-enabled ({label})"
assert all(a in wiz.state.fits for a in range(s.n_angles)
if a != wiz.state.ref_angle_idx), \
f"every non-reference angle got a fit ({label})"
assert wiz.state.params[wiz.state.ref_angle_idx] == compute.ManualAngleParams(), \
"reference angle stays identity after correlation"
assert p1.btn_correlate.isEnabled(), "controls re-enabled when done"
assert p1.table.rowCount() == s.n_angles and p1.table.item(0, 0) is not None, \
"per-angle fit table populated"
assert p1.lbl_overlap.text(), "overlap summary reported"
r = wiz.state.result
assert len(r.per_angle) == s.n_angles, "transform for every angle"
assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \
"reference angle has zero shift"
def test_wizard_retry_changes_geometry(ctx):
"""Editing a parameter and re-running is the retry path, and it must
invalidate anything indexed against the old canvas."""
p1, wiz = ctx.p1, ctx.wiz
gen_before = wiz.state.geometry_generation
p1.spin_threshold.setValue(p1.spin_threshold.value() + 5.0)
p1.spin_threshold.editingFinished.emit()
pump(60)
assert wiz.state.geometry_generation > gen_before, \
"a threshold change rebuilt the geometry"
# Reset drops the fits and returns to pre-rotation only.
p1.btn_reset.click()
pump(60)
assert not wiz.state.fits, "reset cleared the fits"
nominal = compute.nominal_delta_deg(ctx.s, ctx.active, wiz.state.ref_angle_idx)
assert abs(wiz.state.params[ctx.active].rotation_deg - nominal) < 1e-9
assert wiz.state.params[ctx.active].shift_mm == (0.0, 0.0), \
"reset also drops nudged translation"
# Put a real correlation back for the pages that follow.
p1.btn_correlate.click()
assert wait_until(lambda: not ctx.win._job_running("align_correlate"),
timeout_ms=60000)
def test_wizard_roi_page(ctx):
"""The crop page: presets, and two-way sync between the drawn rectangle and
the numeric canvas-pixel boxes."""
wiz = ctx.wiz
wiz.next()
pump(150)
assert wiz.currentId() == wiz.PAGE_ROI, "advanced to the ROI page"
ctx.p2 = p2 = wiz.page(wiz.PAGE_ROI)
st = wiz.state
assert st.crop is not None and p2.isComplete(), \
"a default crop is offered on entry"
n_rows, n_cols = st.result.canvas_shape
assert st.crop[2] > 1 or n_rows == 1, \
f"default crop must not collapse to a single row: {st.crop}"
p2.btn_whole.click()
pump(50)
assert st.crop == (0, 0, n_rows, n_cols), "whole-canvas preset"
p2.btn_fit_union.click()
pump(50)
assert st.counts[st.crop[0]:st.crop[0] + st.crop[2],
st.crop[1]:st.crop[1] + st.crop[3]].sum() == st.counts.sum(), \
"fit-to-union must keep every covered pixel"
if p2.btn_fit_overlap.isEnabled():
p2.btn_fit_overlap.click()
pump(50)
row0, col0, nr, nc = st.crop
assert (st.counts[row0:row0 + nr, col0:col0 + nc] >= 1).all(), \
"full-overlap crop must not include uncovered pixels"
# Numeric -> drawn rectangle.
p2.btn_whole.click()
pump(50)
target = (0, 0, max(1, n_rows // 2), max(1, n_cols // 2))
p2.spin_rows.setValue(target[2])
p2.spin_cols.setValue(target[3])
pump(50)
assert st.crop == target, f"spin boxes drive the crop: {st.crop} vs {target}"
# Drawn rectangle -> numeric, round-tripping exactly.
x0, y0 = wiz.canvas_to_mm(target[1] - 0.5, target[0] - 0.5)
x1, y1 = wiz.canvas_to_mm(target[1] + target[3] - 0.5,
target[0] + target[2] - 0.5)
p2.canvas.set_roi(RoiQuad.from_bbox(min(x0, x1), min(y0, y1),
max(x0, x1), max(y0, y1)))
pump(80)
assert st.crop == target, \
f"drawn rectangle round-trips to the same crop: {st.crop} vs {target}"
# A degenerate crop blocks Next.
st.crop = None
p2.completeChanged.emit()
assert not p2.isComplete(), "an absent crop blocks Next"
p2._set_crop(*target)
assert p2.isComplete()
ctx.crop = target
def test_wizard_crop_dropped_when_going_back(ctx):
"""A crop is canvas-pixel indexed, so it cannot survive a re-correlation."""
wiz, p2 = ctx.wiz, ctx.p2
wiz.back()
pump(120)
assert wiz.currentId() == wiz.PAGE_CORRELATE
assert wiz.state.crop is None, "cleanupPage discarded the stale crop"
assert wiz.cropped_plan() == (None, None), \
"nothing derived from the dropped crop survives either"
wiz.next()
pump(150)
assert wiz.state.crop is not None, "a fresh default crop is offered again"
p2._set_crop(*ctx.crop)
def test_wizard_export(ctx):
"""Writing the file: Finish stays unavailable until a write succeeds."""
win, wiz = ctx.win, ctx.wiz
out = ctx.tmpdir / "wizard_aligned.sras"
with patch("sras_viewer.align_wizard.QMessageBox.question",
return_value=QMessageBox.StandardButton.Yes):
wiz.next()
pump(150)
assert wiz.currentId() == wiz.PAGE_SAVE, "advanced to the save page"
ctx.p3 = p3 = wiz.page(wiz.PAGE_SAVE)
cropped, _ = wiz.cropped_plan()
assert cropped is not None, "crop applied on leaving page 2"
assert cropped.canvas_shape == ctx.crop[2:], \
"cropped result carries the chosen shape"
assert not p3.isComplete(), "Finish unavailable before anything is written"
assert p3.lbl_summary.text(), "a summary of what will be written is shown"
with patch("sras_viewer.align_wizard.QFileDialog.getSaveFileName",
return_value=(str(out), "")):
p3.btn_browse.click()
assert wiz.state.out_path == str(out)
p3.btn_export.click()
assert wait_until(lambda: not win._job_running("align_export"),
timeout_ms=60000), "export finished"
assert wiz.state.exported_path == str(out), p3.lbl_status.text()
assert p3.isComplete(), "Finish available once the file exists"
assert out.exists()
written = SrasFile(str(out))
ctx.written = written
assert written.version == 6, "export is a v6 file"
assert written.n_angles == ctx.s.n_angles
assert all(written.image_shape(a) == ctx.crop[2:]
for a in range(written.n_angles)), \
"every angle shares the cropped grid"
assert not out.with_name(out.name + ".part").exists(), \
"no staging file left behind"
def test_wizard_finish_applies_and_persists(ctx):
"""Finish makes the session match the file: Aligned View shows the exported
extent, and the sidecar records it for the input scan."""
win, wiz = ctx.win, ctx.wiz
wiz.accept()
pump(250)
assert win._align_wizard is None, "wizard reference released"
assert win._wizard_act.isEnabled(), "wizard action available again"
assert win._alignment_result is not None
assert win._alignment_result.canvas_shape == ctx.crop[2:], \
"the *cropped* result is what the view now uses"
assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked()
pump(200)
assert win.image_canvas._img_shape == ctx.crop[2:], \
f"canvas shows the cropped extent: {win.image_canvas._img_shape}"
sidecar = compute.sidecar_path(ctx.s.path)
assert sidecar.exists(), "sidecar written for the input scan"
ctx.sidecar = sidecar
ctx.sidecar_raw = raw = json.loads(sidecar.read_text())
assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION
assert all(raw["per_angle"][str(a)]["rotation_deg"]
== win._alignment_result.per_angle[a].rotation_deg
for a in range(ctx.s.n_angles)), \
"sidecar round-trips the applied rotations"
win.chk_aligned_view.setChecked(False)
pump(200)
assert win.image_canvas._img_shape == ctx.s.image_shape(0), \
f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}"
def test_stale_schema_sidecar_ignored(ctx):
"""An old-schema sidecar (pre-pivot/sign fix) is treated as absent."""
s, raw, sidecar = ctx.s, ctx.sidecar_raw, ctx.sidecar
stale = dict(raw)
stale["schema_version"] = compute._SIDECAR_SCHEMA_VERSION - 1
sidecar.write_text(json.dumps(stale))
assert compute.load_manual_alignment(s) is None, \
"a sidecar with an old schema_version is not loaded"
sidecar.write_text(json.dumps(raw)) # restore for the rest of the sequence
def test_sidecar_restored_on_reload(ctx):
win, active = ctx.win, ctx.active
saved = json.loads(ctx.sidecar.read_text())["per_angle"][str(active)]
old_sras_id = id(win._sras)
win._load_file(str(ctx.path)) # reload the same file fresh
assert wait_until(
lambda: win._sras is not None and id(win._sras) != old_sras_id), \
"file reloaded"
ctx.s = win._sras
assert win._align_wizard is None, "no wizard left open across a reload"
assert win._alignment_result is not None, \
"reload restores the saved alignment automatically"
assert abs(win._alignment_result.per_angle[active].rotation_deg
- saved["rotation_deg"]) < 1e-9, \
"restored rotation matches what was saved"
assert win.chk_aligned_view.isChecked(), \
"Aligned View auto-checked after restoring a saved alignment"
def test_wizard_closes_with_a_reload(ctx):
"""An open wizard belongs to the file it was opened on."""
win = ctx.win
win._on_alignment_wizard()
assert win._align_wizard is not None
assert wait_until(
lambda: win._align_wizard.page(win._align_wizard.PAGE_CORRELATE).isComplete())
win._load_file(str(ctx.path))
assert wait_until(lambda: not win._job_running("load"))
pump(200)
assert win._align_wizard is None, "wizard force-closed by a reload"
ctx.s = win._sras
def test_pixel_inspector(ctx):
win = ctx.win
win.chk_aligned_view.setChecked(False)
pump(100)
win._on_pixel_clicked(0, 0)
pump(150)
assert win.lbl_wave_hint.isHidden(), "waveform hint hidden after a click"
win.combo_channel.setCurrentIndex(CH1_IDX)
wait_until(lambda: not win._job_running("compute"))
win._on_pixel_clicked(1, 1)
pump(150)
assert len(win.wave_canvas.ax_wave.lines) > 0, \
f"RF waveform panel rendered ({len(win.wave_canvas.ax_wave.lines)} lines)"
def test_shutdown(ctx):
win = ctx.win
win.close()
pump(400)
assert len(win._jobs) == 0, f"all background jobs released: {list(win._jobs)}"
def test_no_status_bar_errors(ctx):
unexpected = [e for e in ctx.errors if e]
assert not unexpected, f"status-bar errors seen: {unexpected}"