Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
![Tests](https://github.com/NickEngmann/piDSLM/actions/workflows/test.yml/badge.svg)

piDSLM - Raspberry Pi Digital Single Lens Mirrorless
===============

Expand Down Expand Up @@ -50,5 +52,8 @@ Finally, run the INSTALL.sh script using the following command
sudo ./INSTALL.sh
```

## Running Tests


```bash
pytest
```
Binary file not shown.
Binary file not shown.
167 changes: 167 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
131 changes: 131 additions & 0 deletions tests/test_pidslm.py
Original file line number Diff line number Diff line change
@@ -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)
Loading