Improves and adds tests for Invariant Perspective UI#3888
Improves and adds tests for Invariant Perspective UI#3888jellybean2004 wants to merge 13 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR replaces the legacy monolithic Invariant perspective test with a modular pytest suite and includes small refactors in InvariantPerspective.py to make calculation/extrapolation paths more testable.
Changes:
- Added a new pytest suite under
Invariant/UnitTesting/with shared fixtures inconftest.py. - Refactored invariant calculation/extrapolation logic (extracted
compute_low/compute_high, adjusted error handling/signatures). - Updated UI and existing tests to align with the refactor (e.g.,
TabbedInvariantUI.ui,InvariantDetailsTest.py), and removed the oldInvariantPerspectiveTest.py.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py |
Adds shared Qt/test fixtures for the new test suite. |
src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py |
Adds coverage for default widget/model state and UI behavior. |
src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py |
Adds coverage around data loading/removal and calculate enablement/validation. |
src/sas/qtgui/Perspectives/Invariant/UnitTesting/RealDataTest.py |
Adds “real data” flow tests including extrapolation validation and serialization. |
src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py |
Adds calculation thread/plotting/error handling tests. |
src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py |
Removes the old monolithic test file. |
src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py |
Minor string/formatting cleanup. |
src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui |
Makes txtName read-only (UI consistency with tests). |
src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py |
Refactors calculation/extrapolation + small slot wiring improvements for testability. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if self._porod and self._porod > 0: | ||
| try: | ||
| # choose contrast_for_surface safely | ||
| if self.rbContrast.isChecked(): | ||
| contrast_for_surface = self._contrast | ||
| contrast_for_surface_err = self._contrast_err | ||
| elif self.rbVolFrac.isChecked() and (contrast_out != "ERROR" and contrast_out_error != "ERROR"): | ||
| contrast_for_surface = contrast_out | ||
| contrast_for_surface_err = contrast_out_error |
There was a problem hiding this comment.
In calculate_thread(), contrast_out/contrast_out_error are only defined inside the if self.rbVolFrac.isChecked() and self._volfrac1: block, but are referenced later when computing contrast_for_surface. If VolFrac mode is selected but _volfrac1 is falsy (e.g. 0.0) this will raise UnboundLocalError. Initialize contrast_out/contrast_out_error to a safe default (e.g. "ERROR") before the conditional, or restructure the logic so the Porod block never references potentially-unset locals.
| if low_success: | ||
| qmin_ext = float(self.extrapolation_parameters.ex_q_min) | ||
| if self._low_points is None: | ||
| self.set_low_q_extrapolation_upper_limit(float(self.txtGuinierEnd_ex.text())) | ||
| extrapolated_data = self._calculator.get_extra_data_low(self._low_points, q_start=qmin_ext) | ||
| power_low = self._calculator.get_extrapolation_power(range="low") | ||
| title = f"Low-Q extrapolation [{self._data.name}]" | ||
| self.low_extrapolation_plot = self._manager.createGuiData(extrapolated_data) | ||
| # set attributes on the plot object in worker thread (non-GUI data only) | ||
| self.low_extrapolation_plot.name = title | ||
| self.low_extrapolation_plot.title = title | ||
| self.low_extrapolation_plot.symbol = "Line" | ||
| self.low_extrapolation_plot.has_errors = False | ||
| # copy labels/units (data-only) | ||
| self.low_extrapolation_plot._xaxis = temp_data._xaxis | ||
| self.low_extrapolation_plot._xunit = temp_data._xunit | ||
| self.low_extrapolation_plot._yaxis = temp_data._yaxis | ||
| self.low_extrapolation_plot._yunit = temp_data._yunit | ||
| if self._low_fit: | ||
| _safe_update_model(WIDGETS.W_LOWQ_POWER_VALUE_EX, power_low) | ||
|
|
||
| if high_success: | ||
| qmax_plot = float(self.extrapolation_parameters.point_3) | ||
| power_high = self._calculator.get_extrapolation_power(range="high") | ||
| high_out_data = self._calculator.get_extra_data_high(q_end=qmax_plot, npts=500) | ||
| title = f"High-Q extrapolation [{self._data.name}]" | ||
| self.high_extrapolation_plot = self._manager.createGuiData(high_out_data) | ||
| # set attributes on the plot object in worker thread (non-GUI data only) |
There was a problem hiding this comment.
Plot creation is still happening in the worker thread via self._manager.createGuiData(...), which returns/creates Qt objects (QStandardItem). Even if attribute assignments are “data-only”, constructing Qt objects off the GUI thread is unsafe. Schedule plot creation and any interaction with QStandardItems on the GUI thread (e.g., create Data1D in the worker thread, then reactor.callFromThread to wrap it with createGuiData).
| try: | ||
| qstar_data, qstar_data_err = self._calculator.get_qstar_with_error() | ||
| except Exception as ex: | ||
| calculation_failed = True | ||
| msg += f"Invariant calculation failed: {str(ex)}" | ||
| qstar_data, qstar_data_err = "ERROR", "ERROR" | ||
|
|
||
| reactor.callFromThread(self.update_model_from_thread, WIDGETS.D_DATA_QSTAR, qstar_data) | ||
| reactor.callFromThread(self.update_model_from_thread, WIDGETS.D_DATA_QSTAR_ERR, qstar_data_err) | ||
| self.update_from_model() | ||
|
|
There was a problem hiding this comment.
calculate_thread() runs in a worker thread, but it calls update_from_model() and the compute_low/compute_high() helpers read from Qt objects (self.model, QLineEdit.text()). Accessing Qt widgets/models from a non-GUI thread is not thread-safe and can cause sporadic crashes. Capture all needed values on the GUI thread before deferToThread (e.g., read model/widget values into plain attributes or a dataclass) and make the worker thread operate only on non-Qt data.
|
@jellybean2004 - will this conflict with #3867 at all? You might want to rebase off that branch and point your PR to there for now, just to be sure. |
e83761c to
6d15e56
Compare
6d15e56 to
5293bac
Compare
add to init
5293bac to
04cabcd
Compare
04cabcd to
ebb06ab
Compare
|
@DrPaulSharp @krzywon, I have updated the QRangeSlider Test for Invariant and moved the testfile out of quarantine. |
Description
Replaces the monolithic
InvariantPerspectiveTest.pywith a structured test suite covering the fullInvariantWindowlifecycle — from widget initialisation through calculation and plotting. The suite is split into focused modules with shared fixtures inconftest.py.New test files
conftest.pyDummyManager,MainWindowStub,window_class,real_data,small_dataInitializedStateTest.pyupdate_from_model,updateFromGui, serialisation,closeEvent,resetInvariantLoadedDataTest.pyRealDataTest.pyUIHelpersMixin), Q-range display, low-/high-Q extrapolation limits (getters/setters), bounds and ordering validation,updateGuiFromFile, serialisation (serializePage,serializeCurrentPage,serializeAll)CalculationTest.pycalculate_thread(base case, contrast/vol-frac modes, exceptions, Porod constant, low/high/both extrapolation success),plot_result,calculate_invariant(thread dispatch, callback wiring),on_calculation_failed,deferredPlot,compute_low/compute_high(function selection, exception handling)Changes to
InvariantPerspective.pyMinor refactors to improve testability and correctness:
on_calculation_failedtype annotation corrected (Exception→Failure)calculate_threadextrapolation argument type broadened tostr | Nonecompute_low/compute_highextracted as standalone methods returning(qstar, qstar_err, success)tuples, making them independently testableset_low_q_extrapolation_upper_limit/set_high_q_extrapolation_lower_limitindex calculations expanded for clarityRemoved
InvariantPerspectiveTest.py— superseded by the new suitenote: written using Copilot on VS Code
How Has This Been Tested?
pytestagainst theUnitTesting/directorypytest --cov; new tests bring the perspective from partial coverage to covering most primary code pathsReview Checklist:
Documentation
Installers
Licensing (untick if necessary)