c30c8b1815
- Extract cache mismatch logic into reusable cache_mismatch_reasons() function for cleaner validation and consistent miss reporting - Expose memory_budget_bytes() for external callers (aligned exporter) - Optimize rebuild_stack() with incremental layer updates when only one angle parameters change, avoiding full reprojection overhead - Remove unused seed_per_angle parameter from alignment wizard - Simplify cached_rf_image() DC masking with cleaner early exit logic - Clean up internal state tracking with explicit origin caching Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
689 lines
28 KiB
Python
689 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):
|
|
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"
|
|
|
|
# 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}"
|