From 4747d271922857e218607e61f594e6b3727c50d4 Mon Sep 17 00:00:00 2001 From: Maria Patrou <3339090+mpatrou@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:28:15 -0500 Subject: [PATCH] Testing Framework and initial tests (#2) * test configurations, main window tests added, pytest additional dependencies added, argument parser updated * ruff D code added, docstrings in functions and files everywhere * pytest-xvfb added --- .github/workflows/unittest.yml | 16 +- docs/conf.py | 2 + environment.yml | 4 +- pyproject.toml | 8 +- src/hyspecplanningtools/__init__.py | 9 + .../configuration_template.ini | 1 + src/hyspecplanningtools/home/home_model.py | 3 +- .../home/home_presenter.py | 3 +- src/hyspecplanningtools/home/home_view.py | 3 +- .../hyspecplanningtools.py | 15 +- src/hyspecplanningtools/mainwindow.py | 1 + tests/conftest.py | 29 +++ tests/test_configuration.py | 203 ++++++++++++++++++ tests/test_mainwindow.py | 59 +++++ tests/test_version.py | 16 ++ 15 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_configuration.py create mode 100644 tests/test_mainwindow.py diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 8e9bec4..4d662a6 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -15,21 +15,23 @@ jobs: shell: bash -l {0} steps: - uses: actions/checkout@v4 - - uses: conda-incubator/setup-miniconda@v3 + - uses: mamba-org/setup-micromamba@v1 with: - auto-update-conda: true - channels: conda-forge,defaults - use-mamba: true environment-file: environment.yml - activate-environment: hyspecplanningtools_dev + cache-environment-key: ${{ runner.os }}-env-${{ hashFiles('**/environment.yml') }} + cache-downloads-key: ${{ runner.os }}-downloads-${{ hashFiles('**/environment.yml') }} + condarc: | + channels: + - conda-forge + - anaconda - name: install in editable mode run: | echo "installing in editable mode" python -m pip install -e . - name: run unit tests run: | - echo "running unit tests" - python -m pytest --cov=src --cov-report=xml --cov-report=term-missing tests/ + echo "running unit tests with coverage" + python -m pytest --cov=src --cov-report=xml --cov-report=term - name: upload coverage to codecov uses: codecov/codecov-action@v4 if: diff --git a/docs/conf.py b/docs/conf.py index 904bceb..d4add68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,5 @@ +"""Documentation Configuration""" + # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: diff --git a/environment.yml b/environment.yml index a4360f9..fb70afa 100644 --- a/environment.yml +++ b/environment.yml @@ -7,7 +7,7 @@ dependencies: - qtpy - numpy - scipy - - matplotlib + - matplotlib <3.9 #resolves pyside 6 error - pre-commit # package building: - versioningit @@ -26,5 +26,7 @@ dependencies: - myst-parser # required for parsing markdown files # test: list all test dependencies here - pytest + - pytest-qt - pytest-cov - pytest-xdist + - pytest-xvfb diff --git a/pyproject.toml b/pyproject.toml index a52f42f..3d794e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,12 @@ markers = [ [tool.ruff] line-length = 120 -select = ["A", "ARG","ASYNC","BLE","C90", "E", "F", "I", "N", "UP032", "W"] +[tool.ruff.lint] +select = ["A", "ARG","ASYNC","BLE","C90", "D", "E", "F", "I", "N", "UP032", "W"] +ignore = ["D203", # conflict with D211 + "D213", # conflict with D212 + "D205", # conflicts + "D400", "D401","D404", "D415" # Unnecessary + ] # Add additional 3rd party tool configuration here as needed diff --git a/src/hyspecplanningtools/__init__.py b/src/hyspecplanningtools/__init__.py index 5e1b623..bb1eb4d 100644 --- a/src/hyspecplanningtools/__init__.py +++ b/src/hyspecplanningtools/__init__.py @@ -4,3 +4,12 @@ from ._version import __version__ # noqa: F401 except ImportError: __version__ = "unknown" + + +def HyspecPlanningTool(): # noqa: N802 + """This is needed for backward compatibility because mantid workbench does + "from hyspecplanningtools import HyspecPlanningTool" + """ + from .hyspecplanningtools import HyspecPlanningTool as hyspecplanningtools # noqa: E501, N813 + + return hyspecplanningtools() diff --git a/src/hyspecplanningtools/configuration_template.ini b/src/hyspecplanningtools/configuration_template.ini index 2db846a..5b2f03f 100644 --- a/src/hyspecplanningtools/configuration_template.ini +++ b/src/hyspecplanningtools/configuration_template.ini @@ -1,2 +1,3 @@ [global.other] +#url to documentation help_url = https://github.com/neutrons/HyspecPlanningTools/blob/next/README.md diff --git a/src/hyspecplanningtools/home/home_model.py b/src/hyspecplanningtools/home/home_model.py index 2600c19..1214f9c 100644 --- a/src/hyspecplanningtools/home/home_model.py +++ b/src/hyspecplanningtools/home/home_model.py @@ -5,8 +5,9 @@ logger = logging.getLogger("hyspecplanningtools") -class HomeModel: # pylint: disable=too-many-public-methods +class HomeModel: """Main model""" def __init__(self): + """Constructor""" return diff --git a/src/hyspecplanningtools/home/home_presenter.py b/src/hyspecplanningtools/home/home_presenter.py index 1b55b34..2dddfdd 100644 --- a/src/hyspecplanningtools/home/home_presenter.py +++ b/src/hyspecplanningtools/home/home_presenter.py @@ -1,10 +1,11 @@ """Presenter for the Main tab""" -class HomePresenter: # pylint: disable=too-many-public-methods +class HomePresenter: """Main presenter""" def __init__(self, view, model): + """Constructor""" self._view = view self._model = model diff --git a/src/hyspecplanningtools/home/home_view.py b/src/hyspecplanningtools/home/home_view.py index 61a8e71..e0fa642 100644 --- a/src/hyspecplanningtools/home/home_view.py +++ b/src/hyspecplanningtools/home/home_view.py @@ -3,10 +3,11 @@ from qtpy.QtWidgets import QHBoxLayout, QWidget -class Home(QWidget): # pylint: disable=too-many-public-methods +class Home(QWidget): """Main widget""" def __init__(self, parent=None): + """Constructor""" super().__init__(parent) layout = QHBoxLayout() diff --git a/src/hyspecplanningtools/hyspecplanningtools.py b/src/hyspecplanningtools/hyspecplanningtools.py index fdd7444..d3af381 100644 --- a/src/hyspecplanningtools/hyspecplanningtools.py +++ b/src/hyspecplanningtools/hyspecplanningtools.py @@ -1,5 +1,6 @@ """Main Qt application""" +import argparse import logging import sys @@ -18,11 +19,13 @@ class HyspecPlanningTool(QMainWindow): __instance = None def __new__(cls): - if HyspecPlanningTool.__instance is None: - HyspecPlanningTool.__instance = QMainWindow.__new__(cls) # pylint: disable=no-value-for-parameter - return HyspecPlanningTool.__instance + """Create new instance of the HyspecPlanningTool""" + if not cls.__instance: + cls.__instance = super(HyspecPlanningTool, cls).__new__(cls) + return cls.__instance def __init__(self, parent=None): + """Constructor""" super().__init__(parent) logger.info(f"HyspecPlanningTool version: {__version__}") config = Configuration() @@ -44,8 +47,10 @@ def __init__(self, parent=None): def gui(): """Main entry point for Qt application""" - input_flags = sys.argv[1::] - if "--v" in input_flags or "--version" in input_flags: + parser = argparse.ArgumentParser() + parser.add_argument("-v", "--version", help="print the version", action="store_true") + args = parser.parse_args() + if args.version: print(__version__) sys.exit() else: diff --git a/src/hyspecplanningtools/mainwindow.py b/src/hyspecplanningtools/mainwindow.py index 8dced0b..af31d75 100644 --- a/src/hyspecplanningtools/mainwindow.py +++ b/src/hyspecplanningtools/mainwindow.py @@ -12,6 +12,7 @@ class MainWindow(QWidget): """Main widget""" def __init__(self, parent=None): + """Constructor""" super().__init__(parent) ### Create tabs here ### diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..38289be --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""pytest configuration""" + +import os +from configparser import ConfigParser + +import pytest + +from hyspecplanningtools import HyspecPlanningTool + + +@pytest.fixture +def hyspec_app(qapp): # noqa: ARG001 + """Create a HyspecPlanningTool app""" + app = HyspecPlanningTool() + app.show() + return app + + +@pytest.fixture(scope="session") +def user_conf_file(tmp_path_factory, request): + """Fixture to create a custom configuration file in tmp_path""" + # custom configuration file + config_data = request.param + user_config = ConfigParser(allow_no_value=True) + user_config.read_string(config_data) + user_path = os.path.join(tmp_path_factory.mktemp("data"), "test_config.ini") + with open(user_path, "w", encoding="utf8") as config_file: + user_config.write(config_file) + return user_path diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 0000000..8ca23cb --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,203 @@ +"""Tests for Configuration mechanism""" + +import os +from configparser import ConfigParser +from pathlib import Path + +import pytest +from qtpy.QtWidgets import QApplication + +from hyspecplanningtools import HyspecPlanningTool +from hyspecplanningtools.configuration import Configuration, get_data + + +def test_config_path_default(): + """Test configuration default file path""" + config = Configuration() + assert config.config_file_path.endswith(".hyspecplanningtools/configuration.ini") is True + # check the valid state + assert config.is_valid() + assert config.valid == config.is_valid() + + +def test_config_path_in_folder(monkeypatch, tmp_path): + """Test configuration configuration user-defined file path that does not exist in a new directory""" + user_path = os.path.join(tmp_path, "temp2", "test_config.ini") + assert not os.path.exists(user_path) + + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_path) + + config = Configuration() + # check if the file exists now + assert os.path.exists(user_path) + assert config.is_valid() + + +def test_config_path_does_not_exist(monkeypatch, tmp_path): + """Test configuration user-defined file path that does not exist""" + user_path = os.path.join(tmp_path, "test_config.ini") + assert not os.path.exists(user_path) + + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_path) + + config = Configuration() + # check if the file is exists now + assert os.path.exists(user_path) + assert config.is_valid() + + +@pytest.mark.parametrize( + "user_conf_file", + [ + """ + [global.other] + #url to documentation + help_url = https://github.com/neutrons/HyspecPlanningTools/blob/next/README.md + """ + ], + indirect=True, +) +def test_field_validate_fields_exist(monkeypatch, user_conf_file): + """Test configuration validate all fields exist with the same values as templates + Note: update the parameters if the fields increase + """ + # read the custom configuration file + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + user_config = Configuration() + + assert user_config.config_file_path.endswith(user_conf_file) is True + # check if the file exists + assert os.path.exists(user_conf_file) + + # check all fields are the same as the configuration template file + project_directory = Path(__file__).resolve().parent.parent + template_file_path = os.path.join(project_directory, "src", "hyspecplanningtools", "configuration_template.ini") + template_config = ConfigParser(allow_no_value=True, comment_prefixes="/") + template_config.read(template_file_path) + # comments should be copied too + for section in user_config.config.sections(): + for field in user_config.config[section]: + assert user_config.config[section][field] == template_config[section][field] + + +@pytest.mark.parametrize( + "user_conf_file", + [ + """ + [generate_tab.oncat] + oncat_url = test_url + client_id = 0000-0000 + use_notes = True + """ + ], + indirect=True, +) +def test_field_validate_fields_same(monkeypatch, user_conf_file): + """Test configuration validate all fields exist with their values; different from the template""" + # read the custom configuration file + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + user_config = Configuration() + + # check if the file exists + assert os.path.exists(user_conf_file) + assert user_config.config_file_path == user_conf_file + + # check all field values have the same values as the user configuration file + assert get_data("generate_tab.oncat", "oncat_url") == "test_url" + assert get_data("generate_tab.oncat", "client_id") == "0000-0000" + # cast to bool + assert get_data("generate_tab.oncat", "use_notes") is True + + +@pytest.mark.parametrize( + "user_conf_file", + [ + """ + """ + ], + indirect=True, +) +def test_field_validate_fields_missing(monkeypatch, user_conf_file): + """Test configuration validate missing fields added from the template""" + # read the custom configuration file + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + user_config = Configuration() + + # check if the file exists + assert os.path.exists(user_conf_file) + assert user_config.config_file_path == user_conf_file + + # check all field values have the same values as the user configuration file + assert get_data("global.other", "help_url") == "https://github.com/neutrons/HyspecPlanningTools/blob/next/README.md" + + +@pytest.mark.parametrize("user_conf_file", ["""[global.other]"""], indirect=True) +def test_get_data_valid(monkeypatch, user_conf_file): + """Test configuration get_data - valid""" + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + config = Configuration() + assert config.config_file_path.endswith(user_conf_file) is True + # get the data + # section + assert len(get_data("global.other", "")) == 1 + # fields + assert get_data("global.other", "help_url") == "https://github.com/neutrons/HyspecPlanningTools/blob/next/README.md" + + assert config.is_valid() + + +@pytest.mark.parametrize( + "user_conf_file", + [ + """ + [generate_tab.oncat] + oncat_url = test_url + client_id = 0000-0000 + use_notes = 1 + """ + ], + indirect=True, +) +def test_get_data_invalid(monkeypatch, user_conf_file): + """Test configuration get_data - invalid""" + # read the custom configuration file + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + config = Configuration() + assert config.config_file_path.endswith(user_conf_file) is True + + # section + assert get_data("section_not_here", "") is None + + assert len(get_data("generate_tab.oncat", "")) == 3 + # field + assert get_data("generate_tab.oncat", "field_not_here") is None + + +@pytest.mark.parametrize( + "user_conf_file", + [ + """ + [global.other] + #url to documentation + help_url = https://github.com/neutrons/HyspecPlanningTools/blob/next/README.md + """ + ], + indirect=True, +) +def test_conf_init_invalid(capsys, user_conf_file, monkeypatch): + """Test invalid configuration settings""" + # mock conf info + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + + def mock_is_valid(self): # noqa: ARG001 + return False + + monkeypatch.setattr("hyspecplanningtools.configuration.Configuration.is_valid", mock_is_valid) + with pytest.raises(SystemExit): + # initialization + _ = QApplication([]) + hyspec = HyspecPlanningTool() + hyspec.show() + + captured = capsys.readouterr() + assert captured[0].startswith("Error with configuration settings!") diff --git a/tests/test_mainwindow.py b/tests/test_mainwindow.py new file mode 100644 index 0000000..6826746 --- /dev/null +++ b/tests/test_mainwindow.py @@ -0,0 +1,59 @@ +"""UI tests for the application""" + +import subprocess + +import pytest + +from hyspecplanningtools.hyspecplanningtools import __version__ + + +def test_appwindow(hyspec_app, qtbot): + """Test that the application starts successfully""" + hyspec_app.show() + qtbot.waitUntil(hyspec_app.show, timeout=5000) + assert hyspec_app.isVisible() + assert hyspec_app.windowTitle() == f"HyspecPlanning Tools - {__version__}" + + +def test_gui_version(): + """Test that argument parameter --version prints the version""" + full_command = ["hyspecplanningtools", "--version"] + version_result = subprocess.run(full_command, capture_output=True, text=True) + version_result = version_result.stdout.strip() + assert version_result == __version__ + + +def test_gui_v(): + """Test that argument parameter -v prints the version""" + full_command = ["hyspecplanningtools", "-v"] + version_result = subprocess.run(full_command, capture_output=True, text=True) + version_result = version_result.stdout.strip() + assert version_result == __version__ + + +@pytest.mark.parametrize( + "user_conf_file", + [ + """ + [global.other] + help_url = https://test.url.com + + """ + ], + indirect=True, +) +def test_mainwindow_help(monkeypatch, user_conf_file, hyspec_app): + """Test the help function in the main window""" + # hyspec_app = HyspecPlanningTool() + + help_url = "" + + def fake_webbrowser(url): + nonlocal help_url + help_url = url + + monkeypatch.setattr("hyspecplanningtools.configuration.CONFIG_PATH_FILE", user_conf_file) + monkeypatch.setattr("webbrowser.open", fake_webbrowser) + + hyspec_app.main_window.handle_help() + assert help_url == "https://test.url.com" diff --git a/tests/test_version.py b/tests/test_version.py index d5ddbda..1ed4e46 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,5 +1,21 @@ +"""Version Tests""" + +import sys + +import pytest + from hyspecplanningtools import __version__ def test_version(): + """Test that the version is imported""" assert __version__ != "unknown" + + +def test_version_error(monkeypatch): + """Test that the version is set to unknown.""" + monkeypatch.setitem(sys.modules, "hyspecplanningtools", None) + with pytest.raises(ImportError): + from hyspecplanningtools import __version__ + + assert __version__ == "unknown"