From 5ef5256bb0f5c8bea51021db63e02710e456358e Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Wed, 12 Aug 2026 17:03:17 +0100 Subject: [PATCH 1/9] added gui system test base class GuiSystemBase --- tests/system/gui_system_base.py | 43 +++++++++++++++++++++++++ tests/system/gui_system_loading_test.py | 14 ++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/system/gui_system_base.py create mode 100644 tests/system/gui_system_loading_test.py diff --git a/tests/system/gui_system_base.py b/tests/system/gui_system_base.py new file mode 100644 index 00000000..41007dbf --- /dev/null +++ b/tests/system/gui_system_base.py @@ -0,0 +1,43 @@ +import os +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: + os.environ["START_PROCESSES"] = "False" + self.main_window = MainWindowView() + self.main_window.show() + QTest.qWait(SHORT_DELAY) + + def tearDown(self) -> None: + 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 diff --git a/tests/system/gui_system_loading_test.py b/tests/system/gui_system_loading_test.py new file mode 100644 index 00000000..0e19ff47 --- /dev/null +++ b/tests/system/gui_system_loading_test.py @@ -0,0 +1,14 @@ +from tests.system.gui_system_base import GuiSystemBase + + +class TestGuiSystemLoading(GuiSystemBase): + leak_count_limit = 2 + + def setUp(self) -> None: + super().setUp() + + def tearDown(self) -> None: + super().tearDown() + + def test_load(self): + pass From 90f09a5d37ea8fb5e20845a7ebcf9a53091df5fb Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Thu, 20 Aug 2026 16:28:40 +0100 Subject: [PATCH 2/9] pytests differs between system and unit tests using flags --run-system-tests and --run-unit-tests --- tests/conftest.py | 37 +++++++++++++++++++++++++++++++++ tests/system/gui_system_base.py | 4 +--- 2 files changed, 38 insertions(+), 3 deletions(-) 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 index 41007dbf..44e56ffe 100644 --- a/tests/system/gui_system_base.py +++ b/tests/system/gui_system_base.py @@ -15,9 +15,7 @@ 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. - """ + """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 From a81d640dbb32a7774202aa521bb6ffefc9e9d7c0 Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Thu, 20 Aug 2026 16:32:04 +0100 Subject: [PATCH 3/9] ruff fix --- tests/system/gui_system_base.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/system/gui_system_base.py b/tests/system/gui_system_base.py index 44e56ffe..e708ecb3 100644 --- a/tests/system/gui_system_base.py +++ b/tests/system/gui_system_base.py @@ -11,10 +11,9 @@ SHORT_DELAY = 100 -def wait_until(test_func: Callable[[], bool], - delay=0.1, - max_retry=100, - message: str = "wait_until reached max retries"): +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(): @@ -34,8 +33,10 @@ def setUp(self) -> None: def tearDown(self) -> None: 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") + 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 From 9dc28ca43bb2bde72486d9bbeddf9d310cc18d8a Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Fri, 21 Aug 2026 11:51:57 +0100 Subject: [PATCH 4/9] add exception handling to GuiSystemBase --- tests/system/gui_system_base.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/system/gui_system_base.py b/tests/system/gui_system_base.py index e708ecb3..c4f223a2 100644 --- a/tests/system/gui_system_base.py +++ b/tests/system/gui_system_base.py @@ -1,4 +1,5 @@ import os +import sys import unittest from collections.abc import Callable @@ -27,11 +28,18 @@ class GuiSystemBase(unittest.TestCase): def setUp(self) -> None: 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(), @@ -40,3 +48,7 @@ def tearDown(self) -> None: message="Main window did not close within 3 seconds", ) del self.main_window + + def exception_hook(self, exc_type, exc_value, exc_traceback): + self.no_exceptions = False + sys.__excepthook__(exc_type, exc_value, exc_traceback) From f94108b56000f2db99125f8e74ea0f116ed123a6 Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Fri, 21 Aug 2026 12:55:19 +0100 Subject: [PATCH 5/9] added TestGuiSystemMainWindow test class --- tests/system/gui_system_main_window_test.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/system/gui_system_main_window_test.py 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..e6721f86 --- /dev/null +++ b/tests/system/gui_system_main_window_test.py @@ -0,0 +1,15 @@ +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"] From 5f15bd4ca7d0a44038e11f7ff116dc0535ace22d Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Fri, 21 Aug 2026 12:59:50 +0100 Subject: [PATCH 6/9] add system tests to github workflow --- .github/actions/linux/action.yaml | 5 ++++- .github/actions/windows-mac/action.yaml | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) 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 From a8a2e4927abd5e7d295675114e3828e8c870f874 Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Fri, 21 Aug 2026 14:21:13 +0100 Subject: [PATCH 7/9] ruff fix --- tests/system/gui_system_loading_test.py | 2 -- tests/system/gui_system_main_window_test.py | 1 - 2 files changed, 3 deletions(-) diff --git a/tests/system/gui_system_loading_test.py b/tests/system/gui_system_loading_test.py index 0e19ff47..dc6a93d4 100644 --- a/tests/system/gui_system_loading_test.py +++ b/tests/system/gui_system_loading_test.py @@ -2,8 +2,6 @@ class TestGuiSystemLoading(GuiSystemBase): - leak_count_limit = 2 - def setUp(self) -> None: super().setUp() diff --git a/tests/system/gui_system_main_window_test.py b/tests/system/gui_system_main_window_test.py index e6721f86..22facd46 100644 --- a/tests/system/gui_system_main_window_test.py +++ b/tests/system/gui_system_main_window_test.py @@ -2,7 +2,6 @@ class TestGuiSystemMainWindow(GuiSystemBase): - def setUp(self) -> None: super().setUp() From 83f6b79dca4c15d8bc289db4f45917218f879fe2 Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Fri, 21 Aug 2026 17:13:20 +0100 Subject: [PATCH 8/9] added TestGuiSystemLoading::test_load --- tests/system/gui_system_base.py | 2 ++ tests/system/gui_system_loading_test.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/system/gui_system_base.py b/tests/system/gui_system_base.py index c4f223a2..ebfc8721 100644 --- a/tests/system/gui_system_base.py +++ b/tests/system/gui_system_base.py @@ -27,6 +27,7 @@ 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 @@ -48,6 +49,7 @@ def tearDown(self) -> None: 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 diff --git a/tests/system/gui_system_loading_test.py b/tests/system/gui_system_loading_test.py index dc6a93d4..2b228361 100644 --- a/tests/system/gui_system_loading_test.py +++ b/tests/system/gui_system_loading_test.py @@ -1,4 +1,11 @@ -from tests.system.gui_system_base import GuiSystemBase +import time +from pathlib import Path + +from PyQt6.QtTest import QTest +from PyQt6 import QtCore + +from tests.system.gui_system_base import GuiSystemBase, SHOW_DELAY, SHORT_DELAY +from rascal2.dialogs.startup_dialog import LoadDialog class TestGuiSystemLoading(GuiSystemBase): @@ -9,4 +16,10 @@ def tearDown(self) -> None: super().tearDown() def test_load(self): - pass + 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" From 6f54f501102087a6becdbce81a7d8caaf053d162 Mon Sep 17 00:00:00 2001 From: Mike Sullivan Date: Fri, 21 Aug 2026 17:56:55 +0100 Subject: [PATCH 9/9] ruff fix --- tests/system/gui_system_loading_test.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/system/gui_system_loading_test.py b/tests/system/gui_system_loading_test.py index 2b228361..ce56062b 100644 --- a/tests/system/gui_system_loading_test.py +++ b/tests/system/gui_system_loading_test.py @@ -1,11 +1,7 @@ -import time -from pathlib import Path - from PyQt6.QtTest import QTest -from PyQt6 import QtCore -from tests.system.gui_system_base import GuiSystemBase, SHOW_DELAY, SHORT_DELAY from rascal2.dialogs.startup_dialog import LoadDialog +from tests.system.gui_system_base import SHORT_DELAY, GuiSystemBase class TestGuiSystemLoading(GuiSystemBase):