diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py
index c4aab22529..5cab70bb1d 100644
--- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py
+++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py
@@ -7,6 +7,7 @@
import numpy as np
from PySide6 import QtCore, QtGui, QtWidgets
from twisted.internet import reactor, threads
+from twisted.python.failure import Failure
import sas.qtgui.Utilities.GuiUtils as GuiUtils
from sas.qtgui.Plotting import PlotterData
@@ -123,7 +124,7 @@ def initialize_variables(self) -> None:
self._low_extrapolate: bool = False
self._low_guinier: bool = True
self._low_fit: bool = False
- self._low_power_value: float = DEFAULT_POWER_VALUE
+ self._low_power_value: float | None = DEFAULT_POWER_VALUE
self._high_extrapolate: bool = False
self._high_fit: bool = False
self._high_power_value: float | None = DEFAULT_POWER_VALUE
@@ -219,7 +220,10 @@ def set_low_q_extrapolation_upper_limit(self, value: float) -> None:
:param value: low Q extrapolation upper limit
"""
- self._low_points = (np.abs(self._data.x - value)).argmin() + 1
+ # index of the value closest to the input value
+ idx = (np.abs(self._data.x - value)).argmin()
+ npts = idx + 1
+ self._low_points = npts
def get_high_q_extrapolation_lower_limit(self) -> float:
"""
@@ -236,7 +240,10 @@ def set_high_q_extrapolation_lower_limit(self, value: float) -> None:
:param value: high Q extrapolation lower limit
"""
- self._high_points = len(self._data.x) - (np.abs(self._data.x - value)).argmin() + 1
+ # index of the value closest to the input value
+ idx = (np.abs(self._data.x - value)).argmin()
+ npts = (len(self._data.x) - idx) + 1
+ self._high_points = npts
def enableStatus(self) -> None:
"""Enable the status button."""
@@ -322,7 +329,7 @@ def calculate_invariant(self) -> None:
d.addCallback(lambda model: self.deferredPlot(model, extrapolation))
d.addErrback(self.on_calculation_failed)
- def on_calculation_failed(self, reason: Exception) -> None:
+ def on_calculation_failed(self, reason: Failure) -> None:
"""Handle calculation failure."""
logger.error(f"calculation failed: {reason}")
self.check_status()
@@ -385,8 +392,6 @@ def plot_result(self, model: QtGui.QStandardItemModel) -> None:
self.high_extrapolation_plot.slider_update_on_move = False
self.high_extrapolation_plot.slider_perspective_name = self.name
self.high_extrapolation_plot.slider_low_q_input = self.txtPorodStart_ex.text()
- self.high_extrapolation_plot.slider_low_q_setter = ["set_high_q_extrapolation_lower_limit"]
- self.high_extrapolation_plot.slider_low_q_getter = ["get_high_q_extrapolation_lower_limit"]
self.high_extrapolation_plot.slider_high_q_input = self.txtPorodEnd_ex.text()
GuiUtils.updateModelItemWithPlot(
self._model_item, self.high_extrapolation_plot, self.high_extrapolation_plot.title
@@ -398,12 +403,9 @@ def plot_result(self, model: QtGui.QStandardItemModel) -> None:
self.low_extrapolation_plot.custom_color = "#ff7f0e"
self.low_extrapolation_plot.show_errors = False
self.low_extrapolation_plot.show_q_range_sliders = True
- self.low_extrapolation_plot.slider_update_on_move = False
self.low_extrapolation_plot.slider_perspective_name = self.name
self.low_extrapolation_plot.slider_low_q_input = self.extrapolation_parameters.ex_q_min
self.low_extrapolation_plot.slider_high_q_input = self.txtGuinierEnd_ex.text()
- self.low_extrapolation_plot.slider_high_q_setter = ["set_low_q_extrapolation_upper_limit"]
- self.low_extrapolation_plot.slider_high_q_getter = ["get_low_q_extrapolation_upper_limit"]
GuiUtils.updateModelItemWithPlot(
self._model_item, self.low_extrapolation_plot, self.low_extrapolation_plot.title
)
@@ -423,276 +425,272 @@ def update_details_widget(self) -> None:
if self.detailsDialog.isVisible():
self.onStatus()
- def calculate_thread(self, extrapolation: str) -> None:
- """Perform Invariant calculations."""
- self.update_from_model()
+ def compute_low(self) -> tuple[float | Literal["ERROR"], float | Literal["ERROR"], bool]:
+ """Compute low-q extrapolation and return (qstar, qstar_err, success)."""
+ qstar, qstar_err = 0.0, 0.0
+ success = False
+
+ if not self._low_extrapolate:
+ return qstar, qstar_err, success
- # Set base values
- msg = ""
- qstar_low: float | Literal["ERROR"] = 0.0
- qstar_low_err: float | Literal["ERROR"] = 0.0
- qstar_high: float | Literal["ERROR"] = 0.0
- qstar_high_err: float | Literal["ERROR"] = 0.0
- calculation_failed: bool = False
- low_calculation_pass: bool = False
- high_calculation_pass: bool = False
-
- temp_data = copy.deepcopy(self._data)
-
- # Update calculator with background, scale, and data values
- self._calculator.background = self._background
- self._calculator.scale = self._scale
- self._calculator.set_data(temp_data)
-
- # Low Q extrapolation calculations
- if self._low_extrapolate:
- if self._low_guinier:
- function_low = "guinier"
+ # choose function and power value
+ if self._low_guinier:
+ function_low = "guinier"
+ self._low_power_value = None
+ else:
+ function_low = "power_law"
+ if self._low_fit:
self._low_power_value = None
- else:
- function_low = "power_law"
- if self._low_fit:
- self._low_power_value = None
- elif self._low_fix:
- self._low_power_value = float(self.model.item(WIDGETS.W_LOWQ_POWER_VALUE_EX).text())
+ elif self._low_fix:
+ self._low_power_value = float(self.model.item(WIDGETS.W_LOWQ_POWER_VALUE_EX).text())
- try:
- q_end_val: float = float(self.txtGuinierEnd_ex.text())
+ # determine number of points
+ q_end_val: float = float(self.txtGuinierEnd_ex.text())
+ self.set_low_q_extrapolation_upper_limit(q_end_val)
- # Find the index of the data point closest to q_end_val
- n_pts: int = int(np.abs(self._data.x - q_end_val).argmin()) + 1
+ self._calculator.set_extrapolation(
+ range="low", npts=self._low_points, function=function_low, power=self._low_power_value
+ )
- if n_pts not in range(1, len(self._data.x) + 1):
- raise ValueError("Number of points in low-q Guinier end is out of valid bounds")
+ try:
+ extrapolation_start = float(self.extrapolation_parameters.ex_q_min)
+ low_q_limit: float | None = None if extrapolation_start > self._data.x[0] else extrapolation_start
+ qstar, qstar_err = self._calculator.get_qstar_low(low_q_limit)
+ success = True
+ except Exception as ex:
+ logger.warning(f"Low-q calculation failed: {ex}")
+ qstar, qstar_err = "ERROR", "ERROR"
- self._low_points = n_pts
+ return qstar, qstar_err, success
- except ValueError:
- logger.warning("Could not convert low-q Guinier end value to number of points: {str(ex)}")
+ def compute_high(self) -> tuple[float | Literal["ERROR"], float | Literal["ERROR"], bool]:
+ """Compute high-q extrapolation and return (qstar, qstar_err, success)."""
+ qstar, qstar_err = 0.0, 0.0
+ success = False
- self._calculator.set_extrapolation(
- range="low", npts=int(self._low_points), function=function_low, power=self._low_power_value
- )
+ if not self._high_extrapolate:
+ return qstar, qstar_err, success
- try:
- extrapolation_start: float = float(self.extrapolation_parameters.ex_q_min)
- # If the start is in the data range, set the low q limit to None and let the calculator handle it
- low_q_limit: float | None = None if extrapolation_start > self._data.x[0] else extrapolation_start
- qstar_low, qstar_low_err = self._calculator.get_qstar_low(low_q_limit)
- low_calculation_pass = True
- except Exception as ex:
- logger.warning(f"Low-q calculation failed: {str(ex)}")
- qstar_low = "ERROR"
- qstar_low_err = "ERROR"
-
- # Remove the existing extrapolation plot if it exists and calculation failed
- if self.low_extrapolation_plot and not low_calculation_pass:
- model_items: list[QtGui.QStandardItem] = GuiUtils.getChildrenFromItem(self._model_item)
- for item in model_items:
- if item.text() == self.low_extrapolation_plot.title:
- reactor.callFromThread(self._manager.filesWidget.closePlotsForItem, item)
- reactor.callFromThread(self._model_item.removeRow, item.row())
- break
- self.low_extrapolation_plot = None
-
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.D_LOW_QSTAR, qstar_low)
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.D_LOW_QSTAR_ERR, qstar_low_err)
-
- # High Q Extrapolation calculations
- if self._high_extrapolate:
- function_high: str = "power_law"
- if self._high_fit:
- self._high_power_value = None
- elif self._high_fix:
- self._high_power_value = float(self.model.item(WIDGETS.W_HIGHQ_POWER_VALUE_EX).text())
+ function_high = "power_law"
+ if self._high_fit:
+ self._high_power_value = None
+ elif self._high_fix:
+ self._high_power_value = float(self.model.item(WIDGETS.W_HIGHQ_POWER_VALUE_EX).text())
- try:
- q_start_val = float(self.txtPorodStart_ex.text())
+ q_start_val = float(self.txtPorodStart_ex.text())
+ self.set_high_q_extrapolation_lower_limit(q_start_val)
- # Find the index of the data point closest to q_start_val
- idx = int((np.abs(self._data.x - q_start_val)).argmin())
+ self._calculator.set_extrapolation(
+ range="high", npts=self._high_points, function=function_high, power=self._high_power_value
+ )
- # Compute number of points from that index to the end
- n_pts_high: int = len(self._data.x) - idx
+ try:
+ extrapolation_end = float(self.extrapolation_parameters.ex_q_max)
+ high_q_limit: float | None = None if extrapolation_end < self._data.x[-1] else extrapolation_end
+ qstar, qstar_err = self._calculator.get_qstar_high(high_q_limit)
+ success = True
+ except Exception as ex:
+ logger.warning(f"High-q calculation failed: {ex}")
+ qstar, qstar_err = "ERROR", "ERROR"
- if n_pts_high not in range(1, len(self._data.x) + 1):
- raise ValueError("Number of points in high-q Porod start is out of valid bounds")
+ return qstar, qstar_err, success
- self._high_points = n_pts_high
+ def calculate_thread(self, extrapolation: str | None) -> None:
+ """Perform Invariant calculations.
- except ValueError:
- logger.warning("Could not convert Porod start value to number of points.")
+ This function runs in a worker thread (deferToThread). It must not
+ update widgets directly — use reactor.callFromThread to schedule GUI updates.
+ """
+ def _ui(fn, *args, **kwargs):
+ """Schedule a GUI-thread call safely."""
+ reactor.callFromThread(fn, *args, **kwargs)
- self._calculator.set_extrapolation(
- range="high", npts=int(self._high_points), function=function_high, power=self._high_power_value
- )
+ def _safe_update_model(widget_const, value):
+ _ui(self.update_model_from_thread, widget_const, value)
- try:
- extrapolation_end: float = float(self.extrapolation_parameters.ex_q_max)
- # If the end is in the data range, set the high q limit to None and let the calculator handle it
- high_q_limit: float | None = None if extrapolation_end < self._data.x[-1] else extrapolation_end
- qstar_high, qstar_high_err = self._calculator.get_qstar_high(high_q_limit)
- high_calculation_pass: bool = True
- except Exception as ex:
- logger.warning(f"High-q calculation failed: {str(ex)}")
- qstar_high = "ERROR"
- qstar_high_err = "ERROR"
-
- # Remove the existing high-q extrapolation plot if it exists and calculation was successful
- if self.high_extrapolation_plot and not high_calculation_pass:
- model_items: list[QtGui.QStandardItem] = GuiUtils.getChildrenFromItem(self._model_item)
- for item in model_items:
- if item.text() == self.high_extrapolation_plot.title:
- reactor.callFromThread(self._manager.filesWidget.closePlotsForItem, item)
- reactor.callFromThread(self._model_item.removeRow, item.row())
- break
- self.high_extrapolation_plot = None
-
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.D_HIGH_QSTAR, qstar_high)
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.D_HIGH_QSTAR_ERR, qstar_high_err)
-
- # Q* Data calculations
- qstar_data: float | Literal["ERROR"]
- qstar_data_err: float | Literal["ERROR"]
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"
+ self.update_from_model()
- 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)
-
- # Volume Fraction calculations
- if self.rbContrast.isChecked() and self._contrast:
- volume_fraction: float | Literal["ERROR"]
- volume_fraction_error: float | Literal["ERROR"]
+ # initialise state
+ msg = ""
+ qstar_low, qstar_low_err = 0.0, 0.0
+ qstar_high, qstar_high_err = 0.0, 0.0
+ calculation_failed = False
+
+ temp_data = copy.deepcopy(self._data)
+
+ # Update calculator with background, scale, and data values
+ self._calculator.background = self._background
+ self._calculator.scale = self._scale
+ self._calculator.set_data(temp_data)
+
+ # low / high computations
+ qstar_low, qstar_low_err, low_success = self.compute_low()
+ if not low_success and self.low_extrapolation_plot:
+ # safely remove plot from GUI thread
+ model_items: list[QtGui.QStandardItem] = GuiUtils.getChildrenFromItem(self._model_item)
+ for item in model_items:
+ if item.text() == self.low_extrapolation_plot.title:
+ _ui(self._manager.filesWidget.closePlotsForItem, item)
+ _ui(self._model_item.removeRow, item.row())
+ break
+ self.low_extrapolation_plot = None
+
+ _safe_update_model(WIDGETS.D_LOW_QSTAR, qstar_low)
+ _safe_update_model(WIDGETS.D_LOW_QSTAR_ERR, qstar_low_err)
+
+ qstar_high, qstar_high_err, high_success = self.compute_high()
+ if not high_success and self.high_extrapolation_plot:
+ model_items: list[QtGui.QStandardItem] = GuiUtils.getChildrenFromItem(self._model_item)
+ for item in model_items:
+ if item.text() == self.high_extrapolation_plot.title:
+ _ui(self._manager.filesWidget.closePlotsForItem, item)
+ _ui(self._model_item.removeRow, item.row())
+ break
+ self.high_extrapolation_plot = None
+
+ _safe_update_model(WIDGETS.D_HIGH_QSTAR, qstar_high)
+ _safe_update_model(WIDGETS.D_HIGH_QSTAR_ERR, qstar_high_err)
+
+ # Q* data
try:
- volume_fraction, volume_fraction_error = self._calculator.get_volume_fraction_with_error(
- self._contrast, contrast_err=self._contrast_err, extrapolation=extrapolation
- )
- except (ValueError, ZeroDivisionError) as ex:
+ qstar_data, qstar_data_err = self._calculator.get_qstar_with_error()
+ except Exception as ex:
calculation_failed = True
- msg += f"Volume fraction calculation failed: {str(ex)}"
- volume_fraction, volume_fraction_error = "ERROR", "ERROR"
-
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_VOLUME_FRACTION, volume_fraction)
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_VOLUME_FRACTION_ERR, volume_fraction_error)
+ msg += f"Invariant calculation failed: {ex}"
+ qstar_data, qstar_data_err = "ERROR", "ERROR"
+
+ _safe_update_model(WIDGETS.D_DATA_QSTAR, qstar_data)
+ _safe_update_model(WIDGETS.D_DATA_QSTAR_ERR, qstar_data_err)
+
+ # Volume fraction, contrast, surface (same pattern as above)
+ # kept compact here: call calculator methods inside try/except and schedule model updates with _safe_update_model
+ if self.rbContrast.isChecked() and self._contrast:
+ try:
+ volume_fraction, volume_fraction_error = self._calculator.get_volume_fraction_with_error(
+ self._contrast, contrast_err=self._contrast_err, extrapolation=extrapolation
+ )
+ except (ValueError, ZeroDivisionError) as ex:
+ calculation_failed = True
+ msg += f"Volume fraction calculation failed: {ex}"
+ volume_fraction, volume_fraction_error = "ERROR", "ERROR"
+ _safe_update_model(WIDGETS.W_VOLUME_FRACTION, volume_fraction)
+ _safe_update_model(WIDGETS.W_VOLUME_FRACTION_ERR, volume_fraction_error)
+
+ if self.rbVolFrac.isChecked() and self._volfrac1:
+ try:
+ contrast_out, contrast_out_error = self._calculator.get_contrast_with_error(
+ self._volfrac1, volume_err=self._volfrac1_err, extrapolation=extrapolation
+ )
+ except (ValueError, ZeroDivisionError) as ex:
+ calculation_failed = True
+ msg += f"Contrast calculation failed: {ex}"
+ contrast_out, contrast_out_error = "ERROR", "ERROR"
+ _safe_update_model(WIDGETS.W_CONTRAST_OUT, contrast_out)
+ _safe_update_model(WIDGETS.W_CONTRAST_OUT_ERR, contrast_out_error)
+
+ 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
+ else:
+ contrast_for_surface = None
+ contrast_for_surface_err = None
+
+ if contrast_for_surface is not None:
+ surface, surface_error = self._calculator.get_surface_with_error(
+ contrast_for_surface,
+ self._porod,
+ contrast_err=contrast_for_surface_err,
+ porod_const_err=self._porod_err,
+ )
+ else:
+ surface, surface_error = "ERROR", "ERROR"
+ except (ValueError, ZeroDivisionError) as ex:
+ calculation_failed = True
+ msg += f"Specific surface calculation failed: {ex}"
+ surface, surface_error = "ERROR", "ERROR"
+
+ _safe_update_model(WIDGETS.W_SPECIFIC_SURFACE, surface)
+ _safe_update_model(WIDGETS.W_SPECIFIC_SURFACE_ERR, surface_error)
+
+ # Enable the status button (schedule on GUI thread)
+ _ui(self.cmdStatus.setEnabled, True)
+
+ if calculation_failed:
+ # leave status disabled if something critical failed
+ _ui(self.cmdStatus.setEnabled, False)
+ logger.warning(f"Calculation failed: {msg}")
+ return self.model
+
+ # add extrapolation plots (schedule GUI changes where needed)
+ 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)
+ self.high_extrapolation_plot.name = title
+ self.high_extrapolation_plot.title = title
+ self.high_extrapolation_plot.symbol = "Line"
+ self.high_extrapolation_plot.has_errors = False
+ # copy labels/units (data-only)
+ self.high_extrapolation_plot._xaxis = temp_data._xaxis
+ self.high_extrapolation_plot._xunit = temp_data._xunit
+ self.high_extrapolation_plot._yaxis = temp_data._yaxis
+ self.high_extrapolation_plot._yunit = temp_data._yunit
+ if self._high_fit:
+ _safe_update_model(WIDGETS.W_HIGHQ_POWER_VALUE_EX, power_high)
+
+ # convert any "ERROR" to numeric zeros before summing
+ if qstar_high == "ERROR":
+ qstar_high, qstar_high_err = 0.0, 0.0
+ if qstar_low == "ERROR":
+ qstar_low, qstar_low_err = 0.0, 0.0
+
+ assert qstar_data != "ERROR" and qstar_low != "ERROR" and qstar_high != "ERROR"
+ assert qstar_data_err != "ERROR" and qstar_low_err != "ERROR" and qstar_high_err != "ERROR"
+
+ qstar_total = qstar_data + qstar_low + qstar_high
+ qstar_total_error = np.sqrt(
+ qstar_data_err * qstar_data_err + qstar_low_err * qstar_low_err + qstar_high_err * qstar_high_err
+ )
- # Contrast calculations
- if self.rbVolFrac.isChecked() and self._volfrac1:
- contrast_out: float | Literal["ERROR"]
- contrast_out_error: float | Literal["ERROR"]
- try:
- contrast_out, contrast_out_error = self._calculator.get_contrast_with_error(
- self._volfrac1, volume_err=self._volfrac1_err, extrapolation=extrapolation
- )
- except (ValueError, ZeroDivisionError) as ex:
- calculation_failed: bool = True
- msg += f"Contrast calculation failed: {str(ex)}"
- contrast_out, contrast_out_error = "ERROR", "ERROR"
-
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_CONTRAST_OUT, contrast_out)
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_CONTRAST_OUT_ERR, contrast_out_error)
-
- # Surface Error calculations
- if self._porod and self._porod > 0:
- surface: float | Literal["ERROR"]
- surface_error: float | Literal["ERROR"]
- 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
+ _safe_update_model(WIDGETS.W_INVARIANT, qstar_total)
+ _safe_update_model(WIDGETS.W_INVARIANT_ERR, qstar_total_error)
- try:
- surface, surface_error = self._calculator.get_surface_with_error(
- contrast_for_surface,
- self._porod,
- contrast_err=contrast_for_surface_err,
- porod_const_err=self._porod_err,
- )
- except (ValueError, ZeroDivisionError) as ex:
- calculation_failed: bool = True
- msg += f"Specific surface calculation failed: {str(ex)}"
- surface, surface_error = "ERROR", "ERROR"
-
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_SPECIFIC_SURFACE, surface)
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_SPECIFIC_SURFACE_ERR, surface_error)
-
- # Enable the status button
- self.cmdStatus.setEnabled(True)
-
- # Early exit if calculations failed
- if calculation_failed:
- self.cmdStatus.setEnabled(False)
- logger.warning(f"Calculation failed: {msg}")
return self.model
- if low_calculation_pass:
- qmin_ext: float = float(self.extrapolation_parameters.ex_q_min)
- extrapolated_data = self._calculator.get_extra_data_low(self._low_points, q_start=qmin_ext)
- power_low: float | None = self._calculator.get_extrapolation_power(range="low")
-
- title = f"Low-Q extrapolation [{self._data.name}]"
-
- # Convert the data into plottable
- self.low_extrapolation_plot = self._manager.createGuiData(extrapolated_data)
-
- 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 and units of axes for plotting
- 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:
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_LOWQ_POWER_VALUE_EX, power_low)
-
- if high_calculation_pass:
- qmax_plot: float = float(self.extrapolation_parameters.point_3)
-
- power_high: float | None = 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}]"
-
- # Convert the data into plottable
- self.high_extrapolation_plot = self._manager.createGuiData(high_out_data)
- self.high_extrapolation_plot.name = title
- self.high_extrapolation_plot.title = title
- self.high_extrapolation_plot.symbol = "Line"
- self.high_extrapolation_plot.has_errors = False
-
- # copy labels and units of axes for plotting
- self.high_extrapolation_plot._xaxis = temp_data._xaxis
- self.high_extrapolation_plot._xunit = temp_data._xunit
- self.high_extrapolation_plot._yaxis = temp_data._yaxis
- self.high_extrapolation_plot._yunit = temp_data._yunit
-
- if self._high_fit:
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_HIGHQ_POWER_VALUE_EX, power_high)
-
- if qstar_high == "ERROR":
- qstar_high, qstar_high_err = 0.0, 0.0
- if qstar_low == "ERROR":
- qstar_low, qstar_low_err = 0.0, 0.0
-
- qstar_total = qstar_data + qstar_low + qstar_high
- qstar_total_error = np.sqrt(
- qstar_data_err * qstar_data_err + qstar_low_err * qstar_low_err + qstar_high_err * qstar_high_err
- )
-
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_INVARIANT, qstar_total)
- reactor.callFromThread(self.update_model_from_thread, WIDGETS.W_INVARIANT_ERR, qstar_total_error)
-
- return self.model
+ finally:
+ # ALWAYS restore the Calculate button (schedule on GUI thread)
+ _ui(self.enable_calculation, True, "Calculate")
def update_model_from_thread(self, widget_id: int, value: float) -> None:
"""Update the model in the main thread."""
@@ -748,6 +746,7 @@ def setupSlots(self):
self.txtContrast.textEdited.connect(self.updateFromGui)
self.txtContrastErr.textEdited.connect(self.updateFromGui)
self.txtPorodCst.textEdited.connect(self.updateFromGui)
+ self.txtPorodCstErr.textEdited.connect(self.updateFromGui)
self.txtVolFrac1.textEdited.connect(self.updateFromGui)
self.txtVolFrac1.editingFinished.connect(self.checkVolFrac)
self.txtVolFrac1Err.textEdited.connect(self.updateFromGui)
@@ -785,6 +784,9 @@ def setupSlots(self):
self.HighQGroup.addButton(self.rbHighQFix_ex)
self.HighQGroup.addButton(self.rbHighQFit_ex)
+ self.txtLowQPower_ex.textEdited.connect(self.updateFromGui)
+ self.txtHighQPower_ex.textEdited.connect(self.updateFromGui)
+
# Slider values
self.slider.valueEdited.connect(self.on_extrapolation_slider_changed)
@@ -1037,12 +1039,7 @@ def correct_extrapolation_values(self) -> None:
if messages:
messages.append("Values have been adjusted to the nearest valid value.")
- dialog = QtWidgets.QMessageBox(self)
- dialog.setWindowTitle("Invalid Extrapolation Values")
- dialog.setIcon(QtWidgets.QMessageBox.Warning)
- dialog.setText("\n".join(messages))
- dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
- dialog.exec_()
+ QtWidgets.QMessageBox.warning(self, "Invalid Extrapolation Values", "\n".join(messages))
def apply_parameters_from_ui(self):
"""Sets extrapolation parameters from the text boxes into the model and slider."""
@@ -1065,30 +1062,6 @@ def apply_parameters_from_ui(self):
# re-validate to update any UI flags
self.check_extrapolation_values()
- def stateChanged(self) -> None:
- """Catch modifications from low- and high-Q extrapolation check boxes"""
- sender: QtWidgets.QWidget = self.sender()
- itemf: QtGui.QStandardItem = QtGui.QStandardItem(str(sender.isChecked()).lower())
- if sender.text() == "Enable Low-Q extrapolation":
- self.model.setItem(WIDGETS.W_ENABLE_LOWQ, itemf)
-
- if sender.text() == "Enable High-Q extrapolation":
- self.model.setItem(WIDGETS.W_ENABLE_HIGHQ, itemf)
-
- def checkQExtrapolatedData(self) -> None:
- """
- Match status of low or high-Q extrapolated data checkbox in
- DataExplorer with low or high-Q extrapolation checkbox in invariant
- panel.
- """
- # name to search in DataExplorer
- if "Low" in str(self.sender().text()):
- name: str = "Low-Q extrapolation"
- if "High" in str(self.sender().text()):
- name: str = "High-Q extrapolation"
-
- GuiUtils.updateModelItemStatus(self._manager.filesWidget.model, self._path, name, self.sender().checkState())
-
def checkVolFrac(self) -> None:
"""Check if volfrac1 is strictly between 0 and 1."""
if self.txtVolFrac1.text().strip() != "":
@@ -1098,11 +1071,7 @@ def checkVolFrac(self) -> None:
self.txtVolFrac1.setStyleSheet(BG_RED)
self.enable_calculation(False, "Calculate (Invalid volume fraction)")
msg = "Volume fractions must be valid numbers."
- dialog = QtWidgets.QMessageBox(self, text=msg)
- dialog.setWindowTitle("Invalid Volume Fraction")
- dialog.setIcon(QtWidgets.QMessageBox.Warning)
- dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
- dialog.exec_()
+ QtWidgets.QMessageBox.warning(self, "Invalid Volume Fraction", msg)
return
if 0 < vf1 < 1:
self.txtVolFrac1.setStyleSheet(BG_DEFAULT)
@@ -1111,11 +1080,7 @@ def checkVolFrac(self) -> None:
self.txtVolFrac1.setStyleSheet(BG_RED)
self.enable_calculation(False, "Calculate (Invalid volume fraction)")
msg = "Volume fraction must be between 0 and 1."
- dialog = QtWidgets.QMessageBox(self, text=msg)
- dialog.setWindowTitle("Invalid Volume Fraction")
- dialog.setIcon(QtWidgets.QMessageBox.Warning)
- dialog.setStandardButtons(QtWidgets.QMessageBox.Ok)
- dialog.exec_()
+ QtWidgets.QMessageBox.warning(self, "Invalid Volume Fraction", msg)
def updateFromGui(self) -> None:
"""Update model when new user inputs."""
@@ -1158,6 +1123,8 @@ def updateFromGui(self) -> None:
"txtPorodCstErr",
"txtVolFrac1",
"txtVolFrac1Err",
+ "txtLowQPower_ex",
+ "txtHighQPower_ex",
]
if text_value == "" and sender_name in optional_fields:
@@ -1174,6 +1141,8 @@ def updateFromGui(self) -> None:
"txtPorodCstErr": "_porod_err",
"txtVolFrac1": "_volfrac1",
"txtVolFrac1Err": "_volfrac1_err",
+ "txtLowQPower_ex": "_low_power_value",
+ "txtHighQPower_ex": "_high_power_value",
}
if sender_name in sender_to_attr:
setattr(self, sender_to_attr[sender_name], None)
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py b/src/sas/qtgui/Perspectives/Invariant/OldUnitTesting/InvariantDetailsTest.py
similarity index 82%
rename from src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py
rename to src/sas/qtgui/Perspectives/Invariant/OldUnitTesting/InvariantDetailsTest.py
index 3e8aed8bbd..de9241c3dc 100755
--- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py
+++ b/src/sas/qtgui/Perspectives/Invariant/OldUnitTesting/InvariantDetailsTest.py
@@ -1,12 +1,13 @@
import pytest
-from PySide6 import QtGui, QtWidgets
+from PySide6 import QtGui
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
+from PySide6.QtWidgets import QDialog
from sas.qtgui.Perspectives.Invariant.InvariantDetails import DetailsDialog
from sas.qtgui.Perspectives.Invariant.InvariantUtils import WIDGETS
-BG_COLOR_ERR = 'background-color: rgb(244, 170, 164);'
+BG_COLOR_ERR = "background-color: rgb(244, 170, 164);"
class InvariantDetailsTest:
@@ -14,22 +15,22 @@ class InvariantDetailsTest:
@pytest.fixture(autouse=True)
def widget(self, qapp):
- '''Create/Destroy the Invariant Details window'''
+ """Create/Destroy the Invariant Details window"""
w = DetailsDialog(None)
w._model = QtGui.QStandardItemModel()
- w._model.setItem(WIDGETS.W_INVARIANT, QtGui.QStandardItem(str(10.)))
+ w._model.setItem(WIDGETS.W_INVARIANT, QtGui.QStandardItem(str(10.0)))
w._model.setItem(WIDGETS.W_INVARIANT_ERR, QtGui.QStandardItem(str(0.1)))
- w._model.setItem(WIDGETS.W_ENABLE_LOWQ_EX, QtGui.QStandardItem('true'))
- w._model.setItem(WIDGETS.D_LOW_QSTAR, QtGui.QStandardItem(str(9.)))
+ w._model.setItem(WIDGETS.W_ENABLE_LOWQ_EX, QtGui.QStandardItem("true"))
+ w._model.setItem(WIDGETS.D_LOW_QSTAR, QtGui.QStandardItem(str(9.0)))
w._model.setItem(WIDGETS.D_LOW_QSTAR_ERR, QtGui.QStandardItem(str(0.03)))
- w._model.setItem(WIDGETS.D_DATA_QSTAR, QtGui.QStandardItem(str(10.)))
+ w._model.setItem(WIDGETS.D_DATA_QSTAR, QtGui.QStandardItem(str(10.0)))
w._model.setItem(WIDGETS.D_DATA_QSTAR_ERR, QtGui.QStandardItem(str(0.1)))
- w._model.setItem(WIDGETS.D_HIGH_QSTAR, QtGui.QStandardItem(str(1.)))
+ w._model.setItem(WIDGETS.D_HIGH_QSTAR, QtGui.QStandardItem(str(1.0)))
w._model.setItem(WIDGETS.D_HIGH_QSTAR_ERR, QtGui.QStandardItem(str(0.01)))
# High-Q
- w._model.setItem(WIDGETS.W_ENABLE_HIGHQ_EX, QtGui.QStandardItem('false'))
+ w._model.setItem(WIDGETS.W_ENABLE_HIGHQ_EX, QtGui.QStandardItem("false"))
yield w
@@ -46,7 +47,7 @@ def testDefaults(self, widget):
widget.warning_msg = "No Details on calculations available...\n"
- assert isinstance(widget, QtWidgets.QDialog)
+ assert isinstance(widget, QDialog)
assert widget.progressBarLowQ.minimum() == 0
assert widget.progressBarLowQ.maximum() == 100
@@ -55,7 +56,6 @@ def testDefaults(self, widget):
assert widget.progressBarHighQ.minimum() == 0
assert widget.progressBarHighQ.maximum() == 100
-
# Tooltips
assert widget.txtQData.toolTip() == "Invariant in the data set's Q range."
assert widget.txtQDataErr.toolTip() == "Uncertainty on the invariant from data's range."
@@ -65,7 +65,7 @@ def testDefaults(self, widget):
assert widget.txtQHighQErr.toolTip() == "Uncertainty on the invariant from high-Q range."
def testOnOK(self, widget):
- """ Test closing dialog"""
+ """Test closing dialog"""
okButton = widget.cmdOK
QTest.mouseClick(okButton, Qt.LeftButton)
@@ -74,15 +74,15 @@ def testShowDialog(self, widget):
widget.showDialog()
# Low Q true
assert widget.qlow == 9.0
- assert widget.txtQLowQ.text() == '9.0'
+ assert widget.txtQLowQ.text() == "9.0"
assert widget.progress_low_qstar == 90.0
assert widget.qstar_total == 10.0
- assert widget.txtQData.text() == '10.0'
- assert widget.txtQDataErr.text() == '0.1'
+ assert widget.txtQData.text() == "10.0"
+ assert widget.txtQDataErr.text() == "0.1"
# High Q false
- assert widget.txtQHighQ.text() == ''
- assert widget.txtQHighQErr.text() == ''
+ assert widget.txtQHighQ.text() == ""
+ assert widget.txtQHighQErr.text() == ""
assert widget.progress_high_qstar == 0.0
# Progressbars
@@ -106,8 +106,8 @@ def testCheckValues(self, widget):
widget.progress_qstar = 0
widget.progress_low_qstar = 10
return_string = widget.checkValues()
- assert 'Extrapolated contribution at Low Q is higher than 5% of the invariant.' in return_string
- assert 'The sum of all extrapolated contributions is higher than 5% of the invariant.' in return_string
+ assert "Extrapolated contribution at Low Q is higher than 5% of the invariant." in return_string
+ assert "The sum of all extrapolated contributions is higher than 5% of the invariant." in return_string
widget.progress_low_qstar = -1
assert widget.checkValues() == "Extrapolated contribution at Low Q < 0.\n"
diff --git a/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui b/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui
index ecb8ee07f4..91a53d2da7 100755
--- a/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui
+++ b/src/sas/qtgui/Perspectives/Invariant/UI/TabbedInvariantUI.ui
@@ -135,7 +135,7 @@
false
- false
+ true
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py
new file mode 100644
index 0000000000..5e210cdd8a
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py
@@ -0,0 +1,558 @@
+"""Unit tests for the Invariant Perspective, focusing on the calculation process and error handling."""
+
+import PySide6.QtTest as QtTest
+import pytest
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QApplication
+from twisted.internet import threads
+from twisted.python.failure import Failure
+
+from sas.qtgui.Perspectives import Invariant
+from sas.qtgui.Perspectives.Invariant.InvariantUtils import WIDGETS
+from sas.qtgui.Perspectives.Invariant.UnitTesting.RealDataTest import UIHelpersMixin
+from sas.qtgui.Plotting.PlotterData import Data1D
+
+
+@pytest.mark.parametrize("window_class", ["real_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantCalculateThread(UIHelpersMixin):
+ def test_calculate_thread(self, mocker):
+ """Test that calculate_thread calls the appropriate compute methods and updates the model."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_calculator = mocker.MagicMock()
+ self.window._calculator = mock_calculator
+
+ mock_update = mocker.patch.object(self.window, "update_model_from_thread")
+
+ # wrap the real compute methods so they still execute but are trackable
+ mock_compute_low = mocker.patch.object(self.window, "compute_low", wraps=self.window.compute_low)
+ mock_compute_high = mocker.patch.object(self.window, "compute_high", wraps=self.window.compute_high)
+
+ mock_enable = mocker.patch.object(self.window, "enable_calculation")
+
+ self.window.calculate_thread(extrapolation=None)
+
+ assert mock_compute_low.call_count == 1
+ assert mock_compute_high.call_count == 1
+
+ mock_calculator.get_qstar_with_error.assert_called()
+
+ mock_update.assert_called()
+ mock_enable.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "setup_name,calc_method,other_method,expect_widget",
+ [
+ ("setup_contrast", "get_volume_fraction_with_error", "get_contrast_with_error", WIDGETS.W_VOLUME_FRACTION),
+ (
+ "setup_volume_fraction",
+ "get_contrast_with_error",
+ "get_volume_fraction_with_error",
+ WIDGETS.W_CONTRAST_OUT,
+ ),
+ ],
+ ids=["contrast_mode", "volume_fraction_mode"],
+ )
+ def test_calculate_thread_contrast_or_volfrac(self, mocker, setup_name, calc_method, other_method, expect_widget):
+ """Test that calculate_thread updates the model with the correct volume fraction or contrast values."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_calc = mocker.MagicMock()
+ self.window._calculator = mock_calc
+
+ returned = (0.321, 0.012)
+ getattr(mock_calc, calc_method).return_value = returned
+ mocker.patch.object(mock_calc, "get_qstar_with_error", return_value=(0.1, 0.01))
+
+ mock_update = mocker.patch.object(self.window, "update_model_from_thread")
+ mock_enable = mocker.patch.object(self.window, "enable_calculation")
+
+ getattr(self, setup_name)()
+
+ self.window.calculate_thread(extrapolation=None)
+
+ getattr(mock_calc, calc_method).assert_called_once()
+ getattr(mock_calc, other_method).assert_not_called()
+
+ mock_update.assert_any_call(expect_widget, returned[0])
+ err_widget = expect_widget + 1
+ mock_update.assert_any_call(err_widget, returned[1])
+ mock_enable.assert_called()
+
+ @pytest.mark.parametrize(
+ "setup_name,calc_method,other_method",
+ [
+ ("setup_contrast", "get_volume_fraction_with_error", "get_contrast_with_error"),
+ ("setup_volume_fraction", "get_contrast_with_error", "get_volume_fraction_with_error"),
+ ],
+ ids=["volume_fraction_exception", "contrast_exception"],
+ )
+ def test_calculate_thread_exceptions(self, mocker, setup_name, calc_method, other_method):
+ """Test that calculate_thread handles exceptions from the calculator and logs warnings."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_logger = mocker.patch.object(Invariant.InvariantPerspective, "logger")
+
+ mock_calc = mocker.MagicMock()
+ self.window._calculator = mock_calc
+
+ getattr(mock_calc, calc_method).side_effect = ValueError("calculation error")
+ mocker.patch.object(mock_calc, "get_qstar_with_error", return_value=(0.1, 0.01))
+
+ getattr(self, setup_name)()
+
+ self.window.calculate_thread(extrapolation=None)
+
+ getattr(mock_calc, calc_method).assert_called_once()
+ getattr(mock_calc, other_method).assert_not_called()
+
+ mock_logger.warning.assert_called_once()
+ logged_message = mock_logger.warning.call_args[0][0]
+ assert "Calculation failed:" in logged_message
+
+ @pytest.mark.parametrize(
+ "setup_name,calc_method",
+ [
+ ("setup_contrast", "get_volume_fraction_with_error"),
+ ("setup_volume_fraction", "get_contrast_with_error"),
+ ],
+ ids=["contrast_mode", "volume_fraction_mode"],
+ )
+ def test_calculate_thread_with_porod(self, setup_name, calc_method, mocker):
+ """
+ Test that calculate_thread correctly updates the model with specific surface
+ and q* when Porod constant is provided.
+ """
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_calc = mocker.MagicMock()
+ self.window._calculator = mock_calc
+
+ mocker.patch.object(mock_calc, calc_method, return_value=(0.321, 0.012))
+
+ returned = (0.1, 0.01)
+ mocker.patch.object(mock_calc, "get_surface_with_error", return_value=returned)
+ mocker.patch.object(mock_calc, "get_qstar_with_error", return_value=(0.1, 0.01))
+
+ mock_update = mocker.patch.object(self.window, "update_model_from_thread")
+ mock_enable = mocker.patch.object(self.window, "enable_calculation")
+
+ getattr(self, setup_name)()
+ self.update_and_emit_line_edits(self.window.txtPorodCst, "1e-04")
+ self.window.calculate_thread(extrapolation=None)
+
+ mock_update.assert_any_call(WIDGETS.W_SPECIFIC_SURFACE, returned[0])
+ err_widget = WIDGETS.W_SPECIFIC_SURFACE + 1
+ mock_update.assert_any_call(err_widget, returned[1])
+ mock_enable.assert_called()
+
+ @pytest.mark.parametrize(
+ "setup_name,calc_method",
+ [
+ ("setup_contrast", "get_volume_fraction_with_error"),
+ ("setup_volume_fraction", "get_contrast_with_error"),
+ ],
+ ids=["contrast_mode", "volume_fraction_mode"],
+ )
+ def test_calculate_thread_with_porod_exception(self, setup_name, calc_method, mocker):
+ """Test that calculate_thread handles exceptions in Porod constant calculation and logs a warning."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_logger = mocker.patch.object(Invariant.InvariantPerspective, "logger")
+
+ mock_calc = mocker.MagicMock()
+ self.window._calculator = mock_calc
+
+ mocker.patch.object(mock_calc, calc_method, return_value=(0.321, 0.012))
+
+ mocker.patch.object(mock_calc, "get_surface_with_error", side_effect=ValueError("surface calculation error"))
+ mocker.patch.object(mock_calc, "get_qstar_with_error", return_value=(0.1, 0.01))
+
+ getattr(self, setup_name)()
+ self.update_and_emit_line_edits(self.window.txtPorodCst, "1e-04")
+ self.window.calculate_thread(extrapolation=None)
+
+ mock_logger.warning.assert_called_once()
+ logged_message = mock_logger.warning.call_args[0][0]
+ assert "Calculation failed: Specific surface calculation failed:" in logged_message
+
+ def set_extra_low(self, calc, mocker):
+ extra_low = Data1D(x=[0.001, 0.002, 0.003], y=[0.0, 0.0, 0.0], dy=[0.0, 0.0, 0.0])
+ extra_low.name = "low_extra"
+ extra_low.filename = "low_extra.txt"
+ calc.get_extra_data_low = mocker.MagicMock(return_value=extra_low)
+ return extra_low
+
+ def set_extra_high(self, calc, mocker):
+ extra_high = Data1D(x=[0.1, 0.2, 0.3], y=[0.0, 0.0, 0.0], dy=[0.0, 0.0, 0.0])
+ extra_high.name = "high_extra"
+ extra_high.filename = "high_extra.txt"
+ calc.get_extra_data_high = mocker.MagicMock(return_value=extra_high)
+ return extra_high
+
+ @pytest.mark.parametrize(
+ "extrapolation, plots",
+ [
+ ("low", ["low_extrapolation_plot"]),
+ ("high", ["high_extrapolation_plot"]),
+ ("both", ["low_extrapolation_plot", "high_extrapolation_plot"]),
+ ],
+ ids=["low_extrapolation", "high_extrapolation", "both_extrapolations"],
+ )
+ def test_calculate_thread_with_extrapolation_success(self, extrapolation, plots, mocker):
+ """Test that calculate_thread handles a successful low extrapolation and updates the model accordingly."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_compute_low = mocker.patch.object(self.window, "compute_low")
+ mock_compute_high = mocker.patch.object(self.window, "compute_high")
+ mocker.patch.object(self.window, "update_model_from_thread")
+
+ mock_calc = mocker.MagicMock()
+ self.window._calculator = mock_calc
+
+ npts = 10
+ if extrapolation == "low":
+ mock_compute_low.return_value = (0.1, 0.01, True)
+ mock_compute_high.return_value = (0.0, 0.0, False)
+ self.window._low_points = npts
+ self.set_extra_low(mock_calc, mocker)
+ get_methods = ["get_extra_data_low"]
+ expected_titles = [f"Low-Q extrapolation [{self.window._data.name}]"]
+ elif extrapolation == "high":
+ mock_compute_low.return_value = (0.0, 0.0, False)
+ mock_compute_high.return_value = (0.2, 0.02, True)
+ self.window._high_points = npts
+ self.set_extra_high(mock_calc, mocker)
+ get_methods = ["get_extra_data_high"]
+ expected_titles = [f"High-Q extrapolation [{self.window._data.name}]"]
+ else:
+ mock_compute_low.return_value = (0.1, 0.01, True)
+ mock_compute_high.return_value = (0.2, 0.02, True)
+ self.window._low_points = npts
+ self.window._high_points = npts
+ self.set_extra_low(mock_calc, mocker)
+ self.set_extra_high(mock_calc, mocker)
+ get_methods = ["get_extra_data_low", "get_extra_data_high"]
+ expected_titles = [
+ f"Low-Q extrapolation [{self.window._data.name}]",
+ f"High-Q extrapolation [{self.window._data.name}]",
+ ]
+
+ self.setup_contrast()
+
+ mocker.patch.object(mock_calc, "get_qstar_with_error", return_value=(0.5, 0.05))
+ mocker.patch.object(mock_calc, "get_volume_fraction_with_error", return_value=(0.321, 0.012))
+ mocker.patch.object(mock_calc, "get_extrapolation_power", return_value=4.0)
+
+ self.window.calculate_thread(extrapolation=extrapolation)
+
+ for get_method in get_methods:
+ getattr(self.window._calculator, get_method).assert_called_once()
+
+ if extrapolation == "both":
+ assert self.window._calculator.get_extrapolation_power.call_count == 2
+ else:
+ self.window._calculator.get_extrapolation_power.assert_called_once_with(range=extrapolation)
+
+ for i, plot in enumerate(plots):
+ plot = getattr(self.window, plot)
+ assert plot is not None
+
+ assert getattr(plot, "name", None) == expected_titles[i]
+ assert getattr(plot, "title", None) == expected_titles[i]
+ assert getattr(plot, "symbol", None) == "Line"
+ assert getattr(plot, "has_errors", None) is False
+
+ def test_plot_result(self, mocker):
+ """Test plot_result updates model item and emits plot request when extrapolation plots exist."""
+
+ mock_close = mocker.patch.object(self.window._manager.filesWidget, "closePlotsForItem")
+ mock_update_model_item_with_plot = mocker.patch.object(
+ Invariant.InvariantPerspective.GuiUtils, "updateModelItemWithPlot"
+ )
+ emitted = []
+ self.window.communicate.plotRequestedSignal.connect(lambda plots: emitted.append(plots))
+ mock_details = mocker.patch.object(self.window, "update_details_widget")
+ mock_progress = mocker.patch.object(self.window, "update_progress_bars")
+
+ mock_calc = mocker.MagicMock()
+ self.window._calculator = mock_calc
+ extra_high = self.set_extra_high(mock_calc, mocker)
+ extra_low = self.set_extra_low(mock_calc, mocker)
+
+ self.window.high_extrapolation_plot = self.window._manager.createGuiData(extra_high)
+ title = "High-Q extrapolation"
+ self.window.high_extrapolation_plot.name = title
+ self.window.high_extrapolation_plot.title = title
+
+ self.window.low_extrapolation_plot = self.window._manager.createGuiData(extra_low)
+ title = "Low-Q extrapolation"
+ self.window.low_extrapolation_plot.name = title
+ self.window.low_extrapolation_plot.title = title
+ self.window.extrapolation_made = False
+
+ mock_model = mocker.Mock()
+ mock_model.name = "test_model"
+
+ self.window.plot_result(mock_model)
+
+ mock_close.assert_called_once_with(self.window._model_item)
+
+ assert mock_update_model_item_with_plot.call_count == 2
+
+ assert len(emitted) == 1
+ emitted_arg = emitted[0]
+ assert isinstance(emitted_arg, list)
+ assert emitted_arg[0] is self.window._model_item
+
+ assert getattr(self.window.high_extrapolation_plot, "symbol", None) == "Line"
+ assert getattr(self.window.low_extrapolation_plot, "symbol", None) == "Line"
+
+ mock_details.assert_called_once()
+ mock_progress.assert_called_once()
+
+
+@pytest.mark.parametrize("window_class", ["real_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantCalculateHelpers(UIHelpersMixin):
+ def test_on_calculate_emits_slot(self, mocker):
+ """Ensure clicking the button triggers the connected slot."""
+ mock_calculate = mocker.patch.object(self.window, "calculate_invariant")
+ self.window.cmdCalculate.setEnabled(True)
+
+ QtTest.QTest.mouseClick(self.window.cmdCalculate, Qt.LeftButton)
+ QApplication.processEvents()
+
+ mock_calculate.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "low_q_enabled, high_q_enabled, expected_extrapolation",
+ [
+ (True, True, "both"),
+ (True, False, "low"),
+ (False, True, "high"),
+ (False, False, None),
+ ],
+ ids=["both", "low_only", "high_only", "none"],
+ )
+ def test_calculate_invariant_starts_thread_and_attaches_callbacks(
+ self, mocker, low_q_enabled, high_q_enabled, expected_extrapolation
+ ):
+ """Test that calculate_invariant starts the calculation thread with correct extrapolation and attaches callbacks."""
+
+ self.window._low_extrapolate = low_q_enabled
+ self.window._high_extrapolate = high_q_enabled
+
+ mock_deferred = mocker.MagicMock()
+ mock_defer = mocker.patch.object(threads, "deferToThread", return_value=mock_deferred)
+ mock_calc_thread = mocker.patch.object(self.window, "calculate_thread")
+ mock_enable = mocker.patch.object(self.window, "enable_calculation")
+
+ self.window.calculate_invariant()
+
+ mock_enable.assert_called_once_with(enabled=False, display="Calculating...")
+ mock_defer.assert_called_once_with(self.window.calculate_thread, expected_extrapolation)
+
+ # calculate_thread should not be called synchronously but deferred to the thread
+ mock_calc_thread.assert_not_called()
+
+ mock_deferred.addCallback.assert_called()
+ callback = mock_deferred.addCallback.call_args[0][0]
+ mock_deferred_plot = mocker.patch.object(self.window, "deferredPlot")
+ mock_model = mocker.Mock()
+ callback(mock_model)
+ mock_deferred_plot.assert_called_once_with(mock_model, expected_extrapolation)
+
+ mock_deferred.addErrback.assert_called_once_with(self.window.on_calculation_failed)
+
+ def test_on_calculation_failed(self, mocker):
+ """Test that the on_calculation_failed method logs the error and checks if the button can be reenabled."""
+ mock_check = mocker.patch.object(self.window, "check_status")
+ mock_logger = mocker.patch.object(Invariant.InvariantPerspective, "logger")
+
+ failure = Failure(Exception("some error"))
+ self.window.on_calculation_failed(failure)
+
+ mock_logger.error.assert_called_once()
+
+ # Check that the logged message contains the expected text
+ logged_message = mock_logger.error.call_args[0][0]
+ assert "calculation failed:" in logged_message
+ assert "some error" in logged_message
+
+ mock_check.assert_called_once_with()
+
+ @pytest.mark.parametrize("extrapolation", ["low", "high"], ids=["low", "high"])
+ def test_deferredPlot(self, mocker, extrapolation):
+ """Test that the deferredPlot method updates the plot and checks if the button can be reenabled."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_plot = mocker.patch.object(self.window, "plot_result")
+
+ mock_model = mocker.Mock()
+
+ if extrapolation is None:
+ self.window.extrapolation_made = True
+
+ self.window.deferredPlot(mock_model, extrapolation=extrapolation)
+
+ mock_reactor.callFromThread.assert_called_once()
+ mock_plot.assert_called_once_with(mock_model)
+
+ def test_deferredPlot_recreates_plot_when_no_extrapolation(self, mocker):
+ """Test that deferredPlot creates a new plot when extrapolation is None and extrapolation_made is True."""
+
+ # Patch reactor and make scheduling synchronous for assertions
+ mock_reactor = mocker.patch.object(Invariant.InvariantPerspective, "reactor")
+ mock_reactor.callFromThread.side_effect = lambda fn, *a, **k: fn(*a, **k)
+
+ mock_plot = mocker.patch.object(self.window, "plot_result")
+ mock_check = mocker.patch.object(self.window, "check_status")
+ mock_newplot = mocker.patch.object(self.window._manager.filesWidget, "newPlot")
+
+ mock_model = mocker.Mock()
+
+ self.window.extrapolation_made = True
+
+ self.window.deferredPlot(mock_model, extrapolation=None)
+
+ assert mock_reactor.callFromThread.call_count == 2
+ mock_plot.assert_called_once_with(mock_model)
+ mock_check.assert_called_once()
+ mock_newplot.assert_called_once()
+ assert self.window.extrapolation_made is False
+
+ @pytest.mark.parametrize(
+ "extrapolate_checkbox, extrapolate_param, compute_func, expected",
+ [
+ ("chkLowQ_ex", "_low_extrapolate", "compute_low", (0.0, 0.0, False)),
+ ("chkHighQ_ex", "_high_extrapolate", "compute_high", (0.0, 0.0, False)),
+ ],
+ )
+ def test_no_extrapolation_early_return(self, extrapolate_checkbox, extrapolate_param, compute_func, expected):
+ """Test that compute_low and compute_high return early with expected values when extrapolation is disabled."""
+ checkbox = getattr(self.window, extrapolate_checkbox)
+ func = getattr(self.window, compute_func)
+
+ checkbox.setChecked(False)
+
+ assert not getattr(self.window, extrapolate_param)
+
+ assert func() == expected
+
+ @pytest.mark.parametrize(
+ "low_guinier, low_fit, low_fix, expected_function",
+ [
+ (True, False, False, "guinier"),
+ (False, True, False, "power_law"),
+ (False, False, True, "power_law"),
+ ],
+ ids=["guinier", "fit", "fix"],
+ )
+ def test_compute_low(self, mocker, low_guinier, low_fit, low_fix, expected_function):
+ """Test that compute_low returns expected values when low extrapolation is enabled."""
+ self.window._low_extrapolate = True
+ self.window._low_guinier = low_guinier
+ self.window._low_fit = low_fit
+ self.window._low_fix = low_fix
+
+ if low_fix:
+ power = 4.0
+ self.window.txtLowQPower_ex.setText(str(power))
+
+ # mock_setter = mocker.patch.object(self.window, "set_low_q_extrapolation_upper_limit")
+ mock_calculator = mocker.patch.object(self.window, "_calculator", autospec=True)
+ mock_calculator.get_qstar_low.return_value = (1.0, 0.1)
+
+ qstar, qstar_err, success = self.window.compute_low()
+
+ # self.window.set_low_q_extrapolation_upper_limit.assert_called_once()
+
+ mock_calculator.set_extrapolation.assert_called_once_with(
+ range="low", npts=self.window._low_points, function=expected_function, power=power if low_fix else None
+ )
+
+ assert (qstar, qstar_err, success) == (1.0, 0.1, True)
+
+ def test_compute_low_exception_handling(self, mocker):
+ """Test that compute_low handles exceptions and returns expected values."""
+ self.window._low_extrapolate = True
+ self.window._low_guinier = True
+
+ mock_calculator = mocker.patch.object(self.window, "_calculator", autospec=True)
+ mock_calculator.get_qstar_low.side_effect = Exception("some error")
+ mock_logger = mocker.patch.object(Invariant.InvariantPerspective, "logger")
+
+ qstar, qstar_err, success = self.window.compute_low()
+
+ assert (qstar, qstar_err, success) == ("ERROR", "ERROR", False)
+
+ mock_logger.warning.assert_called_once()
+ assert "Low-q calculation failed: some error" in mock_logger.warning.call_args[0][0]
+
+ @pytest.mark.parametrize(
+ "high_fit, high_fix",
+ [
+ (True, False),
+ (False, True),
+ ],
+ ids=["fit", "fix"],
+ )
+ def test_compute_high(self, mocker, high_fit, high_fix):
+ """Test that compute_high returns expected values when high extrapolation is enabled."""
+ self.window._high_extrapolate = True
+ self.window._high_fit = high_fit
+ self.window._high_fix = high_fix
+
+ if high_fix:
+ power = 4.0
+ self.window.txtHighQPower_ex.setText(str(power))
+
+ # mock_setter = mocker.patch.object(self.window, "set_high_q_extrapolation_upper_limit")
+ mock_calculator = mocker.patch.object(self.window, "_calculator", autospec=True)
+ mock_calculator.get_qstar_high.return_value = (1.0, 0.1)
+
+ qstar, qstar_err, success = self.window.compute_high()
+
+ mock_calculator.set_extrapolation.assert_called_once_with(
+ range="high", npts=self.window._high_points, function="power_law", power=power if high_fix else None
+ )
+ assert (qstar, qstar_err, success) == (1.0, 0.1, True)
+
+ def test_compute_high_exception_handling(self, mocker):
+ """Test that compute_high handles exceptions and returns expected values."""
+ self.window._high_extrapolate = True
+ self.window._high_guinier = True
+
+ mock_calculator = mocker.patch.object(self.window, "_calculator", autospec=True)
+ mock_calculator.get_qstar_high.side_effect = Exception("some error")
+ mock_logger = mocker.patch.object(Invariant.InvariantPerspective, "logger")
+
+ qstar, qstar_err, success = self.window.compute_high()
+
+ assert (qstar, qstar_err, success) == ("ERROR", "ERROR", False)
+
+ mock_logger.warning.assert_called_once()
+ assert "High-q calculation failed: some error" in mock_logger.warning.call_args[0][0]
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py
new file mode 100644
index 0000000000..e38ed45457
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py
@@ -0,0 +1,586 @@
+"""Tests for the Invariant perspective initialized state."""
+
+import pytest
+from PySide6 import QtWidgets
+
+from sas.qtgui.Perspectives.Invariant.InvariantUtils import WIDGETS
+from sas.qtgui.Utilities import GuiUtils
+
+
+@pytest.mark.usefixtures("window_class")
+class TestInvariantDefaults:
+ def test_window_identity(self):
+ """Test the InvariantWindow identity."""
+ assert isinstance(self.window, QtWidgets.QDialog)
+ assert self.window.name == "Invariant"
+ assert self.window.windowTitle() == "Invariant Perspective"
+ assert self.window.title == self.window.windowTitle()
+
+ NUMERIC_CASES: list[tuple[str, float]] = [
+ ("txtTotalQMin", 0.0),
+ ("txtTotalQMax", 0.0),
+ ("txtBackgd", 0.0),
+ ("txtScale", 1.0),
+ ("txtHighQPower_ex", 4.0),
+ ("txtLowQPower_ex", 4.0),
+ ]
+
+ @pytest.mark.parametrize("field_name,expected", NUMERIC_CASES, ids=[c[0] for c in NUMERIC_CASES])
+ def test_numeric_line_edit_defaults(self, field_name, expected):
+ """Test that certain QLineEdits have expected numeric default values."""
+ widget = self.window.findChild(QtWidgets.QLineEdit, field_name)
+ assert widget is not None, f"UI widget not found: {field_name}"
+ assert float(widget.text()) == pytest.approx(expected), f"Expected default text '{expected}' in {field_name}"
+
+ NULL_CASES: list[str] = [
+ "txtName",
+ "txtPorodCst",
+ "txtPorodCstErr",
+ "txtContrast",
+ "txtContrastErr",
+ "txtVolFrac1",
+ "txtVolFrac1Err",
+ "txtVolFract",
+ "txtVolFractErr",
+ "txtContrastOut",
+ "txtContrastOutErr",
+ "txtSpecSurf",
+ "txtSpecSurfErr",
+ "txtInvariantTot",
+ "txtInvariantTotErr",
+ "txtFileName",
+ "txtGuinierEnd_ex",
+ "txtPorodStart_ex",
+ "txtPorodEnd_ex",
+ ]
+
+ @pytest.mark.parametrize("field_name", NULL_CASES, ids=lambda name: name)
+ def test_null_line_edit_defaults(self, field_name):
+ """Test that certain QLineEdits are empty by default."""
+ widget = self.window.findChild(QtWidgets.QLineEdit, field_name)
+ assert widget is not None, f"UI widget not found: {field_name}"
+ assert widget.text() == ""
+
+ def test_tabs_exist(self):
+ """Test that all expected tabs exist, and are labeled and ordered correctly."""
+ tab = self.window.findChild(QtWidgets.QTabWidget, "tabWidget")
+ assert tab is not None, "Tab widget not found"
+ assert tab.count() == 2, "Expected 2 tabs in the Invariant window"
+ assert tab.currentIndex() == 0, "Expected the first tab to be selected by default"
+ expected_tabs = ["Invariant", "Extrapolation"]
+ assert tab.tabText(0) == expected_tabs[0], f"First tab should be '{expected_tabs[0]}'"
+ assert tab.tabText(1) == expected_tabs[1], f"Second tab should be '{expected_tabs[1]}'"
+
+ TOOLTIP_CASES: list[tuple[str, str]] = [
+ ("cmdStatus", "Get more details of computation such as fraction from extrapolation"),
+ ("cmdCalculate", "Compute invariant"),
+ ("txtInvariantTot", "Total invariant [Q*], including extrapolated regions."),
+ ("txtHighQPower_ex", "Exponent to apply to the Power_law function."),
+ ("txtLowQPower_ex", "Exponent to apply to the Power_law function."),
+ ("chkHighQ_ex", "Check to extrapolate data at high-Q"),
+ ("chkLowQ_ex", "Check to extrapolate data at low-Q"),
+ ("txtGuinierEnd_ex", "Q value where low-Q extrapolation ends"),
+ ("txtPorodStart_ex", "Q value where high-Q extrapolation starts"),
+ ("txtPorodEnd_ex", "Q value where high-Q extrapolation ends"),
+ ]
+
+ @pytest.mark.parametrize("widget_name,expected_tooltip", TOOLTIP_CASES, ids=[c[0] for c in TOOLTIP_CASES])
+ def test_tooltips_present(self, widget_name, expected_tooltip):
+ """Test that tooltips are set correctly"""
+ widget = self.window.findChild(QtWidgets.QWidget, widget_name)
+ assert widget is not None, f"Widget {widget_name} not found"
+ assert widget.toolTip() == expected_tooltip
+
+ VALIDATOR_CASES: list[str] = [
+ "txtBackgd",
+ "txtScale",
+ "txtPorodCst",
+ "txtPorodCstErr",
+ "txtContrast",
+ "txtContrastErr",
+ "txtVolFrac1",
+ "txtVolFrac1Err",
+ "txtGuinierEnd_ex",
+ "txtPorodStart_ex",
+ "txtPorodEnd_ex",
+ "txtHighQPower_ex",
+ "txtLowQPower_ex",
+ ]
+
+ @pytest.mark.parametrize("field_name", VALIDATOR_CASES, ids=lambda name: name)
+ def test_validators(self, field_name):
+ """Test that editable QLineEdits have double validators."""
+ widget = self.window.findChild(QtWidgets.QLineEdit, field_name)
+ assert widget is not None, f"UI widget not found: {field_name}"
+ validator = widget.validator()
+ assert validator is not None, f"Expected {field_name} to have a validator"
+ assert isinstance(validator, GuiUtils.DoubleValidator), f"Expected {field_name} to have DoubleValidator"
+
+ @pytest.mark.parametrize(
+ "widget_name",
+ [
+ "txtName",
+ "txtTotalQMin",
+ "txtTotalQMax",
+ "txtContrastOut",
+ "txtContrastOutErr",
+ "txtSpecSurf",
+ "txtSpecSurfErr",
+ "txtInvariantTot",
+ "txtInvariantTotErr",
+ "txtFileName",
+ ],
+ ids=lambda name: name,
+ )
+ def test_readonly_widgets(self, widget_name):
+ """Test that widgets are read-only by default."""
+ widget = self.window.findChild(QtWidgets.QLineEdit, widget_name)
+ assert widget is not None, f"UI widget not found: {widget_name}"
+ assert widget.isReadOnly()
+
+ @pytest.mark.parametrize(
+ "widget_name",
+ [
+ "txtBackgd",
+ "txtScale",
+ "txtPorodCst",
+ "txtPorodCstErr",
+ "txtContrast",
+ "txtContrastErr",
+ "txtVolFrac1",
+ "txtVolFrac1Err",
+ "txtGuinierEnd_ex",
+ "txtPorodStart_ex",
+ "txtPorodEnd_ex",
+ "txtHighQPower_ex",
+ "txtLowQPower_ex",
+ ],
+ ids=lambda name: name,
+ )
+ def test_editable_widgets(self, widget_name):
+ """Test that widgets are editable by default."""
+ widget = self.window.findChild(QtWidgets.QLineEdit, widget_name)
+ assert widget is not None, f"UI widget not found: {widget_name}"
+ assert widget.isReadOnly() is False
+
+ @pytest.mark.parametrize("widget_name", ["cmdStatus", "cmdCalculate"], ids=lambda name: name)
+ def test_disabled_widgets(self, widget_name):
+ """Test that widgets are disabled by default."""
+ widget = self.window.findChild(QtWidgets.QPushButton, widget_name)
+ assert widget is not None, f"UI widget not found: {widget_name}"
+ assert widget.isEnabled() is False
+
+ @pytest.mark.parametrize(
+ "rb_group, rb_list",
+ [
+ ("VolFracContrastGroup", ["rbVolFrac", "rbContrast"]),
+ ("LowQGroup", ["rbLowQGuinier_ex", "rbLowQPower_ex"]),
+ ("LowQPowerGroup", ["rbLowQFix_ex", "rbLowQFit_ex"]),
+ ("HighQGroup", ["rbHighQFix_ex", "rbHighQFit_ex"]),
+ ],
+ ids=lambda name: name[0],
+ )
+ def test_rb_groups(self, rb_group, rb_list):
+ """Test that radio buttons are grouped correctly."""
+ group = getattr(self.window, rb_group)
+ assert group is not None, f"Radio button group not found: {rb_group}"
+ for rb in rb_list:
+ button = getattr(self.window, rb)
+ assert button is not None, f"Radio button not found: {rb}"
+ assert isinstance(button, QtWidgets.QRadioButton), f"Expected {rb} to be a QRadioButton"
+ assert button.group() == group, f"Expected {rb} to be in group {rb_group}"
+
+ @pytest.mark.parametrize(
+ "progress_bar", ["progressBarLowQ", "progressBarData", "progressBarHighQ"], ids=lambda name: name
+ )
+ def test_progress_bar_initial(self, progress_bar):
+ """Test that the progress bar is at 0% initially."""
+ bar = getattr(self.window, progress_bar)
+ assert bar.value() == 0, "Progress bar should be at 0% initially"
+
+ def test_default_calculation_state(self):
+ """Test that contrast calculation is allowed by default."""
+ assert self.window.rbContrast.isChecked() is True
+
+ def test_default_extrapolation_state(self):
+ """Test that extrapolation checkboxes are unchecked by default."""
+ assert self.window.chkLowQ_ex.isChecked() is False
+ assert self.window.chkHighQ_ex.isChecked() is False
+
+ MODEL_LINE_EDITS = [
+ (WIDGETS.W_BACKGROUND, "_background"),
+ (WIDGETS.W_SCALE, "_scale"),
+ (WIDGETS.W_POROD_CST, "_porod"),
+ (WIDGETS.W_POROD_CST_ERR, "_porod_err"),
+ (WIDGETS.W_CONTRAST, "_contrast"),
+ (WIDGETS.W_CONTRAST_ERR, "_contrast_err"),
+ (WIDGETS.W_VOLFRAC1, "_volfrac1"),
+ (WIDGETS.W_VOLFRAC1_ERR, "_volfrac1_err"),
+ (WIDGETS.W_HIGHQ_POWER_VALUE_EX, "_high_power_value"),
+ (WIDGETS.W_LOWQ_POWER_VALUE_EX, "_low_power_value"),
+ ]
+
+ @pytest.mark.parametrize("model_item, variable_name", MODEL_LINE_EDITS, ids=[p[1] for p in MODEL_LINE_EDITS])
+ def test_update_from_model_line_edits(self, model_item: int, variable_name: str):
+ """Update the globals based on the data in the model line edits."""
+ value = "2.0"
+ self.window.model.item(model_item).setText(value)
+ self.window.update_from_model()
+ assert getattr(self.window, variable_name) == float(value)
+
+ MODEL_RB_AND_CHKS = [
+ (WIDGETS.W_ENABLE_LOWQ_EX, "_low_extrapolate"),
+ (WIDGETS.W_ENABLE_HIGHQ_EX, "_high_extrapolate"),
+ (WIDGETS.W_LOWQ_POWER_EX, "_low_power"),
+ ]
+
+ @pytest.mark.parametrize("model_item, variable_name", MODEL_RB_AND_CHKS, ids=[p[1] for p in MODEL_RB_AND_CHKS])
+ def test_update_from_model_rb_and_chks(self, model_item: int, variable_name: str):
+ """Update the globals based on the data in the model radio buttons and checkboxes."""
+ self.window.model.item(model_item).setText("true")
+ self.window.update_from_model()
+ assert getattr(self.window, variable_name)
+
+ GUI_LINE_EDITS = [
+ ("txtBackgd", WIDGETS.W_BACKGROUND),
+ ("txtScale", WIDGETS.W_SCALE),
+ ("txtPorodCst", WIDGETS.W_POROD_CST),
+ ("txtPorodCstErr", WIDGETS.W_POROD_CST_ERR),
+ ("txtContrast", WIDGETS.W_CONTRAST),
+ ("txtContrastErr", WIDGETS.W_CONTRAST_ERR),
+ ("txtVolFrac1", WIDGETS.W_VOLFRAC1),
+ ("txtVolFrac1Err", WIDGETS.W_VOLFRAC1_ERR),
+ ("txtLowQPower_ex", WIDGETS.W_LOWQ_POWER_VALUE_EX),
+ ("txtHighQPower_ex", WIDGETS.W_HIGHQ_POWER_VALUE_EX),
+ ]
+
+ @pytest.mark.parametrize("gui_widget, model_item", GUI_LINE_EDITS, ids=[p[0] for p in GUI_LINE_EDITS])
+ def test_updateFromGui(self, gui_widget: str, model_item: int):
+ """Update the globals based on the data in the GUI line edits."""
+ line_edit = self.window.findChild(QtWidgets.QLineEdit, gui_widget)
+ assert line_edit is not None, f"Line edit not found: {gui_widget}"
+
+ line_edit.setText("5.0")
+ line_edit.textEdited.emit("5.0")
+ QtWidgets.QApplication.processEvents()
+
+ assert self.window.model.item(model_item).text() == "5.0"
+
+ def test_extrapolation_parameters(self):
+ """Test that the extrapolation parameters return None for no data"""
+ assert self.window.extrapolation_parameters is None
+
+ def test_enableStatus(self, mocker):
+ """Test that the enable status is set correctly."""
+ mock_cmdStatus = mocker.patch.object(self.window, "cmdStatus")
+ self.window.enableStatus()
+ mock_cmdStatus.setEnabled.assert_called_once_with(True)
+
+ @pytest.mark.parametrize("closable", [True, False], ids=["True", "False"])
+ def test_setClosable(self, closable):
+ """Test that the closable status is set correctly."""
+ self.window.setClosable(closable)
+ assert self.window._allow_close == closable
+
+ def test_closeEvent(self, mocker):
+ """Test that the close event is handled correctly."""
+ self.window.setClosable(True)
+ self.window.closeEvent(mocker.Mock())
+ assert not self.window._allow_close
+
+ def test_closeEvent_allows_close_with_parent(self, mocker):
+ mock_parent = mocker.MagicMock()
+ mock_set_closable = mocker.patch.object(self.window, "setClosable")
+ mock_set_window_state = mocker.patch.object(self.window, "setWindowState")
+ mocker.patch.object(self.window, "parentWidget", return_value=mock_parent)
+
+ mock_event = mocker.MagicMock()
+
+ self.window._allow_close = True
+ self.window.closeEvent(mock_event)
+
+ mock_set_closable.assert_called_once_with(value=False)
+ mock_parent.close.assert_called_once()
+ mock_event.accept.assert_called_once()
+ mock_event.ignore.assert_not_called()
+ mock_set_window_state.assert_not_called()
+
+ def test_isSerializable(self):
+ """Test that isSerializable returns the expected boolean."""
+ assert self.window.isSerializable() is True
+
+ def test_serialize_state_returns_expected_dict(self, mocker):
+ """Test that the serializeState method returns the expected dictionary."""
+
+ # Set fields to known values (strings for QLineEdit)
+ self.window.txtVolFract.setText("0.123")
+ self.window.txtVolFractErr.setText("0.001")
+ self.window.txtContrastOut.setText("42.0")
+ self.window.txtContrastOutErr.setText("0.5")
+ self.window.txtSpecSurf.setText("3.14")
+ self.window.txtSpecSurfErr.setText("0.01")
+ self.window.txtInvariantTot.setText("10.0")
+ self.window.txtInvariantTotErr.setText("0.2")
+ self.window.txtBackgd.setText("0.0")
+ self.window.txtContrast.setText("1.0")
+ self.window.txtContrastErr.setText("0.05")
+ self.window.txtScale.setText("2.0")
+ self.window.txtPorodCst.setText("1.23")
+ self.window.txtVolFrac1.setText("0.5")
+ self.window.txtVolFrac1Err.setText("0.05")
+ self.window.txtTotalQMin.setText("0.01")
+ self.window.txtTotalQMax.setText("0.25")
+ self.window.txtGuinierEnd_ex.setText("0.02")
+ self.window.txtPorodStart_ex.setText("0.2")
+ self.window.txtPorodEnd_ex.setText("1.0")
+ self.window.txtLowQPower_ex.setText("4.0")
+ self.window.txtHighQPower_ex.setText("3.0")
+
+ # Set checkbox / radio states
+ self.window.rbContrast.setChecked(True)
+ self.window.rbVolFrac.setChecked(False)
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.chkHighQ_ex.setChecked(False)
+ self.window.rbLowQGuinier_ex.setChecked(True)
+ self.window.rbLowQPower_ex.setChecked(False)
+ self.window.rbLowQFit_ex.setChecked(False)
+ self.window.rbLowQFix_ex.setChecked(True)
+ self.window.rbHighQFit_ex.setChecked(False)
+ self.window.rbHighQFix_ex.setChecked(True)
+
+ mock_update_from_model = mocker.patch.object(self.window, "update_from_model", autospec=True)
+
+ state = self.window.serializeState()
+
+ assert mock_update_from_model.called
+ mock_update_from_model.assert_called_once()
+
+ expected = {
+ "vol_fraction": "0.123",
+ "vol_fraction_err": "0.001",
+ "contrast_out": "42.0",
+ "contrast_out_err": "0.5",
+ "specific_surface": "3.14",
+ "specific_surface_err": "0.01",
+ "invariant_total": "10.0",
+ "invariant_total_err": "0.2",
+ "background": "0.0",
+ "contrast": "1.0",
+ "contrast_err": "0.05",
+ "scale": "2.0",
+ "porod": "1.23",
+ "volfrac1": "0.5",
+ "volfrac1_err": "0.05",
+ "enable_contrast": True,
+ "enable_volfrac": False,
+ "total_q_min": "0.01",
+ "total_q_max": "0.25",
+ "guinier_end_low_q_ex": "0.02",
+ "porod_start_high_q_ex": "0.2",
+ "porod_end_high_q_ex": "1.0",
+ "power_low_q_ex": "4.0",
+ "power_high_q_ex": "3.0",
+ "enable_low_q_ex": True,
+ "enable_high_q_ex": False,
+ "low_q_guinier_ex": True,
+ "low_q_power_ex": False,
+ "low_q_fit_ex": False,
+ "low_q_fix_ex": True,
+ "high_q_fit_ex": False,
+ "high_q_fix_ex": True,
+ }
+
+ assert state == expected
+
+ def test_allowBatch(self):
+ """Test that allowBatch returns the expected boolean."""
+ assert not self.window.allowBatch()
+
+ def test_allowSwap(self):
+ """Test that allowSwap returns the expected boolean."""
+ assert not self.window.allowSwap()
+
+ def test_reset(self, mocker):
+ """Test that reset calls removeData"""
+ mock_removeData = mocker.patch.object(self.window, "removeData")
+ self.window.reset()
+
+ mock_removeData.assert_called_once()
+
+ @pytest.mark.parametrize("visible", [True, False], ids=["Visible", "Not Visible"])
+ def test_update_details_widget(self, mocker, visible):
+ """Test that updateDetailsWidget calls the expected methods."""
+ mock_details_dialog = mocker.MagicMock()
+ mock_details_dialog.isVisible.return_value = visible
+
+ mock_on_status = mocker.patch.object(self.window, "onStatus")
+ self.window.detailsDialog = mock_details_dialog
+
+ self.window.update_details_widget()
+
+ if visible:
+ mock_on_status.assert_called_once()
+ else:
+ mock_on_status.assert_not_called()
+
+ def test_onStatus(self, mocker):
+ """Test that onStatus calls the expected methods."""
+ mock_details_dialog = mocker.MagicMock()
+ mock_model = mocker.MagicMock()
+
+ self.window.detailsDialog = mock_details_dialog
+ self.window.model = mock_model
+
+ mock_cmdStatus = mocker.patch.object(self.window, "cmdStatus")
+
+ self.window.onStatus()
+
+ mock_details_dialog.setModel.assert_called_once_with(mock_model)
+ mock_details_dialog.showDialog.assert_called_once()
+ mock_cmdStatus.setEnabled.assert_called_once_with(False)
+
+ def test_onHelp(self, mocker):
+ """Test that onHelp calls showHelp on the parent with the correct path."""
+ mock_show_help = mocker.patch.object(self.window.parent, "showHelp")
+ tree_location = "/user/qtgui/Perspectives/Invariant/invariant_help.html"
+
+ self.window.onHelp()
+
+ mock_show_help.assert_called_once_with(tree_location)
+
+
+@pytest.mark.usefixtures("window_class")
+class TestInvariantUIBehaviour:
+ def test_contrast_volfrac_group(self):
+ """Test that the contrast and volume fraction group works correctly."""
+ self.window.rbContrast.setChecked(True)
+ assert self.window.rbContrast.isChecked()
+ assert not self.window.rbVolFrac.isChecked()
+
+ self.window.rbVolFrac.setChecked(True)
+ assert not self.window.rbContrast.isChecked()
+ assert self.window.rbVolFrac.isChecked()
+
+ def test_contrast_active(self):
+ """Test that the contrast fields are enabled when contrast is selected."""
+ self.window.rbContrast.setChecked(True)
+ # contrast and contrast error should be enabled
+ assert self.window.txtContrast.isEnabled()
+ assert self.window.txtContrastErr.isEnabled()
+ # volume fraction and volume fraction error should be disabled
+ assert not self.window.txtVolFrac1.isEnabled()
+ assert not self.window.txtVolFrac1Err.isEnabled()
+ # volume fraction and volume fraction error output should be enabled
+ assert self.window.txtVolFract.isEnabled()
+ assert self.window.txtVolFractErr.isEnabled()
+ # contrast and contrast error output should be disabled
+ assert not self.window.txtContrastOut.isEnabled()
+ assert not self.window.txtContrastOutErr.isEnabled()
+
+ def test_volfrac_active(self):
+ """Test that the volume fraction fields are enabled when volume fraction is selected."""
+ self.window.rbVolFrac.setChecked(True)
+ # contrast and contrast error should be disabled
+ assert not self.window.txtContrast.isEnabled()
+ assert not self.window.txtContrastErr.isEnabled()
+ # volume fraction and volume fraction error should be enabled
+ assert self.window.txtVolFrac1.isEnabled()
+ assert self.window.txtVolFrac1Err.isEnabled()
+ # volume fraction and volume fraction error output should be disabled
+ assert not self.window.txtVolFract.isEnabled()
+ assert not self.window.txtVolFractErr.isEnabled()
+ # contrast and contrast error output should be enabled
+ assert self.window.txtContrastOut.isEnabled()
+ assert self.window.txtContrastOutErr.isEnabled()
+
+ def test_lowq_extrapolation(self):
+ """Test that the lowq extrapolation fields are enabled when lowq extrapolation is selected."""
+ self.window.chkLowQ_ex.setChecked(True)
+ assert self.window.chkLowQ_ex.isChecked()
+
+ # guinier and power law should be enabled
+ assert self.window.rbLowQGuinier_ex.isEnabled()
+ assert self.window.rbLowQPower_ex.isEnabled()
+
+ # Guinier selected by default
+ assert self.window.rbLowQGuinier_ex.isChecked()
+ assert not self.window.rbLowQPower_ex.isChecked()
+ assert not self.window.rbLowQFit_ex.isChecked()
+ assert not self.window.rbLowQFix_ex.isChecked()
+ assert not self.window.txtLowQPower_ex.isEnabled()
+
+ def test_lowq_power_law(self):
+ """Test that the power law fields are enabled when power law is selected."""
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.rbLowQPower_ex.setChecked(True)
+ assert self.window.rbLowQPower_ex.isChecked()
+
+ # Checking power law should uncheck guinier
+ assert not self.window.rbLowQGuinier_ex.isChecked()
+
+ # Fit and Fix should be enabled
+ assert self.window.rbLowQFit_ex.isEnabled()
+ assert self.window.rbLowQFix_ex.isEnabled()
+
+ # Fit checked by default
+ assert self.window.rbLowQFit_ex.isChecked()
+ assert not self.window.rbLowQFix_ex.isChecked()
+ assert not self.window.txtLowQPower_ex.isEnabled()
+
+ def test_lowq_power_law_fix(self):
+ """Test that the power law fields are enabled when power law is selected."""
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.rbLowQPower_ex.setChecked(True)
+ self.window.rbLowQFix_ex.setChecked(True)
+ assert self.window.rbLowQFix_ex.isChecked()
+ assert not self.window.rbLowQFit_ex.isChecked()
+
+ # fix selected: power text should be enabled
+ assert self.window.txtLowQPower_ex.isEnabled()
+
+ def test_lowq_power_law_fit(self):
+ """Test that the power law fields are enabled when power law is selected."""
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.rbLowQPower_ex.setChecked(True)
+ self.window.rbLowQFix_ex.setChecked(True) # First check fix
+ self.window.rbLowQFit_ex.setChecked(True) # Then check fit
+ assert self.window.rbLowQFit_ex.isChecked()
+ assert not self.window.rbLowQFix_ex.isChecked()
+
+ # fit selected: power text should be disabled
+ assert not self.window.txtLowQPower_ex.isEnabled()
+
+ def test_lowq_guinier(self):
+ """Test that the guinier fields are enabled when guinier is selected."""
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.rbLowQPower_ex.setChecked(True) # First check power
+ self.window.rbLowQGuinier_ex.setChecked(True) # Then check guinier
+ assert self.window.rbLowQGuinier_ex.isChecked()
+ assert not self.window.rbLowQPower_ex.isChecked()
+
+ # guinier selected: power law options should be disabled
+ assert not self.window.rbLowQFit_ex.isEnabled()
+ assert not self.window.rbLowQFix_ex.isEnabled()
+ assert not self.window.txtLowQPower_ex.isEnabled()
+
+ def test_highq_extrapolation(self):
+ """Test that the highq extrapolation fields are enabled when highq extrapolation is selected."""
+ self.window.chkHighQ_ex.setChecked(True)
+ assert self.window.chkHighQ_ex.isChecked()
+
+ # fit should be enabled by default
+ assert self.window.rbHighQFit_ex.isChecked()
+ assert not self.window.rbHighQFix_ex.isChecked()
+ assert not self.window.txtHighQPower_ex.isEnabled()
+
+ # fix selected: power text should be enabled
+ self.window.rbHighQFix_ex.setChecked(True)
+ assert self.window.rbHighQFix_ex.isChecked()
+ assert not self.window.rbHighQFit_ex.isChecked()
+ assert self.window.txtHighQPower_ex.isEnabled()
+
+ # switch back to fit
+ self.window.rbHighQFit_ex.setChecked(True)
+ assert self.window.rbHighQFit_ex.isChecked()
+ assert not self.window.rbHighQFix_ex.isChecked()
+ assert not self.window.txtHighQPower_ex.isEnabled()
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py
new file mode 100644
index 0000000000..0b16952d4e
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py
@@ -0,0 +1,214 @@
+"""Tests for the Invariant perspective with loaded data."""
+
+# from src.sas.qtgui.Utilities.BackgroundColor import BG_DEFAULT, BG_ERROR
+
+import pytest
+from PySide6 import QtGui
+
+from sas.qtgui.Plotting.PlotterData import Data1D
+
+# REMOVE WHEN BG_FIX PR IS MERGED
+# Default background color (transparent)
+BG_DEFAULT = ""
+# Error background color
+BG_ERROR = "background-color: rgb(244, 170, 164);"
+
+
+@pytest.mark.parametrize("window_class", ["small_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantWithData:
+ """Test the Invariant perspective behavior when data is loaded."""
+
+ def test_data_loading(self, small_data: Data1D):
+ """Test that data can be loaded into the perspective."""
+ assert self.window._data is not None, "Data not loaded."
+ assert self.window._data == small_data, "Data is not the same as loaded."
+
+ def test_data_name_displayed(self, small_data: Data1D):
+ """Test that the data name is displayed when data is loaded."""
+ assert self.window.txtName.text() == small_data.name, "Data name not displayed."
+
+ def test_q_range_displayed(self, small_data: Data1D):
+ """Test that Q range is displayed when data is loaded."""
+ assert float(self.window.txtTotalQMin.text()) == pytest.approx(min(small_data.x))
+ assert float(self.window.txtTotalQMax.text()) == pytest.approx(max(small_data.x))
+
+ def test_calculate_button_disabled_on_load(self):
+ """Test that calculate button starts disabled even with data loaded."""
+ assert not self.window.cmdCalculate.isEnabled()
+ assert self.window.cmdCalculate.text() == "Calculate (Enter volume fraction or contrast)"
+
+ def test_removeData(self):
+ """Test that data can be removed from the perspective."""
+ assert self.window._data is not None
+
+ # Pass the QStandardItem that is currently loaded
+ self.window.removeData([self.window._model_item])
+
+ assert self.window._data is None
+ assert self.window.txtName.text() == ""
+
+ def test_removeData_early_return(self, mocker):
+ """Test that removeData returns early when no data is loaded."""
+ # self.window._data = None
+ mock_method = mocker.patch.object(self.window, "updateFromParameters")
+
+ self.window.removeData(None)
+
+ mock_method.assert_not_called()
+
+ def test_load_same_data_twice(self, small_data: Data1D, dummy_manager, mocker):
+ """Test that loading the same data pops up a warning message."""
+ assert self.window._data == small_data
+
+ # Mock warning message box
+ mock_warning = mocker.patch("PySide6.QtWidgets.QMessageBox.warning")
+
+ # Load the same data again
+ data_item = dummy_manager.createGuiData(small_data)
+ self.window.setData([data_item])
+
+ # Assert that the warning message is shown
+ mock_warning.assert_called_once()
+ args, _ = mock_warning.call_args
+ assert args[1] == "Invariant Panel"
+ assert args[2] == "This file is already loaded in Invariant panel."
+
+ assert self.window._data == small_data
+
+ def test_load_incorrect_data_not_list(self, dummy_manager, mocker):
+ """Test that loading incorrect data (not a list) raises an error."""
+ # Create a mock item that has .text() method to pass the preliminary check
+ mock_item = mocker.Mock()
+ mock_item.text.return_value = "new_data"
+
+ # Attempt to load data of incorrect type (not a list)
+ incorrect_data_type = (mock_item,)
+
+ assert not isinstance(incorrect_data_type, list)
+
+ with pytest.raises(AttributeError, match="Incorrect type passed to the Invariant Perspective."):
+ self.window.setData(incorrect_data_type)
+
+ def test_load_incorrect_data_not_qstandarditem(self, dummy_manager, mocker):
+ """Test that loading incorrect data (not a QStandardItem) raises an error."""
+ mock_non_standard_item = mocker.Mock()
+ mock_non_standard_item.text.return_value = "new_data"
+ # mocker.Mock() is NOT an instance of QStandardItem
+
+ incorrect_list = [mock_non_standard_item]
+
+ assert not isinstance(incorrect_list[0], QtGui.QStandardItem)
+
+ with pytest.raises(AttributeError, match="Incorrect type passed to the Invariant Perspective."):
+ self.window.setData(incorrect_list)
+
+ def test_extrapolation_slider_loaded(self, small_data: Data1D):
+ """Test that extrapolation slider values are loaded when data is loaded."""
+ assert self.window.txtFileName.text() == small_data.name
+
+ # Check that the extrapolation slider values are loaded
+ assert not self.window.txtGuinierEnd_ex.text() == ""
+ assert not self.window.txtPorodStart_ex.text() == ""
+ assert not self.window.txtPorodEnd_ex.text() == ""
+
+
+@pytest.mark.parametrize("window_class", ["real_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantCalculationPrerequisites:
+ """Test the conditions required for calculation to be enabled."""
+
+ def test_calculate_enabled_with_contrast(self, real_data: Data1D):
+ """Test that calculate is enabled when data is loaded and contrast is set."""
+ self.window.rbContrast.setChecked(True)
+
+ contrast_value = "2e-06"
+ self.window.txtContrast.setText(contrast_value)
+ self.window.txtContrast.textEdited.emit(contrast_value)
+
+ assert self.window.rbContrast.isChecked()
+ assert self.window._contrast == float(contrast_value)
+ assert self.window.txtContrast.styleSheet() == BG_DEFAULT
+ assert self.window.cmdCalculate.isEnabled(), "Calculate button should be enabled when contrast is set."
+
+ def test_calculate_disabled_without_contrast(self):
+ """Test that calculate is disabled when contrast is empty."""
+ self.window.rbContrast.setChecked(True)
+ self.window.txtContrast.setText("")
+ self.window.txtContrast.textEdited.emit("")
+
+ assert not self.window.cmdCalculate.isEnabled()
+ assert self.window.cmdCalculate.text() == "Calculate (Enter volume fraction or contrast)"
+ assert self.window.txtContrast.styleSheet() == BG_DEFAULT
+
+ def test_enter_invalid_contrast(self):
+ """Test that calculate is disabled when contrast is invalid."""
+ self.window.rbContrast.setChecked(True)
+
+ self.window.txtContrast.setText("invalid")
+ assert not self.window.cmdCalculate.isEnabled()
+
+ self.window.txtContrast.setText("e")
+ self.window.txtContrast.textEdited.emit("e")
+ assert self.window.txtContrast.styleSheet() == BG_ERROR
+ assert not self.window.cmdCalculate.isEnabled()
+ self.window.txtContrast.setText("1e-")
+ self.window.txtContrast.textEdited.emit("1e-")
+ assert self.window.txtContrast.styleSheet() == BG_ERROR
+ assert not self.window.cmdCalculate.isEnabled()
+
+ def test_calculate_enabled_with_volume_fraction(self):
+ """Test that calculate is enabled when data is loaded and volume fraction is set."""
+ self.window.rbVolFrac.setChecked(True)
+ self.window.txtVolFrac1.setText("0.01")
+ self.window.txtVolFrac1.textEdited.emit("0.01")
+
+ assert self.window.rbVolFrac.isChecked()
+ assert self.window.txtVolFrac1.text() == "0.01"
+ assert self.window.txtVolFrac1.styleSheet() == BG_DEFAULT
+ assert self.window.cmdCalculate.isEnabled(), "Calculate button should be enabled when volume fraction is set."
+
+ def test_calculate_disabled_without_volume_fraction(self):
+ """Test that calculate is disabled when volume fraction is empty."""
+ self.window.rbVolFrac.setChecked(True)
+ self.window.txtVolFrac1.setText("")
+ self.window.txtVolFrac1.textEdited.emit("")
+
+ assert not self.window.cmdCalculate.isEnabled()
+ assert self.window.txtVolFrac1.styleSheet() == BG_DEFAULT
+
+ def test_enter_invalid_volume_fraction(self, mocker):
+ """Test that calculate is disabled when volume fraction is invalid."""
+
+ mock_warning = mocker.patch("PySide6.QtWidgets.QMessageBox.warning")
+
+ self.window.rbVolFrac.setChecked(True)
+ self.window.txtVolFrac1.setText("2")
+ self.window.txtVolFrac1.editingFinished.emit()
+
+ mock_warning.assert_called_once()
+ args, _ = mock_warning.call_args
+ assert args[1] == "Invalid Volume Fraction"
+ assert args[2] == "Volume fraction must be between 0 and 1."
+
+ # Assert that the text field is styled red and calculate is disabled
+ assert self.window.txtVolFrac1.styleSheet() == BG_ERROR
+ assert not self.window.cmdCalculate.isEnabled()
+
+ def test_enter_non_numeric_volume_fraction(self, mocker):
+ """Test that calculate is disabled when volume fraction is non-numeric."""
+
+ mock_warning = mocker.patch("PySide6.QtWidgets.QMessageBox.warning")
+
+ self.window.rbVolFrac.setChecked(True)
+ self.window.txtVolFrac1.setText("0,25")
+ self.window.txtVolFrac1.editingFinished.emit()
+
+ mock_warning.assert_called_once()
+ args, _ = mock_warning.call_args
+ assert args[1] == "Invalid Volume Fraction"
+ assert "Volume fractions must be valid numbers." in args[2]
+
+ # Assert that the text field is styled red and calculate is disabled
+ # assert self.window.txtVolFrac1.styleSheet() == BG_ERROR
+ # assert not self.window.cmdCalculate.isEnabled()
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py
deleted file mode 100644
index 9543ca588f..0000000000
--- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py
+++ /dev/null
@@ -1,436 +0,0 @@
-import logging
-
-import pytest
-from PySide6 import QtCore, QtGui, QtWidgets
-from PySide6.QtCore import Qt
-from PySide6.QtTest import QTest
-from twisted.internet import threads
-
-import sas.qtgui.Utilities.GuiUtils as GuiUtils
-from sas.qtgui.Perspectives.Invariant.InvariantPerspective import InvariantWindow
-from sas.qtgui.Perspectives.Invariant.InvariantUtils import WIDGETS
-from sas.qtgui.Plotting.PlotterData import Data1D
-
-BG_COLOR_ERR = 'background-color: rgb(244, 170, 164);'
-
-logger = logging.getLogger(__name__)
-
-
-class InvariantPerspectiveTest:
- """Test the Invariant Perspective Window"""
-
- @pytest.fixture(autouse=True)
- def widget(self, qapp, mocker):
- '''Create/Destroy the Invariant Perspective Window'''
-
- class MainWindow:
- def __init__(self):
- self.model = QtGui.QStandardItemModel()
-
- def plotData(self, data_to_plot):
- pass
-
- class dummy_manager:
- def __init__(self):
- self.filesWidget = MainWindow()
-
- def communicator(self):
- return GuiUtils.Communicate()
-
- def communicate(self):
- return GuiUtils.Communicate()
-
- def createGuiData(self, data_to_plot):
- # Pass data back - testing module - production createGuiData returns a QStandardItem
- return data_to_plot
-
- w = InvariantWindow(dummy_manager())
- mocker.patch.object(w._manager, 'filesWidget')
- mocker.patch.object(w._manager.filesWidget, 'newPlot')
- # Real data taken from src/sas/sasview/test/id_data/AOT_Microemulsion-Core_Contrast.xml
- # Using hard-coded data to limit sascalc imports in gui tests
- self.data = Data1D(
- x=[0.009, 0.011, 0.013, 0.015, 0.017, 0.019, 0.021, 0.023, 0.025, 0.027, 0.029, 0.031, 0.033, 0.035, 0.037,
- 0.039, 0.041, 0.043, 0.045, 0.047, 0.049, 0.051, 0.053, 0.055, 0.057, 0.059, 0.061, 0.063, 0.065, 0.067,
- 0.069, 0.071, 0.073, 0.075, 0.077, 0.079, 0.081, 0.083, 0.085, 0.087, 0.089, 0.091, 0.093, 0.095, 0.097,
- 0.099, 0.101, 0.103, 0.105, 0.107, 0.109, 0.111, 0.113, 0.115, 0.117, 0.119, 0.121, 0.123, 0.125, 0.127,
- 0.129, 0.131, 0.133, 0.135, 0.137, 0.139, 0.141, 0.143, 0.145, 0.147, 0.149, 0.151, 0.153, 0.155, 0.157,
- 0.159, 0.161, 0.163, 0.165, 0.167, 0.169, 0.171, 0.173, 0.175, 0.177, 0.179, 0.181, 0.183, 0.185, 0.187,
- 0.189, 0.191, 0.193, 0.195, 0.197, 0.199, 0.201, 0.203, 0.205, 0.207, 0.209, 0.211, 0.213, 0.215, 0.217,
- 0.219, 0.221, 0.223, 0.225, 0.227, 0.229, 0.231, 0.233, 0.235, 0.237, 0.239, 0.241, 0.243, 0.245, 0.247,
- 0.249, 0.251, 0.253, 0.255, 0.257, 0.259, 0.261, 0.263, 0.265, 0.267, 0.269, 0.271, 0.273, 0.275, 0.277,
- 0.279, 0.281],
- y=[8.66097, 9.07765, 8.74335, 8.97573, 8.01969, 8.50362, 8.21644, 8.5445, 8.25839, 8.385, 8.19833, 8.174,
- 8.10893, 7.90257, 7.92779, 7.77999, 7.55967, 7.73146, 7.64145, 7.43904, 7.26281, 7.10242, 6.98253,
- 6.83064, 6.53401, 6.27756, 6.01229, 5.99131, 5.59393, 5.51664, 5.19822, 4.69725, 4.52997, 4.36966,
- 4.01681, 3.84049, 3.5466, 3.37086, 3.1624, 3.06238, 2.76881, 2.56018, 2.29906, 2.28571, 1.97973, 1.91372,
- 1.72878, 1.63685, 1.45134, 1.43389, 1.29589, 1.09998, 1.0428, 0.844519, 0.85536, 0.739303, 0.631377,
- 0.559972, 0.633137, 0.52837, 0.486401, 0.502888, 0.461518, 0.33547, 0.331639, 0.349024, 0.249295,
- 0.297506, 0.251353, 0.236603, 0.278925, 0.16754, 0.212138, 0.123197, 0.151296, 0.145861, 0.107422,
- 0.160706, 0.10401, 0.0695233, 0.0858619, 0.0557327, 0.185915, 0.0549312, 0.0743549, 0.0841899, 0.0192474,
- 0.175221, 0.0693162, 0.00162097, 0.220803, 0.0846662, 0.0384855, 0.0520236, 0.0679774, -0.0879282,
- 0.00403708, -0.00827498, -0.00896538, 0.0221027, -0.0835404, -0.0781585, 0.0794712, -0.0727371, 0.098657,
- 0.0987721, 0.122134, -0.030629, 0.0393085, -0.0782109, 0.0317806, 0.029647, -0.0138577, -0.188901,
- 0.0535632, -0.0459497, 0.113408, 0.220107, -0.118426, -0.141306, 0.016238, 0.113952, 0.0471965,
- -0.0771868, -0.493606, -0.15584, 0.21327, -0.407363, -0.280523, -0.466429, -0.530037, -0.478568,
- 0.128986, -0.291653, 1.73235, -0.896776, -0.75682],
- dy=[0.678276, 0.415207, 0.33303, 0.266251, 0.229252, 0.207062, 0.187379, 0.17513, 0.163151, 0.156304,
- 0.14797, 0.143222, 0.138323, 0.133951, 0.13133, 0.126702, 0.123018, 0.120643, 0.117301, 0.113626,
- 0.110662, 0.107456, 0.105039, 0.103433, 0.100548, 0.0989847, 0.0968156, 0.095656, 0.0937742, 0.0925144,
- 0.0908407, 0.0888284, 0.0873638, 0.0868543, 0.085489, 0.0837383, 0.0834827, 0.0826536, 0.0812838,
- 0.0807788, 0.079466, 0.0768171, 0.0760352, 0.0758398, 0.0727553, 0.0721901, 0.0718478, 0.069903,
- 0.0699271, 0.0696514, 0.0676085, 0.06646, 0.0660002, 0.065734, 0.0646517, 0.0656619, 0.0647612,
- 0.0637924, 0.0642538, 0.0629895, 0.0639606, 0.0637953, 0.0652337, 0.0649452, 0.0641606, 0.0647814,
- 0.0651144, 0.0648872, 0.0646956, 0.0653164, 0.0663626, 0.0658608, 0.0679627, 0.0683039, 0.0692465,
- 0.0684029, 0.0707, 0.0705329, 0.0710867, 0.0731431, 0.0735345, 0.0754963, 0.0760707, 0.0753411,
- 0.0797642, 0.0805604, 0.0829111, 0.0832278, 0.0839577, 0.0854591, 0.0887341, 0.0923975, 0.0915219,
- 0.0950556, 0.0976872, 0.0995643, 0.0999596, 0.105209, 0.10344, 0.111867, 0.116788, 0.114219, 0.122584,
- 0.126881, 0.131794, 0.130641, 0.139389, 0.141378, 0.149533, 0.153647, 0.1576, 0.163981, 0.179607,
- 0.169998, 0.182096, 0.19544, 0.208226, 0.20631, 0.211599, 0.261127, 0.248377, 0.268117, 0.248487,
- 0.30063, 0.311092, 0.307792, 0.346191, 0.433197, 0.425931, 0.432325, 0.415476, 0.458327, 0.501942,
- 0.526654, 0.671965, 0.605943, 0.772724])
- mocker.patch.object(GuiUtils, 'dataFromItem', return_value=self.data)
- self.fakeData = QtGui.QStandardItem("test")
-
- yield w
-
- """Destroy the DataOperationUtility"""
- w.setClosable(True)
- w.close()
-
- def testDefaults(self, widget):
- """Test the GUI in its default state"""
-
- assert isinstance(widget, QtWidgets.QDialog)
- assert isinstance(widget.model, QtGui.QStandardItemModel)
-
- # name for displaying in the DataExplorer combo box
- assert widget.name == "Invariant"
- assert widget.windowTitle() == "Invariant Perspective"
- assert widget.title == widget.windowTitle()
-
- assert widget._data is None
- assert widget._path == ''
-
- self.checkControlDefaults(widget)
-
- # content of line edits
- assert widget.txtName.text() == ''
- assert widget.txtTotalQMin.text() == '0.0'
- assert widget.txtTotalQMax.text() == '0.0'
- assert widget.txtBackgd.text() == '0.0'
- assert widget.txtScale.text() == '1.0'
- assert widget.txtContrast.text() == ''
-
- # number of tabs
- assert widget.tabWidget.count() == 2
- # default tab
- assert widget.tabWidget.currentIndex() == 0
- # tab's title
- assert widget.tabWidget.tabText(0) == 'Invariant'
- assert widget.tabWidget.tabText(1) == 'Extrapolation'
-
- # Tooltips
- assert widget.cmdStatus.toolTip() == \
- "Get more details of computation such as fraction from extrapolation"
- assert widget.txtInvariantTot.toolTip() == "Total invariant [Q*], including extrapolated regions."
- assert widget.cmdCalculate.toolTip() == "Compute invariant"
-
- # Validators
- assert isinstance(widget.txtBackgd.validator(), GuiUtils.DoubleValidator)
- assert isinstance(widget.txtContrast.validator(), GuiUtils.DoubleValidator)
- assert isinstance(widget.txtScale.validator(), GuiUtils.DoubleValidator)
- assert isinstance(widget.txtPorodCst.validator(), GuiUtils.DoubleValidator)
-
- def checkControlDefaults(self, widget):
- # All values in this list should assert to False
- false_list = [
- widget._allow_close, widget.allowBatch(),
- # disabled buttons
- widget.cmdStatus.isEnabled(), widget.cmdCalculate.isEnabled(),
- # read only text boxes
- widget.txtBackgd.isReadOnly(), widget.txtScale.isReadOnly(), widget.txtContrast.isReadOnly(),
- widget.txtPorodCst.isReadOnly(), widget.txtName.isEnabled(),
- # unchecked check boxes
- widget.chkLowQ_ex.isChecked(), widget.chkHighQ_ex.isChecked()
- ]
- # All values in this list should assert to True
- true_list = [
- # enabled text boxes
- widget.txtVolFract.isReadOnly(), widget.txtVolFractErr.isReadOnly(),
- widget.txtSpecSurf.isReadOnly(), widget.txtSpecSurfErr.isReadOnly(),
- widget.txtInvariantTot.isReadOnly(), widget.txtInvariantTotErr.isReadOnly(),
- # radio buttons exclusivity
- widget.rbLowQFit_ex.autoExclusive(), widget.rbLowQFix_ex.autoExclusive(),
- widget.rbHighQFit_ex.autoExclusive(), widget.rbHighQFix_ex.autoExclusive(),
- # radio buttons exclusivity
- widget.rbLowQGuinier_ex.autoExclusive(), widget.rbLowQPower_ex.autoExclusive()
- ]
- assert all(v is False for v in false_list)
- assert all(v is True for v in true_list)
-
- def testOnCalculate(self, widget, mocker):
- """ Test onCompute function """
- mocker.patch.object(widget, 'calculate_invariant')
- widget.cmdCalculate.setEnabled(True)
- QTest.mouseClick(widget.cmdCalculate, Qt.LeftButton)
- widget.calculate_invariant.assert_called_once()
-
- def testCalculateInvariant(self, widget, mocker):
- """ """
- mocker.patch.object(threads, 'deferToThread')
- widget.calculate_invariant()
- threads.deferToThread.assert_called()
- assert threads.deferToThread.call_args_list[0][0][0].__name__ == 'calculate_thread'
-
- assert widget.cmdCalculate.text() == 'Calculating...'
- assert not widget.cmdCalculate.isEnabled()
-
- def testUpdateFromModel(self, widget):
- """
- update the globals based on the data in the model
- """
- widget.update_from_model()
- assert widget._background == float(widget.model.item(WIDGETS.W_BACKGROUND).text())
- assert str(widget._contrast if widget._contrast else '') == widget.model.item(WIDGETS.W_CONTRAST).text()
- assert widget._scale == float(widget.model.item(WIDGETS.W_SCALE).text())
- assert widget._low_extrapolate == (str(widget.model.item(WIDGETS.W_ENABLE_LOWQ_EX).text()) == 'true')
- assert widget._low_guinier == (str(widget.model.item(WIDGETS.W_LOWQ_GUINIER_EX).text()) == 'true')
- assert widget._low_fit == (str(widget.model.item(WIDGETS.W_LOWQ_FIT_EX).text()) == 'true')
- assert widget._low_power_value == float(widget.model.item(WIDGETS.W_LOWQ_POWER_VALUE_EX).text())
- assert widget._high_extrapolate == (str(widget.model.item(WIDGETS.W_ENABLE_HIGHQ_EX).text()) == 'true')
- assert widget._high_fit == (str(widget.model.item(WIDGETS.W_HIGHQ_FIT_EX).text()) == 'true')
- assert widget._high_power_value == \
- float(widget.model.item(WIDGETS.W_HIGHQ_POWER_VALUE_EX).text())
-
- @pytest.mark.xfail(reason="2026-02: Invariant API changes - I don't know how to fix this")
- def testCheckLength(self, widget, mocker):
- """
- Test validator for number of points for extrapolation
- Error if it is larger than the distribution length
- """
- mocker.patch.object(logger, 'warning')
-
- widget.setData([self.fakeData])
- # Set number of points to 1 larger than the data
- BG_COLOR_ERR = 'background-color: rgb(244, 170, 164);'
- # Ensure a warning is issued in the GUI that the number of points is too large
- assert BG_COLOR_ERR in widget.txtNptsLowQ.styleSheet()
- assert not widget.cmdCalculate.isEnabled()
-
- def testUpdateFromGui(self, widget):
- """ """
- widget.txtBackgd.setText('0.22')
- assert str(widget.model.item(WIDGETS.W_BACKGROUND).text()) == '0.0'
-
- def testLowGuinierAndPowerToggle(self, widget):
- """ """
- # enable all tested radiobuttons
- widget.rbLowQGuinier_ex.setEnabled(True)
- widget.rbLowQPower_ex.setEnabled(True)
- # record initial status
- status_ini = widget.rbLowQGuinier_ex.isChecked()
- # mouse click to run function
- QTest.mouseClick(widget.rbLowQGuinier_ex, Qt.LeftButton)
- # check that status changed
- assert widget.rbLowQGuinier_ex.isChecked() != status_ini
- status_fin = widget.rbLowQGuinier_ex.isChecked()
- assert widget.rbLowQGuinier_ex.isChecked() != (not status_fin)
- assert widget.rbLowQGuinier_ex.isEnabled() != all([not status_fin, not widget._low_fit])
-
- def testHighQToggle(self, widget):
- """ Test enabling / disabling for check box High Q extrapolation """
- widget.chkHighQ_ex.setChecked(True)
- assert widget.chkHighQ_ex.isChecked()
- # Check base state when high Q fit toggled
- assert widget.rbHighQFit_ex.isChecked()
- assert not widget.rbHighQFix_ex.isChecked()
- assert widget.rbHighQFit_ex.isEnabled()
- assert widget.rbHighQFix_ex.isEnabled()
- assert not widget.txtHighQPower_ex.isEnabled()
- # Toggle between fit and fix
- widget.rbHighQFix_ex.setChecked(True)
- assert not widget.rbHighQFit_ex.isChecked()
- assert widget.rbHighQFix_ex.isChecked()
- assert widget.txtHighQPower_ex.isEnabled()
- # Change value and be sure model updates
- widget.txtHighQPower_ex.setText("11")
- assert widget.model.item(WIDGETS.W_HIGHQ_POWER_VALUE_EX).text() == '4'
- # Run the calculation
- widget.setData([self.fakeData])
- widget.calculate_thread('high')
- # Ensure the extrapolation plot is generated
- assert widget.high_extrapolation_plot is not None
- # Ensure Qmax for the plot is equal to Qmax entered into the extrapolation limits
- assert max(widget.high_extrapolation_plot.x) == pytest.approx(10.0, abs=1e-7)
- # Ensure radio buttons unchanged
- assert not widget.rbHighQFit_ex.isChecked()
- assert widget.rbHighQFix_ex.isChecked()
- assert widget.txtHighQPower_ex.text() == '4'
-
- def testLowQToggle(self, widget):
- """ Test enabling / disabling for check box Low Q extrapolation """
- widget.chkLowQ_ex.setChecked(True)
- status_chkLowQ = widget.chkLowQ_ex.isChecked()
- assert status_chkLowQ
- # Check base state
- assert widget.rbLowQGuinier_ex.isEnabled()
- assert widget.rbLowQPower_ex.isEnabled()
- assert not widget.rbLowQFit_ex.isEnabled()
- assert not widget.rbLowQFix_ex.isEnabled()
- assert not widget.chkHighQ_ex.isChecked()
- assert widget.chkLowQ_ex.isChecked()
- # Click the Power Law radio button
- widget.rbLowQPower_ex.setChecked(True)
- assert not widget.rbLowQGuinier_ex.isChecked()
- assert widget.rbLowQFit_ex.isChecked()
- assert not widget.txtLowQPower_ex.isEnabled()
- # Return to the Guinier
- widget.rbLowQGuinier_ex.setChecked(True)
-
- widget.calculate_invariant()
- # Ensure radio buttons unchanged
- assert widget.rbLowQGuinier_ex.isChecked()
- assert widget.rbLowQFit_ex.isChecked()
-
- def testSetupModel(self, widget):
- """ Test default settings of model"""
-
- assert widget.model.item(WIDGETS.W_NAME).text() == widget._path
- assert widget.model.item(WIDGETS.W_QMIN).text() == '0.0'
- assert widget.model.item(WIDGETS.W_QMAX).text() == '0.0'
- assert widget.model.item(WIDGETS.W_BACKGROUND).text() == str(widget._background)
- assert widget.model.item(WIDGETS.W_CONTRAST).text() == ''
- assert widget.model.item(WIDGETS.W_SCALE).text() == str(widget._scale)
- assert str(widget.model.item(WIDGETS.W_POROD_CST).text()) in ['', str(widget._porod)]
-
- assert str(widget.model.item(WIDGETS.W_ENABLE_HIGHQ_EX).text()).lower() == 'false'
- assert str(widget.model.item(WIDGETS.W_ENABLE_LOWQ_EX).text()).lower() == 'false'
- assert str(widget.model.item(WIDGETS.W_LOWQ_GUINIER_EX).text()).lower() == 'false'
- assert str(widget.model.item(WIDGETS.W_LOWQ_FIT_EX).text()).lower() == 'false'
- assert str(widget.model.item(WIDGETS.W_HIGHQ_FIT_EX).text()).lower() == 'false'
-
- assert str(widget.model.item(WIDGETS.W_LOWQ_POWER_VALUE_EX).text()) == '4'
- assert str(widget.model.item(WIDGETS.W_HIGHQ_POWER_VALUE_EX).text()) == '4'
-
- def testSetupMapper(self, widget):
- """ """
- assert isinstance(widget.mapper, QtWidgets.QDataWidgetMapper)
- assert widget.mapper.orientation() == QtCore.Qt.Orientation.Vertical
- assert widget.mapper.model() == widget.model
-
- def testSerialization(self, widget):
- """ Serialization routines """
- assert hasattr(widget, 'isSerializable')
- assert widget.isSerializable()
- widget.setData([self.fakeData])
- self.checkFakeDataState(widget)
- data_return = GuiUtils.dataFromItem(widget._model_item)
- data_id = str(data_return.id)
- # Test three separate serialization routines
- state_all = widget.serializeAll()
- state_one = widget.serializeCurrentPage()
- page = widget.serializePage()
- # Pull out params from state
- params = state_all[data_id]['invar_params']
- # Tests
- assert len(state_all) == len(state_one)
- assert len(state_all) == 1
- # getPage should include an extra param 'data_id' removed by serialize
- assert len(params) != len(page)
- assert len(params) == 33
- assert len(page) == 34
-
- def testLoadParams(self, widget):
- widget.setData([self.fakeData])
- self.checkFakeDataState(widget)
- pageState = widget.serializePage()
- widget.updateFromParameters(pageState)
- self.checkFakeDataState(widget)
- widget.removeData([self.fakeData])
- self.checkControlDefaults(widget)
-
- def testRemoveData(self, widget):
- widget.setData([self.fakeData])
- self.checkFakeDataState(widget)
- # Removing something not already in the perspective should do nothing
- widget.removeData([])
- self.checkFakeDataState(widget)
- # Be sure the defaults hold true after data removal
- widget.removeData([self.fakeData])
- self.checkControlDefaults(widget)
-
- def checkFakeDataState(self, widget):
- """ Ensure the state is constant every time the fake data set loaded """
- assert widget._data is not None
-
- # push buttons enabled
- assert not widget.cmdStatus.isEnabled()
- assert not widget.cmdCalculate.isEnabled()
-
- # disabled, read only line edits
- assert not widget.txtName.isEnabled()
- assert widget.txtVolFract.isReadOnly()
- assert widget.txtVolFractErr.isReadOnly()
-
- assert widget.txtSpecSurf.isReadOnly()
- assert widget.txtSpecSurfErr.isReadOnly()
-
- assert widget.txtInvariantTot.isReadOnly()
- assert widget.txtInvariantTotErr.isReadOnly()
-
- assert not widget.txtBackgd.isReadOnly()
- assert not widget.txtScale.isReadOnly()
- assert not widget.txtContrast.isReadOnly()
- assert not widget.txtPorodCst.isReadOnly()
-
- assert widget.txtPorodStart_ex.isEnabled()
- assert widget.txtPorodEnd_ex.isEnabled()
-
- assert widget.txtTotalQMin.isReadOnly()
- assert widget.txtTotalQMax.isReadOnly()
-
- # content of line edits
- assert widget.txtName.text() == 'data'
- assert widget.txtTotalQMin.text() == '0.009'
- assert widget.txtTotalQMax.text() == '0.281'
- assert widget.txtBackgd.text() == '0.0'
- assert widget.txtScale.text() == '1.0'
- assert widget.txtContrast.text() == ''
- assert widget.txtPorodStart_ex.text() == '0.1677014'
- assert widget.txtPorodEnd_ex.text() == '10'
-
- # unchecked checkboxes
- assert not widget.chkLowQ_ex.isChecked()
- assert not widget.chkHighQ_ex.isChecked()
-
- def test_allow_calculation_requires_input(self, widget):
- # Start with no data -> button disabled
- widget._data = None
- widget.check_status()
- assert not widget.cmdCalculate.isEnabled()
-
- # Fake that we have data
- widget._data = self.data
-
- # Contrast mode: no contrast -> disabled
- widget.rbContrast.setChecked(True)
- widget.txtContrast.setText('')
- widget.check_status()
- assert not widget.cmdCalculate.isEnabled()
-
- # Contrast mode: valid contrast -> enabled
- widget.txtContrast.setText('2.2e-6')
- widget.check_status()
- assert not widget.cmdCalculate.isEnabled()
-
- # Volume fraction mode: no vol frac -> disabled
- widget.rbVolFrac.setChecked(True)
- widget.txtVolFrac1.setText('')
- widget.check_status()
- assert not widget.cmdCalculate.isEnabled()
-
- # Volume fraction mode: valid vol frac -> enabled
- widget.txtVolFrac1.setText('0.01')
- widget.check_status()
- assert not widget.cmdCalculate.isEnabled()
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/RealDataTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/RealDataTest.py
new file mode 100644
index 0000000000..bd03d920df
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/RealDataTest.py
@@ -0,0 +1,400 @@
+"""Tests for the Invariant perspective with real data loaded."""
+
+import pytest
+from PySide6.QtWidgets import QApplication
+
+from sas.qtgui.Plotting.PlotterData import Data1D
+
+# REMOVE WHEN BG_FIX PR IS MERGED
+# Default background color (transparent)
+BG_DEFAULT = ""
+# Error background color
+BG_ERROR = "background-color: rgb(244, 170, 164);"
+
+# Tolerance for floating point comparisons
+TOLERANCE = 1e-7
+
+
+class UIHelpersMixin:
+ """Helper functions for testing."""
+
+ def update_and_emit_line_edits(self, line_edit, value: str):
+ """Helper function to update and emit line edits."""
+ line_edit.setText(value)
+ line_edit.textEdited.emit(value)
+ line_edit.editingFinished.emit()
+ QApplication.processEvents()
+
+ def setup_contrast(self):
+ """Setup contrast for testing."""
+ self.window.rbContrast.setChecked(True)
+ self.update_and_emit_line_edits(self.window.txtContrast, "2e-06")
+
+ def setup_volume_fraction(self):
+ """Setup volume fraction for testing."""
+ self.window.rbVolFrac.setChecked(True)
+ self.update_and_emit_line_edits(self.window.txtVolFrac1, "0.1")
+
+
+@pytest.mark.parametrize("window_class", ["real_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantSetup(UIHelpersMixin):
+ """Test the Invariant perspective behavior when real data is loaded."""
+
+ def test_data_loading(self, real_data: Data1D):
+ """Tests that real data was loaded into the perspective."""
+ assert self.window._data is not None, "Data not loaded."
+ assert self.window._data == real_data, "Data is not the same as loaded."
+ assert self.window.txtName.text() == real_data.name, "Data name not displayed."
+
+ def test_q_range_displayed(self, real_data: Data1D):
+ """Tests that correct Q range is displayed when real data is loaded."""
+ assert float(self.window.txtTotalQMin.text()) == pytest.approx(min(real_data.x))
+ assert float(self.window.txtTotalQMax.text()) == pytest.approx(max(real_data.x))
+
+ def test_calculate_button_disabled_on_load(self):
+ """Test that calculate button starts disabled even with data loaded."""
+ assert not self.window.cmdCalculate.isEnabled()
+ assert self.window.cmdCalculate.text() == "Calculate (Enter volume fraction or contrast)"
+
+ def test_calculate_button_enabled_with_contrast(self):
+ """Test that calculate button is enabled when contrast is set."""
+ self.setup_contrast()
+
+ assert self.window.cmdCalculate.isEnabled()
+ assert self.window.cmdCalculate.text() == "Calculate"
+
+ def test_calculate_button_enabled_with_volume_fraction(self):
+ """Test that calculate button is enabled when volume fraction is set."""
+ self.setup_volume_fraction()
+
+ assert self.window.cmdCalculate.isEnabled()
+ assert self.window.cmdCalculate.text() == "Calculate"
+
+ def test_low_extrapolation_enable(self):
+ """Test that low extrapolation is set correctly."""
+ self.setup_contrast()
+
+ self.window.chkLowQ_ex.setChecked(True)
+
+ assert not self.window.txtGuinierEnd_ex.text() == ""
+ assert self.window.txtGuinierEnd_ex.styleSheet() == BG_DEFAULT
+
+
+@pytest.mark.parametrize("window_class", ["real_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantExtrapolation(UIHelpersMixin):
+ """Test the extrapolation input validation and behavior."""
+
+ @pytest.mark.parametrize(
+ ("field_name", "range_pos"),
+ [
+ ("txtGuinierEnd_ex", "below"),
+ ("txtGuinierEnd_ex", "above"),
+ ("txtPorodStart_ex", "below"),
+ ("txtPorodStart_ex", "above"),
+ ("txtPorodEnd_ex", "below"),
+ ("txtPorodEnd_ex", "above"),
+ ],
+ ids=[
+ "low_q_end_below",
+ "low_q_end_above",
+ "high_q_start_below",
+ "high_q_start_above",
+ "high_q_end_below",
+ "high_q_end_above",
+ ],
+ )
+ def test_extrapolation_value_out_of_range(self, mocker, field_name, range_pos):
+ """Unified test for out-of-range extrapolation fields."""
+ mock_warning = mocker.patch("PySide6.QtWidgets.QMessageBox.warning")
+
+ self.setup_contrast()
+
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.chkHighQ_ex.setChecked(True)
+
+ min = self.window._data.x[0]
+ max = self.window._data.x[-1]
+
+ if field_name == "txtPorodEnd_ex":
+ max = self.window.extrapolation_parameters.ex_q_max
+
+ # compute value from window data
+ if range_pos == "below":
+ value = min - TOLERANCE
+ else:
+ value = max + TOLERANCE
+
+ widget = getattr(self.window, field_name)
+
+ # simulate user edit
+ widget.setText(str(value))
+ widget.textEdited.emit(str(value))
+
+ # error colour displayed while editing
+ assert widget.styleSheet() == BG_ERROR
+
+ widget.editingFinished.emit()
+
+ # value should be reset
+ assert widget.styleSheet() == BG_DEFAULT
+
+ mock_warning.assert_called_once()
+ args, _ = mock_warning.call_args
+ assert args[1] == "Invalid Extrapolation Values"
+
+ @pytest.mark.parametrize(
+ ("field_name", "range_pos"),
+ [
+ ("txtGuinierEnd_ex", "below"),
+ ("txtGuinierEnd_ex", "above"),
+ ("txtPorodStart_ex", "below"),
+ ("txtPorodStart_ex", "above"),
+ ("txtPorodEnd_ex", "below"),
+ ("txtPorodEnd_ex", "above"),
+ ],
+ ids=[
+ "low_q_end_below",
+ "low_q_end_above",
+ "high_q_start_below",
+ "high_q_start_above",
+ "high_q_end_below",
+ "high_q_end_above",
+ ],
+ )
+ def test_extrapolation_value_invalid(self, mocker, field_name, range_pos):
+ """Test for invalid extrapolation fields that include being below/above the adjacent extrapolation point"""
+ mock_warning = mocker.patch("PySide6.QtWidgets.QMessageBox.warning")
+
+ self.setup_contrast()
+
+ self.window.chkLowQ_ex.setChecked(True)
+ self.window.chkHighQ_ex.setChecked(True)
+
+ widget = getattr(self.window, "txtGuinierEnd_ex")
+ value = self.window.extrapolation_parameters.point_2
+
+ # simulate user edit
+ widget.setText(str(value))
+ widget.textEdited.emit(str(value))
+
+ # error colour displayed while editing
+ assert widget.styleSheet() == BG_ERROR
+
+ widget.editingFinished.emit()
+
+ # value should be reset
+ assert widget.styleSheet() == BG_DEFAULT
+ assert float(widget.text()) <= value
+
+ mock_warning.assert_called_once()
+ args, _ = mock_warning.call_args
+ assert args[1] == "Invalid Extrapolation Values"
+
+ def valid_extrapolation_order(self):
+ """Helper to check if the extrapolation values are valid"""
+ params = self.window.extrapolation_parameters
+ min_data = params.data_q_min
+ p1 = params.point_1
+ p2 = params.point_2
+ p3 = params.point_3
+ max_data = params.data_q_max
+ max_ex = params.ex_q_max
+
+ return min_data < p1 < p2 < p3 <= max_ex and p2 < max_data
+
+ @pytest.mark.parametrize(
+ "case",
+ [
+ "valid",
+ "invalid_p2_larger_than_p3",
+ "invalid_p1_equal_p2",
+ "invalid_p2_equal_p3",
+ "invalid_p1_larger_than_p2",
+ "invalid_p1_larger_than_p3",
+ ],
+ ids=["valid", "p2_larger_than_p3", "p1_eq_p2", "p2_eq_p3", "p1_larger_than_p2", "p1_larger_than_p3"],
+ )
+ def test_extrapolation_order(self, mocker, case):
+ """Test that the p1 p3
+ expected_valid = False
+ elif case == "invalid_p1_equal_p2":
+ p1, p2, p3 = base1, base1, base3 # p1 == p2
+ expected_valid = False
+ elif case == "invalid_p2_equal_p3":
+ p1, p2, p3 = base1, base2, base2
+ expected_valid = False
+ elif case == "invalid_p1_larger_than_p2":
+ p1, p2, p3 = base2, base1, base3 # p1 > p2
+ expected_valid = False
+ elif case == "invalid_p1_larger_than_p3":
+ p1, p2, p3 = base3, base2, base1 # p1 > p3
+ expected_valid = False
+ else:
+ pytest.skip(f"unknown case {case}")
+
+ w1 = self.window.txtGuinierEnd_ex
+ w2 = self.window.txtPorodStart_ex
+ w3 = self.window.txtPorodEnd_ex
+
+ w1.setText(str(p1))
+ w1.textEdited.emit(str(p1))
+ w2.setText(str(p2))
+ w2.textEdited.emit(str(p2))
+ w3.setText(str(p3))
+ w3.textEdited.emit(str(p3))
+
+ if expected_valid:
+ assert (
+ self.window.txtGuinierEnd_ex.styleSheet() == BG_DEFAULT
+ and self.window.txtPorodStart_ex.styleSheet() == BG_DEFAULT
+ and self.window.txtPorodEnd_ex.styleSheet() == BG_DEFAULT
+ )
+ else:
+ assert (
+ self.window.txtGuinierEnd_ex.styleSheet() == BG_ERROR
+ or self.window.txtPorodStart_ex.styleSheet() == BG_ERROR
+ or self.window.txtPorodEnd_ex.styleSheet() == BG_ERROR
+ )
+
+ w1.editingFinished.emit()
+ w2.editingFinished.emit()
+ w3.editingFinished.emit()
+
+ if expected_valid:
+ mock_warning.assert_not_called()
+ else:
+ mock_warning.assert_called()
+
+ assert (
+ self.window.txtGuinierEnd_ex.styleSheet() == BG_DEFAULT
+ and self.window.txtPorodStart_ex.styleSheet() == BG_DEFAULT
+ and self.window.txtPorodEnd_ex.styleSheet() == BG_DEFAULT
+ )
+ assert self.valid_extrapolation_order()
+
+
+@pytest.mark.parametrize("window_class", ["real_data"], indirect=True)
+@pytest.mark.usefixtures("window_class")
+class TestInvariantMethods(UIHelpersMixin):
+ def test_low_q_extrapolation_getter(self, real_data):
+ """Test that the low q extrapolation getter works correctly."""
+
+ self.setup_contrast()
+ self.window.chkLowQ_ex.setChecked(True)
+
+ num_points = 10
+ value = self.window._data.x[num_points - 1]
+
+ self.window._low_points = 10
+ upper_limit = self.window.get_low_q_extrapolation_upper_limit()
+
+ assert upper_limit == value
+
+ def test_low_q_extrapolation_setter(self, real_data):
+ """Test that the low q extrapolation setter works correctly."""
+
+ self.setup_contrast()
+ self.window.chkLowQ_ex.setChecked(True)
+
+ index = 15
+ value = self.window._data.x[index - 1]
+
+ self.window.set_low_q_extrapolation_upper_limit(value)
+
+ assert self.window._low_points == index
+
+ def test_high_q_extrapolation_getter(self, real_data):
+ """Test that the high q extrapolation getter works correctly."""
+
+ self.setup_contrast()
+ self.window.chkHighQ_ex.setChecked(True)
+
+ num_points = 10
+ value = self.window._data.x[-num_points - 1]
+
+ self.window._high_points = 10
+ lower_limit = self.window.get_high_q_extrapolation_lower_limit()
+
+ assert lower_limit == value
+
+ def test_high_q_extrapolation_setter(self, real_data):
+ """Test that the high q extrapolation setter works correctly."""
+
+ self.setup_contrast()
+ self.window.chkHighQ_ex.setChecked(True)
+
+ index = 15
+ value = self.window._data.x[-index + 1]
+
+ self.window.set_high_q_extrapolation_lower_limit(value)
+
+ assert self.window._high_points == index
+
+ def test_updateGuiFromFile_1D(self, real_data):
+ """Passing a real Data1D should set _data without raising."""
+ self.window.updateGuiFromFile(real_data)
+ assert self.window._data is real_data
+
+ def test_updateGuiFromFile_not1D(self):
+ """Passing a plain object (not a Data1D) raises the 2D-data error."""
+ with pytest.raises(ValueError, match="Invariant cannot be computed with 2D data."):
+ self.window.updateGuiFromFile(object())
+
+ def test_serialize_page(self, mocker):
+ """Test that the serializePage method returns the expected dictionary."""
+ base = {"vol_fraction": "0.1"}
+ mocker.patch.object(self.window, "serializeState", return_value=dict(base))
+
+ out = self.window.serializePage()
+
+ expected = dict(base)
+ expected["data_name"] = str(self.window._data.name)
+ expected["data_id"] = str(self.window._data.id)
+ assert out == expected
+
+ def test_serialize_current_page(self, mocker):
+ """Test that the serializeCurrentPage method returns the expected dictionary."""
+ tab_data = {
+ "data_id": "abc-123",
+ "vol_fraction": "0.1",
+ }
+ mocker.patch.object(self.window, "serializePage", return_value=dict(tab_data))
+
+ out = self.window.serializeCurrentPage()
+
+ assert "abc-123" in out
+ assert out["abc-123"] == {"invar_params": {"vol_fraction": "0.1"}}
+
+ def test_serialize_all(self, mocker):
+ """Test that the serializeAll method returns the expected dictionary."""
+ sentinel = {"SOMEKEY": {"invar_params": {"x": "y"}}}
+
+ out = self.window.serializeAll()
+
+ mock_serialize_current_page = mocker.patch.object(self.window, "serializeCurrentPage", return_value=sentinel)
+ out = self.window.serializeAll()
+ mock_serialize_current_page.assert_called_once()
+ assert out is sentinel
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/__init__.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/__init__.py
deleted file mode 100755
index e69de29bb2..0000000000
diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py
new file mode 100644
index 0000000000..c13e2c65fa
--- /dev/null
+++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py
@@ -0,0 +1,179 @@
+"""Pytest configuration for Invariant perspective tests."""
+
+import os
+
+# Ensure Qt runs headless in CI environments
+os.environ.setdefault("QT_QPA_PLATFORM", os.environ.get("QT_QPA_PLATFORM", "offscreen"))
+
+from collections.abc import Iterator
+
+import pytest
+from _pytest.fixtures import SubRequest
+from PySide6 import QtGui
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QApplication
+
+from sas.qtgui.Perspectives.Invariant.InvariantPerspective import InvariantWindow
+from sas.qtgui.Plotting.PlotterData import Data1D
+from sas.qtgui.Utilities import GuiUtils
+
+
+class MainWindowStub:
+ """A minimal stub of MainWindow for testing purposes."""
+
+ def __init__(self):
+ self.model = QtGui.QStandardItemModel()
+
+ def plotData(self, *_):
+ pass
+
+ def newPlot(self):
+ pass
+
+ def closePlotsForItem(self, _):
+ pass
+
+
+class DummyManager:
+ """A minimal stub of Manager for testing purposes."""
+
+ def __init__(self):
+ self.filesWidget = MainWindowStub()
+
+ def communicator(self):
+ return GuiUtils.Communicate()
+
+ def communicate(self):
+ return GuiUtils.Communicate()
+
+ def createGuiData(self, data: Data1D) -> QtGui.QStandardItem:
+ item = QtGui.QStandardItem(data.name)
+ item.setData(data, Qt.UserRole) # store the Data1D on the item
+ return item
+
+ def showHelp(self, location: str) -> None:
+ """Stub for the help dialog."""
+
+
+@pytest.fixture
+def small_data() -> Data1D:
+ """A minimal synthetic data for fast unit tests."""
+ data = Data1D(x=[0.01, 0.02], y=[1.0, 0.5], dy=[0.1, 0.1])
+ data.name = "small_data"
+ data.filename = "small_data.txt"
+ return data
+
+
+@pytest.fixture(scope="module")
+def real_data() -> Data1D:
+ """Hard coded real data for integration tests (module-scoped)."""
+ # fmt: off
+ data = Data1D(
+ x=[
+ 0.009, 0.011, 0.013, 0.015, 0.017, 0.019, 0.021, 0.023, 0.025, 0.027,
+ 0.029, 0.031, 0.033, 0.035, 0.037, 0.039, 0.041, 0.043, 0.045, 0.047,
+ 0.049, 0.051, 0.053, 0.055, 0.057, 0.059, 0.061, 0.063, 0.065, 0.067,
+ 0.069, 0.071, 0.073, 0.075, 0.077, 0.079, 0.081, 0.083, 0.085, 0.087,
+ 0.089, 0.091, 0.093, 0.095, 0.097, 0.099, 0.101, 0.103, 0.105, 0.107,
+ 0.109, 0.111, 0.113, 0.115, 0.117, 0.119, 0.121, 0.123, 0.125, 0.127,
+ 0.129, 0.131, 0.133, 0.135, 0.137, 0.139, 0.141, 0.143, 0.145, 0.147,
+ 0.149, 0.151, 0.153, 0.155, 0.157, 0.159, 0.161, 0.163, 0.165, 0.167,
+ 0.169, 0.171, 0.173, 0.175, 0.177, 0.179, 0.181, 0.183, 0.185, 0.187,
+ 0.189, 0.191, 0.193, 0.195, 0.197, 0.199, 0.201, 0.203, 0.205, 0.207,
+ 0.209, 0.211, 0.213, 0.215, 0.217, 0.219, 0.221, 0.223, 0.225, 0.227,
+ 0.229, 0.231, 0.233, 0.235, 0.237, 0.239, 0.241, 0.243, 0.245, 0.247,
+ 0.249, 0.251, 0.253, 0.255, 0.257, 0.259, 0.261, 0.263, 0.265, 0.267,
+ 0.269, 0.271, 0.273, 0.275, 0.277, 0.279, 0.281,
+ ],
+ y=[
+ 8.66097, 9.07765, 8.74335, 8.97573, 8.01969, 8.50362, 8.21644, 8.5445, 8.25839, 8.385,
+ 8.19833, 8.174, 8.10893, 7.90257, 7.92779, 7.77999, 7.55967, 7.73146, 7.64145, 7.43904,
+ 7.26281, 7.10242, 6.98253, 6.83064, 6.53401, 6.27756, 6.01229, 5.99131, 5.59393, 5.51664,
+ 5.19822, 4.69725, 4.52997, 4.36966, 4.01681, 3.84049, 3.5466, 3.37086, 3.1624, 3.06238,
+ 2.76881, 2.56018, 2.29906, 2.28571, 1.97973, 1.91372, 1.72878, 1.63685, 1.45134, 1.43389,
+ 1.29589, 1.09998, 1.0428, 0.844519, 0.85536, 0.739303, 0.631377, 0.559972, 0.633137,
+ 0.52837, 0.486401, 0.502888, 0.461518, 0.33547, 0.331639, 0.349024, 0.249295, 0.297506,
+ 0.251353, 0.236603, 0.278925, 0.16754, 0.212138, 0.123197, 0.151296, 0.145861, 0.107422,
+ 0.160706, 0.10401, 0.0695233, 0.0858619, 0.0557327, 0.185915, 0.0549312, 0.0743549,
+ 0.0841899, 0.0192474, 0.175221, 0.0693162, 0.00162097, 0.220803, 0.0846662, 0.0384855,
+ 0.0520236, 0.0679774, -0.0879282, 0.00403708, -0.00827498, -0.00896538, 0.0221027,
+ -0.0835404, -0.0781585, 0.0794712, -0.0727371, 0.098657, 0.0987721, 0.122134, -0.030629,
+ 0.0393085, -0.0782109, 0.0317806, 0.029647, -0.0138577, -0.188901, 0.0535632,
+ -0.0459497, 0.113408, 0.220107, -0.118426, -0.141306, 0.016238, 0.113952, 0.0471965,
+ -0.0771868, -0.493606, -0.15584, 0.21327, -0.407363, -0.280523, -0.466429, -0.530037,
+ -0.478568, 0.128986, -0.291653, 1.73235, -0.896776, -0.75682,
+ ],
+ dy=[
+ 0.678276, 0.415207, 0.33303, 0.266251, 0.229252, 0.207062, 0.187379, 0.17513, 0.163151,
+ 0.156304, 0.14797, 0.143222, 0.138323, 0.133951, 0.13133, 0.126702, 0.123018, 0.120643,
+ 0.117301, 0.113626, 0.110662, 0.107456, 0.105039, 0.103433, 0.100548, 0.0989847,
+ 0.0968156, 0.095656, 0.0937742, 0.0925144, 0.0908407, 0.0888284, 0.0873638, 0.0868543,
+ 0.085489, 0.0837383, 0.0834827, 0.0826536, 0.0812838, 0.0807788, 0.079466, 0.0768171,
+ 0.0760352, 0.0758398, 0.0727553, 0.0721901, 0.0718478, 0.069903, 0.0699271, 0.0696514,
+ 0.0676085, 0.06646, 0.0660002, 0.065734, 0.0646517, 0.0656619, 0.0647612, 0.0637924,
+ 0.0642538, 0.0629895, 0.0639606, 0.0637953, 0.0652337, 0.0649452, 0.0641606,
+ 0.0647814, 0.0651144, 0.0648872, 0.0646956, 0.0653164, 0.0663626, 0.0658608,
+ 0.0679627, 0.0683039, 0.0692465, 0.0684029, 0.0707, 0.0705329, 0.0710867, 0.0731431,
+ 0.0735345, 0.0754963, 0.0760707, 0.0753411, 0.0797642, 0.0805604, 0.0829111,
+ 0.0832278, 0.0839577, 0.0854591, 0.0887341, 0.0923975, 0.0915219, 0.0950556,
+ 0.0976872, 0.0995643, 0.0999596, 0.105209, 0.10344, 0.111867, 0.116788, 0.114219,
+ 0.122584, 0.126881, 0.131794, 0.130641, 0.139389, 0.141378, 0.149533, 0.153647,
+ 0.1576, 0.163981, 0.179607, 0.169998, 0.182096, 0.19544, 0.208226, 0.20631,
+ 0.211599, 0.261127, 0.248377, 0.268117, 0.248487, 0.30063, 0.311092, 0.307792,
+ 0.346191, 0.433197, 0.425931, 0.432325, 0.415476, 0.458327, 0.501942, 0.526654,
+ 0.671965, 0.605943, 0.772724,
+ ],
+ # fmt: on
+)
+ data.name = "Real Data"
+ data.filename = "real_data.txt"
+ return data
+
+
+@pytest.fixture
+def dummy_manager() -> DummyManager:
+ return DummyManager()
+
+
+@pytest.fixture(scope="session")
+def qapp() -> Iterator[QApplication]:
+ """
+ Provide a single QApplication for the whole pytest session.
+ """
+ app = QApplication.instance()
+ if app is None:
+ app = QApplication([])
+ yield app
+
+
+@pytest.fixture
+def window_class(qapp, mocker, request: SubRequest) -> Iterator[InvariantWindow]:
+ """Creates an InvariantWindow; attaches the window to the test class as self.window."""
+ mgr = DummyManager()
+ w = InvariantWindow(mgr)
+
+ param = getattr(request, "param", None)
+ data_obj = None
+ if param is not None:
+ if isinstance(param, str):
+ data_obj = request.getfixturevalue(param)
+ elif isinstance(param, Data1D):
+ data_obj = param
+ else:
+ raise ValueError("Invalid parameter type")
+
+ if data_obj is not None:
+ mocker.patch.object(GuiUtils, "dataFromItem", return_value=data_obj)
+ data_item = mgr.createGuiData(data_obj)
+ w.setData([data_item])
+
+ # attach to the test class so methods can use self.window
+ if request.cls is not None:
+ request.cls.window = w
+
+ w.show()
+ QApplication.processEvents()
+ yield w
+
+ w.close()
+ QApplication.processEvents()
diff --git a/src/sas/qtgui/Plotting/UnitTesting/Quarantine/QRangeSliderTests.py b/src/sas/qtgui/Plotting/UnitTesting/QRangeSliderTests.py
similarity index 72%
rename from src/sas/qtgui/Plotting/UnitTesting/Quarantine/QRangeSliderTests.py
rename to src/sas/qtgui/Plotting/UnitTesting/QRangeSliderTests.py
index 6e1ae890bc..371e75543e 100644
--- a/src/sas/qtgui/Plotting/UnitTesting/Quarantine/QRangeSliderTests.py
+++ b/src/sas/qtgui/Plotting/UnitTesting/QRangeSliderTests.py
@@ -1,3 +1,4 @@
+from unittest.mock import MagicMock, patch
import pytest
@@ -12,6 +13,20 @@
from sas.qtgui.Plotting.QRangeSlider import QRangeSlider
+@pytest.fixture(scope="session", autouse=True)
+def patch_heavyweight_calculators():
+ """Prevent heavyweight calculator widgets from being instantiated during tests.
+
+ - MuMag: calls plt.figure() twice in __init__, causing 'too many figures' warnings.
+ - Shape2SAS: uses Q3DScatter (OpenGL), which crashes under the offscreen platform.
+ """
+ with (
+ patch("sas.qtgui.MainWindow.GuiManager.MuMag", MagicMock),
+ patch("sas.qtgui.MainWindow.GuiManager.Shape2SAS", MagicMock),
+ ):
+ yield
+
+
class QRangeSlidersTest:
'''Test the QRangeSliders'''
@pytest.fixture(autouse=True)
@@ -33,7 +48,7 @@ def __init__(self, parent=None):
self.manager = GuiManager(MainWindow(None))
self.plotter = Plotter.Plotter(self.manager.filesWidget, quickplot=True)
- self.data = Data1D(x=[0.001,0.1,0.2,0.3,0.4], y=[1000,100,10,1,0.1])
+ self.data = Data1D(x=[0.001,0.1,0.2,0.3,0.4], y=[1000,100,10,1,0.1], dy=[100,10,1,0.1,0.01])
self.data.name = "Test QRangeSliders class"
self.data.show_q_range_sliders = True
self.data.slider_update_on_move = True
@@ -93,32 +108,68 @@ def testFittingSliders(self, slidersetup):
assert self.slider.line_max.setter == widget.options_widget.updateMaxQ
self.moveSliderAndInputs(widget.options_widget.txtMinRange, widget.options_widget.txtMaxRange)
- @pytest.mark.xfail(reason="2026-02: Invariant API change - need to understand how it works")
- def testInvariantSliders(self, slidersetup):
- '''Test the QRangeSlider class within the context of the Invariant perspective'''
- # Ensure invariant prespective is active and send data to it
- self.current_perspective = 'Invariant'
+ @pytest.mark.parametrize(
+ "checkbox, start, end",
+ [
+ ("chkHighQ_ex", "txtPorodStart_ex", "txtPorodEnd_ex"),
+ ("chkLowQ_ex", None, "txtGuinierEnd_ex"),
+ ],
+ ids=["High Q", "Low Q"]
+ )
+ def testInvariantSliders(self, checkbox, start, end, slidersetup):
+ """Test the QRangeSlider class within the context of the Invariant perspective.
+
+ After the refactor, invariant sliders are purely visual.
+ They are positioned by reading a the extrapolation parameters at creation time,
+ and do NOT maintain a live connection to the text-field inputs.
+ Dragging a slider must never update the text fields in the perspective.
+ """
+
+ self.current_perspective = "Invariant"
self.manager.perspectiveChanged(self.current_perspective)
widget = self.manager.perspective()
- widget._data = self.data
- # Create slider on base data set
+ chk_widget = getattr(widget, checkbox)
+ end_widget = getattr(widget, end)
+ start_widget = getattr(widget, start) if start else None
+ chk_widget.setChecked(True)
+ self.manager.filesWidget.sendData()
+
+ assert widget._data is self.data, "Perspective should have received the data from the manager"
+ assert end_widget.text() != ""
+ if start_widget:
+ assert start_widget.text() != ""
+
+ # Mirror the slider attributes exactly as plot_result() sets them
+ self.data.show_q_range_sliders = True
+ self.data.slider_update_on_move = False
self.data.slider_perspective_name = self.current_perspective
- self.data.slider_low_q_input = ['txtNptsHighQ']
- self.data.slider_low_q_setter = ['set_high_q_extrapolation_lower_limit']
- self.data.slider_low_q_getter = ['get_high_q_extrapolation_lower_limit']
- self.data.slider_high_q_input = ['txtExtrapolQMax']
+ self.data.slider_low_q_input = start_widget.text() if start_widget else 1e-5
+ self.data.slider_high_q_input = end_widget.text()
+
self.plotter.plot(self.data)
self.slider = QRangeSlider(self.plotter, self.plotter.ax, data=self.data)
- # Check inputs are linked properly.
+
assert len(self.plotter.sliders) == 1
- # Move slider and ensure text input matches - Npts needs to be checked differently
- self.moveSliderAndInputs(None, widget.txtExtrapolQMax)
- # Check npts after moving line
+ assert not self.slider.updateOnMove, "Invariant sliders should not update on move"
+
+ # Moving the slider should not update the text fields
+ if start_widget:
+ original_start = start_widget.text()
+ original_end = end_widget.text()
+
self.slider.line_min.move(self.data.x[1], self.data.y[1], None)
- assert float(widget.txtNptsHighQ.text()) == pytest.approx(5, abs=1e-7)
- # Move npts and check slider
- widget.txtNptsHighQ.setText('2')
- assert self.data.x[1]-self.slider.line_min.x == pytest.approx(0, abs=1e-7)
+ self.slider.line_max.move(self.data.x[-2], self.data.y[-2], None)
+
+ if start_widget:
+ assert start_widget.text() == original_start
+ assert end_widget.text() == original_end
+
+ # Editing the text fields should update the sliders
+ if start_widget:
+ start_widget.setText(f"{self.data.x[1]}")
+ assert self.slider.line_min.x == pytest.approx(float(start_widget.text()), abs=1e-7)
+ end_widget.setText(f"{self.data.x[-2]}")
+ assert self.slider.line_max.x == pytest.approx(float(end_widget.text()), abs=1e-7)
def testInversionSliders(self, slidersetup):
'''Test the QRangeSlider class within the context of the Inversion perspective'''
diff --git a/src/sas/qtgui/Plotting/UnitTesting/conftest.py b/src/sas/qtgui/Plotting/UnitTesting/conftest.py
new file mode 100644
index 0000000000..e7273a8989
--- /dev/null
+++ b/src/sas/qtgui/Plotting/UnitTesting/conftest.py
@@ -0,0 +1,4 @@
+import os
+
+# Run the tests in offscreen mode
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")