f40c965b74
Alignment was two disconnected menu actions. `Angle Alignment` ran the
registration with ref_angle_idx hard-coded to 0, no exposed parameters and
no way to retry; `Manual Alignment...` was a separate dialog that
deliberately refused to inherit the automatic result, so a bad fit meant
starting over by hand. Neither told you whether the alignment was any
good, and neither produced anything durable beyond an in-memory result.
Fusion -> Alignment Wizard... now covers all of it in three steps:
1. Correlate. Reference angle, DC threshold, source, rotation seed and
sign, search window, coarse step and fine grid are all on the page,
and Run/Re-run is repeatable. Angles start pre-rotated from the
stage angles in the file, so the page is informative before any
correlation runs. The verdict is a picture: every angle's DC mask
reprojected onto the shared canvas and summed, coloured by how many
angles cover each pixel, so a good alignment reads as one saturated
plateau and a bad one as a fringe of low-count halos. A per-angle
fit table flags angles that did not register or that disagree with
their stage angle by more than a degree.
2. Crop. An axis-aligned rectangle on that canvas, with numeric
canvas-pixel boxes synced both ways and a live size estimate.
"Fit to full overlap" uses a largest-rectangle sweep rather than a
bounding box: the overlap region of several rotated scans is roughly
a disc, whose bounding box has corners no angle covers.
3. Save. Writes the aligned, cropped stack to a new .sras.
Because the wizard replaces both actions it absorbs the old dialog's
by-eye nudge editor — without it, a scan the search cannot fit would
have no fallback at all. ManualAlignmentDialog is therefore deleted
rather than left orphaned, and its tests move to the wizard.
Two bugs found while driving it end to end and fixed here: QSpinBox
setRange clamps and emits valueChanged, which committed a 1x1 crop
before the default preset could run; and the mm round trip returns an
exact pixel boundary as 11.000000000000002, so a bare ceil() added a
spurious column on every rectangle edit.
Verified: 103 pass, the 6 test_stored_cache failures are pre-existing on
main, and tools/check_equivalence.py is byte-identical to main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
614 lines
25 KiB
Python
614 lines
25 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_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):
|
|
win = ctx.win
|
|
n_before = len(win._fft_cache)
|
|
win.chk_bg_sub.setChecked(False)
|
|
assert wait_until(
|
|
lambda: not win._job_running("compute") and len(win._fft_cache) > n_before), \
|
|
"recomputed without bg-sub"
|
|
win.chk_bg_sub.setChecked(True)
|
|
pump(200)
|
|
assert not win._job_running("compute"), \
|
|
"returning to bg-sub was a cache hit (no recompute)"
|
|
|
|
|
|
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"
|
|
|
|
# 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.state.cropped_result is None
|
|
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)
|
|
assert wiz.state.cropped_result is not None, "crop applied on leaving page 2"
|
|
assert wiz.state.cropped_result.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}"
|