pyPhotoAlbum/tests/test_thumbnail_browser.py
Duncan Tourolle 8f9f387848
All checks were successful
Python CI / test (push) Successful in 1m31s
Lint / lint (push) Successful in 1m10s
Tests / test (3.11) (push) Successful in 1m42s
Tests / test (3.12) (push) Successful in 1m43s
Tests / test (3.13) (push) Successful in 1m36s
Tests / test (3.14) (push) Successful in 1m14s
Added gallery pane to easily add images on smaller screen devices
2025-12-13 15:30:37 +01:00

474 lines
18 KiB
Python

"""
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()