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 e2df8a2..0000000 Binary files a/__pycache__/dropbox.cpython-37.pyc and /dev/null differ 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)