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 @@
+
+
@@ -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()