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 ff31b498..a50fc024 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ +import os import tempfile from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch +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,40 @@ 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.presenter = MagicMock() + 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() + self.toggle_sliders = MagicMock() diff --git a/tests/core/test_commands.py b/tests/core/test_commands.py new file mode 100644 index 00000000..7421e937 --- /dev/null +++ b/tests/core/test_commands.py @@ -0,0 +1,90 @@ +"""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.core.commands import CommandID, EditControls, EditProject, SaveCalculationOutputs +from rascal2.ui.presenter import MainWindowPresenter + + +@pytest.fixture +def presenter(mock_window_view): + with ( + patch("rascal2.ui.presenter.LOGGER", autospec=True), + 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 = "" + + 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 is 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/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/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() 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"