diff --git a/.gitignore b/.gitignore index dd30cf6..7dda894 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ debug_*.py dist/ build/ *.egg-info/ + +# Auto-added by Marisol pipeline +.pio/ +.gradle/ +*.class +local.properties diff --git a/capture.py b/capture.py new file mode 100644 index 0000000..006f1ea --- /dev/null +++ b/capture.py @@ -0,0 +1,340 @@ +"""Capture module for piDSLM camera interface. + +Separates capture logic from hardware/GUI dependencies for testability. +""" + +import datetime +import os +import subprocess +import glob +from typing import List, Optional, Tuple + + +class CaptureResult: + """Result of a capture operation.""" + + def __init__(self, success: bool, filepath: Optional[str] = None, + timestamp: Optional[str] = None, error: Optional[str] = None, + filename_pattern: Optional[str] = None): + self.success = success + self.filepath = filepath + self.timestamp = timestamp + self.error = error + self.filename_pattern = filename_pattern + + +class ImageCapture: + """Handles image capture operations with realistic simulation.""" + + def __init__(self, output_dir: str = '/home/pi/Downloads', + capture_command: str = 'raspistill', + is_hardware_available: bool = True): + self.output_dir = output_dir + self.capture_command = capture_command + self.is_hardware_available = is_hardware_available + self._capture_history: List[CaptureResult] = [] + + def generate_timestamp(self) -> str: + """Generate a timestamp string for filenames.""" + return datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + + def create_capture_filename(self, prefix: str, timestamp: str, + extension: str = '.jpg') -> str: + """Create a capture filename with optional pattern.""" + filename = f"{prefix}{timestamp}{extension}" + return os.path.join(self.output_dir, filename) + + def simulate_capture(self, filepath: str, filename_pattern: Optional[str] = None) -> CaptureResult: + """Simulate a capture operation for testing.""" + if not self.is_hardware_available: + # In hardware simulation mode, create a dummy file + os.makedirs(self.output_dir, exist_ok=True) + try: + with open(filepath, 'wb') as f: + # Write a minimal JPEG header (1px black image) + f.write(b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\'9equivivalent minimal JPEG') + timestamp_str = filepath.split('/')[-1].replace('.jpg', '') + return CaptureResult(success=True, filepath=filepath, + timestamp=timestamp_str, + filename_pattern=filename_pattern or filepath) + except Exception as e: + return CaptureResult(success=False, error=str(e)) + else: + return CaptureResult(success=False, error="Hardware not available in test mode") + + def capture(self, prefix: str = "", extension: str = '.jpg', + timeout_ms: int = 3500, is_live: bool = False) -> CaptureResult: + """Capture a single image. + + Args: + prefix: Prefix for filename (e.g., "cam", "photo") + extension: File extension (default: .jpg) + timeout_ms: Capture timeout in milliseconds (default: 3500) + is_live: Whether to use preview mode (no output file) + + Returns: + CaptureResult with success status and file info + """ + timestamp = self.generate_timestamp() + + if is_live: + filename = f"{prefix}{timestamp}" + filepath = os.path.join(self.output_dir, filename) + else: + filename = f"{prefix}{timestamp}{extension}" + filepath = os.path.join(self.output_dir, filename) + + result = self._execute_capture(filepath, prefix, timestamp, timeout_ms, extension, is_live) + + if result.success: + self._capture_history.append(result) + + return result + + def _execute_capture(self, filepath: str, prefix: str, timestamp: str, + timeout_ms: int, extension: str, is_live: bool) -> CaptureResult: + """Execute the actual capture command or simulation.""" + if not self.is_hardware_available: + return self.simulate_capture(filepath) + + try: + if is_live: + cmd = [self.capture_command, '-f', '-t', str(timeout_ms)] + else: + cmd = [self.capture_command, '-f', '-t', str(timeout_ms), '-o', filepath] + + subprocess.run(cmd, check=True, timeout=timeout_ms/1000 + 5) + + return CaptureResult( + success=True, + filepath=filepath, + timestamp=timestamp, + filename_pattern=f"{prefix}{timestamp}{extension}" + ) + except subprocess.TimeoutExpired: + return CaptureResult( + success=False, + error=f"Capture timed out after {timeout_ms}ms" + ) + except subprocess.CalledProcessError as e: + return CaptureResult( + success=False, + error=f"Capture failed: {e}" + ) + except FileNotFoundError: + return CaptureResult( + success=False, + error=f"Capture command not found: {self.capture_command}" + ) + + def burst_capture(self, prefix: str = "BR", + timeout_ms: int = 10000, + interval_ms: int = 0) -> CaptureResult: + """Capture a burst of photos with pattern naming. + + Args: + prefix: Filename prefix (default: "BR" for burst) + timeout_ms: Total capture timeout in milliseconds + interval_ms: Interval between captures (0 for continuous) + + Returns: + CaptureResult indicating success + """ + timestamp = self.generate_timestamp() + + if not self.is_hardware_available: + # Simulate burst capture by capturing one file + filepath = self.create_capture_filename(prefix, timestamp, '.jpg') + result = self.simulate_capture(filepath) + result.filename_pattern = f"{prefix}{timestamp}%04d.jpg" + return result + + try: + pattern = f"{prefix}{timestamp}%04d.jpg" + filepath = os.path.join(self.output_dir, pattern) + + cmd = [ + self.capture_command, + '-t', str(timeout_ms), + '-tl', str(interval_ms), + '--thumb', 'none', + '-n', '-bm', + '-o', filepath + ] + + subprocess.run(cmd, check=True, timeout=timeout_ms/1000 + 10) + + return CaptureResult( + success=True, + filepath=filepath.replace('%04d', ''), + timestamp=timestamp, + filename_pattern=pattern + ) + except subprocess.TimeoutExpired: + return CaptureResult( + success=False, + error=f"Burst capture timed out after {timeout_ms}ms" + ) + except subprocess.CalledProcessError as e: + return CaptureResult( + success=False, + error=f"Burst capture failed: {e}" + ) + + def timelapse(self, prefix: str = "TL", + timeout_ms: int = 3600000, + interval_ms: int = 60000) -> CaptureResult: + """Capture a timelapse sequence. + + Args: + prefix: Filename prefix (default: "TL" for timelapse) + timeout_ms: Total capture duration in milliseconds (default: 1h) + interval_ms: Interval between captures in milliseconds (default: 60s) + + Returns: + CaptureResult indicating success + """ + return self.burst_capture( + prefix=prefix, + timeout_ms=timeout_ms, + interval_ms=interval_ms + ) + + def video_capture(self, prefix: str = "vid", + timeout_ms: int = 30000, + is_frame_sequence: bool = False, + segment_time_ms: int = 300000) -> CaptureResult: + """Capture video using raspivid. + + Args: + prefix: Filename prefix (default: "vid") + timeout_ms: Capture duration in milliseconds (default: 30s) + is_frame_sequence: Whether to capture frames as sequence + segment_time_ms: Time between segments (for long recordings) + + Returns: + CaptureResult indicating success + """ + timestamp = self.generate_timestamp() + extension = '.h264' + + if not self.is_hardware_available: + pattern = f"{prefix}{timestamp}%04d.h264" if is_frame_sequence else f"{prefix}{timestamp}.h264" + filepath = self.create_capture_filename(prefix, timestamp, extension) + return self.simulate_capture(filepath, filename_pattern=pattern) + + try: + if is_frame_sequence: + pattern = f"{prefix}{timestamp}%04d.h264" + else: + pattern = f"{prefix}{timestamp}.h264" + + filepath = os.path.join(self.output_dir, pattern) + + cmd = [ + self.capture_command, + '-f' if is_frame_sequence else '', + '-t', str(timeout_ms), + '-sg', str(segment_time_ms) if is_frame_sequence else '', + '-o', filepath + ] + + # Filter out empty strings from cmd + cmd = [c for c in cmd if c] + + subprocess.run(cmd, check=True, timeout=timeout_ms/1000 + 10) + + return CaptureResult( + success=True, + filepath=filepath, + timestamp=timestamp, + filename_pattern=pattern + ) + except subprocess.TimeoutExpired: + return CaptureResult( + success=False, + error=f"Video capture timed out after {timeout_ms}ms" + ) + except subprocess.CalledProcessError as e: + return CaptureResult( + success=False, + error=f"Video capture failed: {e}" + ) + + def get_capture_history(self) -> List[CaptureResult]: + """Get list of all captured images.""" + return self._capture_history.copy() + + def clear_capture_history(self) -> None: + """Clear the capture history.""" + self._capture_history.clear() + + +class GalleryManager: + """Manages the photo gallery display.""" + + def __init__(self, photo_dir: str = '/home/pi/Downloads', + file_pattern: str = '*.jpg'): + self.photo_dir = photo_dir + self.file_pattern = file_pattern + self._photos: List[str] = [] + self._current_index = 0 + + def load_photos(self) -> List[str]: + """Load all photos from the directory.""" + import glob as glob_module + + pattern = os.path.join(self.photo_dir, self.file_pattern) + self._photos = sorted(glob_module.glob(pattern)) + self._current_index = 0 + return self._photos + + def get_photo_count(self) -> int: + """Get the total number of photos.""" + return len(self._photos) + + def get_current_photo(self) -> Optional[str]: + """Get the currently selected photo path.""" + if self._photos: + return self._photos[self._current_index] + return None + + def get_thumbnail_count(self) -> int: + """Get number of thumbnails available.""" + return len(self._photos) + + def navigate_left(self) -> Optional[str]: + """Navigate to previous photo.""" + if not self._photos: + return None + + if self._current_index == 0: + self._current_index = len(self._photos) - 1 + else: + self._current_index -= 1 + + return self._photos[self._current_index] + + def navigate_right(self) -> Optional[str]: + """Navigate to next photo.""" + if not self._photos: + return None + + if self._current_index == len(self._photos) - 1: + self._current_index = 0 + else: + self._current_index += 1 + + return self._photos[self._current_index] + + def go_to(self, index: int) -> Optional[str]: + """Navigate to a specific photo by index.""" + if 0 <= index < len(self._photos): + self._current_index = index + return self._photos[self._current_index] + return None + + def clear(self) -> None: + """Clear the gallery.""" + self._photos.clear() + self._current_index = 0 diff --git a/pidslm.py b/pidslm.py index a426ec6..94ce8f4 100755 --- a/pidslm.py +++ b/pidslm.py @@ -7,6 +7,7 @@ import sys, os import subprocess import RPi.GPIO as GPIO # Import Raspberry Pi GPIO library +from capture import ImageCapture, GalleryManager, CaptureResult class piDSLM: @@ -14,14 +15,24 @@ def __init__(self): self.capture_number = self.timestamp() self.video_capture_number = self.timestamp() self.picture_index = 0 - self.saved_pictures = [] - self.shown_picture = "" - + self.saved_pictures = [] + self.shown_picture = "" + GPIO.setwarnings(False) # Ignore warning for now GPIO.setmode(GPIO.BCM) # set up BCM GPIO numbering GPIO.setup(16, GPIO.IN, pull_up_down=GPIO.PUD_UP) GPIO.add_event_detect(16, GPIO.FALLING, callback=self.takePicture, bouncetime=2500) - + + # Initialize capture manager with real hardware support + self.capture_manager = ImageCapture( + output_dir='/home/pi/Downloads', + capture_command='raspistill', + is_hardware_available=True + ) + + # Initialize gallery manager + self.gallery_manager = GalleryManager(photo_dir='/home/pi/Downloads') + self.app = App(layout="grid", title="Camera Controls", bg="black", width=480, height=320) text0 = Text(self.app,color="white", grid=[1,0], text="- PiDSLM -") @@ -82,79 +93,142 @@ def timestamp(self): return tstring.strftime("%Y%m%d_%H%M%S") def burst(self): + """Capture burst photos using the capture manager.""" self.show_busy() - capture_number = self.timestamp() - print("Raspistill starts") - os.system("raspistill -t 10000 -tl 0 --thumb none -n -bm -o /home/pi/Downloads/BR" +str(capture_number) + "%04d.jpg") - print("Raspistill done") + result = self.capture_manager.burst_capture( + prefix="BR", + timeout_ms=10000, + interval_ms=0 + ) + if result.success: + print(f"Burst capture saved to: {result.filepath}") + else: + print(f"Burst capture failed: {result.error}") self.hide_busy() - def split_hd_30m(self): + def split_hd_30m(self): + """Record 30m video split into 5s segments using the capture manager.""" self.show_busy() - capture_number = self.timestamp() - print("Raspivid starts") - os.system("raspivid -f -t 1800000 -sg 300000 -o /home/pi/Downloads/" +str(capture_number) + "vid%04d.h264") - print("done") + result = self.capture_manager.video_capture( + prefix="vid", + timeout_ms=1800000, # 30 minutes + is_frame_sequence=True, + segment_time_ms=5000 # 5 second segments + ) + if result.success: + print(f"Video capture saved to: {result.filepath}") + else: + print(f"Video capture failed: {result.error}") self.hide_busy() def lapse(self): + """Capture timelapse sequence using the capture manager.""" self.show_busy() - capture_number = self.timestamp() - print("Raspistill timelapse starts") - os.system("raspistill -t 3600000 -tl 60000 --thumb none -n -bm -o /home/pi/Downloads/TL" +str(capture_number) + "%04d.jpg") - print("Raspistill timelapse done") + result = self.capture_manager.timelapse( + prefix="TL", + timeout_ms=3600000, # 1 hour + interval_ms=60000 # 60 seconds between captures + ) + if result.success: + print(f"Timelapse capture saved to: {result.filepath}") + else: + print(f"Timelapse capture failed: {result.error}") self.hide_busy() def long_preview(self): + """Show 15-second preview using the capture manager.""" self.show_busy() - print("15 second preview") - os.system("raspistill -f -t 15000") + result = self.capture_manager.capture( + prefix="preview_", + timeout_ms=15000, + is_live=True + ) self.hide_busy() def capture_image(self): + """Capture a single image using the capture manager.""" self.show_busy() - capture_number = self.timestamp() - print("Raspistill starts") - os.system("raspistill -f -o /home/pi/Downloads/" +str(capture_number) + "cam.jpg") - print("Raspistill done") + result = self.capture_manager.capture( + prefix="cam", + timeout_ms=3500, + extension=".jpg" + ) + if result.success: + print(f"Image captured: {result.filepath}") + else: + print(f"Capture failed: {result.error}") self.hide_busy() def takePicture(self, channel): - print ("Button event callback") - capture_number = self.timestamp() - print("Raspistill starts") - os.system("raspistill -f -t 3500 -o /home/pi/Downloads/" +str(capture_number) + "cam.jpg") - print("Raspistill done") + """Callback for GPIO button press - capture image using the capture manager.""" + print("Button event callback") + result = self.capture_manager.capture( + prefix="cam", + timeout_ms=3500, + extension=".jpg" + ) + if result.success: + print(f"Image captured: {result.filepath}") + else: + print(f"Capture failed: {result.error}") def picture_left(self): - if (self.picture_index == 0): - self.pictures = (len(self.saved_pictures) - 1) + """Navigate to previous photo in gallery.""" + if self.picture_index == 0: + self.picture_index = len(self.saved_pictures) - 1 self.picture_index -= 1 self.shown_picture = self.saved_pictures[self.picture_index] - self.picture_gallery = Picture(self.gallery, width=360, height=270, image=self.shown_picture, grid=[1,0]) + self.picture_gallery = Picture(self.gallery, width=360, height=270, + image=self.shown_picture, grid=[1,0]) def picture_right(self): - if (self.picture_index == (len(self.saved_pictures) - 1)): - self.picture_index = 0 + """Navigate to next photo in gallery.""" + if self.picture_index == (len(self.saved_pictures) - 1): + self.picture_index = 0 self.picture_index += 1 self.shown_picture = self.saved_pictures[self.picture_index] - self.picture_gallery = Picture(self.gallery, width=360, height=270, image=self.shown_picture, grid=[1,0]) + self.picture_gallery = Picture(self.gallery, width=360, height=270, + image=self.shown_picture, grid=[1,0]) def show_gallery(self): - self.gallery = Window(self.app, bg="white", height=300, width=460, layout="grid",title="Gallery") - self.saved_pictures = glob.glob('/home/pi/Downloads/*.jpg') - self.shown_picture = self.saved_pictures[self.picture_index] - button_left = PushButton(self.gallery, grid=[0,0], width=40, height=50, pady=50, padx=10, image="/home/pi/piDSLM/icon/left.png", command=self.picture_left) - self.picture_gallery = Picture(self.gallery, width=360, height=270, image=self.shown_picture, grid=[1,0]) - button_right = PushButton(self.gallery, grid=[2,0], width=40, height=50, pady=50, padx=10, image="/home/pi/piDSLM/icon/right.png", command=self.picture_right) + """Display photo gallery using GalleryManager.""" + self.gallery = Window(self.app, bg="white", height=300, width=460, + layout="grid", title="Gallery") + + # Load photos from directory + self.saved_pictures = self.gallery_manager.load_photos() + + if not self.saved_pictures: + Text(self.gallery, text="No photos found", grid=[1, 0]) + return + + self.picture_index = 0 + self.shown_picture = self.saved_pictures[self.picture_index] + + button_left = PushButton(self.gallery, grid=[0,0], width=40, height=50, + pady=50, padx=10, + image="/home/pi/piDSLM/icon/left.png", + command=self.picture_left) + self.picture_gallery = Picture(self.gallery, width=360, height=270, + image=self.shown_picture, grid=[1,0]) + button_right = PushButton(self.gallery, grid=[2,0], width=40, height=50, + pady=50, padx=10, + image="/home/pi/piDSLM/icon/right.png", + command=self.picture_right) self.gallery.show() def video_capture(self): + """Capture video using the capture manager.""" self.show_busy() - capture_number = self.timestamp() - print("Raspivid starts") - os.system("raspivid -f -t 30000 -o /home/pi/Downloads/" +str(capture_number) + "vid.h264") - print("done") + result = self.capture_manager.video_capture( + prefix="vid", + timeout_ms=30000, # 30 seconds + is_frame_sequence=False + ) + if result.success: + print(f"Video captured: {result.filepath}") + else: + print(f"Video capture failed: {result.error}") self.hide_busy() def upload(self): diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..906f519 --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,270 @@ +"""Tests for capture module - ImageCapture and GalleryManager classes.""" + +import pytest +import os +import tempfile +from capture import ImageCapture, GalleryManager, CaptureResult + + +class TestImageCapture: + """Test suite for the ImageCapture class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up test fixtures.""" + import shutil + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_timestamp_generation(self): + """Test that timestamps are generated correctly.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + timestamp = capture.generate_timestamp() + + assert isinstance(timestamp, str) + assert len(timestamp) == 15 # YYYYMMDD_HHMMSS format + # Timestamp contains digits and underscore, so check format differently + assert '_' in timestamp + assert timestamp[:8].isdigit() # Date part is digits + assert timestamp[9:].replace('_', '').isdigit() # Time part is digits + + def test_filename_creation(self): + """Test filename creation with various parameters.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + filename = capture.create_capture_filename("cam", "20240115_123045", ".jpg") + assert filename == os.path.join(self.temp_dir, "cam20240115_123045.jpg") + + def test_capture_returns_result(self): + """Test that capture method returns CaptureResult.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + result = capture.capture(prefix="test") + + assert isinstance(result, CaptureResult) + # In test simulation mode, capture should succeed + assert result.success is True + + def test_capture_simulates_in_test_mode(self): + """Test capture simulation when hardware is not available.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + result = capture.capture(prefix="cam", extension=".jpg", timeout_ms=100) + + # In simulation mode, it should succeed + assert isinstance(result, CaptureResult) + + def test_capture_history_tracking(self): + """Test that captures are tracked in history.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + capture.capture(prefix="cam") + capture.capture(prefix="cam") + + history = capture.get_capture_history() + assert len(history) == 2 + + def test_capture_clears_history(self): + """Test clearing capture history.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + capture.capture(prefix="cam") + capture.clear_capture_history() + + assert len(capture.get_capture_history()) == 0 + + def test_burst_capture(self): + """Test burst capture functionality.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + result = capture.burst_capture(prefix="BR", timeout_ms=1000, interval_ms=100) + + assert isinstance(result, CaptureResult) + assert '%04d' in result.filename_pattern + + def test_timelapse_delegates_to_burst(self): + """Test that timelapse uses burst capture logic.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + result = capture.timelapse(prefix="TL", timeout_ms=3600000, interval_ms=60000) + + assert isinstance(result, CaptureResult) + # Check that pattern contains TL and %04d + assert result.filename_pattern is not None + assert 'TL' in result.filename_pattern + assert '%04d' in result.filename_pattern + + def test_video_capture_simulation(self): + """Test video capture in simulation mode.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + result = capture.video_capture(prefix="vid", timeout_ms=30000, is_frame_sequence=False) + + assert isinstance(result, CaptureResult) + assert result.filename_pattern is not None + assert result.filename_pattern.endswith('.h264') + + def test_video_capture_frame_sequence(self): + """Test video capture with frame sequence.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + result = capture.video_capture(prefix="vid", timeout_ms=30000, is_frame_sequence=True) + + assert isinstance(result, CaptureResult) + assert result.filename_pattern is not None + assert '%04d.h264' in result.filename_pattern + + +class TestGalleryManager: + """Test suite for the GalleryManager class.""" + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + # Create some dummy photo files + for i in range(5): + filepath = os.path.join(self.temp_dir, f"photo{i}.jpg") + with open(filepath, 'w') as f: + f.write("dummy") + + self.gallery = GalleryManager(photo_dir=self.temp_dir) + + def teardown_method(self): + """Clean up test fixtures.""" + import shutil + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_load_photos(self): + """Test loading photos from directory.""" + photos = self.gallery.load_photos() + + assert len(photos) == 5 + assert all(p.endswith('.jpg') for p in photos) + + def test_get_photo_count(self): + """Test photo count.""" + self.gallery.load_photos() + + assert self.gallery.get_photo_count() == 5 + + def test_navigation_left_at_start(self): + """Test navigation left from first photo wraps to last.""" + self.gallery.load_photos() + self.gallery._current_index = 0 + + result = self.gallery.navigate_left() + + assert result.endswith('photo4.jpg') + assert self.gallery._current_index == 4 + + def test_navigation_left(self): + """Test normal left navigation.""" + self.gallery.load_photos() + self.gallery._current_index = 2 + + result = self.gallery.navigate_left() + + assert result.endswith('photo1.jpg') + assert self.gallery._current_index == 1 + + def test_navigation_right_at_end(self): + """Test navigation right from last photo wraps to first.""" + self.gallery.load_photos() + self.gallery._current_index = 4 + + result = self.gallery.navigate_right() + + assert result.endswith('photo0.jpg') + assert self.gallery._current_index == 0 + + def test_navigation_right(self): + """Test normal right navigation.""" + self.gallery.load_photos() + self.gallery._current_index = 1 + + result = self.gallery.navigate_right() + + assert result.endswith('photo2.jpg') + assert self.gallery._current_index == 2 + + def test_go_to_index(self): + """Test direct index navigation.""" + self.gallery.load_photos() + + result = self.gallery.go_to(3) + + assert result.endswith('photo3.jpg') + assert self.gallery._current_index == 3 + + def test_go_to_invalid_index(self): + """Test navigating to invalid index.""" + self.gallery.load_photos() + + result = self.gallery.go_to(100) + + assert result is None + assert self.gallery._current_index == 0 # Stays at current + + def test_empty_gallery(self): + """Test navigation on empty gallery.""" + gallery = GalleryManager(photo_dir='/nonexistent') + + assert gallery.navigate_left() is None + assert gallery.navigate_right() is None + assert gallery.get_current_photo() is None + + def test_gallery_clear(self): + """Test clearing gallery.""" + self.gallery.load_photos() + self.gallery.clear() + + assert self.gallery.get_photo_count() == 0 + assert self.gallery._current_index == 0 + + +class TestIntegration: + """Integration tests for capture and gallery.""" + + def setup_method(self): + """Set up test fixtures.""" + self.temp_dir = tempfile.mkdtemp() + + def teardown_method(self): + """Clean up test fixtures.""" + import shutil + if os.path.exists(self.temp_dir): + shutil.rmtree(self.temp_dir) + + def test_capture_and_gallery_workflow(self): + """Test complete workflow: capture then view in gallery.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + gallery = GalleryManager(photo_dir=self.temp_dir) + + # Capture some photos (simulated) + result1 = capture.capture(prefix="test1") + result2 = capture.capture(prefix="test2") + + # Load gallery + photos = gallery.load_photos() + + assert len(photos) == 2 + assert gallery.get_current_photo() is not None + + def test_capture_history_persistence(self): + """Test that capture history persists across operations.""" + capture = ImageCapture(output_dir=self.temp_dir, is_hardware_available=False) + + capture.capture(prefix="test") + history_before = len(capture.get_capture_history()) + + # Reload gallery (simulates restart) + gallery = GalleryManager(photo_dir=self.temp_dir) + gallery.load_photos() + + history_after = len(capture.get_capture_history()) + assert history_before == history_after