Convert test scripts to pytest; extend the equivalence harness
- pyproject.toml replaces sras_viewer_requirements.txt (same pins) and adds a dev extra with pytest. - tools/test_refactor.py, test_alignment.py, test_gui.py become tests/test_compute.py, tests/test_alignment.py, tests/test_gui.py with assertions preserved verbatim. test_gui.py stays one ordered integration sequence over a shared module-scoped window. - check_equivalence.py: drop the dead pre-refactor monolith shim (and the _compute_angle_alignment alias it consumed), extend the pad sweep to (1, 2, 4, 8, 40), add legacy-v4 and big-endian int16 legs (new bps=2 option in make_test_sras) so the padded FFT path and the >i2 memmap path are in the baseline before the FFT rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user