From e8688c7074cceb9dbf1e90775337bbf9876d3f2b Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Mon, 20 Apr 2026 09:42:32 +0100 Subject: [PATCH 1/5] Adds command test --- tests/conftest.py | 44 ++++++++++++++- tests/core/test_commands.py | 93 +++++++++++++++++++++++++++++++ tests/{core => }/test_settings.py | 0 tests/ui/test_presenter.py | 4 +- 4 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 tests/core/test_commands.py rename tests/{core => }/test_settings.py (100%) diff --git a/tests/conftest.py b/tests/conftest.py index ff31b498..a3912384 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ import tempfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import patch, MagicMock +import os +os.environ["DELAY_MATLAB_START"] = "1" import pytest from PyQt6 import QtCore, QtWidgets @@ -19,6 +21,11 @@ def global_setting(): return GLOBAL_SETTING +@pytest.fixture +def mock_window_view(): + return MockWindowView() + + @pytest.fixture(scope="session", autouse=True) def mock_setting(request): global GLOBAL_SETTING @@ -38,3 +45,38 @@ def teardown_mock_setting(): target.stop() request.addfinalizer(teardown_mock_setting) + + +class MockUndoStack: + """A mock Undo stack.""" + + def __init__(self): + self.stack = [] + self.clean = True + + def push(self, command): + self.clean = False + command.redo() + + def setClean(self): + self.clean = True + + def isClean(self): + return self.clean + + +class MockWindowView(QtWidgets.QMainWindow): + """A mock MainWindowView class.""" + + def __init__(self): + super().__init__() + self.undo_stack = MockUndoStack() + self.controls_widget = MagicMock() + self.project_widget = MagicMock() + self.terminal_widget = MagicMock() + self.plot_widget = MagicMock() + self.handle_results = MagicMock() + self.settings = MagicMock() + self.get_project_folder = lambda: "new path/" + self.windowTitle = lambda: "RasCAL2" + self.show_message = MagicMock() diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py new file mode 100644 index 00000000..7eb0aaec --- /dev/null +++ b/tests/core/test_commands.py @@ -0,0 +1,93 @@ +"""Tests for the undo Command classes.""" + +from unittest.mock import MagicMock, patch + +import pytest +from ratapi import Controls, Project +from ratapi.rat_core import ProblemDefinition + +from rascal2.ui.presenter import MainWindowPresenter +from rascal2.core.commands import CommandID, EditProject, EditControls, SaveCalculationOutputs + + +@pytest.fixture +def presenter(mock_window_view): + with ( + patch("rascal2.ui.presenter.LOGGER", autospec=True) as mock_log, + patch("rascal2.ui.model.os.chdir", autospec=True), + ): + pr = MainWindowPresenter(mock_window_view) + results = MagicMock() + results.calculationResults.sumChi = 45 + pr.quick_run = MagicMock(return_value=results) + pr.model.controls = Controls() + pr.model.project = Project() + pr.model.results = None + pr.model.result_log = "" + # pr.model.save_path = "some_path/" + # pr.logger = mock_log + + yield pr + + +def test_edit_controls(presenter): + command = EditControls({"procedure": "de", "targetValue": 3}, presenter) + assert command.id() == CommandID.EditControls + assert presenter.model.controls.procedure == "calculate" + assert presenter.model.controls.targetValue == 1 + command.redo() + assert presenter.model.controls.procedure == "de" + assert presenter.model.controls.targetValue == 3 + command.undo() + assert presenter.model.controls.procedure == "calculate" + assert presenter.model.controls.targetValue == 1 + + +def test_edit_project(presenter): + command = EditProject({"model": "custom layers"}, presenter) + assert command.id() == CommandID.EditProject + assert presenter.model.project.model == "standard layers" + command.redo() + assert presenter.model.project.model == "custom layers" + command.undo() + assert presenter.model.project.model == "standard layers" + + +def test_edit_project_preview(presenter): + command = EditProject({"model": "custom layers"}, presenter, preview=True) + command.redo() + presenter.quick_run.assert_called_once() + assert presenter.model.results.calculationResults.sumChi == 45 + command.undo() + assert presenter.model.results == None + command.redo() + # confirm quick_run is always done once + presenter.quick_run.assert_called_once() + assert presenter.model.results.calculationResults.sumChi == 45 + + presenter.quick_run.side_effect = ValueError("calculate error") + command = EditProject({"model": "custom layers"}, presenter, preview=True) + command.redo() + # run failed so result is None + assert command.new_result is None + + + +def test_save_calculation_outputs(presenter): + project = ProblemDefinition() + project.params = [4.5] + results = MagicMock() + results.calculationResults.sumChi = 45 + log = "Stuff happened during calculation" + command = SaveCalculationOutputs(project, results, log, presenter) + assert presenter.model.project.parameters[0].value == 3 + assert presenter.model.results is None + assert presenter.model.result_log == "" + command.redo() + assert presenter.model.project.parameters[0].value == 4.5 + assert presenter.model.results.calculationResults.sumChi == 45 + assert presenter.model.result_log == log + command.undo() + assert presenter.model.project.parameters[0].value == 3 + assert presenter.model.results is None + assert presenter.model.result_log == "" diff --git a/tests/core/test_settings.py b/tests/test_settings.py similarity index 100% rename from tests/core/test_settings.py rename to tests/test_settings.py diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index 0d85e053..d99d664f 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -49,12 +49,12 @@ def __init__(self): @pytest.fixture -def presenter(): +def presenter(mock_window_view): with ( patch("rascal2.ui.presenter.LOGGER", autospec=True) as mock_log, patch("rascal2.ui.model.os.chdir", autospec=True), ): - pr = MainWindowPresenter(MockWindowView()) + pr = MainWindowPresenter(mock_window_view) pr.runner = MagicMock() pr.model.controls = Controls() pr.model.project = MagicMock() From b5b3d596cd3fb19d07bc80d9e367e4f12c429f75 Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Thu, 14 May 2026 16:45:50 +0100 Subject: [PATCH 2/5] more tests --- rascal2/widgets/__init__.py | 11 ++- tests/conftest.py | 6 +- tests/core/test_commands.py | 9 +-- tests/dialogs/test_about_dialog.py | 10 ++- tests/widgets/project/test_project.py | 93 +++++++++-------------- tests/widgets/project/test_slider_view.py | 51 +++++++------ tests/widgets/test_inputs.py | 30 +++++++- 7 files changed, 115 insertions(+), 95 deletions(-) diff --git a/rascal2/widgets/__init__.py b/rascal2/widgets/__init__.py index 92ec968e..dab74dc0 100644 --- a/rascal2/widgets/__init__.py +++ b/rascal2/widgets/__init__.py @@ -1,5 +1,12 @@ from rascal2.widgets.controls import ControlsWidget -from rascal2.widgets.inputs import AdaptiveDoubleSpinBox, MultiSelectComboBox, MultiSelectList, get_validated_input +from rascal2.widgets.inputs import ( + AdaptiveDoubleSpinBox, + MultiSelectComboBox, + MultiSelectList, + PathWidget, + ProgressButton, + get_validated_input, +) from rascal2.widgets.plot import PlotWidget from rascal2.widgets.project.slider_view import SliderViewWidget from rascal2.widgets.terminal import TerminalWidget @@ -10,7 +17,9 @@ "get_validated_input", "MultiSelectComboBox", "MultiSelectList", + "PathWidget", "PlotWidget", + "ProgressButton", "TerminalWidget", "SliderViewWidget", ] diff --git a/tests/conftest.py b/tests/conftest.py index a3912384..a50fc024 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ +import os import tempfile from pathlib import Path -from unittest.mock import patch, MagicMock -import os +from unittest.mock import MagicMock, patch os.environ["DELAY_MATLAB_START"] = "1" import pytest @@ -71,6 +71,7 @@ class MockWindowView(QtWidgets.QMainWindow): def __init__(self): super().__init__() self.undo_stack = MockUndoStack() + self.presenter = MagicMock() self.controls_widget = MagicMock() self.project_widget = MagicMock() self.terminal_widget = MagicMock() @@ -80,3 +81,4 @@ def __init__(self): self.get_project_folder = lambda: "new path/" self.windowTitle = lambda: "RasCAL2" self.show_message = MagicMock() + self.toggle_sliders = MagicMock() diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py index 7eb0aaec..7421e937 100644 --- a/tests/core/test_commands.py +++ b/tests/core/test_commands.py @@ -6,14 +6,14 @@ from ratapi import Controls, Project from ratapi.rat_core import ProblemDefinition +from rascal2.core.commands import CommandID, EditControls, EditProject, SaveCalculationOutputs from rascal2.ui.presenter import MainWindowPresenter -from rascal2.core.commands import CommandID, EditProject, EditControls, SaveCalculationOutputs @pytest.fixture def presenter(mock_window_view): with ( - patch("rascal2.ui.presenter.LOGGER", autospec=True) as mock_log, + patch("rascal2.ui.presenter.LOGGER", autospec=True), patch("rascal2.ui.model.os.chdir", autospec=True), ): pr = MainWindowPresenter(mock_window_view) @@ -24,8 +24,6 @@ def presenter(mock_window_view): pr.model.project = Project() pr.model.results = None pr.model.result_log = "" - # pr.model.save_path = "some_path/" - # pr.logger = mock_log yield pr @@ -59,7 +57,7 @@ def test_edit_project_preview(presenter): presenter.quick_run.assert_called_once() assert presenter.model.results.calculationResults.sumChi == 45 command.undo() - assert presenter.model.results == None + assert presenter.model.results is None command.redo() # confirm quick_run is always done once presenter.quick_run.assert_called_once() @@ -72,7 +70,6 @@ def test_edit_project_preview(presenter): assert command.new_result is None - def test_save_calculation_outputs(presenter): project = ProblemDefinition() project.params = [4.5] diff --git a/tests/dialogs/test_about_dialog.py b/tests/dialogs/test_about_dialog.py index f621fb84..1f83b3c3 100644 --- a/tests/dialogs/test_about_dialog.py +++ b/tests/dialogs/test_about_dialog.py @@ -8,7 +8,9 @@ @patch("rascal2.dialogs.about_dialog.MatlabHelper", autospec=True) def test_update_info_works(mock_matlab): """Check if `update_rascal_info` adds all necessary information to the dialog.""" - mock_matlab.return_value = MagicMock() + helper = MagicMock() + mock_matlab.return_value = helper + helper.get_matlab_path.return_value = "Test_Path" parent = QtWidgets.QMainWindow() about = AboutDialog(parent) assert about._rascal_label.text() == "information about RASCAL-2" @@ -17,5 +19,9 @@ def test_update_info_works(mock_matlab): rascal_info = about._rascal_label.text() assert "Version" in rascal_info assert "RasCAL 2" in rascal_info - assert "Matlab Path:" in rascal_info + assert "Matlab Path:Test_Path" in rascal_info assert "Log File:" in rascal_info + + helper.get_matlab_path.return_value = "" + about.update_rascal_info() + assert "Matlab Path:None" in about._rascal_label.text() diff --git a/tests/widgets/project/test_project.py b/tests/widgets/project/test_project.py index 493e4b43..075ae747 100644 --- a/tests/widgets/project/test_project.py +++ b/tests/widgets/project/test_project.py @@ -7,12 +7,7 @@ from ratapi.utils.enums import Calculations, Geometries, LayerModels from rascal2.widgets.project.project import ProjectTabWidget, ProjectWidget, create_draft_project -from rascal2.widgets.project.tables import ( - ClassListTableModel, - ParameterFieldWidget, - ParametersModel, - ProjectFieldWidget, -) +from rascal2.widgets.project.tables import ParameterFieldWidget, ProjectFieldWidget class MockModel(QtCore.QObject): @@ -31,15 +26,6 @@ def __init__(self): self.edit_project = MagicMock() -class MockMainWindow(QtWidgets.QMainWindow): - def __init__(self): - super().__init__() - self.presenter = MockPresenter() - self.controls_widget = MagicMock() - self.project_widget = None - self.toggle_sliders = MagicMock() - - class DataModel(pydantic.BaseModel, validate_assignment=True): """Test Pydantic model.""" @@ -47,9 +33,6 @@ class DataModel(pydantic.BaseModel, validate_assignment=True): value: int = 15 -parent = MockMainWindow() - - @pytest.fixture def classlist(): """Test ClassList.""" @@ -57,23 +40,17 @@ def classlist(): @pytest.fixture -def table_model(classlist): - """Test ClassListTableModel.""" - return ClassListTableModel(classlist, parent) - - -@pytest.fixture -def setup_project_widget(): - parent = MockMainWindow() - project_widget = ProjectWidget(parent) +def project_widget(mock_window_view): + mock_window_view.presenter = MockPresenter() + project_widget = ProjectWidget(mock_window_view) project_widget.update_project_view() return project_widget @pytest.fixture -def project_with_draft(): +def project_with_draft(mock_window_view): draft = create_draft_project(ratapi.Project()) - project = ProjectWidget(parent) + project = ProjectWidget(mock_window_view) project.draft_project = draft return project @@ -91,19 +68,8 @@ def _classlist(protected_indices): return _classlist -@pytest.fixture -def param_model(param_classlist): - def _param_model(protected_indices): - model = ParametersModel(param_classlist(protected_indices), parent) - return model - - return _param_model - - -def test_project_widget_initial_state(setup_project_widget): - """Tests the inital state of the ProjectWidget class.""" - project_widget = setup_project_widget - +def test_project_widget_initial_state(project_widget): + """Tests the initial state of the ProjectWidget class.""" # Check the layout of the project view assert project_widget.stacked_widget.currentIndex() == 0 @@ -146,10 +112,8 @@ def test_project_widget_initial_state(setup_project_widget): assert project_widget.edit_project_tab.currentIndex() == 0 -def test_edit_cancel_button_toggle(setup_project_widget): +def test_edit_cancel_button_toggle(project_widget): """Tests clicking the edit button causes the stacked widget to change state.""" - project_widget = setup_project_widget - assert project_widget.stacked_widget.currentIndex() == 0 project_widget.edit_project_button.click() assert project_widget.stacked_widget.currentIndex() == 1 @@ -166,10 +130,24 @@ def test_edit_cancel_button_toggle(setup_project_widget): assert project_widget.calculation_type.text() == Calculations.Normal -def test_save_changes_to_model_project(setup_project_widget): - """Tests that making changes to the project settings.""" - project_widget = setup_project_widget +def test_show_slider_view(project_widget): + assert project_widget.stacked_widget.currentIndex() == 0 + project_widget.show_slider_view() + assert project_widget.stacked_widget.currentIndex() == 2 + slider_view = project_widget.stacked_widget.currentWidget() + assert len(slider_view.parameters) == 1 + project_widget.parent_model.project.parameters.append(name="test", fit=True) + project_widget.update_slider_view() + + # show slider creates a new slider + project_widget.show_slider_view() + slider_view_2 = project_widget.stacked_widget.currentWidget() + assert len(slider_view_2.parameters) == 2 + + +def test_save_changes_to_model_project(project_widget): + """Tests that making changes to the project settings.""" project_widget.edit_project_button.click() project_widget.calculation_combobox.setCurrentText(Calculations.Domains) @@ -184,10 +162,8 @@ def test_save_changes_to_model_project(setup_project_widget): assert project_widget.parent.presenter.edit_project.call_count == 1 -def test_cancel_changes_to_model_project(setup_project_widget): +def test_cancel_changes_to_model_project(project_widget): """Tests that making changes to the project settings and not saving them reverts the changes.""" - project_widget = setup_project_widget - project_widget.edit_project_button.click() project_widget.calculation_combobox.setCurrentText(Calculations.Domains) @@ -209,9 +185,8 @@ def test_cancel_changes_to_model_project(setup_project_widget): assert project_widget.geometry_type.text() == Geometries.AirSubstrate -def test_domains_tab(setup_project_widget): +def test_domains_tab(project_widget): """Tests that domain tab is visible.""" - project_widget = setup_project_widget project_widget.edit_project_button.click() project_widget.calculation_combobox.setCurrentText(Calculations.Domains) assert project_widget.draft_project["calculation"] == Calculations.Domains @@ -222,11 +197,11 @@ def test_domains_tab(setup_project_widget): assert project_widget.edit_project_tab.isTabVisible(domains_tab_index) -def test_project_tab_init(): +def test_project_tab_init(mock_window_view): """Test that the project tab correctly creates field widgets.""" fields = ["my_field", "parameters", "bulk_in"] - tab = ProjectTabWidget(fields, parent) + tab = ProjectTabWidget(fields, mock_window_view) for field in fields: if field in ratapi.project.parameter_class_lists: @@ -236,11 +211,11 @@ def test_project_tab_init(): @pytest.mark.parametrize("edit_mode", [True, False]) -def test_project_tab_update_model(classlist, param_classlist, edit_mode): +def test_project_tab_update_model(classlist, param_classlist, edit_mode, mock_window_view): """Test that updating a ProjectTabEditWidget produces the desired models.""" new_model = {"my_field": classlist, "parameters": param_classlist([])} - tab = ProjectTabWidget(list(new_model), parent, edit_mode=edit_mode) + tab = ProjectTabWidget(list(new_model), mock_window_view, edit_mode=edit_mode) # change the parent to a mock to avoid spec issues for table in tab.tables.values(): table.parent = MagicMock() @@ -260,7 +235,7 @@ def test_project_tab_update_model(classlist, param_classlist, edit_mode): ], ) @pytest.mark.parametrize("absorption", [True, False]) -def test_project_tab_validate_layers(input_params, absorption): +def test_project_tab_validate_layers(input_params, absorption, mock_window_view): """Test that the project tab produces the correct result for validating the layers tab.""" params = ["Param 1", "Param 2", "Invalid Param", ""] if absorption: @@ -300,7 +275,7 @@ def test_project_tab_validate_layers(input_params, absorption): ] ) - project = ProjectWidget(parent) + project = ProjectWidget(mock_window_view) project.draft_project = draft assert list(project.validate_layers()) == expected_err diff --git a/tests/widgets/project/test_slider_view.py b/tests/widgets/project/test_slider_view.py index cb7270c3..2aea0ba9 100644 --- a/tests/widgets/project/test_slider_view.py +++ b/tests/widgets/project/test_slider_view.py @@ -4,7 +4,6 @@ import ratapi from PyQt6 import QtWidgets -from rascal2.ui.view import MainWindowView from rascal2.widgets.project.project import create_draft_project from rascal2.widgets.project.slider_view import LabeledSlider, SliderViewWidget @@ -52,22 +51,20 @@ def draft_project(): return draft -def test_no_sliders_creation(): +def test_no_sliders_creation(mock_window_view): """Slider view should show warning when there is no fitted parameter.""" - mw = MainWindowView() draft = create_draft_project(ratapi.Project()) draft["parameters"][0].fit = False - slider_view = SliderViewWidget(draft, mw) + slider_view = SliderViewWidget(draft, mock_window_view) assert len(slider_view.parameters) == 0 assert len(slider_view._sliders) == 0 label = slider_view.slider_content_layout.takeAt(0).widget() assert label.text().startswith("There are no fitted parameters") -def test_sliders_creation(draft_project): +def test_sliders_creation(draft_project, mock_window_view): """Sliders should be created for fitted parameter only.""" - mw = MainWindowView() - slider_view = SliderViewWidget(draft_project, mw) + slider_view = SliderViewWidget(draft_project, mock_window_view) assert len(slider_view.parameters) == 8 assert len(slider_view._sliders) == 8 @@ -76,30 +73,30 @@ def test_sliders_creation(draft_project): assert param_name == slider_name draft_project["parameters"][0].fit = False - slider_view = SliderViewWidget(draft_project, mw) + slider_view = SliderViewWidget(draft_project, mock_window_view) assert len(slider_view.parameters) == 7 assert draft_project["parameters"][0].name not in slider_view._sliders -def test_accept_and_cancel_slider_buttons(): - mw = MainWindowView() +def test_accept_and_cancel_slider_buttons(mock_window_view): + mock_window_view.presenter = MagicMock() draft = create_draft_project(ratapi.Project()) - mw.toggle_sliders = MagicMock() - mw.plot_widget.update_plots = MagicMock() - mw.presenter.edit_project = MagicMock() + mock_window_view.toggle_sliders = MagicMock() + mock_window_view.plot_widget.update_plots = MagicMock() + mock_window_view.presenter.edit_project = MagicMock() - slider_view = SliderViewWidget(draft, mw) + slider_view = SliderViewWidget(draft, mock_window_view) buttons = slider_view.findChildren(QtWidgets.QPushButton) accept_button = buttons[0] accept_button.click() - mw.toggle_sliders.assert_called_once() - mw.presenter.edit_project.assert_called_once_with(draft) + mock_window_view.toggle_sliders.assert_called_once() + mock_window_view.presenter.edit_project.assert_called_once_with(draft) - mw.toggle_sliders.reset_mock() + mock_window_view.toggle_sliders.reset_mock() cancel_button = buttons[1] cancel_button.click() - mw.toggle_sliders.assert_called_once() - mw.plot_widget.update_plots.assert_called_once() + mock_window_view.toggle_sliders.assert_called_once() + mock_window_view.plot_widget.update_plots.assert_called_once() @pytest.mark.parametrize( @@ -110,9 +107,12 @@ def test_accept_and_cancel_slider_buttons(): ratapi.models.Parameter(name="Param 3", min=3, max=3, value=3, fit=True), ], ) -@patch("rascal2.widgets.project.slider_view.SliderViewWidget", autospec=True) -def test_labelled_slider_value(slider_view, param): - slider_view.update_result_and_plots = MagicMock() +@patch("rascal2.widgets.project.slider_view.LOGGER") +def test_labelled_slider_value(mock_logger, param, mock_window_view): + draft = create_draft_project(ratapi.Project()) + mock_window_view.presenter = MagicMock() + slider_view = SliderViewWidget(draft, mock_window_view) + slider = LabeledSlider(param, slider_view) # actual range of the slider should never change but # value would be scaled to parameter range. @@ -122,4 +122,9 @@ def test_labelled_slider_value(slider_view, param): slider._slider.setValue(79) assert param.value == slider._slider_value_to_param_value(slider._slider.value()) - slider_view.update_result_and_plots.assert_called_once() + mock_window_view.presenter.quick_run.assert_called_once() + mock_window_view.plot_widget.reflectivity_plot.plot.assert_called_once() + + mock_window_view.presenter.quick_run.side_effect = ValueError("calculate error") + slider._slider.setValue(90) + mock_logger.error.assert_called_once() diff --git a/tests/widgets/test_inputs.py b/tests/widgets/test_inputs.py index 6fa7b78d..8143aa5b 100644 --- a/tests/widgets/test_inputs.py +++ b/tests/widgets/test_inputs.py @@ -11,8 +11,14 @@ from pydantic.fields import FieldInfo from PyQt6 import QtWidgets -from rascal2.widgets import AdaptiveDoubleSpinBox, MultiSelectComboBox, MultiSelectList, get_validated_input -from rascal2.widgets.inputs import PathWidget +from rascal2.widgets import ( + AdaptiveDoubleSpinBox, + MultiSelectComboBox, + MultiSelectList, + PathWidget, + ProgressButton, + get_validated_input, +) class MyEnum(StrEnum): @@ -86,3 +92,23 @@ def test_path_widget(): widget.setText(path) assert widget.path == path.parent.as_posix() assert widget.text() == path.name + + +def test_progress_button(): + widget = ProgressButton("Progress", "Testing button") + + assert widget.text() == "Progress" + widget.default_text = "Start" + assert widget.text() == "Start" + + widget.click() + assert not widget.isEnabled() + assert widget.text() == "Testing button ..." + + widget.update_progress(1, 2) + assert not widget.isEnabled() + assert widget.text() == "Testing button - 1 of 2" + + widget.hide_progress() + assert widget.isEnabled() + assert widget.text() == "Start" From 3b9704023b9bcdec0dc5ce9ce1e522fa4b46248f Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Thu, 28 May 2026 11:36:19 +0100 Subject: [PATCH 3/5] Remove duplicate Main Window View mocks --- tests/conftest.py | 1 + tests/ui/test_presenter.py | 36 ---------------------------------- tests/widgets/test_controls.py | 20 +++---------------- tests/widgets/test_plot.py | 36 ++++++++++++---------------------- 4 files changed, 16 insertions(+), 77 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a50fc024..97160a16 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,3 +82,4 @@ def __init__(self): self.windowTitle = lambda: "RasCAL2" self.show_message = MagicMock() self.toggle_sliders = MagicMock() + self.set_editing_enabled = MagicMock() diff --git a/tests/ui/test_presenter.py b/tests/ui/test_presenter.py index d99d664f..da571b37 100644 --- a/tests/ui/test_presenter.py +++ b/tests/ui/test_presenter.py @@ -4,7 +4,6 @@ import pytest from pydantic import ValidationError -from PyQt6 import QtWidgets from ratapi import Controls from ratapi.events import ProgressEventData from ratapi.inputs import ProblemDefinition @@ -13,41 +12,6 @@ from rascal2.ui.presenter import MainWindowPresenter -class MockUndoStack: - """A mock Undo stack.""" - - def __init__(self): - self.stack = [] - self.clean = True - - def push(self, command): - self.clean = False - command.redo() - - def setClean(self): - self.clean = True - - def isClean(self): - return self.clean - - -class MockWindowView(QtWidgets.QMainWindow): - """A mock MainWindowView class.""" - - def __init__(self): - super().__init__() - self.undo_stack = MockUndoStack() - self.controls_widget = MagicMock() - self.project_widget = MagicMock() - self.terminal_widget = MagicMock() - self.plot_widget = MagicMock() - self.handle_results = MagicMock() - self.settings = MagicMock() - self.get_project_folder = lambda: "new path/" - self.windowTitle = lambda: "RasCAL2" - self.show_message = MagicMock() - - @pytest.fixture def presenter(mock_window_view): with ( diff --git a/tests/widgets/test_controls.py b/tests/widgets/test_controls.py index a17b7e4a..2203f306 100644 --- a/tests/widgets/test_controls.py +++ b/tests/widgets/test_controls.py @@ -10,25 +10,11 @@ from rascal2.widgets.controls import ControlsWidget, FitSettingsWidget -class MockWindowView(QtWidgets.QMainWindow): - """A mock MainWindowView class.""" - - def __init__(self): - super().__init__() - self.presenter = MagicMock() - self.presenter.model = MagicMock() - self.presenter.model.controls = Controls() - self.project_widget = MagicMock() - self.set_editing_enabled = MagicMock() - - -view = MockWindowView() - - @pytest.fixture -def controls_widget() -> ControlsWidget: +def controls_widget(mock_window_view) -> ControlsWidget: def _widget(): - widget = ControlsWidget(view) + mock_window_view.presenter.model.controls = Controls() + widget = ControlsWidget(mock_window_view) widget.setup_controls() return widget diff --git a/tests/widgets/test_plot.py b/tests/widgets/test_plot.py index 49ff3e95..0df35168 100644 --- a/tests/widgets/test_plot.py +++ b/tests/widgets/test_plot.py @@ -12,29 +12,17 @@ ) -class MockWindowView(QtWidgets.QMainWindow): - """A mock MainWindowView class.""" - - def __init__(self): - super().__init__() - self.presenter = MagicMock() - self.presenter.model = MagicMock() - - -view = MockWindowView() - - @pytest.fixture -def plot_widget(): - plot_widget = PlotWidget(view) +def plot_widget(mock_window_view): + plot_widget = PlotWidget(mock_window_view) plot_widget.reflectivity_plot = MagicMock() return plot_widget @pytest.fixture -def sld_widget(): - sld_widget = RefSLDWidget(view) +def sld_widget(mock_window_view): + sld_widget = RefSLDWidget(mock_window_view) sld_widget.canvas = MagicMock() sld_widget.update_figure_size = MagicMock() @@ -42,8 +30,8 @@ def sld_widget(): @pytest.fixture -def shaded_plot_widget(): - shaded_plot_widget = ShadedPlotWidget(view) +def shaded_plot_widget(mock_window_view): + shaded_plot_widget = ShadedPlotWidget(mock_window_view) shaded_plot_widget.canvas = MagicMock() return shaded_plot_widget @@ -171,11 +159,11 @@ def test_ref_sld_plot(mock_inputs, sld_widget): sld_widget.canvas.draw.assert_called_once() -def test_param_combobox_items(mock_bayes_results): +def test_param_combobox_items(mock_bayes_results, mock_window_view): """Test that the parameter multi-select combobox items are the full set of fit parameters.""" bayes_results = mock_bayes_results(["A", "B", "C"]) - widget = MockPanelPlot(view) + widget = MockPanelPlot(mock_window_view) widget.plot(None, bayes_results) assert widget.all_params == ["A", "B", "C"] @@ -188,11 +176,11 @@ def test_param_combobox_items(mock_bayes_results): @pytest.mark.parametrize("init_select", ([], ["A", "C"], ["B"], ["A", "B", "C"])) -def test_param_combobox_select(mock_bayes_results, init_select): +def test_param_combobox_select(mock_bayes_results, init_select, mock_window_view): """Test that the select button correctly selects all parameters.""" bayes_results = mock_bayes_results(["A", "B", "C"]) - widget = MockPanelPlot(view) + widget = MockPanelPlot(mock_window_view) widget.plot(None, bayes_results) widget.param_combobox.select_items(init_select) @@ -211,11 +199,11 @@ def test_param_combobox_select(mock_bayes_results, init_select): @pytest.mark.parametrize("init_select", ([], ["A", "C"], ["B"], ["A", "B", "C"])) -def test_param_combobox_deselect(mock_bayes_results, init_select): +def test_param_combobox_deselect(mock_bayes_results, init_select, mock_window_view): """Test that the select button correctly selects all parameters.""" bayes_results = mock_bayes_results(["A", "B", "C"]) - widget = MockPanelPlot(view) + widget = MockPanelPlot(mock_window_view) widget.plot(None, bayes_results) widget.param_combobox.select_items(init_select) From c0db3702f4d2538f39ddd0f8e84db8ca9b78b4bd Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Fri, 29 May 2026 13:32:00 +0100 Subject: [PATCH 4/5] Add tests for setting dialog --- tests/dialogs/test_settings_dialog.py | 92 +++++++++++++++++++++++++++ tests/utils.py | 15 +++++ 2 files changed, 107 insertions(+) create mode 100644 tests/dialogs/test_settings_dialog.py diff --git a/tests/dialogs/test_settings_dialog.py b/tests/dialogs/test_settings_dialog.py new file mode 100644 index 00000000..707ba2de --- /dev/null +++ b/tests/dialogs/test_settings_dialog.py @@ -0,0 +1,92 @@ +import platform +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from rascal2.dialogs.settings_dialog import SettingsDialog +from rascal2.settings import Settings +from tests.utils import edit_line_edit_text + + +class FakeSetting: + def __init__(self): + self.settings = Settings() + self.reset_global_settings = MagicMock() + self.set_global_settings = MagicMock() + + def __getattribute__(self, name): + if name in ["settings", "reset_global_settings", "set_global_settings"]: + return object.__getattribute__(self, name) + else: + return getattr(self.settings, name) + + def __setattr__(self, name, value): + if name in ["settings", "reset_global_settings", "set_global_settings"]: + object.__setattr__(self, name, value) + else: + setattr(self.settings, name, value) + + +@patch("rascal2.dialogs.settings_dialog.SETTINGS", new_callable=FakeSetting) +@patch("rascal2.dialogs.settings_dialog.MatlabHelper") +def test_setting_dialog(mock_matlab_helper, fake_setting, mock_window_view): + mock_matlab_helper.return_value = MagicMock() + mock_matlab_helper.return_value.matlab_dir = "matlab_2023a" + + dialog = SettingsDialog(mock_window_view) + + general_tab = dialog.tab_widget.widget(0) + old_fontsize = general_tab.widgets["editor_fontsize"].editor.value() + new_fontsize = old_fontsize + 5 + edit_line_edit_text(general_tab.widgets["editor_fontsize"].editor, str(new_fontsize)) + + old_live_recalculate = general_tab.widgets["live_recalculate"].editor.isChecked() + general_tab.widgets["live_recalculate"].editor.setChecked(not old_live_recalculate) + + dialog.accept_button.click() + assert fake_setting.editor_fontsize == new_fontsize + assert fake_setting.live_recalculate != old_live_recalculate + fake_setting.set_global_settings.assert_called_once() + + dialog.reset_button.click() + fake_setting.reset_global_settings.assert_called_once() + + +@patch("rascal2.dialogs.settings_dialog.sys", autospec=True) +@patch("rascal2.dialogs.settings_dialog.QtWidgets.QFileDialog.getOpenFileName") +@patch("rascal2.dialogs.settings_dialog.QtWidgets.QFileDialog.getExistingDirectory") +@patch("rascal2.dialogs.settings_dialog.SETTINGS", new_callable=FakeSetting) +@patch("rascal2.dialogs.settings_dialog.MatlabHelper") +def test_matlab_setup(mock_matlab_helper, fake_setting, mock_get_dir, mock_get_file, mock_sys, mock_window_view): + mock_matlab_helper.return_value = MagicMock() + matlab_dir = "matlab_2023a" + mock_matlab_helper.return_value.matlab_dir = matlab_dir + + dialog = SettingsDialog(mock_window_view) + assert dialog.matlab_tab.matlab_path.text() == matlab_dir + + matlab_dir = "matlab_2024b.app" if platform.system() == "Darwin" else "matlab_2024b" + mock_get_file.return_value = matlab_dir + mock_get_dir.return_value = matlab_dir + assert not dialog.matlab_tab.changed + dialog.matlab_tab.open_folder_selector() + assert dialog.matlab_tab.changed + assert dialog.matlab_tab.matlab_path.text() == matlab_dir + + with patch("rascal2.dialogs.settings_dialog.MATLAB_ARCH_FILE", new=""): + dialog.matlab_tab.set_matlab_paths() + mock_matlab_helper.return_value.async_start.assert_called_once() + + with tempfile.TemporaryDirectory() as tmp: + file = Path(tmp, "_arch.txt") + file.write_text("arch file") + with patch("rascal2.dialogs.settings_dialog.MATLAB_ARCH_FILE", new=file.as_posix()): + dialog.matlab_tab.set_matlab_paths() + assert mock_matlab_helper.return_value.async_start.call_count == 1 + + mock_sys.frozen = True + dialog.matlab_tab.set_matlab_paths() + arch_content = file.read_text().split("\n") + assert len(arch_content) == 5 + assert arch_content[-1] == "" + assert mock_matlab_helper.return_value.async_start.call_count == 2 diff --git a/tests/utils.py b/tests/utils.py index 0a5fd8a9..6a55c254 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,6 @@ import numpy as np import ratapi.outputs +from PyQt6 import QtCore, QtTest def check_results_equal(actual_results, expected_results) -> None: @@ -90,3 +91,17 @@ def check_bayes_fields_equal(actual_results, expected_results) -> None: ) assert (actual_results.chain == expected_results.chain).all() + + +def edit_line_edit_text(line_edit, text): + """Clear and edit of a line edit. + + line_edit: QtWidgets.QLineEdit + widget to edit + text: str + The new text + """ + QtTest.QTest.keyClick(line_edit, QtCore.Qt.Key.Key_A, QtCore.Qt.KeyboardModifier.ControlModifier) + QtTest.QTest.keyClicks(line_edit, text) + QtTest.QTest.keyClick(line_edit, QtCore.Qt.Key.Key_Enter) + QtTest.QTest.qWait(100) From ccef9bd34b9337684c27d226ca52d8bfe5c3e1dd Mon Sep 17 00:00:00 2001 From: Stephen Nneji Date: Mon, 1 Jun 2026 10:55:58 +0100 Subject: [PATCH 5/5] Add more tests for custom file editor --- tests/dialogs/test_custom_file_editor.py | 105 +++++++++++++++++++---- tests/dialogs/test_settings_dialog.py | 4 +- tests/utils.py | 17 ++++ 3 files changed, 106 insertions(+), 20 deletions(-) diff --git a/tests/dialogs/test_custom_file_editor.py b/tests/dialogs/test_custom_file_editor.py index e2189814..e73a7c3f 100644 --- a/tests/dialogs/test_custom_file_editor.py +++ b/tests/dialogs/test_custom_file_editor.py @@ -1,6 +1,7 @@ """Tests for the custom file editor.""" import logging +import os import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -9,15 +10,22 @@ from PyQt6 import Qsci, QtWidgets from ratapi.utils.enums import Languages -from rascal2.dialogs.custom_file_editor import CustomFileEditorDialog, edit_file_local, edit_file_matlab - -parent = QtWidgets.QMainWindow() +from rascal2.dialogs.custom_file_editor import ( + CustomFileEditorDialog, + create_new_file, + edit_file, + edit_file_local, + edit_file_matlab, +) +from tests.utils import assert_error_logged @pytest.fixture def custom_file_dialog(): """Fixture for a custom file dialog.""" + parent = QtWidgets.QMainWindow() dlg = CustomFileEditorDialog(parent) + dlg.show = MagicMock() yield dlg dlg.reject() @@ -31,28 +39,24 @@ def temp_file(): f.close() -@patch("rascal2.dialogs.custom_file_editor.CustomFileEditorDialog.show") -def test_edit_file_local(exec_mock): +def test_edit_file_local(custom_file_dialog, mock_window_view): """Test that the dialog is executed when edit_file_local() is called on a valid file.""" with tempfile.TemporaryDirectory() as tmp: file = Path(tmp, "testfile.py") file.touch() - edit_file_local(file, Languages.Python, parent) + edit_file_local(file, Languages.Python, mock_window_view) - exec_mock.assert_called_once() + custom_file_dialog.show.assert_called_once() @pytest.mark.parametrize("filepath", ["dir/", "not_there.m"]) -@patch("rascal2.dialogs.custom_file_editor.CustomFileEditorDialog") -def test_edit_incorrect_file(dialog_mock, filepath, caplog): +def test_edit_incorrect_file(filepath, caplog, mock_window_view): """A logger error should be emitted if a directory or nonexistent file is given to the editor.""" with tempfile.TemporaryDirectory() as tmp: file = Path(tmp, filepath) - edit_file_local(file, Languages.Python, parent) + edit_file_local(file, Languages.Python, mock_window_view) - errors = [record for record in caplog.get_records("call") if record.levelno == logging.ERROR] - assert len(errors) == 1 - assert "Attempted to edit a custom file which does not exist!" in caplog.text + assert_error_logged(caplog, "Attempted to edit a custom file which does not exist!") @patch("rascal2.dialogs.custom_file_editor.MatlabHelper", autospec=True) @@ -101,9 +105,8 @@ def test_dialog_init(custom_file_dialog, temp_file, language, expected_lexer): assert custom_file_dialog.editor.text() == "Test text for a test dialog!" -@patch("rascal2.dialogs.custom_file_editor.LOGGER") @patch("rascal2.dialogs.custom_file_editor.QtWidgets.QMessageBox") -def test_dialog_save(mock_msg_box, mock_logger, custom_file_dialog): +def test_dialog_save(mock_msg_box, caplog, custom_file_dialog): """Text changes to the editor are saved to the file when save_file is called.""" temp_file = MagicMock() temp_file.read_text = MagicMock(return_value="This is a test") @@ -132,11 +135,11 @@ def test_dialog_save(mock_msg_box, mock_logger, custom_file_dialog): custom_file_dialog.save_file() temp_file.write_text.assert_called_once() assert not custom_file_dialog.is_modified - custom_file_dialog.unchanged_text = temp_file.write_text.call_args[0] + custom_file_dialog.editor.setText("User changed text") temp_file.write_text = MagicMock(side_effect=OSError) custom_file_dialog.save_file() - mock_logger.error.assert_called_once() + assert_error_logged(caplog, f"Failed to save custom file to {custom_file_dialog.file}") mock_msg_box.critical.assert_called_once() @@ -146,7 +149,6 @@ def test_save_changes_when_opening_file(mock_msg_box, custom_file_dialog, temp_f custom_file_dialog.open_file(temp_file, Languages.Python) custom_file_dialog.editor.setText("New test text...") - # Opening the same file should not trigger a save warning custom_file_dialog.open_file(temp_file, Languages.Python) @@ -167,3 +169,70 @@ def test_save_changes_when_opening_file(mock_msg_box, custom_file_dialog, temp_f # Changes should be discarded as user selected discard in msg box custom_file_dialog.open_file(temp_file, Languages.Python) assert new_file.read_text() == "This is a new file" + + +@pytest.mark.parametrize( + "language, file_type, domain", + ( + ["python", "Background", False], + ["python", "Model", True], + ["python", "Model", False], + ["matlab", "Background", False], + ["matlab", "Model", True], + ["matlab", "Model", False], + ), +) +@patch("rascal2.dialogs.custom_file_editor.edit_file", autospec=True) +def test_create_file(mock_edit_file, mock_window_view, caplog, language, file_type, domain): + with tempfile.TemporaryDirectory() as tmp: + cur_dir = os.getcwd() + try: + os.chdir(tmp) + create_new_file("hello world", language, domain, file_type, mock_window_view) + mock_edit_file.assert_called() + file = Path("hello_world.py" if language == "python" else "hello_world.m") + assert file.is_file() + assert file.read_text().find("hello_world(") != -1 + + create_new_file("hello world", language, domain, file_type, mock_window_view) + # non-unique name so file already exist + assert_error_logged( + caplog, f"The file ({file.name}) already exists, change custom file name to create a different file." + ) + finally: + os.chdir(cur_dir) + + +@pytest.mark.parametrize( + "language, file_type, domain", + ( + ["c++", "Background", False], + ["c++", "Model", True], + ), +) +@patch("rascal2.dialogs.custom_file_editor.edit_file", autospec=True) +def test_create_bad_file_type(mock_edit_file, mock_window_view, caplog, language, file_type, domain): + create_new_file("hello world", language, domain, file_type, mock_window_view) + mock_edit_file.assert_not_called() + + assert_error_logged(caplog, f"Creating a new file for {language} is not supported.") + + +@patch("rascal2.dialogs.custom_file_editor.SETTINGS", autospec=True) +@patch("rascal2.dialogs.custom_file_editor.edit_file_matlab", autospec=True) +@patch("rascal2.dialogs.custom_file_editor.edit_file_local", autospec=True) +def test_edit_file(mock_edit_file_local, mock_edit_file_matlab, mock_setting, mock_window_view): + mock_setting.matlab_as_default_editor = False + edit_file("hello", "python", mock_window_view) + mock_edit_file_local.assert_called_once() + + mock_edit_file_matlab.return_value = True + edit_file("hello", "matlab", mock_window_view) + mock_edit_file_matlab.assert_called_once() + assert mock_edit_file_local.call_count == 1 + + mock_edit_file_matlab.return_value = False # matlab editor failed so fallback on local + mock_setting.matlab_as_default_editor = True + edit_file("hello", "python", mock_window_view) + assert mock_edit_file_matlab.call_count == 2 + assert mock_edit_file_local.call_count == 2 diff --git a/tests/dialogs/test_settings_dialog.py b/tests/dialogs/test_settings_dialog.py index 707ba2de..38f9d148 100644 --- a/tests/dialogs/test_settings_dialog.py +++ b/tests/dialogs/test_settings_dialog.py @@ -57,7 +57,7 @@ def test_setting_dialog(mock_matlab_helper, fake_setting, mock_window_view): @patch("rascal2.dialogs.settings_dialog.QtWidgets.QFileDialog.getExistingDirectory") @patch("rascal2.dialogs.settings_dialog.SETTINGS", new_callable=FakeSetting) @patch("rascal2.dialogs.settings_dialog.MatlabHelper") -def test_matlab_setup(mock_matlab_helper, fake_setting, mock_get_dir, mock_get_file, mock_sys, mock_window_view): +def test_matlab_setup(mock_matlab_helper, _fake_setting, mock_get_dir, mock_get_file, mock_sys, mock_window_view): mock_matlab_helper.return_value = MagicMock() matlab_dir = "matlab_2023a" mock_matlab_helper.return_value.matlab_dir = matlab_dir @@ -66,7 +66,7 @@ def test_matlab_setup(mock_matlab_helper, fake_setting, mock_get_dir, mock_get_f assert dialog.matlab_tab.matlab_path.text() == matlab_dir matlab_dir = "matlab_2024b.app" if platform.system() == "Darwin" else "matlab_2024b" - mock_get_file.return_value = matlab_dir + mock_get_file.return_value = (matlab_dir,) mock_get_dir.return_value = matlab_dir assert not dialog.matlab_tab.changed dialog.matlab_tab.open_folder_selector() diff --git a/tests/utils.py b/tests/utils.py index 6a55c254..979ebb46 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,5 @@ +import logging + import numpy as np import ratapi.outputs from PyQt6 import QtCore, QtTest @@ -105,3 +107,18 @@ def edit_line_edit_text(line_edit, text): QtTest.QTest.keyClicks(line_edit, text) QtTest.QTest.keyClick(line_edit, QtCore.Qt.Key.Key_Enter) QtTest.QTest.qWait(100) + + +def assert_error_logged(caplog, last_error_message, expected_error_count=1): + """Assert an exception was logged. + + caplog: caplog + pytest caplog + last_error_message: str + The partial or full error message + expected_error_count: int + number of expected errors + """ + errors = [record for record in caplog.get_records("call") if record.levelno == logging.ERROR] + assert len(errors) == expected_error_count + assert last_error_message in caplog.text