""" Unit tests for the thumbnail browser functionality. """ import unittest from pathlib import Path from unittest.mock import Mock, MagicMock, patch import tempfile import os import time from PyQt6.QtWidgets import QApplication from PyQt6.QtCore import Qt, QTimer from PyQt6.QtTest import QTest from pyPhotoAlbum.thumbnail_browser import ThumbnailItem, ThumbnailGLWidget, ThumbnailBrowserDock from pyPhotoAlbum.models import ImageData from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.project import Project, Page try: from PIL import Image PILLOW_AVAILABLE = True except ImportError: PILLOW_AVAILABLE = False class TestThumbnailItem(unittest.TestCase): """Test ThumbnailItem class.""" def test_thumbnail_item_initialization(self): """Test ThumbnailItem initializes correctly.""" item = ThumbnailItem("/path/to/image.jpg", (0, 0), 100.0) self.assertEqual(item.image_path, "/path/to/image.jpg") self.assertEqual(item.grid_row, 0) self.assertEqual(item.grid_col, 0) self.assertEqual(item.thumbnail_size, 100.0) self.assertFalse(item.is_used_in_project) def test_thumbnail_item_position_calculation(self): """Test that thumbnail position is calculated correctly based on grid.""" # Position (0, 0) item1 = ThumbnailItem("/path/1.jpg", (0, 0), 100.0) self.assertEqual(item1.x, 10.0) # spacing self.assertEqual(item1.y, 10.0) # spacing # Position (0, 1) - second column item2 = ThumbnailItem("/path/2.jpg", (0, 1), 100.0) self.assertEqual(item2.x, 120.0) # 10 + (100 + 10) * 1 self.assertEqual(item2.y, 10.0) # Position (1, 0) - second row item3 = ThumbnailItem("/path/3.jpg", (1, 0), 100.0) self.assertEqual(item3.x, 10.0) self.assertEqual(item3.y, 120.0) # 10 + (100 + 10) * 1 def test_thumbnail_item_bounds(self): """Test get_bounds returns correct values.""" item = ThumbnailItem("/path/to/image.jpg", (0, 0), 100.0) bounds = item.get_bounds() self.assertEqual(bounds, (10.0, 10.0, 100.0, 100.0)) def test_thumbnail_item_contains_point(self): """Test contains_point correctly detects if point is inside thumbnail.""" item = ThumbnailItem("/path/to/image.jpg", (0, 0), 100.0) # Point inside self.assertTrue(item.contains_point(50.0, 50.0)) self.assertTrue(item.contains_point(10.0, 10.0)) # Top-left corner self.assertTrue(item.contains_point(110.0, 110.0)) # Bottom-right corner # Points outside self.assertFalse(item.contains_point(5.0, 5.0)) self.assertFalse(item.contains_point(120.0, 120.0)) self.assertFalse(item.contains_point(50.0, 150.0)) class TestThumbnailGLWidget(unittest.TestCase): """Test ThumbnailGLWidget class.""" @classmethod def setUpClass(cls): """Set up QApplication for tests.""" if not QApplication.instance(): cls.app = QApplication([]) else: cls.app = QApplication.instance() def setUp(self): """Set up test fixtures.""" self.widget = ThumbnailGLWidget(main_window=None) def test_widget_initialization(self): """Test widget initializes with correct defaults.""" self.assertEqual(len(self.widget.thumbnails), 0) self.assertIsNone(self.widget.current_folder) self.assertEqual(self.widget.zoom_level, 1.0) self.assertEqual(self.widget.pan_offset, (0, 0)) def test_screen_to_viewport_conversion(self): """Test screen to viewport coordinate conversion.""" self.widget.zoom_level = 2.0 self.widget.pan_offset = (10, 20) vp_x, vp_y = self.widget.screen_to_viewport(50, 60) # (50 - 10) / 2.0 = 20.0 # (60 - 20) / 2.0 = 20.0 self.assertEqual(vp_x, 20.0) self.assertEqual(vp_y, 20.0) def test_load_folder_with_no_images(self): """Test loading a folder with no images.""" with tempfile.TemporaryDirectory() as tmpdir: self.widget.load_folder(Path(tmpdir)) self.assertEqual(self.widget.current_folder, Path(tmpdir)) self.assertEqual(len(self.widget.thumbnails), 0) @patch('pyPhotoAlbum.thumbnail_browser.ThumbnailGLWidget._request_thumbnail_load') def test_load_folder_with_images(self, mock_request_load): """Test loading a folder with image files.""" with tempfile.TemporaryDirectory() as tmpdir: # Create some dummy image files img1 = Path(tmpdir) / "image1.jpg" img2 = Path(tmpdir) / "image2.png" img3 = Path(tmpdir) / "image3.gif" img1.touch() img2.touch() img3.touch() self.widget.load_folder(Path(tmpdir)) self.assertEqual(self.widget.current_folder, Path(tmpdir)) self.assertEqual(len(self.widget.thumbnails), 3) self.assertEqual(len(self.widget.image_files), 3) # Check that all thumbnails have valid grid positions for thumb in self.widget.thumbnails: self.assertGreaterEqual(thumb.grid_row, 0) self.assertGreaterEqual(thumb.grid_col, 0) # Verify load was requested for each thumbnail self.assertEqual(mock_request_load.call_count, 3) def test_get_thumbnail_at_position(self): """Test getting thumbnail at a specific screen position.""" # Manually add some thumbnails thumb1 = ThumbnailItem("/path/1.jpg", (0, 0), 100.0) thumb2 = ThumbnailItem("/path/2.jpg", (0, 1), 100.0) self.widget.thumbnails = [thumb1, thumb2] # No zoom or pan self.widget.zoom_level = 1.0 self.widget.pan_offset = (0, 0) # Point inside first thumbnail result = self.widget.get_thumbnail_at(50, 50) self.assertEqual(result, thumb1) # Point inside second thumbnail result = self.widget.get_thumbnail_at(130, 50) self.assertEqual(result, thumb2) # Point outside both thumbnails result = self.widget.get_thumbnail_at(300, 300) self.assertIsNone(result) def test_update_used_images(self): """Test that used images are correctly marked.""" # Create mock main window with project mock_main_window = Mock() mock_project = Mock(spec=Project) # Create mock pages with image elements mock_layout = Mock(spec=PageLayout) mock_page = Mock(spec=Page) mock_page.layout = mock_layout # Create image element that uses /path/to/used.jpg mock_image = Mock(spec=ImageData) mock_image.image_path = "assets/used.jpg" mock_image.resolve_image_path.return_value = "/path/to/used.jpg" mock_layout.elements = [mock_image] mock_project.pages = [mock_page] mock_main_window.project = mock_project # Mock the window() method to return our mock main window with patch.object(self.widget, 'window', return_value=mock_main_window): # Add thumbnails thumb1 = ThumbnailItem("/path/to/used.jpg", (0, 0)) thumb2 = ThumbnailItem("/path/to/unused.jpg", (0, 1)) self.widget.thumbnails = [thumb1, thumb2] # Update used images self.widget.update_used_images() # Check results self.assertTrue(thumb1.is_used_in_project) self.assertFalse(thumb2.is_used_in_project) class TestThumbnailBrowserDock(unittest.TestCase): """Test ThumbnailBrowserDock class.""" @classmethod def setUpClass(cls): """Set up QApplication for tests.""" if not QApplication.instance(): cls.app = QApplication([]) else: cls.app = QApplication.instance() def setUp(self): """Set up test fixtures.""" self.dock = ThumbnailBrowserDock() def test_dock_initialization(self): """Test dock widget initializes correctly.""" self.assertEqual(self.dock.windowTitle(), "Image Browser") self.assertIsNotNone(self.dock.gl_widget) self.assertIsNotNone(self.dock.folder_label) self.assertIsNotNone(self.dock.select_folder_btn) def test_initial_folder_label(self): """Test initial folder label text.""" self.assertEqual(self.dock.folder_label.text(), "No folder selected") @patch('pyPhotoAlbum.thumbnail_browser.QFileDialog.getExistingDirectory') @patch('pyPhotoAlbum.thumbnail_browser.ThumbnailGLWidget.load_folder') def test_select_folder(self, mock_load_folder, mock_dialog): """Test folder selection updates the widget.""" # Mock the dialog to return a path test_path = "/test/folder" mock_dialog.return_value = test_path # Trigger folder selection self.dock._select_folder() # Verify dialog was called mock_dialog.assert_called_once() # Verify load_folder was called with the path mock_load_folder.assert_called_once_with(Path(test_path)) @patch('pyPhotoAlbum.thumbnail_browser.QFileDialog.getExistingDirectory') @patch('pyPhotoAlbum.thumbnail_browser.ThumbnailGLWidget.load_folder') def test_select_folder_cancel(self, mock_load_folder, mock_dialog): """Test folder selection handles cancel.""" # Mock the dialog to return empty (cancel) mock_dialog.return_value = "" # Trigger folder selection self.dock._select_folder() # Verify load_folder was NOT called mock_load_folder.assert_not_called() def test_load_folder_updates_label(self): """Test that loading a folder updates the label.""" with tempfile.TemporaryDirectory() as tmpdir: folder_path = Path(tmpdir) folder_name = folder_path.name self.dock.load_folder(folder_path) self.assertEqual(self.dock.folder_label.text(), f"Folder: {folder_name}") @unittest.skipUnless(PILLOW_AVAILABLE, "Pillow not available") class TestThumbnailBrowserIntegration(unittest.TestCase): """Integration tests for thumbnail browser with actual image files.""" @classmethod def setUpClass(cls): """Set up QApplication for tests.""" if not QApplication.instance(): cls.app = QApplication([]) else: cls.app = QApplication.instance() def setUp(self): """Set up test fixtures.""" self.widget = ThumbnailGLWidget(main_window=None) def tearDown(self): """Clean up after tests.""" if hasattr(self.widget, 'thumbnails'): # Clean up any GL textures for thumb in self.widget.thumbnails: if hasattr(thumb, '_texture_id') and thumb._texture_id: try: from pyPhotoAlbum.gl_imports import glDeleteTextures glDeleteTextures([thumb._texture_id]) except: pass def _create_test_jpeg(self, path: Path, width: int = 100, height: int = 100, color: tuple = (255, 0, 0)): """Create a test JPEG file with the specified dimensions and color.""" img = Image.new('RGB', (width, height), color=color) img.save(path, 'JPEG', quality=85) def test_load_folder_with_real_jpegs(self): """Integration test: Load a folder with real JPEG files.""" with tempfile.TemporaryDirectory() as tmpdir: folder = Path(tmpdir) # Create test JPEG files with different colors colors = [ (255, 0, 0), # Red (0, 255, 0), # Green (0, 0, 255), # Blue (255, 255, 0), # Yellow (255, 0, 255), # Magenta ] created_files = [] for i, color in enumerate(colors): img_path = folder / f"test_image_{i:02d}.jpg" self._create_test_jpeg(img_path, 200, 150, color) created_files.append(img_path) # Load the folder self.widget.load_folder(folder) # Verify folder was set self.assertEqual(self.widget.current_folder, folder) # Verify image files were found self.assertEqual(len(self.widget.image_files), 5) self.assertEqual(len(self.widget.thumbnails), 5) # Verify all created files are in the list found_paths = [str(f) for f in self.widget.image_files] for created_file in created_files: self.assertIn(str(created_file), found_paths) # Verify grid positions are valid for thumb in self.widget.thumbnails: self.assertGreaterEqual(thumb.grid_row, 0) self.assertGreaterEqual(thumb.grid_col, 0) self.assertTrue(thumb.image_path.endswith('.jpg')) def test_thumbnail_async_loading_with_mock_loader(self): """Test thumbnail loading with a mock async loader.""" with tempfile.TemporaryDirectory() as tmpdir: folder = Path(tmpdir) # Create 3 test images for i in range(3): img_path = folder / f"image_{i}.jpg" self._create_test_jpeg(img_path, 150, 150, (100 + i * 50, 100, 100)) # Create a mock main window with async loader and project mock_main_window = Mock() mock_gl_widget = Mock() mock_async_loader = Mock() mock_project = Mock() mock_project.pages = [] # Empty pages list # Track requested loads requested_loads = [] def mock_request_load(path, priority, target_size, user_data): requested_loads.append({ 'path': path, 'user_data': user_data }) # Simulate immediate load by loading the image try: img = Image.open(path) img = img.convert('RGBA') img.thumbnail(target_size, Image.Resampling.LANCZOS) # Call the callback directly user_data._pending_pil_image = img user_data._img_width = img.width user_data._img_height = img.height except Exception as e: print(f"Error in mock load: {e}") mock_async_loader.request_load = mock_request_load mock_gl_widget.async_image_loader = mock_async_loader mock_main_window._gl_widget = mock_gl_widget mock_main_window.project = mock_project # Patch the widget's window() method with patch.object(self.widget, 'window', return_value=mock_main_window): # Load the folder self.widget.load_folder(folder) # Verify load was requested for each image self.assertEqual(len(requested_loads), 3) # Verify images were "loaded" (pending images set) loaded_count = sum(1 for thumb in self.widget.thumbnails if hasattr(thumb, '_pending_pil_image') and thumb._pending_pil_image) self.assertEqual(loaded_count, 3) # Verify image dimensions were set for thumb in self.widget.thumbnails: if hasattr(thumb, '_img_width'): self.assertGreater(thumb._img_width, 0) self.assertGreater(thumb._img_height, 0) def test_large_folder_loading(self): """Test loading a folder with many images.""" with tempfile.TemporaryDirectory() as tmpdir: folder = Path(tmpdir) # Create 50 test images num_images = 50 for i in range(num_images): img_path = folder / f"img_{i:03d}.jpg" # Use smaller images for speed color = (i * 5 % 256, (i * 7) % 256, (i * 11) % 256) self._create_test_jpeg(img_path, 50, 50, color) # Load the folder self.widget.load_folder(folder) # Verify all images were found self.assertEqual(len(self.widget.image_files), num_images) self.assertEqual(len(self.widget.thumbnails), num_images) # Verify grid layout exists and all positions are valid for thumb in self.widget.thumbnails: self.assertGreaterEqual(thumb.grid_row, 0) self.assertGreaterEqual(thumb.grid_col, 0) def test_mixed_file_extensions(self): """Test loading folder with mixed image extensions.""" with tempfile.TemporaryDirectory() as tmpdir: folder = Path(tmpdir) # Create files with different extensions extensions = ['jpg', 'jpeg', 'JPG', 'JPEG', 'png', 'PNG'] for i, ext in enumerate(extensions): img_path = folder / f"image_{i}.{ext}" self._create_test_jpeg(img_path, 100, 100, (i * 40, 100, 100)) # Also create a non-image file that should be ignored text_file = folder / "readme.txt" text_file.write_text("This should be ignored") # Load the folder self.widget.load_folder(folder) # Should find all image files (6) but not the text file self.assertEqual(len(self.widget.image_files), 6) # Verify text file is not in the list found_names = [f.name for f in self.widget.image_files] self.assertNotIn("readme.txt", found_names) def test_empty_folder(self): """Test loading an empty folder.""" with tempfile.TemporaryDirectory() as tmpdir: folder = Path(tmpdir) # Load empty folder self.widget.load_folder(folder) # Should have no images self.assertEqual(len(self.widget.image_files), 0) self.assertEqual(len(self.widget.thumbnails), 0) self.assertEqual(self.widget.current_folder, folder) if __name__ == '__main__': unittest.main()