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
474 lines
18 KiB
Python
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()
|