"""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, angle alignment, manual angle alignment, 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.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.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_angle_alignment(ctx): 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._alignment_act.isEnabled(), "alignment action enabled" win._on_angle_alignment() assert wait_until( lambda: win._alignment_result is not None and not win._job_running("align"), timeout_ms=60000), "alignment completed" r = win._alignment_result assert len(r.per_angle) == s.n_angles, "transform for every angle" assert all(r.canvas_shape[0] >= int(s.n_rows[a]) and r.canvas_shape[1] >= int(s.n_frames[a]) for a in range(s.n_angles)), \ f"canvas is at least as large as any single angle: {r.canvas_shape}" assert r.per_angle[r.ref_angle_idx].shift_mm == (0.0, 0.0), \ "reference angle has zero shift" assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \ "Aligned View auto-enabled and checked" pump(200) assert win.image_canvas._img_shape == r.canvas_shape, \ f"{win.image_canvas._img_shape} vs {r.canvas_shape}" win.chk_aligned_view.setChecked(False) pump(200) assert win.image_canvas._img_shape == s.image_shape(0), \ f"unchecking returns to the raw per-angle grid: {win.image_canvas._img_shape}" def test_manual_alignment_geometry(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 assert win._manual_align_act.isEnabled(), "manual alignment 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. 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_manual_dialog_opens_at_identity(ctx): """Open must NOT seed from the still-live automatic AlignmentResult. Manual mode exists to fix up whatever the automatic registration got wrong, so it must start from identity (every angle centered on the reference, no rotation) regardless of whatever the automatic run last computed. Only a previously *saved manual* alignment (sidecar) should ever seed this dialog.""" win, s = ctx.win, ctx.s win._on_manual_alignment() assert win._manual_align_dialog is not None, "dialog opened" ctx.dlg = dlg = win._manual_align_dialog assert not win._job_running("manual_align_masks"), \ "mask prep needed no background worker (already DC-cached)" assert all(dlg._angle_params[a] == compute.ManualAngleParams() for a in range(s.n_angles)), \ "no manual sidecar yet -> dialog starts at identity, not the automatic result" def test_reference_angle_is_locked(ctx): dlg = ctx.dlg dlg.combo_active_angle.setCurrentIndex(dlg._ref_angle_idx) pump(30) before_ref = dlg._angle_params[dlg._ref_angle_idx] dlg._on_nudge_translate(1, 0, False) dlg._on_nudge_rotate(1, False) assert not dlg.grp_manual_adjust.isEnabled(), "reference angle group disabled" assert dlg._angle_params[dlg._ref_angle_idx] == before_ref, \ "reference angle untouched by nudge attempts" def test_nudges(ctx): """Nudging a real angle (fine + coarse, translate + rotate).""" dlg, s = ctx.dlg, ctx.s ctx.active = active = 1 if s.n_angles > 1 else 0 dlg.combo_active_angle.setCurrentIndex(active) pump(30) before = dlg._angle_params[active].shift_mm dlg._on_nudge_translate(1, 0, False) # fine +X fine_step = dlg.spin_step_translate_mm.value() assert abs(dlg._angle_params[active].shift_mm[0] - (before[0] + fine_step)) < 1e-9, \ "fine translate nudge moved shift_x by exactly one fine step" before = dlg._angle_params[active].shift_mm dlg._on_nudge_translate(0, -1, True) # coarse -Y coarse_step = fine_step * dlg.spin_step_multiplier.value() assert abs(dlg._angle_params[active].shift_mm[1] - (before[1] - coarse_step)) < 1e-9, \ "coarse translate nudge uses the multiplier" before_rot = dlg._angle_params[active].rotation_deg dlg._on_nudge_rotate(1, False) assert dlg._angle_params[active].rotation_deg != before_rot, \ "rotate nudge changed rotation_deg" assert len(dlg._preview_layers) == s.n_angles, \ "preview canvas rebuilt for every angle after a rotation nudge" # Real key-event wiring (proves keyPressEvent -> signal -> slot). before = dlg._angle_params[active].shift_mm QTest.keyClick(dlg.canvas, Qt.Key.Key_Right) assert dlg._angle_params[active].shift_mm[0] > before[0], \ "a real Right-arrow key event nudged shift_x" def test_auto_derotate(ctx): """Auto De-rotate: seeds rotation from the stage angle, no translation.""" dlg, s, active = ctx.dlg, ctx.s, ctx.active shift_before_derotate = dlg._angle_params[active].shift_mm dlg._on_auto_derotate() nominal = compute.nominal_delta_deg(s, active, dlg._ref_angle_idx) assert abs(dlg._angle_params[active].rotation_deg - nominal) < 1e-6, \ "auto de-rotate seeded rotation from the stage's reported angle" assert dlg._angle_params[active].shift_mm == shift_before_derotate, \ "auto de-rotate left translation untouched" assert dlg._angle_params[dlg._ref_angle_idx].rotation_deg == 0.0, \ "reference angle stays identity after auto de-rotate" # Clicking again offers the other sign, since which one lines the scans up # is not knowable from the file. dlg._on_auto_derotate() assert abs(dlg._angle_params[active].rotation_deg + nominal) < 1e-6, \ "auto de-rotate offers the opposite sign on a second click" def test_auto_cross_correlate(ctx): """Auto Cross-Correlate: searches rotation *and* translation.""" win, dlg, s = ctx.win, ctx.dlg, ctx.s assert dlg.btn_auto_correlate.isEnabled(), \ "cross-correlate action enabled once masks are ready" for label_idx, (label, _sources) in enumerate(dlg._CORRELATE_SOURCES): dlg.combo_correlate_source.setCurrentIndex(label_idx) dlg._on_auto_correlate() assert wait_until( lambda: not win._job_running("manual_align_correlate"), timeout_ms=60000), f"auto cross-correlate completed ({label})" assert all(a in dlg._fit_notes for a in range(s.n_angles) if a != dlg._ref_angle_idx), \ f"every non-reference angle got a fit ({label})" assert dlg._angle_params[dlg._ref_angle_idx] == compute.ManualAngleParams(), \ "auto cross-correlate reference angle stays identity" assert dlg.grp_correlate.isEnabled() and dlg.btn_save.isEnabled(), \ "auto cross-correlate re-enabled controls when done" assert len(dlg._preview_layers) == s.n_angles, \ "preview canvas rebuilt after cross-correlate" assert dlg._fit_report(), "fit quality is reported per angle" def test_save_sidecar(ctx): win, dlg, s, active = ctx.win, ctx.dlg, ctx.s, ctx.active dlg._on_save() sidecar = compute.sidecar_path(s.path) assert sidecar.exists(), "sidecar file written" ctx.sidecar = sidecar ctx.sidecar_raw = raw = json.loads(sidecar.read_text()) assert raw.get("schema_version") == compute._SIDECAR_SCHEMA_VERSION, \ "sidecar schema_version is current" assert all(raw.get("per_angle", {}).get(str(a), {}).get("rotation_deg") == dlg._angle_params[a].rotation_deg for a in range(s.n_angles)), \ "sidecar per_angle round-trips the dialog's resolved params" assert (win._alignment_result is not None and win._alignment_result.per_angle[active].rotation_deg == dlg._angle_params[active].rotation_deg), \ "main window's alignment_result replaced by the manual build" assert win.chk_aligned_view.isEnabled() and win.chk_aligned_view.isChecked(), \ "Aligned View auto-enabled after Save" 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_clear_with_confirmation(ctx): win, dlg, s = ctx.win, ctx.dlg, ctx.s with patch("sras_viewer.QMessageBox.question", return_value=QMessageBox.StandardButton.Yes): dlg._on_clear() assert not ctx.sidecar.exists(), "sidecar file deleted" assert all(dlg._angle_params[a] == compute.ManualAngleParams() for a in range(s.n_angles)), "dialog params reset to identity" assert win._alignment_result is None, "main window alignment_result cleared" assert (not win.chk_aligned_view.isEnabled() and not win.chk_aligned_view.isChecked()), \ "Aligned View disabled after Clear" dlg.close() pump(150) assert win._manual_align_dialog is None, "dialog reference released on close" def test_sidecar_restored_on_reload(ctx): win, active = ctx.win, ctx.active win._on_manual_alignment() dlg = win._manual_align_dialog dlg.combo_active_angle.setCurrentIndex(active) pump(30) dlg._on_auto_derotate() dlg._on_nudge_translate(1, 1, True) saved_rotation = dlg._angle_params[active].rotation_deg saved_shift = dlg._angle_params[active].shift_mm dlg._on_save() dlg.close() pump(150) 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._manual_align_dialog is None, \ "manual dialog force-closed by a reload" assert win._alignment_result is not None, \ "reload restores the saved manual alignment automatically" assert abs(win._alignment_result.per_angle[active].rotation_deg - saved_rotation) < 1e-9, "restored rotation matches what was saved" assert win._alignment_result.per_angle[active].shift_mm == saved_shift, \ "restored shift matches what was saved" assert win.chk_aligned_view.isChecked(), \ "Aligned View auto-checked after restoring a saved alignment" 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}"