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")