191d1b8946
Lets a user export the current ROI as one CSV with a column per selected angle's value (RF peak freq, Bias A, Bias B, or velocity), once angles share a common (x, y) grid -- either via a live Fusion alignment result or because the open file is itself a previous Alignment Wizard export whose angles already share one grid on disk. Never triggers a background compute; angles without cached/stored data for the chosen value type are simply unavailable in the picker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
882 lines
36 KiB
Python
882 lines
36 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, QDialog, QMessageBox
|
|
|
|
import sras_compute as compute
|
|
from sras_format import CH1_IDX, CH3_IDX, CH4_IDX, SrasFile
|
|
from sras_viewer import (
|
|
FusedRoiExportDialog, 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Export Fused ROI
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Two independent ways angles can end up sharing one (x, y) grid to fuse
|
|
# onto: a live alignment result (case a, exercised on ctx.win -- the reload
|
|
# above restored one from the sidecar), or a file that is itself a previous
|
|
# Alignment Wizard export, whose angles already share a grid on disk with no
|
|
# alignment result needed at all (case b, exercised on a second window
|
|
# opened on ctx.written from test_wizard_export).
|
|
|
|
def test_fused_export_gating_case_a(ctx):
|
|
"""A live alignment result bridges the raw scan's per-angle grids --
|
|
angles_share_raw_grid() alone would be False here."""
|
|
win, s = ctx.win, ctx.s
|
|
assert win._alignment_result is not None, "alignment restored from sidecar"
|
|
assert not s.angles_share_raw_grid(), \
|
|
"sanity check: the raw (un-aligned) scan must not already share a grid"
|
|
|
|
x, y = win._aligned_canvas_axes()
|
|
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._fused_grid_ready()
|
|
assert win.btn_export_fused_roi.isEnabled()
|
|
|
|
|
|
def test_write_fused_roi_csv_case_a_content(ctx):
|
|
win, s = ctx.win, ctx.s
|
|
roi = win.image_canvas.get_roi()
|
|
assert roi is not None, "ROI drawn by test_fused_export_gating_case_a"
|
|
|
|
csv_path = ctx.tmpdir / "fused_roi_case_a.csv"
|
|
win._write_fused_roi_csv(roi, CH4_IDX, [0, 1], str(csv_path))
|
|
assert csv_path.exists()
|
|
|
|
lines = csv_path.read_text().splitlines()
|
|
body = [l for l in lines if not l.startswith("#")]
|
|
header, *data_lines = body
|
|
assert header == (
|
|
f"x_mm,y_mm,v_{s.angles_deg[0]:.4g}deg,v_{s.angles_deg[1]:.4g}deg")
|
|
|
|
x, y = win._fused_export_axes()
|
|
mask = roi.mask_for_grid(x, y)
|
|
assert len(data_lines) == int(mask.sum())
|
|
|
|
data = np.array([[float(v) for v in line.split(",")] for line in data_lines])
|
|
expect0 = win._fused_value_image(0, CH4_IDX)[mask]
|
|
expect1 = win._fused_value_image(1, CH4_IDX)[mask]
|
|
assert np.allclose(data[:, 2], expect0, rtol=1e-5, atol=1e-4)
|
|
assert np.allclose(data[:, 3], expect1, rtol=1e-5, atol=1e-4)
|
|
|
|
|
|
def test_fused_roi_dialog_availability_live_updates(ctx):
|
|
"""Switching the value-type radio re-evaluates every angle checkbox,
|
|
disabling/auto-unchecking whichever ones are no longer available --
|
|
independent of what actually backs availability_fn, so a synthetic
|
|
stand-in keeps this a fast, deterministic test of the dialog itself."""
|
|
win, s = ctx.win, ctx.s
|
|
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
|
|
only_angle0_has_ch1 = lambda a, c: (a == 0) if c == CH1_IDX else True
|
|
|
|
dlg = FusedRoiExportDialog(
|
|
win, angles=angles, availability_fn=only_angle0_has_ch1,
|
|
default_ch_idx=CH1_IDX, out_dir=str(s.path.parent), stem=s.path.stem,
|
|
grid_note="test")
|
|
try:
|
|
assert dlg._angle_checks[0].isEnabled()
|
|
assert all(not dlg._angle_checks[a].isEnabled()
|
|
for a in range(1, s.n_angles))
|
|
|
|
dlg._angle_checks[0].setChecked(True)
|
|
dlg._val_buttons[CH4_IDX].click()
|
|
assert all(dlg._angle_checks[a].isEnabled() for a in range(s.n_angles)), \
|
|
"CH4 is available for every angle"
|
|
assert dlg._angle_checks[0].isChecked(), \
|
|
"stays checked -- still available under CH4"
|
|
|
|
if s.n_angles > 1:
|
|
dlg._angle_checks[1].setChecked(True)
|
|
dlg._val_buttons[CH1_IDX].click()
|
|
assert dlg._angle_checks[0].isChecked()
|
|
if s.n_angles > 1:
|
|
assert not dlg._angle_checks[1].isEnabled()
|
|
assert not dlg._angle_checks[1].isChecked(), \
|
|
"auto-unchecked: angle 1 has no data under CH1"
|
|
finally:
|
|
dlg.close()
|
|
|
|
|
|
def test_fused_roi_dialog_select_all_none(ctx):
|
|
win, s = ctx.win, ctx.s
|
|
angles = [(a, float(s.angles_deg[a])) for a in range(s.n_angles)]
|
|
dlg = FusedRoiExportDialog(
|
|
win, angles=angles, availability_fn=lambda a, c: c == CH4_IDX,
|
|
default_ch_idx=CH4_IDX, out_dir=str(s.path.parent), stem=s.path.stem,
|
|
grid_note="test")
|
|
try:
|
|
assert not dlg._btn_export.isEnabled(), "nothing checked yet"
|
|
dlg._on_select_all_available()
|
|
assert all(cb.isChecked() for cb in dlg._angle_checks.values())
|
|
assert dlg._btn_export.isEnabled()
|
|
dlg._on_select_none()
|
|
assert not any(cb.isChecked() for cb in dlg._angle_checks.values())
|
|
assert not dlg._btn_export.isEnabled()
|
|
finally:
|
|
dlg.close()
|
|
|
|
|
|
def test_on_export_fused_roi_csv_end_to_end(ctx):
|
|
win, s = ctx.win, ctx.s
|
|
roi = win.image_canvas.get_roi()
|
|
assert roi is not None, "ROI from the earlier fused-export tests is still set"
|
|
|
|
csv_path = ctx.tmpdir / "fused_roi_e2e.csv"
|
|
with patch("sras_viewer.main_window.FusedRoiExportDialog") as MockDlg:
|
|
inst = MockDlg.return_value
|
|
inst.exec.return_value = QDialog.DialogCode.Accepted
|
|
inst.get_ch_idx.return_value = CH4_IDX
|
|
inst.get_selected_angles.return_value = [0, 1]
|
|
inst.get_output_path.return_value = str(csv_path)
|
|
win.btn_export_fused_roi.click()
|
|
|
|
assert csv_path.exists()
|
|
kwargs = MockDlg.call_args.kwargs
|
|
assert kwargs["default_ch_idx"] == win.combo_channel.currentIndex()
|
|
assert kwargs["out_dir"] == str(s.path.parent)
|
|
assert kwargs["stem"] == s.path.stem
|
|
|
|
|
|
def test_fused_export_no_alignment_shared_grid_path(ctx):
|
|
"""ctx.written (from test_wizard_export) is itself a previous Alignment
|
|
Wizard export: opened fresh with no sidecar for its own path, so no
|
|
alignment result is ever restored -- but its angles already share one
|
|
grid on disk, so the export must work through the no-resample path."""
|
|
win2 = SrasViewerWindow()
|
|
try:
|
|
win2._load_file(str(ctx.written.path))
|
|
assert wait_until(lambda: win2._sras is not None)
|
|
s2 = win2._sras
|
|
assert win2._alignment_result is None, \
|
|
"no sidecar exists for this path -- nothing auto-restored"
|
|
assert s2.angles_share_raw_grid(), \
|
|
"a wizard export already shares one grid across angles"
|
|
assert wait_until(lambda: all((a, CH4_IDX) in win2._dc_cache
|
|
for a in range(s2.n_angles))), \
|
|
"DC precomputed for every angle"
|
|
|
|
x, y = s2.x_axis_mm(0), s2.y_positions_mm(0)
|
|
roi = RoiQuad.from_bbox(float(x[1]), float(y[1]), float(x[-2]), float(y[-2]))
|
|
win2.image_canvas.set_roi(roi)
|
|
pump(120)
|
|
assert win2._fused_grid_ready()
|
|
assert win2.btn_export_fused_roi.isEnabled()
|
|
|
|
aligned_cache_before = len(win2._aligned_cache)
|
|
angle_idxs = list(range(min(2, s2.n_angles)))
|
|
csv_path = ctx.tmpdir / "fused_roi_case_b.csv"
|
|
win2._write_fused_roi_csv(roi, CH4_IDX, angle_idxs, str(csv_path))
|
|
assert csv_path.exists()
|
|
assert len(win2._aligned_cache) == aligned_cache_before, \
|
|
"no-resample path must never touch apply_alignment"
|
|
|
|
header = next(l for l in csv_path.read_text().splitlines()
|
|
if not l.startswith("#"))
|
|
expected_header = "x_mm,y_mm," + ",".join(
|
|
f"v_{s2.angles_deg[a]:.4g}deg" for a in angle_idxs)
|
|
assert header == expected_header
|
|
|
|
# Break the shared grid and confirm gating flips off.
|
|
mutate_idx = 1 if s2.n_angles > 1 else 0
|
|
s2.x_start_mm[mutate_idx] += 1.0
|
|
assert not s2.angles_share_raw_grid()
|
|
win2._update_fused_export_enabled()
|
|
assert not win2._fused_grid_ready()
|
|
assert not win2.btn_export_fused_roi.isEnabled()
|
|
assert "Alignment Wizard" in win2.btn_export_fused_roi.toolTip()
|
|
finally:
|
|
win2.close()
|
|
pump(200)
|
|
|
|
|
|
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}"
|