"""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" # 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}"