diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7a7e3f4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.11', '3.12'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Run tests + run: python -m pytest tests/ -v diff --git a/README.md b/README.md index ff81653..63dc617 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +![Tests](https://github.com/NickEngmann/piDSLM/actions/workflows/test.yml/badge.svg) + piDSLM - Raspberry Pi Digital Single Lens Mirrorless =============== @@ -50,5 +52,8 @@ Finally, run the INSTALL.sh script using the following command sudo ./INSTALL.sh ``` +## Running Tests - +```bash +pytest +``` diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..22f3fd5 Binary files /dev/null and b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_pidslm.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_pidslm.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..2c6d483 Binary files /dev/null and b/tests/__pycache__/test_pidslm.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..de03a58 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,167 @@ +"""Auto-generated conftest.py — mocks Raspberry Pi hardware modules. + +Provides: +- 15+ RPi hardware modules pre-mocked (RPi.GPIO with realistic constants, guizero, etc.) +- source_module fixture: loads repo .py files with while-True loops stripped via AST +- All other module-level init code runs safely against mocks +""" +import sys +import os +import ast +import types +import glob as _glob +from unittest.mock import MagicMock +import pytest + +# ─── Mock ALL RPi hardware modules ─── +_RPI_MODULES = [ + 'RPi', 'RPi.GPIO', 'spidev', 'smbus', 'smbus2', + 'guizero', 'guizero.app', 'guizero.widgets', + 'picamera', 'picamera2', + 'gpiozero', 'gpiozero.pins', 'gpiozero.pins.mock', + 'board', 'digitalio', 'busio', 'adafruit_dht', + 'w1thermsensor', 'Adafruit_DHT', + 'RPIO', 'pigpio', 'wiringpi', + 'sense_hat', 'luma.core', 'luma.oled', 'luma.led_matrix', + 'serial', +] + +for _mod in _RPI_MODULES: + sys.modules[_mod] = MagicMock() + +# Realistic RPi.GPIO constants +_gpio = sys.modules['RPi.GPIO'] +_gpio.BCM = 11 +_gpio.BOARD = 10 +_gpio.OUT = 0 +_gpio.IN = 1 +_gpio.HIGH = 1 +_gpio.LOW = 0 +_gpio.PUD_UP = 22 +_gpio.PUD_DOWN = 21 +_gpio.RISING = 31 +_gpio.FALLING = 32 +_gpio.BOTH = 33 + +# Make guizero App work as context manager +_guizero = sys.modules['guizero'] +_guizero.App.return_value.__enter__ = MagicMock(return_value=MagicMock()) +_guizero.App.return_value.__exit__ = MagicMock(return_value=False) + + +def _strip_while_true(source_path): + """Strip while-True loops from module level, keep everything else.""" + with open(source_path) as f: + source = f.read() + try: + tree = ast.parse(source) + new_body = [] + for node in tree.body: + if isinstance(node, ast.While): + test = node.test + if isinstance(test, ast.Constant) and test.value in (True, 1): + continue + if isinstance(test, ast.NameConstant) and test.value is True: + continue + new_body.append(node) + tree.body = new_body + ast.fix_missing_locations(tree) + return compile(tree, source_path, 'exec') + except SyntaxError: + return compile(source, source_path, 'exec') + + +class _SourceProxy: + """Proxy that forwards attribute writes back to the originating module.""" + def __init__(self): + object.__setattr__(self, '_modules', []) + object.__setattr__(self, '_attr_to_mod', {}) + + def _add_module(self, mod): + self._modules.append(mod) + for attr in dir(mod): + if not attr.startswith('_'): + self._attr_to_mod[attr] = mod + + def __getattr__(self, name): + if name.startswith('_'): + raise AttributeError(name) + for mod in reversed(self._modules): + try: + return getattr(mod, name) + except AttributeError: + continue + raise AttributeError(f"source_module has no attribute '{name}'") + + def __setattr__(self, name, value): + if name.startswith('_'): + object.__setattr__(self, name, value) + return + if name in self._attr_to_mod: + setattr(self._attr_to_mod[name], name, value) + elif self._modules: + setattr(self._modules[0], name, value) + + def __delattr__(self, name): + if name.startswith('_'): + object.__delattr__(self, name) + return + if name in self._attr_to_mod: + try: + delattr(self._attr_to_mod[name], name) + except AttributeError: + pass + elif self._modules: + try: + delattr(self._modules[0], name) + except AttributeError: + pass + + def __dir__(self): + return sorted(self._attr_to_mod.keys()) + + +@pytest.fixture +def source_module(): + """Load .py source files from repo with while-True loops stripped. + + All hardware modules are already mocked. Module-level init code runs safely. + Returns a _SourceProxy that forwards writes back to the actual module globals. + + Usage: + def test_capture(source_module): + source_module.capture_image() + """ + repo_root = '/workspace/repo' + proxy = _SourceProxy() + + search_dirs = [repo_root] + for subdir in ['src', 'lib']: + subpath = os.path.join(repo_root, subdir) + if os.path.isdir(subpath): + search_dirs.append(subpath) + + for search_dir in search_dirs: + pattern = os.path.join(search_dir, '**', '*.py') if search_dir != repo_root else os.path.join(search_dir, '*.py') + for py_file in sorted(_glob.glob(pattern, recursive=True)): + basename = os.path.basename(py_file) + if basename.startswith('test_') or basename in ('conftest.py', 'setup.py'): + continue + + mod_name = os.path.splitext(basename)[0] + try: + code_obj = _strip_while_true(py_file) + mod = types.ModuleType(mod_name) + mod.__file__ = py_file + for rm in _RPI_MODULES: + short = rm.split('.')[-1] + mod.__dict__[short] = sys.modules[rm] + exec(code_obj, mod.__dict__) + sys.modules[mod_name] = mod # Register so @patch('mod_name.x') works + + proxy._add_module(mod) + except Exception as e: + print(f"[conftest] Warning loading {basename}: {e}") + continue + + return proxy diff --git a/tests/test_pidslm.py b/tests/test_pidslm.py new file mode 100644 index 0000000..c79caaa --- /dev/null +++ b/tests/test_pidslm.py @@ -0,0 +1,131 @@ +import re +from unittest.mock import patch, MagicMock +import datetime + + +def test_timestamp_format(source_module): + """Test that timestamp() returns properly formatted string YYYYMMDD_HHMMSS""" + obj = source_module.piDSLM() + ts = obj.timestamp() + + # Verify format matches expected pattern + pattern = r"^\d{8}_\d{6}$" + assert re.match(pattern, ts), f"Timestamp '{ts}' does not match expected format YYYYMMDD_HHMMSS" + + # Verify it's a valid datetime representation + try: + datetime.datetime.strptime(ts, "%Y%m%d_%H%M%S") + except ValueError: + assert False, f"Timestamp '{ts}' is not a valid datetime string" + + +def test_clear_calls_show_hide_busy_and_os_system(source_module): + """Test clear() properly orchestrates busy state and file deletion""" + with patch('os.system') as mock_system: + obj = source_module.piDSLM() + + # Mock the busy window's show/hide methods to avoid GUI dependencies + with patch.object(obj.busy, 'show') as mock_show, \ + patch.object(obj.busy, 'hide') as mock_hide: + obj.clear() + + # Verify show_busy was called first + mock_show.assert_called_once() + + # Verify os.system was called with correct command + mock_system.assert_called_once_with("rm -v /home/pi/Downloads/*") + + # Verify hide_busy was called last + mock_hide.assert_called_once() + + +def test_show_busy_shows_window_and_prints(source_module, capsys): + """Test show_busy() displays the busy window and prints status""" + obj = source_module.piDSLM() + + with patch.object(obj.busy, 'show') as mock_show: + obj.show_busy() + + # Verify window show was called + mock_show.assert_called_once() + + # Verify print output + captured = capsys.readouterr() + assert "busy now" in captured.out + + +def test_hide_busy_hides_window_and_prints(source_module, capsys): + """Test hide_busy() hides the busy window and prints status""" + obj = source_module.piDSLM() + + with patch.object(obj.busy, 'hide') as mock_hide: + obj.hide_busy() + + # Verify window hide was called + mock_hide.assert_called_once() + + # Verify print output + captured = capsys.readouterr() + assert "no longer busy" in captured.out + + +def test_burst_calls_show_hide_busy_and_raspistill(source_module): + """Test burst() properly orchestrates busy state and raspistill command""" + with patch('os.system') as mock_system: + obj = source_module.piDSLM() + + # Mock timestamp to get predictable filename + with patch.object(obj, 'timestamp', return_value='20231015_123045'): + with patch.object(obj.busy, 'show') as mock_show, \ + patch.object(obj.busy, 'hide') as mock_hide: + obj.burst() + + # Verify show_busy was called + mock_show.assert_called_once() + + # Verify raspistill command was called with correct parameters + expected_cmd = "raspistill -t 10000 -tl 0 --thumb none -n -bm -o /home/pi/Downloads/BR20231015_123045%04d.jpg" + mock_system.assert_called_once_with(expected_cmd) + + # Verify hide_busy was called + mock_hide.assert_called_once() + + +def test_split_hd_30m_calls_show_hide_busy_and_raspivid(source_module): + """Test split_hd_30m() properly orchestrates busy state and raspivid command""" + with patch('os.system') as mock_system: + obj = source_module.piDSLM() + + # Mock timestamp to get predictable filename + with patch.object(obj, 'timestamp', return_value='20231015_123045'): + with patch.object(obj.busy, 'show') as mock_show, \ + patch.object(obj.busy, 'hide') as mock_hide: + obj.split_hd_30m() + + # Verify show_busy was called + mock_show.assert_called_once() + + # Verify raspivid command was called with correct parameters + expected_cmd = "raspivid -f -t 1800000 -sg 300000 -o /home/pi/Downloads/20231015_123045vid%04d.h264" + mock_system.assert_called_once_with(expected_cmd) + + # Verify hide_busy was called + mock_hide.assert_called_once() + + +def test_gpio_setup_runs_on_init(source_module): + """Test that GPIO setup is properly configured during initialization""" + import RPi.GPIO as GPIO + + # Reset mocks since they were called during module import + GPIO.setup.reset_mock() + GPIO.add_event_detect.reset_mock() + + # Create instance to trigger __init__ again + obj = source_module.piDSLM() + + # Verify GPIO setup was called for pin 16 + GPIO.setup.assert_any_call(16, GPIO.IN, pull_up_down=GPIO.PUD_UP) + + # Verify event detection was set up with correct parameters + GPIO.add_event_detect.assert_any_call(16, GPIO.FALLING, callback=obj.takePicture, bouncetime=2500) \ No newline at end of file