From b911e073f85a59500fe717c565bcc737abac3781 Mon Sep 17 00:00:00 2001 From: Marisol Date: Thu, 26 Feb 2026 07:28:45 +0000 Subject: [PATCH] Add automated tests --- .github/workflows/test.yml | 25 +++ .gitignore | 14 +- __pycache__/dropbox.cpython-37.pyc | Bin 7022 -> 0 bytes tests/conftest.py | 241 +++++++++++++++++++++++++++++ tests/test_example.py | 35 +++++ tests/test_pidslm.py | 206 ++++++++++++++++++++++++ 6 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml delete mode 100644 __pycache__/dropbox.cpython-37.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_example.py create mode 100644 tests/test_pidslm.py 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/.gitignore b/.gitignore index 7c8d622..dd30cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,14 @@ ./__pycache__/ -./__pycache__ \ No newline at end of file +./__pycache__ +# Auto-added by Marisol pipeline +node_modules/ +*.pyc +.pytest_cache/ +*.o +*.so +.env +debug_*.py +.cache/ +dist/ +build/ +*.egg-info/ diff --git a/__pycache__/dropbox.cpython-37.pyc b/__pycache__/dropbox.cpython-37.pyc deleted file mode 100644 index e2df8a2fe0bf178f64d049e864d30680f3be251d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7022 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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..62c73b4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,241 @@ +"""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() + +# Import hook: auto-mock ANY unknown hardware module during source file loading +# This catches custom libraries like mp2624, adafruit_* variants, etc. +# Uses find_spec (Python 3.4+) since find_module is deprecated and ignored in Python 3.12 +import importlib.abc +import importlib.machinery + +class _AutoMockFinder(importlib.abc.MetaPathFinder): + """Meta-path finder that auto-mocks missing modules instead of raising ImportError.""" + _BUILTIN_SKIP = { + 'os', 'sys', 'json', 'time', 'datetime', 'math', 're', 'pathlib', + 'subprocess', 'shutil', 'collections', 'functools', 'itertools', + 'logging', 'typing', 'dataclasses', 'enum', 'copy', 'io', + 'threading', 'multiprocessing', 'socket', 'http', 'urllib', + 'hashlib', 'base64', 'struct', 'array', 'configparser', 'argparse', + 'unittest', 'pytest', 'glob', 'fnmatch', 'csv', 'string', + } + _active = False + + def find_spec(self, name, path, target=None): + if not self._active: + return None + top = name.split('.')[0] + if top in self._BUILTIN_SKIP: + return None + if name in sys.modules: + return None + # Create a mock module and register it + mock = MagicMock() + mock.__name__ = name + mock.__spec__ = importlib.machinery.ModuleSpec(name, None) + sys.modules[name] = mock + return mock.__spec__ + +_auto_mocker = _AutoMockFinder() +sys.meta_path.insert(0, _auto_mocker) + +# 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 _is_while_true(node): + """Check if an AST node is a while-True/while-1 loop.""" + if not isinstance(node, ast.While): + return False + test = node.test + if isinstance(test, ast.Constant) and test.value in (True, 1): + return True + if isinstance(test, ast.NameConstant) and test.value is True: + return True + return False + + +def _strip_while_true_from_body(body): + """Recursively strip while-True loops from AST node bodies.""" + new_body = [] + for node in body: + if _is_while_true(node): + continue + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + node.body = _strip_while_true_from_body(node.body) + elif isinstance(node, ast.ClassDef): + node.body = _strip_while_true_from_body(node.body) + elif isinstance(node, ast.If): + node.body = _strip_while_true_from_body(node.body) + if node.orelse: + node.orelse = _strip_while_true_from_body(node.orelse) + elif isinstance(node, (ast.For, ast.AsyncFor, ast.While)): + node.body = _strip_while_true_from_body(node.body) + elif isinstance(node, (ast.With, ast.AsyncWith)): + node.body = _strip_while_true_from_body(node.body) + elif isinstance(node, ast.Try): + node.body = _strip_while_true_from_body(node.body) + for handler in node.handlers: + handler.body = _strip_while_true_from_body(handler.body) + if node.orelse: + node.orelse = _strip_while_true_from_body(node.orelse) + if node.finalbody: + node.finalbody = _strip_while_true_from_body(node.finalbody) + new_body.append(node) + return new_body + + +def _strip_while_true(source_path): + """Recursively strip while-True loops from all nesting levels.""" + with open(source_path) as f: + source = f.read() + try: + tree = ast.parse(source) + tree.body = _strip_while_true_from_body(tree.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 = '' + 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] + _auto_mocker._active = True # Enable catch-all mock for unknown imports + try: + exec(code_obj, mod.__dict__) + finally: + _auto_mocker._active = False + sys.modules[mod_name] = mod # Register so @patch('mod_name.x') works + + proxy._add_module(mod) + except Exception as e: + _auto_mocker._active = False + print(f"[conftest] Warning loading {basename}: {e}") + continue + + return proxy diff --git a/tests/test_example.py b/tests/test_example.py new file mode 100644 index 0000000..c23330a --- /dev/null +++ b/tests/test_example.py @@ -0,0 +1,35 @@ +"""test_example.py — Starter template for Raspberry Pi Python project tests. + +RPi.GPIO and other hardware modules are pre-mocked in conftest.py. +DO NOT modify conftest.py. +""" +import pytest +from unittest.mock import MagicMock + + +def test_gpio_pin_control(source_module): + """Test GPIO pin output using mocked RPi.GPIO.""" + import RPi.GPIO as GPIO + + # Test GPIO setup and output + GPIO.setmode(GPIO.BCM) + GPIO.setup(17, GPIO.OUTPUT) + # Mock the input to return HIGH + GPIO.input = MagicMock(return_value=GPIO.HIGH) + GPIO.output(17, GPIO.HIGH) + assert GPIO.input(17) == GPIO.HIGH + GPIO.output(17, GPIO.LOW) + GPIO.cleanup() + + +def test_app_initialization(source_module): + """Test that the piDSLM app initializes correctly.""" + app = source_module.piDSLM() + assert app is not None + assert hasattr(app, 'app') + + +# NOTE: Use the source_module fixture to import project source files: +# def test_some_function(source_module): +# result = source_module.some_function(args) +# assert result == expected diff --git a/tests/test_pidslm.py b/tests/test_pidslm.py new file mode 100644 index 0000000..9903de2 --- /dev/null +++ b/tests/test_pidslm.py @@ -0,0 +1,206 @@ +"""Tests for piDSLM - Raspberry Pi DSLR Camera Controller""" +import pytest +import os +import sys +from unittest.mock import MagicMock, patch + + +def test_app_initialization(source_module): + """Test that the piDSLM app initializes correctly.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('guizero.App') as mock_app: + with patch('guizero.Window') as mock_window: + # Setup GPIO mock + mock_gpio.BCM = 11 + mock_gpio.BOARD = 10 + mock_gpio.OUT = 0 + mock_gpio.IN = 1 + mock_gpio.HIGH = 1 + mock_gpio.LOW = 0 + mock_gpio.PUD_UP = 2 + + # Mock the display method to prevent blocking + mock_app_instance = MagicMock() + mock_app_instance.display = MagicMock() + mock_app_instance.tk = MagicMock() + mock_app_instance.tk.attributes = MagicMock() + mock_app.return_value = mock_app_instance + + # Initialize the app + app = source_module.piDSLM() + + # Verify app was created + assert app is not None + assert hasattr(app, 'app') + assert hasattr(app, 'busy') + + +def test_clear_folder(source_module): + """Test clear folder functionality.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test clear + app.clear() + + # Verify os.system was called for clearing folder + assert mock_system.called + assert "rm -v" in str(mock_system.call_args) + + +def test_show_hide_busy(source_module): + """Test busy indicator functionality.""" + with patch('RPi.GPIO') as mock_gpio: + app = source_module.piDSLM() + + # Test show and hide busy + app.show_busy() + app.hide_busy() + + # Verify busy indicator methods were called + assert hasattr(app, 'busy') + + +def test_burst_mode(source_module): + """Test burst capture mode.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test burst mode + app.burst() + + # Verify raspistill command was called + assert mock_system.called + assert "raspistill" in str(mock_system.call_args) + + +def test_split_hd_30m(source_module): + """Test 30 minute split recording.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test split recording + app.split_hd_30m() + + # Verify raspivid command was called + assert mock_system.called + assert "raspivid" in str(mock_system.call_args) + + +def test_lapse_mode(source_module): + """Test timelapse mode.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test lapse mode + app.lapse() + + # Verify raspistill command was called with timelapse interval + assert mock_system.called + call_args = str(mock_system.call_args) + assert "raspistill" in call_args + assert "-tl" in call_args # timelapse interval flag + + +def test_long_preview(source_module): + """Test long preview functionality.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test long preview + app.long_preview() + + # Verify raspistill command was called + assert mock_system.called + assert "15000" in str(mock_system.call_args) + + +def test_capture_image(source_module): + """Test single image capture.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test capture image + app.capture_image() + + # Verify raspistill command was called + assert mock_system.called + assert "raspistill" in str(mock_system.call_args) + + +def test_video_capture(source_module): + """Test video capture functionality.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test video capture + app.video_capture() + + # Verify raspivid command was called + assert mock_system.called + assert "raspivid" in str(mock_system.call_args) + + +def test_upload_to_dropbox(source_module): + """Test Dropbox upload functionality.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('subprocess.Popen') as mock_popen: + mock_popen.return_value = MagicMock() + + app = source_module.piDSLM() + + # Test upload + app.upload() + + # Verify subprocess was called for upload + assert mock_popen.called + + +def test_timestamp(source_module): + """Test timestamp generation.""" + with patch('RPi.GPIO') as mock_gpio: + app = source_module.piDSLM() + + # Test timestamp + ts = app.timestamp() + + # Verify timestamp format + assert isinstance(ts, str) + assert "_" in ts # Should have date_time separator + + +def test_take_picture_callback(source_module): + """Test picture taking callback.""" + with patch('RPi.GPIO') as mock_gpio: + with patch('os.system') as mock_system: + mock_system.return_value = 0 + + app = source_module.piDSLM() + + # Test takePicture callback + app.takePicture(16) # Pass channel argument + + # Verify raspistill command was called + assert mock_system.called + assert "3500" in str(mock_system.call_args)