From f410739043df8791f24aaa0b4773fe965c36cd56 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Thu, 12 Feb 2026 13:47:00 +0000 Subject: [PATCH 01/13] Initialization tests add to init --- .../Invariant/InvariantPerspective.py | 8 + .../Invariant/Tests/InitializedStateTest.py | 489 ++++++++++++++++++ .../Perspectives/Invariant/Tests/conftest.py | 161 ++++++ .../Invariant/UI/TabbedInvariantUI.ui | 2 +- 4 files changed, 659 insertions(+), 1 deletion(-) create mode 100644 src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py create mode 100644 src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index c4aab22529..2bb185e268 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -748,6 +748,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 +786,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) @@ -1158,6 +1162,8 @@ def updateFromGui(self) -> None: "txtPorodCstErr", "txtVolFrac1", "txtVolFrac1Err", + "txtLowQPower_ex", + "txtHighQPower_ex", ] if text_value == "" and sender_name in optional_fields: @@ -1174,6 +1180,8 @@ def updateFromGui(self) -> None: "txtPorodCstErr": "_porod_err", "txtVolFrac1": "_volfrac1", "txtVolFrac1Err": "_volfrac1_err", + "txtLowQPower_ex": "_low_q_power_ex", + "txtHighQPower_ex": "_high_q_power_ex", } if sender_name in sender_to_attr: setattr(self, sender_to_attr[sender_name], None) diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py new file mode 100644 index 0000000000..2943244b1b --- /dev/null +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py @@ -0,0 +1,489 @@ +"""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) == True + + 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.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.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/Tests/conftest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py new file mode 100644 index 0000000000..41b66dff37 --- /dev/null +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py @@ -0,0 +1,161 @@ +"""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")) + +import sys +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("test") + item.setData(data, Qt.UserRole) # store the Data1D on the item + return item + + +@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 +) + 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(sys.argv) + yield app + + +@pytest.fixture +def window_class(qapp, mocker, small_data: Data1D, request: SubRequest) -> Iterator[InvariantWindow]: + """Creates an InvariantWindow; attaches the window to the test class as self.window.""" + mgr = DummyManager() + w = InvariantWindow(mgr) + mocker.patch.object(GuiUtils, "dataFromItem", return_value=small_data) + + # attach to the test class so methods can use self.window + if request.cls is not None: + request.cls.window = w + + w.show() + qapp.processEvents() + yield w + + w.close() + qapp.processEvents() 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 From 7fc9ca75885b4c3e1f4f2c2f5d282cf8a0027264 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 16 Feb 2026 15:41:30 +0000 Subject: [PATCH 02/13] Tests loaded data --- .../Invariant/InvariantPerspective.py | 43 +--- .../Tests/InvariantLoadedDataTest.py | 213 ++++++++++++++++++ .../Perspectives/Invariant/Tests/conftest.py | 16 +- 3 files changed, 231 insertions(+), 41 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index 2bb185e268..f06f4aaed9 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -1041,12 +1041,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.""" @@ -1069,30 +1064,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() != "": @@ -1102,11 +1073,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) @@ -1115,11 +1082,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.""" diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py new file mode 100644 index 0000000000..dd0588ccb3 --- /dev/null +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py @@ -0,0 +1,213 @@ +"""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.usefixtures("window_with_small_data") +class TestInvariantWithData: + """Test the Invariant perspective behavior when data is loaded.""" + + def test_data_loading(self, small_data): + """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.usefixtures("window_with_real_data") +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/Tests/conftest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py index 41b66dff37..e0c158c1c1 100644 --- a/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py @@ -48,7 +48,7 @@ def communicate(self): return GuiUtils.Communicate() def createGuiData(self, data: Data1D) -> QtGui.QStandardItem: - item = QtGui.QStandardItem("test") + item = QtGui.QStandardItem(data.name) item.setData(data, Qt.UserRole) # store the Data1D on the item return item @@ -123,6 +123,8 @@ def real_data() -> Data1D: ], # fmt: on ) + data.name = "Real Data" + data.filename = "real_data.txt" return data @@ -159,3 +161,15 @@ def window_class(qapp, mocker, small_data: Data1D, request: SubRequest) -> Itera w.close() qapp.processEvents() + + +@pytest.fixture +def window_with_small_data(window_class, small_data: Data1D, dummy_manager): + """Creates an InvariantWindow with data loaded.""" + data_item = dummy_manager.createGuiData(small_data) + window_class.setData([data_item]) + yield window_class + + # Clean up + window_class.close() + From 96d61fa2fdba6afd276411fc1940064ec3ea1318 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Tue, 17 Feb 2026 10:37:48 +0000 Subject: [PATCH 03/13] fixes data loading --- .../Tests/InvariantLoadedDataTest.py | 8 ++++-- .../Perspectives/Invariant/Tests/conftest.py | 28 +++++++++++++++++-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py index dd0588ccb3..dae84add80 100644 --- a/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py @@ -14,11 +14,12 @@ BG_ERROR = "background-color: rgb(244, 170, 164);" -@pytest.mark.usefixtures("window_with_small_data") +@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): + 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." @@ -112,7 +113,8 @@ def test_extrapolation_slider_loaded(self, small_data: Data1D): assert not self.window.txtPorodEnd_ex.text() == "" -@pytest.mark.usefixtures("window_with_real_data") +@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.""" diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py index e0c158c1c1..19ec295267 100644 --- a/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py @@ -145,11 +145,25 @@ def qapp() -> Iterator[QApplication]: @pytest.fixture -def window_class(qapp, mocker, small_data: Data1D, request: SubRequest) -> Iterator[InvariantWindow]: +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) - mocker.patch.object(GuiUtils, "dataFromItem", return_value=small_data) + + 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: @@ -173,3 +187,13 @@ def window_with_small_data(window_class, small_data: Data1D, dummy_manager): # Clean up window_class.close() + +@pytest.fixture +def window_with_real_data(window_class, real_data: Data1D, dummy_manager): + """Creates an InvariantWindow with data loaded.""" + data_item = dummy_manager.createGuiData(real_data) + window_class.setData([data_item]) + yield window_class + + # Clean up + window_class.close() From c6b0bc97dbec12d8c26e0b6370e10a95786988ab Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Wed, 25 Feb 2026 10:18:39 +0000 Subject: [PATCH 04/13] tests with real data --- .../Invariant/InvariantPerspective.py | 441 +++++++++--------- .../Invariant/Tests/RealDataTest.py | 400 ++++++++++++++++ .../Perspectives/Invariant/Tests/conftest.py | 30 +- 3 files changed, 627 insertions(+), 244 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/Invariant/Tests/RealDataTest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index f06f4aaed9..4c03b7544c 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -424,28 +424,27 @@ def update_details_widget(self) -> None: self.onStatus() def calculate_thread(self, extrapolation: str) -> None: - """Perform Invariant calculations.""" - self.update_from_model() + """Perform Invariant calculations. + + 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) + + def _safe_update_model(widget_const, value): + _ui(self.update_model_from_thread, widget_const, value) - # 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: + def _compute_low() -> 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 + + # choose function and power value if self._low_guinier: function_low = "guinier" self._low_power_value = None @@ -456,51 +455,40 @@ def calculate_thread(self, extrapolation: str) -> None: elif self._low_fix: self._low_power_value = float(self.model.item(WIDGETS.W_LOWQ_POWER_VALUE_EX).text()) + # determine number of points try: q_end_val: float = float(self.txtGuinierEnd_ex.text()) - - # 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 - 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") - self._low_points = n_pts - - except ValueError: - logger.warning("Could not convert low-q Guinier end value to number of points: {str(ex)}") + except ValueError as ex: + logger.warning(f"Could not convert low-q Guinier end: {ex}") self._calculator.set_extrapolation( range="low", npts=int(self._low_points), function=function_low, power=self._low_power_value ) 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 + 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_low, qstar_low_err = self._calculator.get_qstar_low(low_q_limit) - low_calculation_pass = True + qstar, qstar_err = self._calculator.get_qstar_low(low_q_limit) + success = 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" + logger.warning(f"Low-q calculation failed: {ex}") + qstar, qstar_err = "ERROR", "ERROR" + + return qstar, qstar_err, success + + def _compute_high() -> 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 + + if not self._high_extrapolate: + return qstar, qstar_err, success + + function_high = "power_law" if self._high_fit: self._high_power_value = None elif self._high_fix: @@ -508,191 +496,210 @@ def calculate_thread(self, extrapolation: str) -> None: try: q_start_val = float(self.txtPorodStart_ex.text()) - - # Find the index of the data point closest to q_start_val idx = int((np.abs(self._data.x - q_start_val)).argmin()) - - # Compute number of points from that index to the end n_pts_high: int = len(self._data.x) - idx - 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") - self._high_points = n_pts_high - - except ValueError: - logger.warning("Could not convert Porod start value to number of points.") + except ValueError as ex: + logger.warning(f"Could not convert Porod start: {ex}") self._calculator.set_extrapolation( range="high", npts=int(self._high_points), function=function_high, power=self._high_power_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 + 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_high, qstar_high_err = self._calculator.get_qstar_high(high_q_limit) - high_calculation_pass: bool = True + qstar, qstar_err = self._calculator.get_qstar_high(high_q_limit) + success = 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" - - 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"] - 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: {str(ex)}" - volume_fraction, volume_fraction_error = "ERROR", "ERROR" + logger.warning(f"High-q calculation failed: {ex}") + qstar, qstar_err = "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) + return qstar, qstar_err, success - # 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 - - 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) + try: + self.update_from_model() - if qstar_high == "ERROR": - qstar_high, qstar_high_err = 0.0, 0.0 - if qstar_low == "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 = _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 = _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: + qstar_data, qstar_data_err = self._calculator.get_qstar_with_error() + except Exception as ex: + calculation_failed = True + 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) + 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 + ) - 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 - ) + _safe_update_model(WIDGETS.W_INVARIANT, qstar_total) + _safe_update_model(WIDGETS.W_INVARIANT_ERR, qstar_total_error) - 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 - 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.""" diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/RealDataTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/RealDataTest.py new file mode 100644 index 0000000000..bd03d920df --- /dev/null +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/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/Tests/conftest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py index 19ec295267..b71ef1ee08 100644 --- a/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py @@ -5,7 +5,6 @@ # Ensure Qt runs headless in CI environments os.environ.setdefault("QT_QPA_PLATFORM", os.environ.get("QT_QPA_PLATFORM", "offscreen")) -import sys from collections.abc import Iterator import pytest @@ -127,7 +126,6 @@ def real_data() -> Data1D: data.filename = "real_data.txt" return data - @pytest.fixture def dummy_manager() -> DummyManager: return DummyManager() @@ -140,7 +138,7 @@ def qapp() -> Iterator[QApplication]: """ app = QApplication.instance() if app is None: - app = QApplication(sys.argv) + app = QApplication([]) yield app @@ -170,30 +168,8 @@ def window_class(qapp, mocker, request: SubRequest) -> Iterator[InvariantWindow] request.cls.window = w w.show() - qapp.processEvents() + QApplication.processEvents() yield w w.close() - qapp.processEvents() - - -@pytest.fixture -def window_with_small_data(window_class, small_data: Data1D, dummy_manager): - """Creates an InvariantWindow with data loaded.""" - data_item = dummy_manager.createGuiData(small_data) - window_class.setData([data_item]) - yield window_class - - # Clean up - window_class.close() - - -@pytest.fixture -def window_with_real_data(window_class, real_data: Data1D, dummy_manager): - """Creates an InvariantWindow with data loaded.""" - data_item = dummy_manager.createGuiData(real_data) - window_class.setData([data_item]) - yield window_class - - # Clean up - window_class.close() + QApplication.processEvents() From 9b58601d7710ad7961db1c97fddc36a7fe899c79 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Thu, 26 Feb 2026 11:00:51 +0000 Subject: [PATCH 05/13] Initialisation tests addition and correction --- .../Invariant/Tests/InitializedStateTest.py | 126 +++++++++++++----- 1 file changed, 91 insertions(+), 35 deletions(-) diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py index 2943244b1b..537a4af97a 100644 --- a/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py @@ -9,7 +9,6 @@ @pytest.mark.usefixtures("window_class") class TestInvariantDefaults: - def test_window_identity(self): """Test the InvariantWindow identity.""" assert isinstance(self.window, QtWidgets.QDialog) @@ -33,11 +32,27 @@ def test_numeric_line_edit_defaults(self, field_name, expected): 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"] + 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): @@ -76,11 +91,20 @@ def test_tooltips_present(self, widget_name, expected_tooltip): 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" + "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) @@ -92,42 +116,70 @@ def test_validators(self, field_name): 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) + @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) + @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) + @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]) + @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) @@ -138,9 +190,9 @@ def test_rb_groups(self, rb_group, rb_list): 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) + @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) @@ -167,7 +219,8 @@ def test_default_extrapolation_state(self): (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]) + + @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" @@ -180,12 +233,13 @@ def test_update_from_model_line_edits(self, model_item: int, variable_name: str) (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]) + + @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) == True + assert getattr(self.window, variable_name) GUI_LINE_EDITS = [ ("txtBackgd", WIDGETS.W_BACKGROUND), @@ -199,12 +253,14 @@ def test_update_from_model_rb_and_chks(self, model_item: int, variable_name: str ("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]) + + @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() From 5ca91b56efed23353479443338cbb0d1be986e39 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Thu, 26 Feb 2026 11:02:48 +0000 Subject: [PATCH 06/13] Adds tests related to the calculation button --- .../Invariant/InvariantPerspective.py | 168 +++++++------- .../Invariant/Tests/CalculationTest.py | 214 ++++++++++++++++++ 2 files changed, 295 insertions(+), 87 deletions(-) create mode 100644 src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index 4c03b7544c..d8ea4d7246 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 @@ -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() @@ -423,101 +430,88 @@ def update_details_widget(self) -> None: if self.detailsDialog.isVisible(): self.onStatus() - def calculate_thread(self, extrapolation: str) -> None: - """Perform Invariant calculations. + 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 - 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) - - def _safe_update_model(widget_const, value): - _ui(self.update_model_from_thread, widget_const, value) - - def _compute_low() -> 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 + if not self._low_extrapolate: + return qstar, qstar_err, success - # choose function and power value - 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()) - # determine number of points - try: - q_end_val: float = float(self.txtGuinierEnd_ex.text()) - n_pts: int = int(np.abs(self._data.x - q_end_val).argmin()) + 1 - 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") - self._low_points = n_pts - except ValueError as ex: - logger.warning(f"Could not convert low-q Guinier end: {ex}") - - self._calculator.set_extrapolation( - range="low", npts=int(self._low_points), function=function_low, power=self._low_power_value - ) + # determine number of points + q_end_val: float = float(self.txtGuinierEnd_ex.text()) + self.set_low_q_extrapolation_upper_limit(q_end_val) - 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._calculator.set_extrapolation( + range="low", npts=self._low_points, function=function_low, power=self._low_power_value + ) + 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" + + return qstar, qstar_err, success + + 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 + + if not self._high_extrapolate: return qstar, qstar_err, success - def _compute_high() -> 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 + 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()) - if not self._high_extrapolate: - return qstar, qstar_err, success + q_start_val = float(self.txtPorodStart_ex.text()) + self.set_high_q_extrapolation_lower_limit(q_start_val) - 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()) + self._calculator.set_extrapolation( + range="high", npts=self._high_points, function=function_high, power=self._high_power_value + ) - try: - q_start_val = float(self.txtPorodStart_ex.text()) - idx = int((np.abs(self._data.x - q_start_val)).argmin()) - n_pts_high: int = len(self._data.x) - idx - 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") - self._high_points = n_pts_high - except ValueError as ex: - logger.warning(f"Could not convert Porod start: {ex}") - - self._calculator.set_extrapolation( - range="high", npts=int(self._high_points), function=function_high, power=self._high_power_value - ) + 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" - 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" + return qstar, qstar_err, success - return qstar, qstar_err, success + def calculate_thread(self, extrapolation: str) -> None: + """Perform Invariant calculations. + + 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) + + def _safe_update_model(widget_const, value): + _ui(self.update_model_from_thread, widget_const, value) try: self.update_from_model() @@ -536,7 +530,7 @@ def _compute_high() -> tuple[float | Literal["ERROR"], float | Literal["ERROR"], self._calculator.set_data(temp_data) # low / high computations - qstar_low, qstar_low_err, low_success = _compute_low() + 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) @@ -550,7 +544,7 @@ def _compute_high() -> tuple[float | Literal["ERROR"], float | Literal["ERROR"], _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 = _compute_high() + 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: diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py new file mode 100644 index 0000000000..3fd792a50a --- /dev/null +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py @@ -0,0 +1,214 @@ +"""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 + + +@pytest.mark.parametrize("window_class", ["real_data"], indirect=True) +@pytest.mark.usefixtures("window_class") +class TestInvariantCalculation: + 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 + ): + self.window._low_extrapolate = low_q_enabled + self.window._high_extrapolate = high_q_enabled + + mock_enable = mocker.patch.object(self.window, "enable_calculation") + 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") + + 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_once_with(self.window.deferredPlot) + 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() + + def test_deferred_plot(self, mocker): + """Test that the deferredPlot method updates the plot and checks if the button can be reenabled.""" + mock_call = mocker.patch.object(Invariant.InvariantPerspective, "reactor").callFromThread + + mock_plot = mocker.patch.object(self.window, "plot_result") + mock_check = mocker.patch.object(self.window, "check_status") + + mock_model = mocker.Mock() + + self.window.deferredPlot(mock_model) + + mock_call.assert_called_once() + + # Check that the first arg is plot_result and it is called with the model + calledfunc = mock_call.call_args[0][0] + # assert callable(calledfunc) + calledfunc() + mock_plot.assert_called_once_with(mock_model) + + mock_check.assert_called_once() + + @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 + ): + 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] From 1fbb7bc080578184d17c16e5ce0d1b965b635f61 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 2 Mar 2026 11:04:26 +0000 Subject: [PATCH 07/13] Tests for invariant calculations and plots --- .../Invariant/InvariantPerspective.py | 6 +- .../Invariant/Tests/CalculationTest.py | 396 ++++++++++++++++-- 2 files changed, 375 insertions(+), 27 deletions(-) diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index d8ea4d7246..d06d085f83 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -125,9 +125,11 @@ def initialize_variables(self) -> None: self._low_guinier: bool = True self._low_fit: bool = False self._low_power_value: float = DEFAULT_POWER_VALUE + # self._low_points: int = 0 self._high_extrapolate: bool = False self._high_fit: bool = False self._high_power_value: float | None = DEFAULT_POWER_VALUE + # self._high_points: int = 0 # Define plots self.high_extrapolation_plot: PlotterData | None = None @@ -500,7 +502,7 @@ def compute_high(self) -> tuple[float | Literal["ERROR"], float | Literal["ERROR return qstar, qstar_err, success - def calculate_thread(self, extrapolation: str) -> None: + def calculate_thread(self, extrapolation: str | None) -> None: """Perform Invariant calculations. This function runs in a worker thread (deferToThread). It must not @@ -636,6 +638,8 @@ def _safe_update_model(widget_const, value): # 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}]" diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py b/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py index 3fd792a50a..9d0bdaad22 100644 --- a/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py @@ -8,11 +8,332 @@ 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.Tests.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 TestInvariantCalculation: +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") @@ -36,13 +357,15 @@ def test_on_calculate_emits_slot(self, mocker): 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_enable = mocker.patch.object(self.window, "enable_calculation") 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() @@ -52,7 +375,13 @@ def test_calculate_invariant_starts_thread_and_attaches_callbacks( # calculate_thread should not be called synchronously but deferred to the thread mock_calc_thread.assert_not_called() - # mock_deferred.addCallback.assert_called_once_with(self.window.deferredPlot) + 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): @@ -72,26 +401,48 @@ def test_on_calculation_failed(self, mocker): mock_check.assert_called_once_with() - def test_deferred_plot(self, mocker): + @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.""" - mock_call = mocker.patch.object(Invariant.InvariantPerspective, "reactor").callFromThread + + # 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_model = mocker.Mock() - self.window.deferredPlot(mock_model) + if extrapolation is None: + self.window.extrapolation_made = True - mock_call.assert_called_once() + self.window.deferredPlot(mock_model, extrapolation=extrapolation) - # Check that the first arg is plot_result and it is called with the model - calledfunc = mock_call.call_args[0][0] - # assert callable(calledfunc) - calledfunc() + 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", @@ -100,9 +451,8 @@ def test_deferred_plot(self, mocker): ("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 - ): + 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) @@ -119,7 +469,7 @@ def test_no_extrapolation_early_return( (False, True, False, "power_law"), (False, False, True, "power_law"), ], - ids = ["guinier", "fit", "fix"] + 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.""" @@ -136,15 +486,12 @@ def test_compute_low(self, mocker, low_guinier, low_fit, low_fix, expected_funct 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() + 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 + 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) @@ -171,7 +518,7 @@ def test_compute_low_exception_handling(self, mocker): (True, False), (False, True), ], - ids = ["fit", "fix"] + 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.""" @@ -190,10 +537,7 @@ def test_compute_high(self, mocker, high_fit, high_fix): 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 + 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) From 8f5668bf10ecbfd1476e12326040d969ce739f0c Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 2 Mar 2026 11:07:26 +0000 Subject: [PATCH 08/13] Removes old tests --- .../{Tests => UnitTesting}/CalculationTest.py | 0 .../InitializedStateTest.py | 0 .../InvariantLoadedDataTest.py | 0 .../UnitTesting/InvariantPerspectiveTest.py | 436 ------------------ .../{Tests => UnitTesting}/RealDataTest.py | 0 .../Invariant/UnitTesting/__init__.py | 0 .../{Tests => UnitTesting}/conftest.py | 0 7 files changed, 436 deletions(-) rename src/sas/qtgui/Perspectives/Invariant/{Tests => UnitTesting}/CalculationTest.py (100%) rename src/sas/qtgui/Perspectives/Invariant/{Tests => UnitTesting}/InitializedStateTest.py (100%) rename src/sas/qtgui/Perspectives/Invariant/{Tests => UnitTesting}/InvariantLoadedDataTest.py (100%) delete mode 100644 src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantPerspectiveTest.py rename src/sas/qtgui/Perspectives/Invariant/{Tests => UnitTesting}/RealDataTest.py (100%) delete mode 100755 src/sas/qtgui/Perspectives/Invariant/UnitTesting/__init__.py rename src/sas/qtgui/Perspectives/Invariant/{Tests => UnitTesting}/conftest.py (100%) diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py similarity index 100% rename from src/sas/qtgui/Perspectives/Invariant/Tests/CalculationTest.py rename to src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py similarity index 100% rename from src/sas/qtgui/Perspectives/Invariant/Tests/InitializedStateTest.py rename to src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py similarity index 100% rename from src/sas/qtgui/Perspectives/Invariant/Tests/InvariantLoadedDataTest.py rename to src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py 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/Tests/RealDataTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/RealDataTest.py similarity index 100% rename from src/sas/qtgui/Perspectives/Invariant/Tests/RealDataTest.py rename to src/sas/qtgui/Perspectives/Invariant/UnitTesting/RealDataTest.py 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/Tests/conftest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py similarity index 100% rename from src/sas/qtgui/Perspectives/Invariant/Tests/conftest.py rename to src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py From 95f8b95a65f3560d654b7c6190b6bb15f73418e0 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 2 Mar 2026 11:14:44 +0000 Subject: [PATCH 09/13] Format --- .../UnitTesting/InvariantDetailsTest.py | 27 +++++++++---------- .../UnitTesting/InvariantLoadedDataTest.py | 7 +++-- .../Invariant/UnitTesting/conftest.py | 1 + 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py index 3e8aed8bbd..f5dd083a27 100755 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py @@ -6,7 +6,7 @@ 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,18 +14,18 @@ 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.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 @@ -55,7 +55,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 +64,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 +73,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 +105,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/UnitTesting/InvariantLoadedDataTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py index dae84add80..0b16952d4e 100644 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantLoadedDataTest.py @@ -1,4 +1,4 @@ -"""Tests for the Invariant perspective with loaded data."""\ +"""Tests for the Invariant perspective with loaded data.""" # from src.sas.qtgui.Utilities.BackgroundColor import BG_DEFAULT, BG_ERROR @@ -11,7 +11,7 @@ # Default background color (transparent) BG_DEFAULT = "" # Error background color -BG_ERROR = "background-color: rgb(244, 170, 164);" +BG_ERROR = "background-color: rgb(244, 170, 164);" @pytest.mark.parametrize("window_class", ["small_data"], indirect=True) @@ -151,8 +151,7 @@ def test_enter_invalid_contrast(self): self.window.txtContrast.setText("e") self.window.txtContrast.textEdited.emit("e") assert self.window.txtContrast.styleSheet() == BG_ERROR - assert not self.window.cmdCalculate.isEnabled()\ - + assert not self.window.cmdCalculate.isEnabled() self.window.txtContrast.setText("1e-") self.window.txtContrast.textEdited.emit("1e-") assert self.window.txtContrast.styleSheet() == BG_ERROR diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py index b71ef1ee08..cc62fd2601 100644 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py @@ -126,6 +126,7 @@ def real_data() -> Data1D: data.filename = "real_data.txt" return data + @pytest.fixture def dummy_manager() -> DummyManager: return DummyManager() From fd42bba1bb12a5a7d6122d7917f28c2ff66ae55f Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 2 Mar 2026 11:45:49 +0000 Subject: [PATCH 10/13] Adds a tests for a few missing methods --- .../UnitTesting/InitializedStateTest.py | 41 +++++++++++++++++++ .../Invariant/UnitTesting/conftest.py | 3 ++ 2 files changed, 44 insertions(+) diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py index 537a4af97a..e38ed45457 100644 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InitializedStateTest.py @@ -407,6 +407,47 @@ def test_reset(self, mocker): 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: diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py index cc62fd2601..c13e2c65fa 100644 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/conftest.py @@ -51,6 +51,9 @@ def createGuiData(self, data: Data1D) -> QtGui.QStandardItem: 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: From 8f3dda12f7a7c59619cd0cb2b8aded1c95c07c16 Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 2 Mar 2026 11:58:05 +0000 Subject: [PATCH 11/13] Moves old InvariantDetailsTest to another folder --- .../InvariantDetailsTest.py | 11 ++++++----- .../Invariant/UnitTesting/CalculationTest.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) rename src/sas/qtgui/Perspectives/Invariant/{UnitTesting => OldUnitTesting}/InvariantDetailsTest.py (96%) diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py b/src/sas/qtgui/Perspectives/Invariant/OldUnitTesting/InvariantDetailsTest.py similarity index 96% rename from src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py rename to src/sas/qtgui/Perspectives/Invariant/OldUnitTesting/InvariantDetailsTest.py index f5dd083a27..de9241c3dc 100755 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/InvariantDetailsTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/OldUnitTesting/InvariantDetailsTest.py @@ -1,7 +1,8 @@ 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 @@ -20,8 +21,8 @@ def widget(self, qapp): w._model = QtGui.QStandardItemModel() 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.0))) w._model.setItem(WIDGETS.D_DATA_QSTAR_ERR, QtGui.QStandardItem(str(0.1))) @@ -29,7 +30,7 @@ def widget(self, qapp): 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 diff --git a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py index 9d0bdaad22..5e210cdd8a 100644 --- a/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py +++ b/src/sas/qtgui/Perspectives/Invariant/UnitTesting/CalculationTest.py @@ -9,7 +9,7 @@ from sas.qtgui.Perspectives import Invariant from sas.qtgui.Perspectives.Invariant.InvariantUtils import WIDGETS -from sas.qtgui.Perspectives.Invariant.Tests.RealDataTest import UIHelpersMixin +from sas.qtgui.Perspectives.Invariant.UnitTesting.RealDataTest import UIHelpersMixin from sas.qtgui.Plotting.PlotterData import Data1D From ebb06ab19a4d7154bcae3a27996c8e0a800d17fc Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Mon, 2 Mar 2026 15:18:00 +0000 Subject: [PATCH 12/13] Copilot review suggstions --- .../qtgui/Perspectives/Invariant/InvariantPerspective.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index d06d085f83..9862262c61 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -124,12 +124,10 @@ 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_points: int = 0 + 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 - # self._high_points: int = 0 # Define plots self.high_extrapolation_plot: PlotterData | None = None @@ -1148,8 +1146,8 @@ def updateFromGui(self) -> None: "txtPorodCstErr": "_porod_err", "txtVolFrac1": "_volfrac1", "txtVolFrac1Err": "_volfrac1_err", - "txtLowQPower_ex": "_low_q_power_ex", - "txtHighQPower_ex": "_high_q_power_ex", + "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) From 085791d26bbb8f01c636ec4f84b116bb7f270d9e Mon Sep 17 00:00:00 2001 From: jellybean2004 Date: Thu, 5 Mar 2026 14:48:52 +0000 Subject: [PATCH 13/13] Updates QRangeSlider tests for Invariant --- .../Invariant/InvariantPerspective.py | 5 - .../{Quarantine => }/QRangeSliderTests.py | 91 +++++++++++++++---- .../qtgui/Plotting/UnitTesting/conftest.py | 4 + 3 files changed, 75 insertions(+), 25 deletions(-) rename src/sas/qtgui/Plotting/UnitTesting/{Quarantine => }/QRangeSliderTests.py (72%) create mode 100644 src/sas/qtgui/Plotting/UnitTesting/conftest.py diff --git a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py index 9862262c61..5cab70bb1d 100644 --- a/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py +++ b/src/sas/qtgui/Perspectives/Invariant/InvariantPerspective.py @@ -392,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 @@ -405,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 ) 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")