diff --git a/.github/actions/linux/action.yaml b/.github/actions/linux/action.yaml index 911281bc..1a55e8b0 100644 --- a/.github/actions/linux/action.yaml +++ b/.github/actions/linux/action.yaml @@ -13,6 +13,9 @@ runs: - name: Install RasCAL2 shell: bash -l {0} run: pip install . - - name: + - name: Pytest + shell: bash -l {0} + run: xvfb-run pytest -s tests/ ${{ inputs.pytest-options }} --cov=rascal2 --cov-report=term + - name: Pytest System Tests shell: bash -l {0} run: xvfb-run pytest -s tests/ ${{ inputs.pytest-options }} --cov=rascal2 --cov-report=term diff --git a/.github/actions/windows-mac/action.yaml b/.github/actions/windows-mac/action.yaml index 7d7bd0cc..0efb7b4e 100644 --- a/.github/actions/windows-mac/action.yaml +++ b/.github/actions/windows-mac/action.yaml @@ -11,3 +11,6 @@ runs: - name: Run Pytest shell: bash -l {0} run: pytest -s tests/ ${{ inputs.pytest-options }} --cov=rascal2 --cov-report=term + - name: Run Pytest System Tests + shell: bash -l {0} + run: pytest -s tests/ ${{ inputs.pytest-options }} --cov=rascal2 --cov-report=term --run-system-tests diff --git a/tests/conftest.py b/tests/conftest.py index 38e1aeb7..021b1de7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import tempfile from pathlib import Path from unittest.mock import patch @@ -54,3 +55,39 @@ def teardown_mock_setting(): target.stop() request.addfinalizer(teardown_mock_setting) + + +def pytest_addoption(parser): + parser.addoption("--run-system-tests", action="store_true", default=False, help="Run GUI system tests offscreen") + parser.addoption("--run-system-tests-show", action="store_true", default=False, help="Run GUI system tests") + parser.addoption("--run-unit-tests", action="store_true", default=False, help="Run unit tests") + + +def pytest_configure(config): + config.addinivalue_line("markers", "system: GUI system tests") + config.addinivalue_line("markers", "unit: unit tests") + + +allowed_markers = [] +skipped_tests = [] + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--run-system-tests"): + allowed_markers.append(pytest.mark.system.mark) + os.environ["QT_QPA_PLATFORM"] = "minimal" + if config.getoption("--run-system-tests-show"): + allowed_markers.append(pytest.mark.system.mark) + os.environ["QT_QPA_PLATFORM"] = "" + if config.getoption("--run-unit-tests") or len(allowed_markers) == 0: + allowed_markers.append(pytest.mark.unit.mark) + for item in items: + if "gui_system" in item.nodeid: + item.add_marker(pytest.mark.system) + else: + item.add_marker(pytest.mark.unit) + if any(mark in allowed_markers for mark in item.own_markers): + pass + else: + item.add_marker(pytest.mark.skip(reason="Test not selected")) + skipped_tests.append(item.nodeid) diff --git a/tests/system/gui_system_base.py b/tests/system/gui_system_base.py new file mode 100644 index 00000000..ebfc8721 --- /dev/null +++ b/tests/system/gui_system_base.py @@ -0,0 +1,56 @@ +import os +import sys +import unittest +from collections.abc import Callable + +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QApplication + +from rascal2.ui.view import MainWindowView + +SHOW_DELAY = 10 # Can be increased to watch tests +SHORT_DELAY = 100 + + +def wait_until( + test_func: Callable[[], bool], delay=0.1, max_retry=100, message: str = "wait_until reached max retries" +): + """Repeat test_func every delay seconds until it becomes true. Raises RuntimeError if max_retry is reached.""" + for _ in range(max_retry): + if test_func(): + return True + QTest.qWait(int(delay * 1000)) + raise RuntimeError(message) + + +class GuiSystemBase(unittest.TestCase): + app: QApplication + + def setUp(self) -> None: + self.start_processes_old = os.getenv("START_PROCESSES") + os.environ["START_PROCESSES"] = "False" + self.no_exceptions = True + + sys.excepthook = self.exception_hook + self.main_window = MainWindowView() + self.main_window.show() + QTest.qWait(SHORT_DELAY) + + def tearDown(self) -> None: + if not self.no_exceptions: + raise Exception("An exception occurred in a PyQt slot") + sys.excepthook = sys.__excepthook__ + + self.main_window.close() + wait_until( + lambda: not self.main_window.isVisible(), + delay=0.05, + max_retry=60, + message="Main window did not close within 3 seconds", + ) + del self.main_window + os.environ["START_PROCESSES"] = self.start_processes_old + + def exception_hook(self, exc_type, exc_value, exc_traceback): + self.no_exceptions = False + sys.__excepthook__(exc_type, exc_value, exc_traceback) diff --git a/tests/system/gui_system_loading_test.py b/tests/system/gui_system_loading_test.py new file mode 100644 index 00000000..ce56062b --- /dev/null +++ b/tests/system/gui_system_loading_test.py @@ -0,0 +1,21 @@ +from PyQt6.QtTest import QTest + +from rascal2.dialogs.startup_dialog import LoadDialog +from tests.system.gui_system_base import SHORT_DELAY, GuiSystemBase + + +class TestGuiSystemLoading(GuiSystemBase): + def setUp(self) -> None: + super().setUp() + + def tearDown(self) -> None: + super().tearDown() + + def test_load(self): + QTest.qWait(SHORT_DELAY) + self.main_window.startup_dlg.import_project_button.click() + load_dialog = self.main_window.findChild(LoadDialog) + load_dialog.tabs.setCurrentIndex(2) + load_dialog.example_list_widget.itemClicked.emit(load_dialog.example_list_widget.item(0)) + QTest.qWait(SHORT_DELAY) + assert self.main_window.presenter.model.project.name == "DSPC Standard Layers" diff --git a/tests/system/gui_system_main_window_test.py b/tests/system/gui_system_main_window_test.py new file mode 100644 index 00000000..22facd46 --- /dev/null +++ b/tests/system/gui_system_main_window_test.py @@ -0,0 +1,14 @@ +from tests.system.gui_system_base import GuiSystemBase + + +class TestGuiSystemMainWindow(GuiSystemBase): + def setUp(self) -> None: + super().setUp() + + def tearDown(self) -> None: + super().tearDown() + + def test_main_window(self): + self.main_window.presenter.create_project("project", ".") + names = [win.windowTitle() for win in self.main_window.mdi.subWindowList()] + assert names == ["Fitting Controls", "Terminal", "Project", "Plots"]