diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f37ac0c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,70 @@ +name: Tests + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v5 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies (Ubuntu) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y python3-tk xvfb + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest pytest-cov + shell: bash + + - name: Install requirements if present + run: | + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + shell: bash + if: runner.os != 'Windows' + + - name: Install requirements if present (Windows) + run: | + if (Test-Path requirements.txt) { pip install -r requirements.txt } + shell: pwsh + if: runner.os == 'Windows' + + - name: Run tests (Linux) + if: runner.os == 'Linux' + run: | + xvfb-run -a python -m pytest tests/ -v --cov=. --cov-report=xml --cov-report=term + + - name: Run tests (Windows/macOS) + if: runner.os != 'Linux' + run: | + python -m pytest tests/ -v --cov=. --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + with: + file: ./coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false diff --git a/README.md b/README.md index 857a961..92e7c87 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ PyPI Build State Pylint State + + Tests State CodeQL State @@ -106,6 +108,36 @@ However, you may want to add the version window to your program. To do so, follo Customization for ProgramVer can be found in the [`CUSTOMIZATION`](https://github.com/Dog-Face-Development/ProgramVer/blob/master/docs/CUSTOMIZATION.md) doc. More documentation is available in the **[Documentation](https://github.com/Dog-Face-Development/ProgramVer/tree/master/docs)** and on the **[Wiki](https://github.com/Dog-Face-Development/ProgramVer/wiki)**. If more support is required, please open a **[GitHub Discussion](https://github.com/Dog-Face-Development/ProgramVer/discussions)** or join our **[Discord](https://discord.gg/x3G8adwVUe)**. +## Testing + +ProgramVer includes a comprehensive test suite to ensure code quality and reliability. The test suite achieves 100% code coverage for the main module. + +### Running Tests + +To run the test suite locally: + +```bash +# Install test dependencies +pip install -r requirements.txt + +# Run tests (Linux) +xvfb-run -a python -m pytest tests/ -v + +# Run tests (Windows/macOS) +python -m pytest tests/ -v + +# Run tests with coverage +python -m pytest tests/ --cov=main --cov-report=term-missing +``` + +For more information about testing, see the [tests README](tests/README.md). + +### Continuous Integration + +Tests are automatically run on GitHub Actions for every push and pull request across: +- Operating Systems: Ubuntu, Windows, and macOS +- Python Versions: 3.9, 3.10, 3.11, and 3.12 + ## Contributing Please contribute using [GitHub Flow](https://guides.github.com/introduction/flow). Create a branch, add commits, and [open a pull request](https://github.com/Dog-Face-Development/ProgramVer/compare). diff --git a/main.py b/main.py index f5068f0..bd65a60 100644 --- a/main.py +++ b/main.py @@ -14,21 +14,32 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -#pylint: disable=import-error, invalid-name +# pylint: disable=import-error, invalid-name + +import os from tkinter import Tk, Text, INSERT, PhotoImage, Label, Button, TOP, BOTTOM # Import Statements +# Helper Functions + + +def get_resource_path(filename): + """Get the absolute path to a resource file.""" + base_dir = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(base_dir, filename) + + # Document Functions def openLicense(): """Opens the license file in a new window.""" windowl = Tk() - with open("LICENSE.txt", "r", encoding="UTF-8") as licensefile: + license_path = get_resource_path("LICENSE.txt") + with open(license_path, "r", encoding="UTF-8") as licensefile: licensecontents = licensefile.read() - licensefile.close() windowl.title("License") licensetext = Text(windowl) licensetext.insert(INSERT, licensecontents) @@ -38,9 +49,9 @@ def openLicense(): def openEULA(): """Opens the EULA file in a new window.""" windowl = Tk() - with open("EULA.txt", "r", encoding="UTF-8") as eulafile: + eula_path = get_resource_path("EULA.txt") + with open(eula_path, "r", encoding="UTF-8") as eulafile: eulacontents = eulafile.read() - eulafile.close() windowl.title("EULA") eulatext = Text(windowl) eulatext.insert(INSERT, eulacontents) @@ -56,8 +67,8 @@ def ProgramVer(): "Copyright & Version Info for ProgramVer" ) # change name based on program name # UI Elements - dfdimage = PhotoImage(file="imgs/dfdlogo.gif") - pythonimage = PhotoImage(file="imgs/pythonpoweredlengthgif.gif") + dfdimage = PhotoImage(file=get_resource_path("imgs/dfdlogo.gif")) + pythonimage = PhotoImage(file=get_resource_path("imgs/pythonpoweredlengthgif.gif")) dfdlogo = Label(window, image=dfdimage) pythonpowered = Label(window, image=pythonimage) info = Label( diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..5525cab --- /dev/null +++ b/pytest.ini @@ -0,0 +1,10 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings diff --git a/requirements.txt b/requirements.txt index 2dcd9aa..f8fe4ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ # Project Requirements +pytest>=7.4.0 +pytest-cov>=4.1.0 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..4b17237 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,100 @@ +# ProgramVer Test Suite + +This directory contains the comprehensive test suite for ProgramVer. + +## Running Tests + +### Prerequisites + +Install the required testing dependencies: + +```bash +pip install -r requirements.txt +``` + +On Linux systems, you'll also need to install tkinter and xvfb for headless GUI testing: + +```bash +sudo apt-get install python3-tk xvfb +``` + +### Running All Tests + +To run all tests: + +```bash +# On Linux (headless environment) +xvfb-run -a python -m pytest tests/ -v + +# On Windows/macOS (with display) +python -m pytest tests/ -v +``` + +### Running Tests with Coverage + +To run tests with coverage report: + +```bash +# On Linux +xvfb-run -a python -m pytest tests/ --cov=. --cov-report=term-missing --cov-report=html + +# On Windows/macOS +python -m pytest tests/ --cov=. --cov-report=term-missing --cov-report=html +``` + +The HTML coverage report will be generated in the `htmlcov` directory. + +### Running Specific Tests + +To run a specific test file: + +```bash +xvfb-run -a python -m pytest tests/test_main.py -v +``` + +To run a specific test class: + +```bash +xvfb-run -a python -m pytest tests/test_main.py::TestOpenLicense -v +``` + +To run a specific test method: + +```bash +xvfb-run -a python -m pytest tests/test_main.py::TestOpenLicense::test_openLicense_creates_window -v +``` + +## Test Structure + +The test suite is organized as follows: + +- `test_main.py` - Tests for the main ProgramVer module + - `TestOpenLicense` - Tests for the openLicense function + - `TestOpenEULA` - Tests for the openEULA function + - `TestProgramVer` - Tests for the ProgramVer main function + - `TestModuleIntegration` - Integration tests for the module + +## GitHub Actions Integration + +The test suite is automatically run on GitHub Actions for every push and pull request. The workflow: + +- Runs on Ubuntu, Windows, and macOS +- Tests against Python 3.9, 3.10, 3.11, and 3.12 +- Generates coverage reports +- Uploads coverage to Codecov (for master branch) + +See `.github/workflows/tests.yml` for the complete configuration. + +## Writing New Tests + +When adding new features to ProgramVer, please add corresponding tests following these guidelines: + +1. Create test classes that inherit from `unittest.TestCase` +2. Use descriptive test method names that start with `test_` +3. Use mocking for GUI components to avoid requiring a display +4. Add docstrings to explain what each test verifies +5. Ensure tests are independent and can run in any order + +## Coverage Goals + +We aim to maintain at least 90% code coverage for the main module. Currently, we have 100% coverage for `main.py`. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..053fb24 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package for ProgramVer.""" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..5576bbf --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,270 @@ +""" +Tests for ProgramVer main module. +Copyright (C) 2017-2024 Dog Face Development Co. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, version 3 of the License. +""" + +# pylint: disable=import-error, invalid-name, wrong-import-position, import-outside-toplevel, unused-argument +# unused-argument is disabled because @patch decorators inject mocked objects as parameters +# even when not all mocks are used in every test + +import unittest +from unittest.mock import Mock, patch, mock_open +import os +import sys + +# Add parent directory to path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from main import openLicense, openEULA, ProgramVer, get_resource_path + + +class TestGetResourcePath(unittest.TestCase): + """Test cases for get_resource_path helper function.""" + + def test_get_resource_path_returns_absolute_path(self): + """Test that get_resource_path returns an absolute path.""" + result = get_resource_path("LICENSE.txt") + self.assertTrue(os.path.isabs(result)) + + def test_get_resource_path_includes_filename(self): + """Test that get_resource_path includes the filename.""" + result = get_resource_path("LICENSE.txt") + self.assertTrue(result.endswith("LICENSE.txt")) + + def test_get_resource_path_handles_subdirectories(self): + """Test that get_resource_path handles subdirectories correctly.""" + result = get_resource_path("imgs/dfdlogo.gif") + self.assertTrue("imgs" in result) + self.assertTrue(result.endswith("dfdlogo.gif")) + + +class TestOpenLicense(unittest.TestCase): + """Test cases for openLicense function.""" + + @patch("main.Text") + @patch("main.Tk") + @patch( + "builtins.open", new_callable=mock_open, read_data="GNU GENERAL PUBLIC LICENSE" + ) + def test_openLicense_creates_window(self, mock_file, mock_tk, mock_text): + """Test that openLicense creates a window and reads LICENSE.txt.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_text_widget = Mock() + mock_text.return_value = mock_text_widget + + openLicense() + + # Verify window was created + mock_tk.assert_called_once() + # Verify file was opened with absolute path + mock_file.assert_called_once() + call_args = mock_file.call_args[0] + self.assertTrue(call_args[0].endswith("LICENSE.txt")) + # Verify window title was set + mock_window.title.assert_called_once_with("License") + + @patch("main.Tk") + @patch("builtins.open", new_callable=mock_open, read_data="Test License Content") + @patch("main.Text") + def test_openLicense_displays_content(self, mock_text, mock_file, mock_tk): + """Test that openLicense displays license content.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_text_widget = Mock() + mock_text.return_value = mock_text_widget + + openLicense() + + # Verify text widget was created with window + mock_text.assert_called_once_with(mock_window) + # Verify content was inserted + mock_text_widget.insert.assert_called_once() + # Verify widget was packed + mock_text_widget.pack.assert_called_once() + + +class TestOpenEULA(unittest.TestCase): + """Test cases for openEULA function.""" + + @patch("main.Text") + @patch("main.Tk") + @patch( + "builtins.open", new_callable=mock_open, read_data="END USER LICENSE AGREEMENT" + ) + def test_openEULA_creates_window(self, mock_file, mock_tk, mock_text): + """Test that openEULA creates a window and reads EULA.txt.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_text_widget = Mock() + mock_text.return_value = mock_text_widget + + openEULA() + + # Verify window was created + mock_tk.assert_called_once() + # Verify file was opened with absolute path + mock_file.assert_called_once() + call_args = mock_file.call_args[0] + self.assertTrue(call_args[0].endswith("EULA.txt")) + # Verify window title was set + mock_window.title.assert_called_once_with("EULA") + + @patch("main.Tk") + @patch("builtins.open", new_callable=mock_open, read_data="Test EULA Content") + @patch("main.Text") + def test_openEULA_displays_content(self, mock_text, mock_file, mock_tk): + """Test that openEULA displays EULA content.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_text_widget = Mock() + mock_text.return_value = mock_text_widget + + openEULA() + + # Verify text widget was created with window + mock_text.assert_called_once_with(mock_window) + # Verify content was inserted + mock_text_widget.insert.assert_called_once() + # Verify widget was packed + mock_text_widget.pack.assert_called_once() + + +class TestProgramVer(unittest.TestCase): + """Test cases for ProgramVer function.""" + + @patch("main.Tk") + @patch("main.PhotoImage") + @patch("main.Label") + @patch("main.Button") + def test_programver_creates_window( + self, mock_button, mock_label, mock_photoimage, mock_tk + ): + """Test that ProgramVer creates main window with all components.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_img = Mock() + mock_photoimage.return_value = mock_img + + # Mock mainloop to prevent blocking + mock_window.mainloop = Mock() + + ProgramVer() + + # Verify window was created + mock_tk.assert_called_once() + # Verify window title was set + mock_window.title.assert_called_once() + title_text = mock_window.title.call_args[0][0] + assert "ProgramVer" in title_text + + @patch("main.Tk") + @patch("main.PhotoImage") + @patch("main.Label") + @patch("main.Button") + def test_programver_loads_images( + self, mock_button, mock_label, mock_photoimage, mock_tk + ): + """Test that ProgramVer loads required images.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_window.mainloop = Mock() + + ProgramVer() + + # Verify PhotoImage was called to load images + assert mock_photoimage.call_count == 2 + # Check that both images are loaded with absolute paths + calls = mock_photoimage.call_args_list + image_files = [call[1]["file"] for call in calls] + assert any("dfdlogo.gif" in img for img in image_files) + assert any("pythonpoweredlengthgif.gif" in img for img in image_files) + + @patch("main.Tk") + @patch("main.PhotoImage") + @patch("main.Label") + @patch("main.Button") + def test_programver_creates_labels( + self, mock_button, mock_label, mock_photoimage, mock_tk + ): + """Test that ProgramVer creates appropriate labels.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_window.mainloop = Mock() + + ProgramVer() + + # Verify Label was called multiple times to create all labels + assert mock_label.call_count >= 5 + + @patch("main.Tk") + @patch("main.PhotoImage") + @patch("main.Label") + @patch("main.Button") + def test_programver_creates_buttons( + self, mock_button, mock_label, mock_photoimage, mock_tk + ): + """Test that ProgramVer creates license and EULA buttons.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_window.mainloop = Mock() + + ProgramVer() + + # Verify Button was called for both buttons + assert mock_button.call_count == 2 + # Verify buttons have correct text and commands + calls = mock_button.call_args_list + button_texts = [call[1]["text"] for call in calls] + assert "Open License" in button_texts + assert "Open EULA" in button_texts + + @patch("main.Tk") + @patch("main.PhotoImage") + @patch("main.Label") + @patch("main.Button") + def test_programver_button_commands( + self, mock_button, mock_label, mock_photoimage, mock_tk + ): + """Test that buttons are linked to correct command functions.""" + mock_window = Mock() + mock_tk.return_value = mock_window + mock_window.mainloop = Mock() + + ProgramVer() + + calls = mock_button.call_args_list + commands = [call[1].get("command") for call in calls] + # Verify that openLicense and openEULA are set as commands + assert openLicense in commands + assert openEULA in commands + + +class TestModuleIntegration(unittest.TestCase): + """Integration tests for the module.""" + + def test_module_imports(self): + """Test that the main module can be imported successfully.""" + import main + + assert hasattr(main, "ProgramVer") + assert hasattr(main, "openLicense") + assert hasattr(main, "openEULA") + assert hasattr(main, "get_resource_path") + + def test_functions_are_callable(self): + """Test that all exported functions are callable.""" + import main + + assert callable(main.ProgramVer) + assert callable(main.openLicense) + assert callable(main.openEULA) + assert callable(main.get_resource_path) + + +if __name__ == "__main__": + unittest.main()