increase test coverage
All checks were successful
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s

This commit is contained in:
Duncan Tourolle 2025-11-28 19:54:41 +01:00
parent 558b7be01a
commit b18a780a33
14 changed files with 8281 additions and 4 deletions

View File

@ -12,7 +12,7 @@ import asyncio
import logging
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from enum import Enum
from enum import IntEnum
from pathlib import Path
from typing import Optional, Callable, Dict, Any, Tuple, Union
from concurrent.futures import Future
@ -27,7 +27,7 @@ from pyPhotoAlbum.image_utils import convert_to_rgba, resize_to_fit
logger = logging.getLogger(__name__)
class LoadPriority(Enum):
class LoadPriority(IntEnum):
"""Priority levels for load requests."""
LOW = 0 # Offscreen, not visible
@ -531,7 +531,7 @@ class AsyncPDFGenerator(QObject):
# Export state
self._current_export: Optional[Future[Any]] = None
self._cancel_requested = False
self._lock = threading.Lock()
self._lock = threading.RLock() # Use RLock to allow re-entrant locking
self._shutdown = False
# Event loop for async operations

View File

@ -0,0 +1,596 @@
"""
Tests for asset_heal_dialog module
"""
import pytest
import os
import shutil
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, call
from PyQt6.QtWidgets import QMessageBox, QFileDialog
from PyQt6.QtCore import Qt
class TestAssetHealDialog:
"""Tests for AssetHealDialog class"""
@pytest.fixture
def mock_project(self, tmp_path):
"""Create a mock project with folder_path"""
project = Mock()
project.folder_path = str(tmp_path / "project")
os.makedirs(project.folder_path, exist_ok=True)
# Create assets folder
assets_path = os.path.join(project.folder_path, "assets")
os.makedirs(assets_path, exist_ok=True)
project.asset_manager = Mock()
project.pages = []
return project
@pytest.fixture
def mock_page_with_image(self):
"""Create a mock page with an image element"""
from pyPhotoAlbum.models import ImageData
page = Mock()
element = Mock(spec=ImageData)
element.image_path = "assets/image.jpg"
page.layout = Mock()
page.layout.elements = [element]
return page
def test_init(self, qtbot, mock_project):
"""Test AssetHealDialog initialization"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert dialog.project is mock_project
assert dialog.search_paths == []
assert dialog.missing_assets == set()
assert dialog.windowTitle() == "Heal Missing Assets"
def test_init_ui(self, qtbot, mock_project):
"""Test UI initialization"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
# Check that UI elements exist
assert dialog.missing_list is not None
assert dialog.search_list is not None
def test_scan_missing_assets_no_missing(self, qtbot, mock_project, tmp_path):
"""Test scanning when no assets are missing"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create a valid image in assets folder
img_path = os.path.join(mock_project.folder_path, "assets", "image.jpg")
Path(img_path).touch()
element = Mock(spec=ImageData)
element.image_path = "assets/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert len(dialog.missing_assets) == 0
assert dialog.missing_list.count() == 1 # "No missing assets found!" message
item = dialog.missing_list.item(0)
assert "No missing assets" in item.text()
def test_scan_missing_assets_absolute_path(self, qtbot, mock_project):
"""Test scanning detects absolute paths as needing healing"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "/absolute/path/to/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert "/absolute/path/to/image.jpg" in dialog.missing_assets
def test_scan_missing_assets_not_in_assets_folder(self, qtbot, mock_project):
"""Test scanning detects paths not in assets/ folder"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "images/photo.jpg" # Not in assets/
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert "images/photo.jpg" in dialog.missing_assets
def test_scan_missing_assets_file_missing(self, qtbot, mock_project):
"""Test scanning detects missing files in assets/ folder"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "assets/missing.jpg" # File doesn't exist
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert "assets/missing.jpg" in dialog.missing_assets
def test_scan_missing_assets_non_image_elements_ignored(self, qtbot, mock_project):
"""Test that non-ImageData elements are ignored"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
# TextBox element (not ImageData)
element = Mock()
element.image_path = None
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert len(dialog.missing_assets) == 0
def test_scan_missing_assets_empty_image_path(self, qtbot, mock_project):
"""Test that elements with empty image_path are ignored"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = ""
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
assert len(dialog.missing_assets) == 0
def test_add_search_path(self, qtbot, mock_project, tmp_path):
"""Test adding a search path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
search_path = str(tmp_path / "search")
os.makedirs(search_path, exist_ok=True)
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
dialog._add_search_path()
assert search_path in dialog.search_paths
assert dialog.search_list.count() == 1
def test_add_search_path_duplicate(self, qtbot, mock_project, tmp_path):
"""Test adding duplicate search path is ignored"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
search_path = str(tmp_path / "search")
os.makedirs(search_path, exist_ok=True)
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
dialog._add_search_path()
dialog._add_search_path()
assert dialog.search_paths.count(search_path) == 1
assert dialog.search_list.count() == 1
def test_add_search_path_cancelled(self, qtbot, mock_project):
"""Test cancelling search path dialog"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QFileDialog, 'getExistingDirectory', return_value=""):
dialog._add_search_path()
assert len(dialog.search_paths) == 0
def test_remove_search_path(self, qtbot, mock_project, tmp_path):
"""Test removing a search path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
search_path = str(tmp_path / "search")
dialog.search_paths.append(search_path)
dialog.search_list.addItem(search_path)
dialog.search_list.setCurrentRow(0)
dialog._remove_search_path()
assert len(dialog.search_paths) == 0
assert dialog.search_list.count() == 0
def test_remove_search_path_none_selected(self, qtbot, mock_project):
"""Test removing when no path is selected"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.search_paths.append("/some/path")
dialog.search_list.addItem("/some/path")
dialog.search_list.setCurrentRow(-1) # No selection
dialog._remove_search_path()
# Should not remove anything
assert len(dialog.search_paths) == 1
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_no_missing_assets(self, mock_set_context, qtbot, mock_project):
"""Test healing when there are no missing assets"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
mock_project.pages = []
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
mock_info.assert_called_once()
args = mock_info.call_args[0]
assert "Assets found: 0" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_resolve_relative_path(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by resolving relative path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create the actual image file outside project
external_img = tmp_path / "external" / "image.jpg"
external_img.parent.mkdir(exist_ok=True)
external_img.touch()
# Element with relative path that resolves to external image
element = Mock(spec=ImageData)
rel_path = os.path.relpath(str(external_img), mock_project.folder_path)
element.image_path = rel_path
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
# Mock the import_asset method
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
# Should have imported the asset
mock_project.asset_manager.import_asset.assert_called()
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_absolute_path_exists(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing absolute path that exists"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create image at absolute path
abs_img = tmp_path / "image.jpg"
abs_img.touch()
element = Mock(spec=ImageData)
element.image_path = str(abs_img)
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
# Should import the asset
mock_project.asset_manager.import_asset.assert_called_with(str(abs_img))
# Should update element path
assert element.image_path == "assets/image.jpg"
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_search_path_by_filename(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by finding file in search path by filename"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create search path with image
search_dir = tmp_path / "search"
search_dir.mkdir()
found_img = search_dir / "photo.jpg"
found_img.touch()
element = Mock(spec=ImageData)
element.image_path = "/missing/path/photo.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.missing_assets.add("/missing/path/photo.jpg")
dialog.search_paths.append(str(search_dir))
mock_project.asset_manager.import_asset.return_value = "assets/photo.jpg"
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_search_path_by_relative_structure(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by finding file in search path with same relative structure"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create search path with subdirectory structure
search_dir = tmp_path / "search"
subdir = search_dir / "photos"
subdir.mkdir(parents=True)
found_img = subdir / "image.jpg"
found_img.touch()
element = Mock(spec=ImageData)
element.image_path = "photos/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.missing_assets.add("photos/image.jpg")
dialog.search_paths.append(str(search_dir))
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_restore_to_assets_folder(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing by restoring file to assets folder"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create source image
source_img = tmp_path / "source" / "image.jpg"
source_img.parent.mkdir()
source_img.touch()
element = Mock(spec=ImageData)
element.image_path = "assets/image.jpg" # Already correct path, just missing file
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
with patch.object(AssetHealDialog, '_scan_missing_assets'):
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
dialog.missing_assets.add("assets/image.jpg")
dialog.search_paths.append(str(source_img.parent))
with patch('shutil.copy2') as mock_copy:
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
# Should copy file, not import
mock_copy.assert_called_once()
assert not mock_project.asset_manager.import_asset.called
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_not_found(self, mock_set_context, qtbot, mock_project):
"""Test healing when asset cannot be found"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
element = Mock(spec=ImageData)
element.image_path = "/missing/image.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
args = mock_info.call_args[0]
assert "Still missing: 1" in args[2]
assert "/missing/image.jpg" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_import_error(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing when import_asset raises an error"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create image
img = tmp_path / "image.jpg"
img.touch()
element = Mock(spec=ImageData)
element.image_path = str(img)
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
# Mock import to raise error
mock_project.asset_manager.import_asset.side_effect = Exception("Import failed")
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
# Should be in still_missing list
args = mock_info.call_args[0]
assert "Still missing: 1" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_many_missing(self, mock_set_context, qtbot, mock_project):
"""Test healing with more than 10 missing assets (tests truncation)"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
pages = []
for i in range(15):
element = Mock(spec=ImageData)
element.image_path = f"/missing/image{i}.jpg"
page = Mock()
page.layout = Mock()
page.layout.elements = [element]
pages.append(page)
mock_project.pages = pages
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(QMessageBox, 'information') as mock_info:
dialog._attempt_healing()
args = mock_info.call_args[0]
# Should show "... and X more"
assert "and 5 more" in args[2]
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_multiple_elements_same_path(self, mock_set_context, qtbot, mock_project, tmp_path):
"""Test healing when multiple elements reference the same missing path"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
from pyPhotoAlbum.models import ImageData
# Create image
img = tmp_path / "image.jpg"
img.touch()
# Two elements with same path
element1 = Mock(spec=ImageData)
element1.image_path = str(img)
element2 = Mock(spec=ImageData)
element2.image_path = str(img)
page = Mock()
page.layout = Mock()
page.layout.elements = [element1, element2]
mock_project.pages = [page]
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
# Both elements should be updated
assert element1.image_path == "assets/image.jpg"
assert element2.image_path == "assets/image.jpg"
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
def test_attempt_healing_rescans_after(self, mock_set_context, qtbot, mock_project):
"""Test that _scan_missing_assets is called after healing"""
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
mock_project.pages = []
dialog = AssetHealDialog(mock_project)
qtbot.addWidget(dialog)
with patch.object(dialog, '_scan_missing_assets') as mock_scan:
with patch.object(QMessageBox, 'information'):
dialog._attempt_healing()
# Should rescan after healing
mock_scan.assert_called_once()

824
tests/test_async_backend.py Normal file
View File

@ -0,0 +1,824 @@
"""
Tests for async_backend module
"""
import pytest
import asyncio
import threading
import time
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, call
from PIL import Image
from io import BytesIO
class TestLoadPriority:
"""Tests for LoadPriority enum"""
def test_load_priority_values(self):
"""Test that LoadPriority enum has correct values"""
from pyPhotoAlbum.async_backend import LoadPriority
assert LoadPriority.LOW.value == 0
assert LoadPriority.NORMAL.value == 1
assert LoadPriority.HIGH.value == 2
assert LoadPriority.URGENT.value == 3
def test_load_priority_ordering(self):
"""Test that LoadPriority values are ordered correctly"""
from pyPhotoAlbum.async_backend import LoadPriority
assert LoadPriority.LOW.value < LoadPriority.NORMAL.value
assert LoadPriority.NORMAL.value < LoadPriority.HIGH.value
assert LoadPriority.HIGH.value < LoadPriority.URGENT.value
class TestGetImageDimensions:
"""Tests for get_image_dimensions function"""
def test_get_image_dimensions_simple(self, tmp_path):
"""Test getting dimensions of a simple image"""
from pyPhotoAlbum.async_backend import get_image_dimensions
# Create a test image
img = Image.new("RGB", (800, 600), color="red")
img_path = tmp_path / "test.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path))
assert dims == (800, 600)
def test_get_image_dimensions_with_max_size_width_larger(self, tmp_path):
"""Test dimensions scaled down when width is larger"""
from pyPhotoAlbum.async_backend import get_image_dimensions
# Create a wide image
img = Image.new("RGB", (1000, 500), color="blue")
img_path = tmp_path / "wide.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path), max_size=300)
# Should be scaled down to fit within 300
assert dims == (300, 150)
def test_get_image_dimensions_with_max_size_height_larger(self, tmp_path):
"""Test dimensions scaled down when height is larger"""
from pyPhotoAlbum.async_backend import get_image_dimensions
# Create a tall image
img = Image.new("RGB", (500, 1000), color="green")
img_path = tmp_path / "tall.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path), max_size=300)
# Should be scaled down to fit within 300
assert dims == (150, 300)
def test_get_image_dimensions_already_smaller_than_max(self, tmp_path):
"""Test dimensions not scaled when already smaller than max"""
from pyPhotoAlbum.async_backend import get_image_dimensions
img = Image.new("RGB", (200, 150), color="yellow")
img_path = tmp_path / "small.jpg"
img.save(img_path)
dims = get_image_dimensions(str(img_path), max_size=300)
# Should remain the same
assert dims == (200, 150)
def test_get_image_dimensions_invalid_file(self):
"""Test get_image_dimensions with invalid file returns None"""
from pyPhotoAlbum.async_backend import get_image_dimensions
dims = get_image_dimensions("/nonexistent/file.jpg")
assert dims is None
def test_get_image_dimensions_not_an_image(self, tmp_path):
"""Test get_image_dimensions with non-image file returns None"""
from pyPhotoAlbum.async_backend import get_image_dimensions
text_file = tmp_path / "not_image.txt"
text_file.write_text("This is not an image")
dims = get_image_dimensions(str(text_file))
assert dims is None
class TestLoadRequest:
"""Tests for LoadRequest dataclass"""
def test_load_request_creation(self):
"""Test creating a LoadRequest"""
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
request = LoadRequest(
priority=LoadPriority.HIGH,
request_id=1,
path=Path("/test/image.jpg"),
target_size=(300, 300),
callback=None,
user_data={"test": "data"},
)
assert request.priority == LoadPriority.HIGH
assert request.request_id == 1
assert request.path == Path("/test/image.jpg")
assert request.target_size == (300, 300)
assert request.user_data == {"test": "data"}
def test_load_request_ordering_by_priority(self):
"""Test that LoadRequests are ordered by priority (fixed with IntEnum)"""
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
req1 = LoadRequest(priority=LoadPriority.LOW, request_id=1, path=Path("/a.jpg"))
req2 = LoadRequest(priority=LoadPriority.HIGH, request_id=2, path=Path("/b.jpg"))
# LOW priority (value 0) should be < HIGH priority (value 2) in the priority queue
# This means LOW will be processed before HIGH (priority queue uses min-heap)
assert req1 < req2
def test_load_request_ordering_by_id_when_same_priority(self):
"""Test that LoadRequests with same priority are ordered by request_id"""
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
req1 = LoadRequest(priority=LoadPriority.NORMAL, request_id=1, path=Path("/a.jpg"))
req2 = LoadRequest(priority=LoadPriority.NORMAL, request_id=2, path=Path("/b.jpg"))
assert req1 < req2
class TestImageCache:
"""Tests for ImageCache class"""
def test_image_cache_init(self):
"""Test ImageCache initialization"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache(max_memory_mb=256)
assert cache.max_memory_bytes == 256 * 1024 * 1024
assert cache.current_memory_bytes == 0
def test_image_cache_estimate_image_size_rgba(self):
"""Test estimating RGBA image size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGBA", (100, 100))
size = cache._estimate_image_size(img)
# 100 * 100 * 4 bytes (RGBA)
assert size == 40000
def test_image_cache_estimate_image_size_rgb(self):
"""Test estimating RGB image size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100))
size = cache._estimate_image_size(img)
# 100 * 100 * 3 bytes (RGB)
assert size == 30000
def test_image_cache_make_key_without_size(self):
"""Test making cache key without target size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
key = cache._make_key(Path("/test/image.jpg"))
assert key == "/test/image.jpg"
def test_image_cache_make_key_with_size(self):
"""Test making cache key with target size"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
key = cache._make_key(Path("/test/image.jpg"), (300, 300))
assert key == "/test/image.jpg:300x300"
def test_image_cache_put_and_get(self):
"""Test putting and getting image from cache"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100), color="red")
path = Path("/test/image.jpg")
cache.put(path, img)
cached_img = cache.get(path)
assert cached_img is not None
assert cached_img.size == img.size
assert cached_img.mode == img.mode
def test_image_cache_get_returns_copy(self):
"""Test that get returns a copy of the image"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100))
path = Path("/test/image.jpg")
cache.put(path, img)
cached_img = cache.get(path)
# Modify the cached image
cached_img.putpixel((0, 0), (255, 0, 0))
# Get it again - should be unchanged
cached_img2 = cache.get(path)
assert cached_img2.getpixel((0, 0)) != (255, 0, 0)
def test_image_cache_miss(self):
"""Test cache miss returns None"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
cached_img = cache.get(Path("/nonexistent.jpg"))
assert cached_img is None
def test_image_cache_different_sizes_different_keys(self):
"""Test that different target sizes use different cache keys"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img1 = Image.new("RGB", (100, 100), color="red")
img2 = Image.new("RGB", (50, 50), color="blue")
path = Path("/test/image.jpg")
cache.put(path, img1, target_size=None)
cache.put(path, img2, target_size=(50, 50))
cached_full = cache.get(path, target_size=None)
cached_small = cache.get(path, target_size=(50, 50))
assert cached_full.size == (100, 100)
assert cached_small.size == (50, 50)
def test_image_cache_lru_eviction(self):
"""Test that LRU items are evicted when cache is full"""
from pyPhotoAlbum.async_backend import ImageCache
# Small cache that can hold only 1 small image
cache = ImageCache(max_memory_mb=1)
# Create images that will fill the cache
img1 = Image.new("RGB", (500, 500)) # ~750KB
img2 = Image.new("RGB", (500, 500)) # ~750KB
# Add img1
cache.put(Path("/img1.jpg"), img1)
assert cache.get(Path("/img1.jpg")) is not None
# Add img2 - should evict img1 due to memory limit
cache.put(Path("/img2.jpg"), img2)
# img1 should be evicted to make room for img2
assert cache.get(Path("/img1.jpg")) is None
# img2 should be there
assert cache.get(Path("/img2.jpg")) is not None
def test_image_cache_update_existing(self):
"""Test updating an existing cache entry"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img1 = Image.new("RGB", (100, 100), color="red")
img2 = Image.new("RGB", (200, 200), color="blue")
path = Path("/test/image.jpg")
cache.put(path, img1)
cache.put(path, img2) # Update
cached = cache.get(path)
assert cached.size == (200, 200)
def test_image_cache_clear(self):
"""Test clearing the cache"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (100, 100))
cache.put(Path("/img1.jpg"), img)
cache.put(Path("/img2.jpg"), img)
cache.clear()
assert cache.current_memory_bytes == 0
assert cache.get(Path("/img1.jpg")) is None
assert cache.get(Path("/img2.jpg")) is None
def test_image_cache_get_stats(self):
"""Test getting cache statistics"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache(max_memory_mb=100)
img = Image.new("RGB", (100, 100))
cache.put(Path("/img1.jpg"), img)
cache.put(Path("/img2.jpg"), img)
stats = cache.get_stats()
assert stats["items"] == 2
assert stats["memory_mb"] > 0
assert stats["max_memory_mb"] == 100
assert 0 <= stats["utilization"] <= 100
def test_image_cache_thread_safety(self):
"""Test that cache operations are thread-safe"""
from pyPhotoAlbum.async_backend import ImageCache
cache = ImageCache()
img = Image.new("RGB", (50, 50))
def put_images(start):
for i in range(start, start + 10):
cache.put(Path(f"/img{i}.jpg"), img)
def get_images(start):
for i in range(start, start + 10):
cache.get(Path(f"/img{i}.jpg"))
threads = []
for i in range(5):
t1 = threading.Thread(target=put_images, args=(i * 10,))
t2 = threading.Thread(target=get_images, args=(i * 10,))
threads.extend([t1, t2])
for t in threads:
t.start()
for t in threads:
t.join()
# Should not crash
assert cache.current_memory_bytes >= 0
class TestAsyncImageLoader:
"""Tests for AsyncImageLoader class"""
def test_async_image_loader_init(self):
"""Test AsyncImageLoader initialization"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache
cache = ImageCache()
loader = AsyncImageLoader(cache=cache, max_workers=2)
assert loader.cache is cache
assert loader.max_workers == 2
assert loader._shutdown is False
def test_async_image_loader_init_creates_cache(self):
"""Test AsyncImageLoader creates cache if not provided"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
assert loader.cache is not None
def test_async_image_loader_start(self):
"""Test starting AsyncImageLoader"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
loader.start()
# Give it time to start
time.sleep(0.1)
assert loader._loop is not None
assert loader._loop_thread is not None
assert loader._loop_thread.is_alive()
loader.stop()
def test_async_image_loader_start_twice(self):
"""Test starting AsyncImageLoader twice doesn't create multiple threads"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
thread1 = loader._loop_thread
loader.start() # Should warn but not create new thread
time.sleep(0.1)
assert loader._loop_thread is thread1
loader.stop()
def test_async_image_loader_stop(self):
"""Test stopping AsyncImageLoader"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
loader.stop()
time.sleep(0.2)
assert loader._shutdown is True
def test_async_image_loader_load_and_process_image(self, tmp_path):
"""Test _load_and_process_image method"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
# Create test image
img = Image.new("RGB", (800, 600), color="blue")
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
result = loader._load_and_process_image(img_path, None)
assert result is not None
assert result.mode == "RGBA" # Should be converted to RGBA
def test_async_image_loader_load_and_process_image_with_resize(self, tmp_path):
"""Test _load_and_process_image with target size"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
# Create large test image
img = Image.new("RGB", (2000, 1500), color="green")
img_path = tmp_path / "large.jpg"
img.save(img_path)
loader = AsyncImageLoader()
result = loader._load_and_process_image(img_path, (500, 500))
assert result is not None
# Should be resized to fit within 500x500
assert result.size[0] <= 500
assert result.size[1] <= 500
def test_async_image_loader_emit_loaded(self, qtbot):
"""Test _emit_loaded signal"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
signal_received = []
def on_loaded(path, img, user_data):
signal_received.append((path, img, user_data))
loader.image_loaded.connect(on_loaded)
mock_img = Mock()
user_data = {"test": "data"}
loader._emit_loaded(Path("/test.jpg"), mock_img, user_data)
assert len(signal_received) == 1
assert signal_received[0][0] == Path("/test.jpg")
assert signal_received[0][2] == user_data
def test_async_image_loader_emit_failed(self, qtbot):
"""Test _emit_failed signal"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
signal_received = []
def on_failed(path, error, user_data):
signal_received.append((path, error, user_data))
loader.load_failed.connect(on_failed)
user_data = {"test": "data"}
loader._emit_failed(Path("/test.jpg"), "Error message", user_data)
assert len(signal_received) == 1
assert signal_received[0][0] == Path("/test.jpg")
assert signal_received[0][1] == "Error message"
def test_async_image_loader_request_load_not_started(self):
"""Test request_load when loader not started"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
loader = AsyncImageLoader()
result = loader.request_load(Path("/test.jpg"), priority=LoadPriority.HIGH)
assert result is False
def test_async_image_loader_request_load_success(self, tmp_path):
"""Test successful request_load"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
# Create test image
img = Image.new("RGB", (100, 100), color="red")
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
result = loader.request_load(img_path, priority=LoadPriority.HIGH)
assert result is True
loader.stop()
def test_async_image_loader_request_load_duplicate(self, tmp_path):
"""Test requesting same image twice returns False"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
img = Image.new("RGB", (100, 100))
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
result1 = loader.request_load(img_path, priority=LoadPriority.HIGH)
result2 = loader.request_load(img_path, priority=LoadPriority.HIGH)
assert result1 is True
assert result2 is False # Already pending
loader.stop()
def test_async_image_loader_cancel_pending(self, tmp_path):
"""Test canceling a pending load request"""
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
img = Image.new("RGB", (100, 100))
img_path = tmp_path / "test.jpg"
img.save(img_path)
loader = AsyncImageLoader()
loader.start()
time.sleep(0.1)
loader.request_load(img_path, priority=LoadPriority.LOW)
result = loader.cancel_load(img_path)
assert result is True
loader.stop()
def test_async_image_loader_cancel_nonexistent(self):
"""Test canceling a non-existent load request"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
result = loader.cancel_load(Path("/nonexistent.jpg"))
assert result is False
def test_async_image_loader_get_stats(self):
"""Test getting loader statistics"""
from pyPhotoAlbum.async_backend import AsyncImageLoader
loader = AsyncImageLoader()
stats = loader.get_stats()
assert "pending" in stats
assert "active" in stats
assert "cache" in stats
class TestAsyncPDFGenerator:
"""Tests for AsyncPDFGenerator class"""
def test_async_pdf_generator_init(self):
"""Test AsyncPDFGenerator initialization"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator, ImageCache
cache = ImageCache()
generator = AsyncPDFGenerator(image_cache=cache, max_workers=1)
assert generator.image_cache is cache
assert generator.max_workers == 1
assert generator._shutdown is False
def test_async_pdf_generator_init_creates_cache(self):
"""Test AsyncPDFGenerator creates cache if not provided"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
assert generator.image_cache is not None
def test_async_pdf_generator_start(self):
"""Test starting AsyncPDFGenerator"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
assert generator._loop is not None
assert generator._loop_thread is not None
assert generator._loop_thread.is_alive()
generator.stop()
def test_async_pdf_generator_start_twice(self):
"""Test starting AsyncPDFGenerator twice doesn't create multiple threads"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
thread1 = generator._loop_thread
generator.start() # Should warn
time.sleep(0.1)
assert generator._loop_thread is thread1
generator.stop()
def test_async_pdf_generator_stop(self):
"""Test stopping AsyncPDFGenerator"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
generator.stop()
time.sleep(0.2)
assert generator._shutdown is True
def test_async_pdf_generator_export_not_started(self):
"""Test export_pdf when generator not started"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
mock_project = Mock()
result = generator.export_pdf(mock_project, "/output.pdf")
assert result is False
def test_async_pdf_generator_export_already_exporting(self):
"""Test export_pdf when already exporting"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator.start()
time.sleep(0.1)
mock_project = Mock()
# Start first export
generator._current_export = Mock()
generator._current_export.done.return_value = False
result = generator.export_pdf(mock_project, "/output.pdf")
assert result is False
generator.stop()
def test_async_pdf_generator_cancel_export(self):
"""Test cancel_export method"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
# Mock an active export
generator._current_export = Mock()
generator._current_export.done.return_value = False
generator.cancel_export()
assert generator._cancel_requested is True
generator._current_export.cancel.assert_called_once()
def test_async_pdf_generator_is_exporting_true(self):
"""Test is_exporting returns True when exporting"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
generator._current_export = Mock()
generator._current_export.done.return_value = False
assert generator.is_exporting() is True
def test_async_pdf_generator_is_exporting_false(self):
"""Test is_exporting returns False when not exporting"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
assert generator.is_exporting() is False
def test_async_pdf_generator_get_stats(self):
"""Test getting generator statistics"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
stats = generator.get_stats()
assert "exporting" in stats
assert "cache" in stats
def test_async_pdf_generator_export_with_cache_uses_cache(self):
"""Test _export_with_cache uses cached images"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
from PIL import Image
from unittest.mock import patch
generator = AsyncPDFGenerator()
# Mock exporter that tries to open an image
mock_exporter = Mock()
mock_exporter.export.return_value = (True, [])
def mock_progress(current, total, msg):
return True
# Run export (just verify the method exists and can be called)
with patch('PIL.Image.open') as mock_open:
mock_img = Image.new("RGBA", (50, 50), color="blue")
mock_open.return_value = mock_img
success, warnings = generator._export_with_cache(mock_exporter, "/fake/output.pdf", mock_progress)
assert success is True
mock_exporter.export.assert_called_once()
def test_async_pdf_generator_progress_signal(self, qtbot):
"""Test progress_updated signal"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
signal_received = []
def on_progress(current, total, message):
signal_received.append((current, total, message))
generator.progress_updated.connect(on_progress)
generator.progress_updated.emit(5, 10, "Processing page 5")
assert len(signal_received) == 1
assert signal_received[0] == (5, 10, "Processing page 5")
def test_async_pdf_generator_complete_signal(self, qtbot):
"""Test export_complete signal"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
signal_received = []
def on_complete(success, warnings):
signal_received.append((success, warnings))
generator.export_complete.connect(on_complete)
generator.export_complete.emit(True, ["warning1"])
assert len(signal_received) == 1
assert signal_received[0] == (True, ["warning1"])
def test_async_pdf_generator_failed_signal(self, qtbot):
"""Test export_failed signal"""
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
generator = AsyncPDFGenerator()
signal_received = []
def on_failed(error_msg):
signal_received.append(error_msg)
generator.export_failed.connect(on_failed)
generator.export_failed.emit("Export failed")
assert len(signal_received) == 1
assert signal_received[0] == "Export failed"

View File

@ -0,0 +1,824 @@
"""
Tests for FileOperationsMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch, call
from PyQt6.QtWidgets import QMainWindow, QDialog
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.file_ops import FileOperationsMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.commands import CommandHistory
class TestFileOpsWindow(FileOperationsMixin, ApplicationStateMixin, QMainWindow):
"""Test window with file operations mixin"""
def __init__(self):
super().__init__()
self._gl_widget = Mock()
self._gl_widget.current_page_index = 0
self._gl_widget.zoom_level = 1.0
self._gl_widget.pan_offset = [0, 0]
self._gl_widget._page_renderers = []
self._gl_widget.width = Mock(return_value=800)
self._gl_widget.height = Mock(return_value=600)
self._gl_widget.export_pdf_async = Mock(return_value=True)
self._project = Project(name="Test")
self._project.page_size_mm = (210, 297)
self._project.working_dpi = 96
self._project.export_dpi = 300
self._project.history = CommandHistory()
self._update_view_called = False
self._status_message = None
self._status_timeout = None
self._info_title = None
self._info_message = None
self._warning_title = None
self._warning_message = None
self._error_title = None
self._error_message = None
@property
def gl_widget(self):
return self._gl_widget
@property
def project(self):
return self._project
@project.setter
def project(self, value):
self._project = value
def update_view(self):
self._update_view_called = True
def show_status(self, message, timeout=0):
self._status_message = message
self._status_timeout = timeout
def show_info(self, title, message):
self._info_title = title
self._info_message = message
def show_warning(self, title, message):
self._warning_title = title
self._warning_message = message
def show_error(self, title, message):
self._error_title = title
self._error_message = message
def resolve_asset_path(self, path):
"""Mock asset path resolution"""
if path.startswith("assets/") and path.endswith("exists.jpg"):
return "/fake/path/exists.jpg"
return None
class TestNewProject:
"""Test new_project method"""
def test_new_project_dialog_cancelled(self, qtbot):
"""Test returns when user cancels dialog"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
old_project = window.project
# Patch QDialog.exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
window.new_project()
# Should keep old project
assert window.project == old_project
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
def test_new_project_creates_project_default_values(self, mock_set_context, qtbot):
"""Test creates new project with default dialog values"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
old_project = window.project
old_project.cleanup = Mock()
# Patch QDialog.exec to accept with default values (140x140, 300 DPI)
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
window.new_project()
# Verify new project was created with default values
assert window.project != old_project
assert window.project.name == "New Project" # Default name
assert window.project.page_size_mm == (140.0, 140.0) # Default size
assert window.project.working_dpi == 300 # Default working DPI
assert window.project.export_dpi == 300 # Default export DPI
assert window._update_view_called
mock_set_context.assert_called_once()
old_project.cleanup.assert_called_once()
class TestOpenProject:
"""Test open_project method"""
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
def test_open_project_dialog_cancelled(self, mock_file_dialog, qtbot):
"""Test returns when user cancels file dialog"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Mock dialog to return empty path
mock_file_dialog.return_value = ("", "")
window.open_project()
# Should not create loader
assert not hasattr(window, "_project_loader")
@patch("pyPhotoAlbum.mixins.operations.file_ops.AsyncProjectLoader")
@patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
def test_open_project_starts_async_loading(self, mock_file_dialog, mock_loading_widget, mock_loader, qtbot):
"""Test starts async loading when file selected"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Mock dialog to return file path
mock_file_dialog.return_value = ("/path/to/project.ppz", "")
# Mock loader
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
# Mock loading widget
mock_loading_instance = Mock()
mock_loading_widget.return_value = mock_loading_instance
window.open_project()
# Verify loader was created and started
mock_loader.assert_called_once_with("/path/to/project.ppz")
mock_loader_instance.start.assert_called_once()
mock_loading_instance.show_loading.assert_called_once()
class TestLoadCallbacks:
"""Test async loading callback methods"""
def test_on_load_progress(self, qtbot):
"""Test progress callback updates loading widget"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Create mock loading widget
window._loading_widget = Mock()
window._on_load_progress(5, 10, "Loading page 5...")
window._loading_widget.set_progress.assert_called_once_with(5, 10)
window._loading_widget.set_status.assert_called_once_with("Loading page 5...")
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
def test_on_load_complete_success(self, mock_set_context, qtbot):
"""Test successful load callback"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Create mock loading widget
window._loading_widget = Mock()
window._opening_file_path = "/path/to/project.ppz"
# Create mock project
new_project = Mock()
new_project.name = "Loaded Project"
new_project.mark_clean = Mock()
# Mock check missing assets
window._check_missing_assets = Mock(return_value=[])
old_project = window.project
old_project.cleanup = Mock()
window._on_load_complete(new_project)
# Verify old project was cleaned up
old_project.cleanup.assert_called_once()
# Verify new project was set
assert window.project == new_project
assert window.project.file_path == "/path/to/project.ppz"
new_project.mark_clean.assert_called_once()
# Verify UI was updated
window._loading_widget.hide_loading.assert_called_once()
assert window._update_view_called
assert "Project opened" in window._status_message
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
def test_on_load_complete_with_missing_assets(self, mock_set_context, qtbot):
"""Test load callback with missing assets"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
window._loading_widget = Mock()
window._opening_file_path = "/path/to/project.ppz"
new_project = Mock()
new_project.name = "Project with Missing"
new_project.mark_clean = Mock()
# Mock missing assets
window._check_missing_assets = Mock(return_value=["/missing/image1.jpg", "/missing/image2.jpg"])
window._show_missing_assets_warning = Mock()
window._on_load_complete(new_project)
# Verify warning was shown
window._show_missing_assets_warning.assert_called_once_with(["/missing/image1.jpg", "/missing/image2.jpg"])
assert "2 missing images" in window._status_message
def test_on_load_failed(self, qtbot):
"""Test load failure callback"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
window._loading_widget = Mock()
window._on_load_failed("File corrupted")
# Verify error was shown
window._loading_widget.hide_loading.assert_called_once()
assert "Failed to open project" in window._error_message
assert "File corrupted" in window._error_message
class TestSaveProject:
"""Test save_project method"""
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
def test_save_project_with_existing_path(self, mock_save, qtbot):
"""Test saves to existing file path"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
window.project.file_path = "/path/to/existing.ppz"
mock_save.return_value = (True, None)
window.save_project()
# Verify save was called with existing path
mock_save.assert_called_once_with(window.project, "/path/to/existing.ppz")
assert "Project saved" in window._status_message
assert not window.project.is_dirty() # is_dirty() is a method
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_save_project_prompts_for_path(self, mock_file_dialog, mock_save, qtbot):
"""Test prompts for path when none exists"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# No existing file path
window.project.file_path = None
# Mock dialog to return path
mock_file_dialog.return_value = ("/path/to/new.ppz", "")
mock_save.return_value = (True, None)
window.save_project()
# Verify dialog was shown and save was called
mock_file_dialog.assert_called_once()
mock_save.assert_called_once_with(window.project, "/path/to/new.ppz")
assert window.project.file_path == "/path/to/new.ppz"
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_save_project_user_cancels(self, mock_file_dialog, mock_save, qtbot):
"""Test returns when user cancels file dialog"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
window.project.file_path = None
mock_file_dialog.return_value = ("", "")
window.save_project()
# Should not call save
mock_save.assert_not_called()
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
def test_save_project_handles_error(self, mock_save, qtbot):
"""Test handles save errors"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
window.project.file_path = "/path/to/project.ppz"
mock_save.return_value = (False, "Disk full")
window.save_project()
# Verify error was shown
assert "Failed to save project" in window._status_message
assert "Disk full" in window._status_message
class TestHealAssets:
"""Test heal_assets method"""
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
def test_heal_assets_opens_dialog(self, mock_dialog, qtbot):
"""Test opens asset heal dialog"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
mock_dialog_instance = Mock()
mock_dialog.return_value = mock_dialog_instance
window.heal_assets()
# Verify dialog was created and executed
mock_dialog.assert_called_once_with(window.project, window)
mock_dialog_instance.exec.assert_called_once()
assert window._update_view_called
class TestCheckMissingAssets:
"""Test _check_missing_assets method"""
def test_check_missing_assets_absolute_path(self, qtbot):
"""Test detects absolute path as missing"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Create page with absolute path
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="/absolute/path/image.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
missing = window._check_missing_assets()
assert len(missing) == 1
assert "/absolute/path/image.jpg" in missing
def test_check_missing_assets_non_assets_path(self, qtbot):
"""Test detects non-assets path as missing"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="relative/path/image.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
missing = window._check_missing_assets()
assert len(missing) == 1
assert "relative/path/image.jpg" in missing
def test_check_missing_assets_not_found(self, qtbot):
"""Test detects assets that don't exist"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="assets/missing.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
missing = window._check_missing_assets()
assert len(missing) == 1
assert "assets/missing.jpg" in missing
def test_check_missing_assets_found(self, qtbot):
"""Test ignores assets that exist"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="assets/exists.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
missing = window._check_missing_assets()
assert len(missing) == 0
def test_check_missing_assets_removes_duplicates(self, qtbot):
"""Test removes duplicate missing paths"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout1 = PageLayout(width=210, height=297)
layout1.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
page1 = Page(layout=layout1, page_number=1)
layout2 = PageLayout(width=210, height=297)
layout2.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
page2 = Page(layout=layout2, page_number=2)
window.project.pages = [page1, page2]
missing = window._check_missing_assets()
assert len(missing) == 1
assert "/missing/image.jpg" in missing
class TestShowMissingAssetsWarning:
"""Test _show_missing_assets_warning method"""
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
def test_show_warning_few_assets(self, mock_heal_dialog, qtbot):
"""Test shows all assets when count is small"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Patch QMessageBox.exec to return Ok (not Open)
from PyQt6.QtWidgets import QMessageBox
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
missing = ["/path1.jpg", "/path2.jpg", "/path3.jpg"]
window._show_missing_assets_warning(missing)
# Heal dialog should not be opened for Ok
mock_heal_dialog.assert_not_called()
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
def test_show_warning_many_assets(self, mock_heal_dialog, qtbot):
"""Test truncates list when many assets missing"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
from PyQt6.QtWidgets import QMessageBox
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
missing = [f"/path{i}.jpg" for i in range(10)]
window._show_missing_assets_warning(missing)
# Verify it completed without error
mock_heal_dialog.assert_not_called()
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
def test_show_warning_opens_heal_dialog(self, mock_heal_dialog, qtbot):
"""Test opens heal dialog when user clicks Open"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
from PyQt6.QtWidgets import QMessageBox
mock_heal_instance = Mock()
mock_heal_dialog.return_value = mock_heal_instance
# Patch QMessageBox.exec to return Open
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Open):
missing = ["/path1.jpg"]
window._show_missing_assets_warning(missing)
# Verify heal dialog was opened
mock_heal_dialog.assert_called_once()
mock_heal_instance.exec.assert_called_once()
class TestProjectSettings:
"""Test project_settings method"""
def test_project_settings_cancelled(self, qtbot):
"""Test returns when user cancels"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
old_size = window.project.page_size_mm
# Patch QDialog.exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
window.project_settings()
# Size should not change
assert window.project.page_size_mm == old_size
def test_project_settings_updates_values_default(self, qtbot):
"""Test updates project settings with dialog defaults"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
# Set initial values
window.project.page_size_mm = (210, 297)
window.project.working_dpi = 96
window.project.export_dpi = 300
# Patch QDialog.exec to accept (will use current project values as defaults)
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
window.project_settings()
# Values should remain the same (since dialog uses current values as defaults)
assert window.project.page_size_mm == (210, 297)
assert window.project.working_dpi == 96
assert window.project.export_dpi == 300
assert window._update_view_called
class TestApplyPageSizeToProject:
"""Test _apply_page_size_to_project method"""
def test_apply_page_size_skips_manual_pages(self, qtbot):
"""Test skips manually sized pages"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
page = Page(layout=layout, page_number=1)
page.manually_sized = True
window.project.pages = [page]
window._apply_page_size_to_project((210, 297), (200, 200), "proportional")
# Page size should not change
assert page.layout.size == (210, 297)
def test_apply_page_size_proportional_scaling(self, qtbot):
"""Test proportional scaling mode"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
page = Page(layout=layout, page_number=1)
page.manually_sized = False
window.project.pages = [page]
# Double the size
window._apply_page_size_to_project((100, 100), (200, 200), "proportional")
# Page should be resized
assert page.layout.size == (200, 200)
# Elements should be scaled uniformly
assert layout.elements[0].position == (20.0, 20.0)
assert layout.elements[0].size == (100.0, 100.0)
def test_apply_page_size_stretch_scaling(self, qtbot):
"""Test stretch scaling mode"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
page = Page(layout=layout, page_number=1)
page.manually_sized = False
window.project.pages = [page]
# Stretch to different aspect ratio
window._apply_page_size_to_project((100, 100), (200, 150), "stretch")
# Page should be resized
assert page.layout.size == (200, 150)
# Elements should be scaled independently
assert layout.elements[0].position == (20.0, 15.0) # x*2, y*1.5
assert layout.elements[0].size == (100.0, 75.0) # w*2, h*1.5
def test_apply_page_size_reposition_mode(self, qtbot):
"""Test reposition mode"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
page = Page(layout=layout, page_number=1)
page.manually_sized = False
window.project.pages = [page]
# Increase size
window._apply_page_size_to_project((100, 100), (150, 150), "reposition")
# Page should be resized
assert page.layout.size == (150, 150)
# Elements should be offset to center
assert layout.elements[0].position == (35.0, 35.0) # +25, +25
assert layout.elements[0].size == (50, 50) # unchanged
def test_apply_page_size_none_mode(self, qtbot):
"""Test none mode (no element changes)"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
page = Page(layout=layout, page_number=1)
page.manually_sized = False
window.project.pages = [page]
window._apply_page_size_to_project((100, 100), (200, 200), "none")
# Page should be resized
assert page.layout.size == (200, 200)
# Elements should not change
assert layout.elements[0].position == (10, 10)
assert layout.elements[0].size == (50, 50)
def test_apply_page_size_double_spread(self, qtbot):
"""Test handles double spread pages correctly"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=200, height=100)
page = Page(layout=layout, page_number=1)
page.manually_sized = False
page.is_double_spread = True
window.project.pages = [page]
window._apply_page_size_to_project((100, 100), (150, 150), "none")
# Double spread should be 2x width
assert page.layout.size == (300, 150)
class TestScalePageElements:
"""Test _scale_page_elements method"""
def test_scale_elements_uniform(self, qtbot):
"""Test uniform scaling"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [
ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
]
page = Page(layout=layout, page_number=1)
window._scale_page_elements(page, 2.0, 2.0)
# Check first element
assert layout.elements[0].position == (20.0, 40.0)
assert layout.elements[0].size == (100.0, 120.0)
# Check second element
assert layout.elements[1].position == (10.0, 20.0)
assert layout.elements[1].size == (60.0, 80.0)
def test_scale_elements_non_uniform(self, qtbot):
"""Test non-uniform scaling"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
page = Page(layout=layout, page_number=1)
window._scale_page_elements(page, 2.0, 1.5)
assert layout.elements[0].position == (20.0, 30.0)
assert layout.elements[0].size == (100.0, 90.0)
class TestRepositionPageElements:
"""Test _reposition_page_elements method"""
def test_reposition_elements_larger_page(self, qtbot):
"""Test repositioning on larger page"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [
ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
]
page = Page(layout=layout, page_number=1)
window._reposition_page_elements(page, (100, 100), (150, 130))
# Offset should be (25, 15)
assert layout.elements[0].position == (35.0, 35.0)
assert layout.elements[1].position == (30.0, 25.0)
def test_reposition_elements_smaller_page(self, qtbot):
"""Test repositioning on smaller page"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=100, height=100)
layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
page = Page(layout=layout, page_number=1)
window._reposition_page_elements(page, (100, 100), (80, 90))
# Offset should be (-10, -5)
assert layout.elements[0].position == (0.0, 15.0)
class TestExportPdf:
"""Test export_pdf method"""
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_export_pdf_no_pages(self, mock_file_dialog, qtbot):
"""Test returns early when no pages"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
window.project.pages = []
window.export_pdf()
# Should not show file dialog
mock_file_dialog.assert_not_called()
assert "No pages to export" in window._status_message
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_export_pdf_user_cancels(self, mock_file_dialog, qtbot):
"""Test returns when user cancels"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
mock_file_dialog.return_value = ("", "")
window.export_pdf()
# Should not call export
window.gl_widget.export_pdf_async.assert_not_called()
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_export_pdf_success(self, mock_file_dialog, qtbot):
"""Test successful PDF export"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
window.gl_widget.export_pdf_async.return_value = True
window.export_pdf()
# Verify export was called
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
assert "PDF export started" in window._status_message
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_export_pdf_adds_extension(self, mock_file_dialog, qtbot):
"""Test adds .pdf extension if missing"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
mock_file_dialog.return_value = ("/path/to/output", "")
window.gl_widget.export_pdf_async.return_value = True
window.export_pdf()
# Verify .pdf was added
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
"""Test handles export failure"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
window.gl_widget.export_pdf_async.return_value = False
window.export_pdf()
assert "PDF export failed to start" in window._status_message
class TestShowAbout:
"""Test show_about method"""
@patch("pyPhotoAlbum.mixins.operations.file_ops.format_version_info")
@patch("pyPhotoAlbum.mixins.operations.file_ops.QDialog")
def test_show_about_displays_dialog(self, mock_dialog_class, mock_format_version, qtbot):
"""Test shows about dialog with version info"""
window = TestFileOpsWindow()
qtbot.addWidget(window)
mock_dialog = Mock()
mock_dialog_class.return_value = mock_dialog
mock_format_version.return_value = "Version 1.0.0\nData Format: v2"
window.show_about()
# Verify dialog was created and executed
mock_dialog_class.assert_called_once()
mock_dialog.exec.assert_called_once()
mock_format_version.assert_called_once()

View File

@ -0,0 +1,803 @@
"""
Tests for KeyboardNavigationMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import Qt
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData, TextBoxData
# Create test widget combining necessary mixins
class KeyboardNavWidget(KeyboardNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining keyboard navigation and viewport mixins"""
def __init__(self):
super().__init__()
self.selected_elements = set()
class TestNavigateToNextPage:
"""Test _navigate_to_next_page method"""
def test_navigate_next_no_project_attribute(self, qtbot):
"""Test does nothing when main window has no project attribute"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
del mock_window.project
widget.window = Mock(return_value=mock_window)
# Should not raise exception
widget._navigate_to_next_page()
def test_navigate_next_no_project(self, qtbot):
"""Test does nothing when project is None"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
widget._navigate_to_next_page()
def test_navigate_next_empty_pages(self, qtbot):
"""Test does nothing when project has no pages"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
widget._navigate_to_next_page()
def test_navigate_next_at_last_page(self, qtbot):
"""Test does nothing when already at last page"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window._get_most_visible_page_index = Mock(return_value=1) # Last page
widget.window = Mock(return_value=mock_window)
initial_pan = widget.pan_offset.copy()
widget._navigate_to_next_page()
# Should not navigate (already at last page)
def test_navigate_next_to_next_page(self, qtbot):
"""Test navigates to next page successfully"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Need to set widget size for calculations
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
mock_window.project.pages = [page1, page2, page3]
mock_window._get_most_visible_page_index = Mock(return_value=0) # First page
mock_window.project.get_page_display_name = Mock(return_value="Page 2")
mock_window.show_status = Mock()
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
# Mock update method
widget.update = Mock()
widget._navigate_to_next_page()
# Should have scrolled to page 2
mock_window.show_status.assert_called_once_with("Navigated to Page 2", 2000)
widget.update.assert_called()
def test_navigate_next_without_status_bar(self, qtbot):
"""Test navigation works even without show_status method"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window._get_most_visible_page_index = Mock(return_value=0)
mock_window.update_scrollbars = Mock()
# No show_status attribute
del mock_window.show_status
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
# Should not raise exception
widget._navigate_to_next_page()
widget.update.assert_called()
class TestNavigateToPreviousPage:
"""Test _navigate_to_previous_page method"""
def test_navigate_prev_no_project_attribute(self, qtbot):
"""Test does nothing when main window has no project attribute"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
del mock_window.project
widget.window = Mock(return_value=mock_window)
widget._navigate_to_previous_page()
def test_navigate_prev_no_project(self, qtbot):
"""Test does nothing when project is None"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
widget._navigate_to_previous_page()
def test_navigate_prev_empty_pages(self, qtbot):
"""Test does nothing when project has no pages"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
widget._navigate_to_previous_page()
def test_navigate_prev_at_first_page(self, qtbot):
"""Test does nothing when already at first page"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window._get_most_visible_page_index = Mock(return_value=0) # First page
widget.window = Mock(return_value=mock_window)
widget._navigate_to_previous_page()
# Should not navigate (already at first page)
def test_navigate_prev_to_previous_page(self, qtbot):
"""Test navigates to previous page successfully"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
mock_window.project.pages = [page1, page2, page3]
mock_window._get_most_visible_page_index = Mock(return_value=2) # Third page
mock_window.project.get_page_display_name = Mock(return_value="Page 2")
mock_window.show_status = Mock()
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._navigate_to_previous_page()
# Should have scrolled to page 2
mock_window.show_status.assert_called_once_with("Navigated to Page 2", 2000)
widget.update.assert_called()
def test_navigate_prev_without_status_bar(self, qtbot):
"""Test navigation works even without show_status method"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window._get_most_visible_page_index = Mock(return_value=1)
mock_window.update_scrollbars = Mock()
del mock_window.show_status
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._navigate_to_previous_page()
widget.update.assert_called()
class TestScrollToPage:
"""Test _scroll_to_page method"""
def test_scroll_to_page_no_project_attribute(self, qtbot):
"""Test does nothing when main window has no project attribute"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
del mock_window.project
widget.window = Mock(return_value=mock_window)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
widget._scroll_to_page(page, 0)
def test_scroll_to_page_first_page(self, qtbot):
"""Test scrolling to first page"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._scroll_to_page(page1, 0)
# Should have updated pan offset
assert widget.pan_offset[1] != 0 # Y offset should be set
widget.update.assert_called()
mock_window.update_scrollbars.assert_called()
def test_scroll_to_page_second_page(self, qtbot):
"""Test scrolling to second page accounts for first page height"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
# Scroll to first page
widget._scroll_to_page(page1, 0)
first_page_offset = widget.pan_offset[1]
# Scroll to second page
widget._scroll_to_page(page2, 1)
second_page_offset = widget.pan_offset[1]
# Second page offset should be different (accounting for first page height + spacing)
assert second_page_offset != first_page_offset
def test_scroll_to_page_with_different_zoom(self, qtbot):
"""Test scrolling respects zoom level"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
mock_window.project.pages = [page1, page2]
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
# Test at 100% zoom
widget.zoom_level = 1.0
widget._scroll_to_page(page2, 1)
offset_100 = widget.pan_offset[1]
# Test at 50% zoom
widget.zoom_level = 0.5
widget._scroll_to_page(page2, 1)
offset_50 = widget.pan_offset[1]
# Offsets should be different due to zoom
assert offset_100 != offset_50
def test_scroll_to_page_calls_clamp_if_available(self, qtbot):
"""Test that clamp_pan_offset is called if available"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
widget.clamp_pan_offset = Mock()
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page1]
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._scroll_to_page(page1, 0)
# clamp_pan_offset should have been called
widget.clamp_pan_offset.assert_called()
def test_scroll_to_page_without_update_scrollbars(self, qtbot):
"""Test scrolling works even without update_scrollbars method"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
with patch.object(widget, "height", return_value=800):
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page1]
del mock_window.update_scrollbars
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
# Should not raise exception
widget._scroll_to_page(page1, 0)
widget.update.assert_called()
class TestMoveViewportWithArrowKeys:
"""Test _move_viewport_with_arrow_keys method"""
def test_move_viewport_up(self, qtbot):
"""Test moving viewport up with up arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
# Pan Y should increase by 50
assert widget.pan_offset[1] == 50
assert widget.pan_offset[0] == 0
widget.update.assert_called()
def test_move_viewport_down(self, qtbot):
"""Test moving viewport down with down arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Down)
# Pan Y should decrease by 50
assert widget.pan_offset[1] == -50
assert widget.pan_offset[0] == 0
widget.update.assert_called()
def test_move_viewport_left(self, qtbot):
"""Test moving viewport left with left arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Left)
# Pan X should increase by 50
assert widget.pan_offset[0] == 50
assert widget.pan_offset[1] == 0
widget.update.assert_called()
def test_move_viewport_right(self, qtbot):
"""Test moving viewport right with right arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Right)
# Pan X should decrease by 50
assert widget.pan_offset[0] == -50
assert widget.pan_offset[1] == 0
widget.update.assert_called()
def test_move_viewport_multiple_moves(self, qtbot):
"""Test multiple consecutive moves accumulate"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Right)
# Should have moved up (Y+50) and right (X-50)
assert widget.pan_offset[0] == -50
assert widget.pan_offset[1] == 50
def test_move_viewport_calls_clamp_if_available(self, qtbot):
"""Test that clamp_pan_offset is called if available"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
widget.clamp_pan_offset = Mock()
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
widget.clamp_pan_offset.assert_called()
def test_move_viewport_updates_scrollbars_if_available(self, qtbot):
"""Test that update_scrollbars is called if available"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.pages = []
mock_window.update_scrollbars = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
mock_window.update_scrollbars.assert_called()
def test_move_viewport_without_scrollbars(self, qtbot):
"""Test movement works even without update_scrollbars method"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.pan_offset = [0, 0]
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.pages = []
del mock_window.update_scrollbars
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
# Should not raise exception
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
assert widget.pan_offset[1] == 50
class TestMoveSelectedElementsWithArrowKeys:
"""Test _move_selected_elements_with_arrow_keys method"""
def test_move_elements_no_project_attribute(self, qtbot):
"""Test does nothing when main window has no project attribute"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
del mock_window.project
widget.window = Mock(return_value=mock_window)
# Should not raise exception
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
def test_move_elements_no_selected_elements(self, qtbot):
"""Test works when no elements are selected (just updates view)"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
widget.selected_elements = set()
mock_window = Mock()
mock_window.project = Project(name="Test")
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Update is called even with no selected elements
widget.update.assert_called()
def test_move_elements_up(self, qtbot):
"""Test moving elements up with up arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
# Create element without parent page (no snapping)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Y should decrease by 1mm
assert element.position == (5, 4)
widget.update.assert_called()
def test_move_elements_down(self, qtbot):
"""Test moving elements down with down arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Down)
# Y should increase by 1mm
assert element.position == (5, 6)
widget.update.assert_called()
def test_move_elements_left(self, qtbot):
"""Test moving elements left with left arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Left)
# X should decrease by 1mm
assert element.position == (4, 5)
widget.update.assert_called()
def test_move_elements_right(self, qtbot):
"""Test moving elements right with right arrow"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Right)
# X should increase by 1mm
assert element.position == (6, 5)
widget.update.assert_called()
def test_move_multiple_elements(self, qtbot):
"""Test moving multiple selected elements"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element1 = ImageData(image_path="test1.jpg", width=10, height=10, x=5, y=5)
element2 = TextBoxData(text_content="Test", width=20, height=20, x=10, y=10)
widget.selected_elements = {element1, element2}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Both elements should move
assert element1.position == (5, 4)
assert element2.position == (10, 9)
mock_window.show_status.assert_called_once_with("Moved 2 elements", 1000)
def test_move_elements_with_snapping(self, qtbot):
"""Test moving elements with snapping enabled"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
# Create element with parent page
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
# Create mock page with snapping system
mock_page = Mock()
mock_snapping_system = Mock()
mock_snapping_system.snap_position = Mock(return_value=(5.5, 4.5)) # Snapped position
mock_layout = Mock()
mock_layout.snapping_system = mock_snapping_system
mock_layout.size = (210, 297)
mock_page.layout = mock_layout
element._parent_page = mock_page
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Position should be the snapped position
assert element.position == (5.5, 4.5)
# Snapping system should have been called
mock_snapping_system.snap_position.assert_called_once()
def test_move_elements_without_snapping(self, qtbot):
"""Test moving elements without parent page (no snapping)"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
# No _parent_page attribute
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Position should be directly set (no snapping)
assert element.position == (5, 4)
def test_move_elements_shows_status_single_element(self, qtbot):
"""Test status message for single element"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
mock_window.show_status.assert_called_once_with("Moved 1 element", 1000)
def test_move_elements_shows_status_multiple_elements(self, qtbot):
"""Test status message for multiple elements"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element1 = ImageData(image_path="test1.jpg", width=10, height=10, x=5, y=5)
element2 = ImageData(image_path="test2.jpg", width=10, height=10, x=10, y=10)
element3 = ImageData(image_path="test3.jpg", width=10, height=10, x=15, y=15)
widget.selected_elements = {element1, element2, element3}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Right)
mock_window.show_status.assert_called_once_with("Moved 3 elements", 1000)
def test_move_elements_without_status_bar(self, qtbot):
"""Test movement works even without show_status method"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
del mock_window.show_status
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
# Should not raise exception
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
assert element.position == (5, 4)
widget.update.assert_called()
def test_move_elements_element_without_parent_page_attribute(self, qtbot):
"""Test moving elements that don't have _parent_page attribute"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
# Ensure no _parent_page attribute exists
if hasattr(element, "_parent_page"):
delattr(element, "_parent_page")
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Should use direct positioning (no snapping)
assert element.position == (5, 4)
def test_move_elements_with_none_parent_page(self, qtbot):
"""Test moving elements with _parent_page set to None"""
widget = KeyboardNavWidget()
qtbot.addWidget(widget)
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
element._parent_page = None
widget.selected_elements = {element}
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
widget.update = Mock()
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
# Should use direct positioning (no snapping)
assert element.position == (5, 4)

View File

@ -0,0 +1,391 @@
"""
Tests for loading_widget module
"""
import pytest
from unittest.mock import Mock, patch
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QWidget
class TestLoadingWidget:
"""Tests for LoadingWidget class"""
def test_init(self, qtbot):
"""Test LoadingWidget initialization"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget.isHidden()
assert widget.width() == 280
assert widget.height() == 80
def test_init_with_parent(self, qtbot):
"""Test LoadingWidget initialization with parent"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
assert widget.parent() == parent
def test_opacity_property(self, qtbot):
"""Test opacity property getter and setter"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
# Test getter
assert widget.opacity == 1.0
# Test setter
widget.opacity = 0.5
assert widget.opacity == 0.5
def test_show_loading(self, qtbot):
"""Test showing the loading widget"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget.show_loading("Processing...")
assert widget.isVisible()
assert widget._status_label.text() == "Processing..."
assert widget._progress_bar.value() == 0
def test_show_loading_default_message(self, qtbot):
"""Test showing loading widget with default message"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget.show_loading()
assert widget._status_label.text() == "Loading..."
def test_hide_loading(self, qtbot):
"""Test hiding the loading widget"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
# Show first
widget.show_loading()
assert widget.isVisible()
# Then hide
widget.hide_loading()
# Animation starts but widget may not be immediately hidden
# Just verify the method runs without error
def test_set_status(self, qtbot):
"""Test setting status message"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
widget.set_status("Processing files...")
assert widget._status_label.text() == "Processing files..."
def test_set_progress(self, qtbot):
"""Test setting progress value"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
widget.set_progress(50)
assert widget._progress_bar.value() == 50
assert widget._progress_bar.maximum() == 100
def test_set_progress_with_custom_maximum(self, qtbot):
"""Test setting progress with custom maximum"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
widget.set_progress(75, maximum=200)
assert widget._progress_bar.value() == 75
assert widget._progress_bar.maximum() == 200
def test_set_indeterminate_true(self, qtbot):
"""Test setting indeterminate mode to True"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
widget.set_indeterminate(True)
assert widget._progress_bar.minimum() == 0
assert widget._progress_bar.maximum() == 0
def test_set_indeterminate_false(self, qtbot):
"""Test setting indeterminate mode to False"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
# First set to indeterminate
widget.set_indeterminate(True)
# Then set back to normal
widget.set_indeterminate(False)
assert widget._progress_bar.minimum() == 0
assert widget._progress_bar.maximum() == 100
def test_reposition_with_parent(self, qtbot):
"""Test repositioning in lower-right corner of parent"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget._reposition()
# Should be in lower-right corner with 20px margin
expected_x = 800 - 280 - 20
expected_y = 600 - 80 - 20
assert widget.x() == expected_x
assert widget.y() == expected_y
def test_reposition_no_parent(self, qtbot):
"""Test repositioning when there's no parent"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
# Should not crash
widget._reposition()
def test_show_event(self, qtbot):
"""Test show event triggers repositioning"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
with patch.object(widget, '_reposition') as mock_reposition:
widget.show()
mock_reposition.assert_called()
def test_resize_parent(self, qtbot):
"""Test resizeParent method"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget.show_loading()
# Resize parent
parent.resize(1000, 800)
with patch.object(widget, '_reposition') as mock_reposition:
widget.resizeParent()
mock_reposition.assert_called_once()
def test_resize_parent_when_hidden(self, qtbot):
"""Test resizeParent when widget is hidden"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
# Widget is hidden by default
with patch.object(widget, '_reposition') as mock_reposition:
widget.resizeParent()
# Should not call reposition when hidden
mock_reposition.assert_not_called()
def test_fade_animation_on_show(self, qtbot):
"""Test fade animation starts on show_loading"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget.show_loading("Test")
# Animation should be running
assert widget._fade_animation.state() != 0 # Not stopped
def test_fade_animation_on_hide(self, qtbot):
"""Test fade animation starts on hide_loading"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget.show_loading()
widget.hide_loading()
# Animation should be running
assert widget._fade_animation.state() != 0 # Not stopped
def test_progress_bar_format(self, qtbot):
"""Test progress bar displays percentage format"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget._progress_bar.format() == "%p%"
def test_progress_bar_text_visible(self, qtbot):
"""Test progress bar text is visible"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget._progress_bar.isTextVisible() is True
def test_window_flags(self, qtbot):
"""Test widget has correct window flags"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
flags = widget.windowFlags()
assert Qt.WindowType.ToolTip in Qt.WindowType(flags)
assert Qt.WindowType.FramelessWindowHint in Qt.WindowType(flags)
def test_fixed_size(self, qtbot):
"""Test widget has fixed size"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget.minimumWidth() == 280
assert widget.maximumWidth() == 280
assert widget.minimumHeight() == 80
assert widget.maximumHeight() == 80
def test_sequential_show_hide(self, qtbot):
"""Test showing and hiding widget multiple times"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
# First cycle
widget.show_loading("Task 1")
widget.set_progress(50)
widget.hide_loading()
# Second cycle
widget.show_loading("Task 2")
widget.set_progress(25)
assert widget._status_label.text() == "Task 2"
assert widget._progress_bar.value() == 25
def test_update_progress_while_visible(self, qtbot):
"""Test updating progress while widget is visible"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
widget.show_loading("Processing")
# Update progress multiple times
for i in range(0, 101, 10):
widget.set_progress(i)
assert widget._progress_bar.value() == i
def test_status_label_alignment(self, qtbot):
"""Test status label is left-aligned"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget._status_label.alignment() == Qt.AlignmentFlag.AlignLeft
def test_initial_opacity(self, qtbot):
"""Test initial opacity value"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget._opacity == 1.0
def test_fade_animation_duration(self, qtbot):
"""Test fade animation has correct duration"""
from pyPhotoAlbum.loading_widget import LoadingWidget
widget = LoadingWidget()
qtbot.addWidget(widget)
assert widget._fade_animation.duration() == 300
def test_show_loading_resets_progress(self, qtbot):
"""Test that show_loading resets progress to 0"""
from pyPhotoAlbum.loading_widget import LoadingWidget
parent = QWidget()
parent.resize(800, 600)
qtbot.addWidget(parent)
widget = LoadingWidget(parent)
# Set some progress
widget.set_progress(75)
# Show loading should reset to 0
widget.show_loading("New task")
assert widget._progress_bar.value() == 0

605
tests/test_merge_dialog.py Normal file
View File

@ -0,0 +1,605 @@
"""
Tests for merge_dialog module
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QRadioButton
class TestPagePreviewWidget:
"""Tests for PagePreviewWidget class"""
def test_init(self, qtbot):
"""Test PagePreviewWidget initialization"""
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
page_data = {
"page_number": 1,
"layout": {"elements": []},
"last_modified": "2024-01-01 12:00:00",
}
widget = PagePreviewWidget(page_data)
qtbot.addWidget(widget)
assert widget.page_data == page_data
assert widget.minimumSize().width() == 200
assert widget.minimumSize().height() == 280
def test_paint_event_basic(self, qtbot):
"""Test basic paint event rendering"""
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
page_data = {
"page_number": 2,
"layout": {"elements": []},
"last_modified": "2024-01-01",
}
widget = PagePreviewWidget(page_data)
qtbot.addWidget(widget)
widget.show()
# Trigger paint event
widget.repaint()
def test_paint_event_with_elements(self, qtbot):
"""Test paint event with elements"""
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
page_data = {
"page_number": 3,
"layout": {
"elements": [
{"type": "image", "deleted": False},
{"type": "textbox", "deleted": False},
{"type": "shape", "deleted": True},
]
},
"last_modified": "2024-01-01 10:00:00",
}
widget = PagePreviewWidget(page_data)
qtbot.addWidget(widget)
widget.show()
widget.repaint()
def test_paint_event_many_elements(self, qtbot):
"""Test paint event with more than 5 elements (tests truncation)"""
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
elements = [{"type": f"elem{i}", "deleted": False} for i in range(10)]
page_data = {
"page_number": 4,
"layout": {"elements": elements},
"last_modified": "2024-01-01",
}
widget = PagePreviewWidget(page_data)
qtbot.addWidget(widget)
widget.show()
widget.repaint()
class TestConflictItemWidget:
"""Tests for ConflictItemWidget class"""
@pytest.fixture
def mock_conflict_page(self):
"""Create a mock page conflict"""
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict = ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid="page-uuid-1",
element_uuid=None,
description="Page 1 modified in both versions",
our_version={
"page_number": 1,
"layout": {"elements": [{"type": "image"}]},
"last_modified": "2024-01-01 10:00:00",
},
their_version={
"page_number": 1,
"layout": {"elements": [{"type": "image"}, {"type": "textbox"}]},
"last_modified": "2024-01-01 11:00:00",
},
)
return conflict
@pytest.fixture
def mock_conflict_element(self):
"""Create a mock element conflict"""
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid="page-uuid-1",
element_uuid="element-uuid-1",
description="Element modified in both versions",
our_version={
"type": "image",
"position": (10, 20),
"size": (100, 100),
"deleted": False,
"last_modified": "2024-01-01",
},
their_version={
"type": "image",
"position": (15, 25),
"size": (120, 120),
"deleted": False,
"last_modified": "2024-01-02",
},
)
return conflict
@pytest.fixture
def mock_conflict_settings(self):
"""Create a mock settings conflict"""
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict = ConflictInfo(
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
page_uuid=None,
element_uuid=None,
description="Settings modified",
our_version={"theme": "light", "font_size": 12, "last_modified": "2024-01-01"},
their_version={"theme": "dark", "font_size": 14, "last_modified": "2024-01-02"},
)
return conflict
def test_init_page_conflict(self, qtbot, mock_conflict_page):
"""Test initialization with page conflict"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_page)
qtbot.addWidget(widget)
assert widget.conflict_index == 0
assert widget.conflict == mock_conflict_page
def test_init_element_conflict(self, qtbot, mock_conflict_element):
"""Test initialization with element conflict"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(1, mock_conflict_element)
qtbot.addWidget(widget)
assert widget.conflict_index == 1
assert widget.conflict == mock_conflict_element
def test_init_settings_conflict(self, qtbot, mock_conflict_settings):
"""Test initialization with settings conflict"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(2, mock_conflict_settings)
qtbot.addWidget(widget)
assert widget.conflict_index == 2
def test_resolution_changed_signal_ours(self, qtbot, mock_conflict_page):
"""Test resolution_changed signal for 'ours'"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_page)
qtbot.addWidget(widget)
signal_received = []
def on_resolution_changed(index, choice):
signal_received.append((index, choice))
widget.resolution_changed.connect(on_resolution_changed)
# First select "theirs", then select "ours" to trigger the signal
for button in widget.button_group.buttons():
if "Other" in button.text():
button.setChecked(True)
break
# Clear the signal list
signal_received.clear()
# Now select "ours" to trigger the signal
for button in widget.button_group.buttons():
if "Your" in button.text():
button.setChecked(True)
break
assert len(signal_received) == 1
assert signal_received[0] == (0, "ours")
def test_resolution_changed_signal_theirs(self, qtbot, mock_conflict_page):
"""Test resolution_changed signal for 'theirs'"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_page)
qtbot.addWidget(widget)
signal_received = []
def on_resolution_changed(index, choice):
signal_received.append((index, choice))
widget.resolution_changed.connect(on_resolution_changed)
# Find "Use Other Version" button and click it
for button in widget.button_group.buttons():
if "Other" in button.text():
button.setChecked(True)
break
assert len(signal_received) == 1
assert signal_received[0] == (0, "theirs")
def test_get_resolution_ours(self, qtbot, mock_conflict_page):
"""Test get_resolution returns 'ours' when selected"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_page)
qtbot.addWidget(widget)
# Default is "ours"
assert widget.get_resolution() == "ours"
def test_get_resolution_theirs(self, qtbot, mock_conflict_page):
"""Test get_resolution returns 'theirs' when selected"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_page)
qtbot.addWidget(widget)
# Select "theirs"
for button in widget.button_group.buttons():
if "Other" in button.text():
button.setChecked(True)
break
assert widget.get_resolution() == "theirs"
def test_create_element_details_image(self, qtbot, mock_conflict_element):
"""Test creating element details for image element"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_element)
qtbot.addWidget(widget)
details = widget._create_element_details(mock_conflict_element.our_version)
assert details is not None
assert "Type: image" in details.toPlainText()
def test_create_element_details_textbox(self, qtbot):
"""Test creating element details for textbox element"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
element_data = {
"type": "textbox",
"position": (0, 0),
"size": (100, 50),
"deleted": False,
"last_modified": "2024-01-01",
"text_content": "This is a long text that should be truncated after 50 characters for display",
}
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid="page-uuid-1",
element_uuid="element-uuid-2",
description="Text element",
our_version=element_data,
their_version=element_data,
)
widget = ConflictItemWidget(0, conflict)
qtbot.addWidget(widget)
details = widget._create_element_details(element_data)
text = details.toPlainText()
assert "Type: textbox" in text
assert "Text:" in text
def test_create_settings_details(self, qtbot, mock_conflict_settings):
"""Test creating settings details"""
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
widget = ConflictItemWidget(0, mock_conflict_settings)
qtbot.addWidget(widget)
details = widget._create_settings_details(mock_conflict_settings.our_version)
assert details is not None
text = details.toPlainText()
assert "theme: light" in text
assert "font_size: 12" in text
assert "Modified:" in text
class TestMergeDialog:
"""Tests for MergeDialog class"""
@pytest.fixture
def our_project_data(self):
"""Create mock 'our' project data"""
return {
"name": "Our Project",
"last_modified": "2024-01-01 10:00:00",
"pages": [
{
"page_number": 1,
"layout": {"elements": [{"type": "image"}]},
"last_modified": "2024-01-01 10:00:00",
}
],
}
@pytest.fixture
def their_project_data(self):
"""Create mock 'their' project data"""
return {
"name": "Their Project",
"last_modified": "2024-01-01 11:00:00",
"pages": [
{
"page_number": 1,
"layout": {"elements": [{"type": "image"}, {"type": "textbox"}]},
"last_modified": "2024-01-01 11:00:00",
}
],
}
@pytest.fixture
def mock_conflicts(self):
"""Create mock conflicts"""
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
return [
ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid="page-uuid-conflict",
element_uuid=None,
description="Page conflict",
our_version={"page_number": 1, "layout": {"elements": []}},
their_version={"page_number": 1, "layout": {"elements": [{"type": "image"}]}},
)
]
def test_init(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test MergeDialog initialization"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
assert dialog.our_project_data == our_project_data
assert dialog.their_project_data == their_project_data
assert len(dialog.conflicts) == 1
assert len(dialog.resolutions) == 1
assert dialog.resolutions[0] == "ours" # Default
def test_init_ui(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test UI initialization"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
assert dialog.windowTitle() == "Merge Projects"
assert dialog.strategy_combo is not None
assert len(dialog.conflict_widgets) == 1
def test_on_resolution_changed(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test handling resolution changes"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
dialog._on_resolution_changed(0, "theirs")
assert dialog.resolutions[0] == "theirs"
def test_auto_resolve_latest_wins(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test auto-resolve with LATEST_WINS strategy"""
from pyPhotoAlbum.merge_dialog import MergeDialog
from pyPhotoAlbum.merge_manager import MergeStrategy
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
mock_manager.auto_resolve_conflicts.return_value = {0: "theirs"}
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
# Set strategy to LATEST_WINS
dialog.strategy_combo.setCurrentIndex(0)
dialog._auto_resolve()
mock_manager.auto_resolve_conflicts.assert_called_once()
assert dialog.resolutions[0] == "theirs"
def test_auto_resolve_ours(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test auto-resolve with OURS strategy"""
from pyPhotoAlbum.merge_dialog import MergeDialog
from pyPhotoAlbum.merge_manager import MergeStrategy
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
mock_manager.auto_resolve_conflicts.return_value = {0: "ours"}
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
# Set strategy to OURS
dialog.strategy_combo.setCurrentIndex(1)
dialog._auto_resolve()
assert dialog.resolutions[0] == "ours"
def test_auto_resolve_theirs(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test auto-resolve with THEIRS strategy"""
from pyPhotoAlbum.merge_dialog import MergeDialog
from pyPhotoAlbum.merge_manager import MergeStrategy
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
mock_manager.auto_resolve_conflicts.return_value = {0: "theirs"}
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
# Set strategy to THEIRS
dialog.strategy_combo.setCurrentIndex(2)
dialog._auto_resolve()
assert dialog.resolutions[0] == "theirs"
def test_auto_resolve_updates_ui(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test that auto-resolve updates UI radio buttons"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
mock_manager.auto_resolve_conflicts.return_value = {0: "theirs"}
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
dialog._auto_resolve()
# Check that the "Other Version" button is now checked
conflict_widget = dialog.conflict_widgets[0]
resolution = conflict_widget.get_resolution()
assert resolution == "theirs"
def test_get_merged_project_data(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test getting merged project data"""
from pyPhotoAlbum.merge_dialog import MergeDialog
merged_data = {"name": "Merged", "pages": []}
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
mock_manager.apply_resolutions.return_value = merged_data
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
result = dialog.get_merged_project_data()
mock_manager.apply_resolutions.assert_called_once_with(
our_project_data, their_project_data, dialog.resolutions
)
assert result == merged_data
def test_accept_button(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test clicking Accept button"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
# This should trigger accept without errors
dialog.accept()
def test_reject_button(self, qtbot, our_project_data, their_project_data, mock_conflicts):
"""Test clicking Cancel button"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = mock_conflicts
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
# This should trigger reject without errors
dialog.reject()
def test_no_conflicts(self, qtbot, our_project_data, their_project_data):
"""Test dialog with no conflicts"""
from pyPhotoAlbum.merge_dialog import MergeDialog
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = []
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
assert len(dialog.conflicts) == 0
assert len(dialog.conflict_widgets) == 0
def test_multiple_conflicts(self, qtbot, our_project_data, their_project_data):
"""Test dialog with multiple conflicts"""
from pyPhotoAlbum.merge_dialog import MergeDialog
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflicts = [
ConflictInfo(
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
page_uuid=f"page-uuid-{i}",
element_uuid=None,
description=f"Conflict {i}",
our_version={"page_number": i},
their_version={"page_number": i},
)
for i in range(5)
]
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
mock_manager = Mock()
mock_manager.detect_conflicts.return_value = conflicts
MockMergeManager.return_value = mock_manager
dialog = MergeDialog(our_project_data, their_project_data)
qtbot.addWidget(dialog)
assert len(dialog.conflicts) == 5
assert len(dialog.conflict_widgets) == 5
assert len(dialog.resolutions) == 5
# All should default to "ours"
for i in range(5):
assert dialog.resolutions[i] == "ours"

View File

@ -0,0 +1,547 @@
"""
Tests for MergeOperationsMixin
"""
import pytest
import tempfile
import os
from unittest.mock import Mock, MagicMock, patch, call
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QFileDialog
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import TextBoxData
class MergeOpsWindow(MergeOperationsMixin, ApplicationStateMixin, QMainWindow):
"""Test window with merge operations mixin"""
def __init__(self):
super().__init__()
self._project = Project(name="Test Project")
self._gl_widget = Mock()
self._gl_widget.current_page_index = 0
self._autosave_timer = Mock()
self._status_bar = Mock()
# Track calls
self._save_project_called = False
self._update_view_called = False
@property
def gl_widget(self):
return self._gl_widget
@property
def status_bar(self):
return self._status_bar
def save_project(self):
self._save_project_called = True
def update_view(self):
self._update_view_called = True
class TestMergeProjects:
"""Test merge_projects method"""
def test_dirty_project_user_cancels(self, qtbot):
"""Test user cancels when prompted about unsaved changes"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Make project dirty
window.project.mark_dirty()
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Cancel):
window.merge_projects()
# Should return early without attempting merge
assert not window._save_project_called
def test_dirty_project_user_saves(self, qtbot):
"""Test user chooses to save before merging"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Make project dirty
window.project.mark_dirty()
# Mock QMessageBox.question to return Yes, then mock file dialog to cancel
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')):
window.merge_projects()
# Should have called save_project
assert window._save_project_called
def test_dirty_project_user_skips_save(self, qtbot):
"""Test user chooses not to save before merging"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Make project dirty
window.project.mark_dirty()
# Mock QMessageBox.question to return No, then mock file dialog to cancel
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No):
with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')):
window.merge_projects()
# Should NOT have called save_project
assert not window._save_project_called
def test_user_cancels_file_selection(self, qtbot):
"""Test user cancels the file selection dialog"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Project is clean (not dirty)
assert not window.project.is_dirty()
with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')):
window.merge_projects()
# Should return early
assert not window._update_view_called
def test_autosave_timer_stopped_and_restarted(self, qtbot, tmp_path):
"""Test autosave timer is stopped during merge and restarted after"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Create a temporary project file
test_project = Project("Other Project")
test_file = tmp_path / "test.ppz"
from pyPhotoAlbum.project_serializer import save_to_zip
save_to_zip(test_project, str(test_file))
# Track timer calls
timer_stop_called = False
timer_start_called = False
def mock_stop():
nonlocal timer_stop_called
timer_stop_called = True
def mock_start():
nonlocal timer_start_called
timer_start_called = True
window._autosave_timer.stop = mock_stop
window._autosave_timer.start = mock_start
# Mock file dialog to return our test file
with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')):
# Mock QMessageBox for the concatenation question
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
# Mock the information box
with patch.object(QMessageBox, 'information'):
window.merge_projects()
# Verify timer was stopped and started
assert timer_stop_called
assert timer_start_called
def test_merge_same_project_no_conflicts(self, qtbot, tmp_path):
"""Test merging same project with no conflicts"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Create base project
base_project = Project("Base Project")
base_project._project_id = "same-id-123"
page = Page(page_number=1)
text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50)
page.layout.add_element(text)
base_project.add_page(page)
# Set window's project to have same ID
window._project = Project("Base Project")
window._project._project_id = "same-id-123"
page1 = Page(page_number=1)
window._project.add_page(page1)
# Create temporary project file
test_file = tmp_path / "test.ppz"
from pyPhotoAlbum.project_serializer import save_to_zip
save_to_zip(base_project, str(test_file))
# Mock file dialog
with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')):
# Mock QMessageBox to accept auto-merge (no conflicts)
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
# Mock the completion information box
with patch.object(QMessageBox, 'information') as mock_info:
window.merge_projects()
# Should show completion message
assert mock_info.called
def test_merge_different_projects_concatenation(self, qtbot, tmp_path):
"""Test concatenating different projects"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Create another project with different ID
other_project = Project("Other Project")
page = Page(page_number=1)
other_project.add_page(page)
# Create temporary project file
test_file = tmp_path / "test.ppz"
from pyPhotoAlbum.project_serializer import save_to_zip
save_to_zip(other_project, str(test_file))
# Mock file dialog
with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')):
# Mock QMessageBox to accept concatenation
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
# Mock the completion information box
with patch.object(QMessageBox, 'information') as mock_info:
window.merge_projects()
# Should show completion message
assert mock_info.called
def test_merge_error_handling(self, qtbot):
"""Test error handling during merge"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Mock file dialog to return invalid file
with patch.object(QFileDialog, 'getOpenFileName', return_value=('/invalid/path.ppz', '')):
# Mock QMessageBox.critical to capture error
with patch.object(QMessageBox, 'critical') as mock_critical:
window.merge_projects()
# Should show error message
assert mock_critical.called
args = mock_critical.call_args[0]
assert "Merge Error" in args[1]
def test_merge_timer_restarted_after_error(self, qtbot):
"""Test autosave timer is restarted even after error"""
window = MergeOpsWindow()
qtbot.addWidget(window)
timer_start_called = False
def mock_start():
nonlocal timer_start_called
timer_start_called = True
window._autosave_timer.start = mock_start
# Mock file dialog to return invalid file (will cause error)
with patch.object(QFileDialog, 'getOpenFileName', return_value=('/invalid/path.ppz', '')):
with patch.object(QMessageBox, 'critical'):
window.merge_projects()
# Timer should be restarted even after error
assert timer_start_called
class TestPerformMergeWithConflicts:
"""Test _perform_merge_with_conflicts method"""
def test_no_conflicts_user_accepts(self, qtbot):
"""Test auto-merge when no conflicts and user accepts"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Create mock data
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
# Mock MergeManager to return no conflicts
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
mock_manager = MockMergeManager.return_value
mock_manager.detect_conflicts.return_value = []
mock_manager.apply_resolutions.return_value = {'pages': [], 'name': 'Merged'}
# Mock QMessageBox to accept auto-merge
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
with patch.object(QMessageBox, 'information') as mock_info:
window._perform_merge_with_conflicts(our_data, their_data)
# Should have called apply_resolutions
mock_manager.apply_resolutions.assert_called_once()
# Should show completion message
assert mock_info.called
def test_no_conflicts_user_rejects(self, qtbot):
"""Test user rejects auto-merge when no conflicts"""
window = MergeOpsWindow()
qtbot.addWidget(window)
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
# Mock MergeManager to return no conflicts
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
mock_manager = MockMergeManager.return_value
mock_manager.detect_conflicts.return_value = []
# Mock QMessageBox to reject auto-merge
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No):
window._perform_merge_with_conflicts(our_data, their_data)
# Should NOT have called apply_resolutions
mock_manager.apply_resolutions.assert_not_called()
def test_with_conflicts_user_accepts_dialog(self, qtbot):
"""Test merge with conflicts when user accepts dialog"""
window = MergeOpsWindow()
qtbot.addWidget(window)
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
# Mock MergeManager to return conflicts
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
mock_manager = MockMergeManager.return_value
mock_manager.detect_conflicts.return_value = [Mock()] # One conflict
# Mock MergeDialog
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeDialog') as MockDialog:
mock_dialog = MockDialog.return_value
mock_dialog.exec.return_value = QMessageBox.DialogCode.Accepted
mock_dialog.get_merged_project_data.return_value = {'pages': [], 'name': 'Merged'}
with patch.object(QMessageBox, 'information'):
window._perform_merge_with_conflicts(our_data, their_data)
# Should have shown dialog
MockDialog.assert_called_once()
# Should have gotten merged data
mock_dialog.get_merged_project_data.assert_called_once()
def test_with_conflicts_user_cancels_dialog(self, qtbot):
"""Test merge with conflicts when user cancels dialog"""
window = MergeOpsWindow()
qtbot.addWidget(window)
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
# Mock MergeManager to return conflicts
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
mock_manager = MockMergeManager.return_value
mock_manager.detect_conflicts.return_value = [Mock()] # One conflict
# Mock MergeDialog
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeDialog') as MockDialog:
mock_dialog = MockDialog.return_value
mock_dialog.exec.return_value = QMessageBox.DialogCode.Rejected
with patch.object(QMessageBox, 'information') as mock_info:
window._perform_merge_with_conflicts(our_data, their_data)
# Should have shown cancellation message
assert mock_info.called
args = mock_info.call_args[0]
assert "Cancelled" in args[1]
# Should NOT have gotten merged data
mock_dialog.get_merged_project_data.assert_not_called()
class TestPerformConcatenation:
"""Test _perform_concatenation method"""
def test_user_accepts_concatenation(self, qtbot):
"""Test concatenation when user accepts"""
window = MergeOpsWindow()
qtbot.addWidget(window)
our_data = {'pages': [], 'name': 'Project A', 'project_id': 'id-a'}
their_data = {'pages': [], 'name': 'Project B', 'project_id': 'id-b'}
# Mock concatenate_projects
with patch('pyPhotoAlbum.mixins.operations.merge_ops.concatenate_projects') as mock_concat:
mock_concat.return_value = {'pages': [], 'name': 'Combined'}
# Mock QMessageBox to accept
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
with patch.object(QMessageBox, 'information') as mock_info:
window._perform_concatenation(our_data, their_data)
# Should have called concatenate_projects
mock_concat.assert_called_once_with(our_data, their_data)
# Should show completion message
assert mock_info.called
args = mock_info.call_args[0]
assert "Concatenation Complete" in args[1]
def test_user_rejects_concatenation(self, qtbot):
"""Test concatenation when user rejects"""
window = MergeOpsWindow()
qtbot.addWidget(window)
our_data = {'pages': [], 'name': 'Project A', 'project_id': 'id-a'}
their_data = {'pages': [], 'name': 'Project B', 'project_id': 'id-b'}
# Mock concatenate_projects
with patch('pyPhotoAlbum.mixins.operations.merge_ops.concatenate_projects') as mock_concat:
# Mock QMessageBox to reject
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No):
window._perform_concatenation(our_data, their_data)
# Should NOT have called concatenate_projects
mock_concat.assert_not_called()
def test_concatenation_shows_project_names(self, qtbot):
"""Test concatenation dialog shows both project names"""
window = MergeOpsWindow()
qtbot.addWidget(window)
our_data = {'pages': [], 'name': 'My Project', 'project_id': 'id-a'}
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'id-b'}
# Mock QMessageBox.question to capture the message
with patch.object(QMessageBox, 'question') as mock_question:
mock_question.return_value = QMessageBox.StandardButton.No
window._perform_concatenation(our_data, their_data)
# Check that the dialog message contains both project names
args = mock_question.call_args[0]
message = args[2] # Third argument is the message
assert 'My Project' in message
assert 'Their Project' in message
class TestApplyMergedData:
"""Test _apply_merged_data method"""
def test_apply_merged_data_updates_project(self, qtbot):
"""Test applying merged data creates new project"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Create merged data
merged_data = {
'pages': [],
'name': 'Merged Project',
'project_id': 'merged-123'
}
# Mock set_asset_resolution_context
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
window._apply_merged_data(merged_data)
# Should have updated project
assert window.project.name == 'Merged Project'
# Project should be marked dirty
assert window.project.is_dirty()
def test_apply_merged_data_updates_gl_widget(self, qtbot):
"""Test applying merged data updates GL widget"""
window = MergeOpsWindow()
qtbot.addWidget(window)
merged_data = {
'pages': [],
'name': 'Merged Project',
'project_id': 'merged-123'
}
# Mock set_asset_resolution_context
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
window._apply_merged_data(merged_data)
# Should have updated gl_widget
window.gl_widget.set_project.assert_called_once()
window.gl_widget.update.assert_called_once()
def test_apply_merged_data_shows_status(self, qtbot):
"""Test applying merged data shows status message"""
window = MergeOpsWindow()
qtbot.addWidget(window)
merged_data = {
'pages': [],
'name': 'Merged Project',
'project_id': 'merged-123'
}
# Mock set_asset_resolution_context
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
window._apply_merged_data(merged_data)
# Should have shown status message
window.status_bar.showMessage.assert_called_once()
args = window.status_bar.showMessage.call_args[0]
assert "Merge completed successfully" in args[0]
def test_apply_merged_data_sets_asset_context(self, qtbot, tmp_path):
"""Test applying merged data sets asset resolution context"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Create project with folder_path
test_path = tmp_path / "test"
test_path.mkdir()
window._project.folder_path = str(test_path)
merged_path = tmp_path / "merged"
merged_path.mkdir()
merged_data = {
'pages': [],
'name': 'Merged Project',
'project_id': 'merged-123',
'folder_path': str(merged_path)
}
# Mock set_asset_resolution_context
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context') as mock_set:
window._apply_merged_data(merged_data)
# Should have called set_asset_resolution_context with new project's folder_path
mock_set.assert_called_once()
def test_apply_merged_data_without_gl_widget(self, qtbot):
"""Test applying merged data when gl_widget doesn't exist"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Remove gl_widget
delattr(window, '_gl_widget')
merged_data = {
'pages': [],
'name': 'Merged Project',
'project_id': 'merged-123'
}
# Should not raise error
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
window._apply_merged_data(merged_data)
# Project should still be updated
assert window.project.name == 'Merged Project'
def test_apply_merged_data_without_status_bar(self, qtbot):
"""Test applying merged data when status_bar doesn't exist"""
window = MergeOpsWindow()
qtbot.addWidget(window)
# Remove status_bar
delattr(window, '_status_bar')
merged_data = {
'pages': [],
'name': 'Merged Project',
'project_id': 'merged-123'
}
# Should not raise error
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
window._apply_merged_data(merged_data)
# Project should still be updated
assert window.project.name == 'Merged Project'

View File

@ -3,7 +3,20 @@ Unit tests for pyPhotoAlbum models
"""
import pytest
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData, BaseLayoutElement
import os
import tempfile
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from PIL import Image
from pyPhotoAlbum.models import (
ImageData,
PlaceholderData,
TextBoxData,
GhostPageData,
BaseLayoutElement,
set_asset_resolution_context,
get_asset_search_paths,
)
class TestBaseLayoutElement:
@ -14,6 +27,77 @@ class TestBaseLayoutElement:
with pytest.raises(TypeError):
BaseLayoutElement()
def test_mark_modified(self):
"""Test that mark_modified updates the last_modified timestamp"""
img = ImageData()
original_modified = img.last_modified
# Wait a tiny bit to ensure timestamp changes
import time
time.sleep(0.01)
img.mark_modified()
assert img.last_modified != original_modified
# Verify it's a valid ISO format timestamp
datetime.fromisoformat(img.last_modified)
def test_mark_deleted(self):
"""Test that mark_deleted properly marks an element as deleted"""
img = ImageData()
assert img.deleted is False
assert img.deleted_at is None
img.mark_deleted()
assert img.deleted is True
assert img.deleted_at is not None
# Verify deleted_at is a valid ISO format timestamp
datetime.fromisoformat(img.deleted_at)
def test_serialize_base_fields(self):
"""Test that base fields are included in serialization"""
img = ImageData()
data = img.serialize()
assert "uuid" in data
assert "created" in data
assert "last_modified" in data
assert "deleted" in data
assert "deleted_at" in data
assert data["deleted"] is False
def test_deserialize_base_fields_with_uuid(self):
"""Test deserializing base fields when uuid is present"""
img = ImageData()
test_uuid = "test-uuid-12345"
test_created = "2024-01-01T00:00:00+00:00"
test_modified = "2024-01-02T00:00:00+00:00"
data = {
"uuid": test_uuid,
"created": test_created,
"last_modified": test_modified,
"deleted": False,
"deleted_at": None,
}
img.deserialize(data)
assert img.uuid == test_uuid
assert img.created == test_created
assert img.last_modified == test_modified
assert img.deleted is False
assert img.deleted_at is None
def test_deserialize_base_fields_generates_uuid_if_missing(self):
"""Test that deserialize generates UUID if not present (backwards compatibility)"""
img = ImageData()
data = {} # No UUID
img.deserialize(data)
# Should generate a UUID
assert img.uuid is not None
assert len(img.uuid) > 0
class TestImageData:
"""Tests for ImageData class"""
@ -130,6 +214,549 @@ class TestImageData:
img.size = (400.0, 300.0)
assert img.size == (400.0, 300.0)
def test_resolve_image_path_absolute_exists(self, temp_image_file):
"""Test resolve_image_path with absolute path that exists"""
img = ImageData(image_path=temp_image_file)
resolved = img.resolve_image_path()
assert resolved == temp_image_file
assert os.path.isabs(resolved)
def test_resolve_image_path_absolute_not_exists(self):
"""Test resolve_image_path with absolute path that doesn't exist"""
img = ImageData(image_path="/nonexistent/absolute/path.jpg")
resolved = img.resolve_image_path()
assert resolved is None
def test_resolve_image_path_empty(self):
"""Test resolve_image_path with empty path"""
img = ImageData(image_path="")
resolved = img.resolve_image_path()
assert resolved is None
def test_resolve_image_path_relative_with_project_folder(self, temp_dir):
"""Test resolve_image_path with relative path and project folder set"""
# Create a test image in the temp directory
test_image_path = os.path.join(temp_dir, "test_image.jpg")
img = Image.new("RGB", (50, 50), color="blue")
img.save(test_image_path)
# Set the asset resolution context
set_asset_resolution_context(temp_dir)
# Create ImageData with relative path
img_data = ImageData(image_path="test_image.jpg")
resolved = img_data.resolve_image_path()
assert resolved is not None
assert os.path.exists(resolved)
assert resolved == test_image_path
# Reset context
set_asset_resolution_context(None)
def test_resolve_image_path_relative_no_project_folder(self):
"""Test resolve_image_path with relative path but no project folder set"""
# Reset context
set_asset_resolution_context(None)
img = ImageData(image_path="relative/path.jpg")
resolved = img.resolve_image_path()
assert resolved is None
def test_rotation_conversion_90_degrees(self):
"""Test conversion of old visual rotation (90°) to pil_rotation_90"""
img = ImageData()
data = {
"rotation": 90.0,
"pil_rotation_90": 0, # Old data without PIL rotation
}
img.deserialize(data)
assert img.rotation == 0 # Visual rotation should be reset
assert img.pil_rotation_90 == 1 # Should be converted to 1 (90°)
def test_rotation_conversion_180_degrees(self):
"""Test conversion of old visual rotation (180°) to pil_rotation_90"""
img = ImageData()
data = {
"rotation": 180.0,
"pil_rotation_90": 0,
}
img.deserialize(data)
assert img.rotation == 0
assert img.pil_rotation_90 == 2
def test_rotation_conversion_270_degrees(self):
"""Test conversion of old visual rotation (270°) to pil_rotation_90"""
img = ImageData()
data = {
"rotation": 270.0,
"pil_rotation_90": 0,
}
img.deserialize(data)
assert img.rotation == 0
assert img.pil_rotation_90 == 3
def test_no_rotation_conversion_when_pil_rotation_set(self):
"""Test that rotation conversion doesn't happen if pil_rotation_90 is already set"""
img = ImageData()
data = {
"rotation": 90.0,
"pil_rotation_90": 2, # Already set to 180°
}
img.deserialize(data)
# Should keep existing pil_rotation_90, not convert
assert img.pil_rotation_90 == 2
assert img.rotation == 90.0 # Keeps original rotation
def test_image_dimensions_serialization(self):
"""Test that image_dimensions are serialized when available"""
img = ImageData()
img.image_dimensions = (800, 600)
data = img.serialize()
assert "image_dimensions" in data
assert data["image_dimensions"] == (800, 600)
def test_image_dimensions_deserialization(self):
"""Test that image_dimensions are deserialized correctly"""
img = ImageData()
data = {
"image_dimensions": [1920, 1080] # List format from JSON
}
img.deserialize(data)
assert img.image_dimensions == (1920, 1080) # Converted to tuple
def test_pil_rotation_serialization(self):
"""Test that pil_rotation_90 is serialized"""
img = ImageData()
img.pil_rotation_90 = 2
data = img.serialize()
assert data["pil_rotation_90"] == 2
def test_async_loading_initial_state(self):
"""Test initial state of async loading flags"""
img = ImageData()
assert img._async_loading is False
assert img._async_load_requested is False
def test_on_async_image_loaded_callback(self):
"""Test _on_async_image_loaded callback sets pending image"""
img = ImageData(image_path="test.jpg")
# Create a mock PIL image
pil_image = Image.new("RGBA", (200, 100), color="red")
# Call the callback
img._on_async_image_loaded(pil_image)
# Verify the pending image is set
assert hasattr(img, "_pending_pil_image")
assert img._pending_pil_image is not None
assert img._img_width == 200
assert img._img_height == 100
assert img._async_loading is False
assert img.image_dimensions == (200, 100)
def test_on_async_image_loaded_with_pil_rotation(self):
"""Test _on_async_image_loaded applies PIL rotation"""
img = ImageData(image_path="test.jpg")
img.pil_rotation_90 = 1 # 90 degrees
# Create a mock PIL image (100x200)
pil_image = Image.new("RGBA", (100, 200), color="blue")
# Call the callback
img._on_async_image_loaded(pil_image)
# After 90° rotation, dimensions should be swapped (200x100)
assert img._img_width == 200
assert img._img_height == 100
assert img.image_dimensions == (200, 100)
def test_on_async_image_load_failed_callback(self):
"""Test _on_async_image_load_failed callback"""
img = ImageData(image_path="test.jpg")
img._async_loading = True
img._async_load_requested = True
# Call the failure callback
img._on_async_image_load_failed("File not found")
# Verify flags are reset
assert img._async_loading is False
assert img._async_load_requested is False
def test_on_async_image_loaded_exception_handling(self):
"""Test _on_async_image_loaded handles exceptions gracefully"""
img = ImageData(image_path="test.jpg")
# Create a mock object that will raise an exception when accessed
class BadImage:
@property
def size(self):
raise RuntimeError("Simulated error")
@property
def width(self):
raise RuntimeError("Simulated error")
bad_image = BadImage()
# Call should not raise, but should handle the error
img._on_async_image_loaded(bad_image)
# Verify that async loading is reset and pending image is None
assert img._async_loading is False
assert not hasattr(img, "_pending_pil_image") or img._pending_pil_image is None
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_without_texture(self, mock_glEnd, mock_glBegin, mock_glVertex2f, mock_glColor3f):
"""Test ImageData.render() without texture (placeholder mode)"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img.render()
# Should draw a light blue placeholder rectangle
mock_glColor3f.assert_any_call(0.7, 0.85, 1.0)
# Should draw a black border
mock_glColor3f.assert_any_call(0.0, 0.0, 0.0)
# Verify GL_QUADS and GL_LINE_LOOP were used
assert mock_glBegin.call_count >= 2
assert mock_glEnd.call_count >= 2
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexCoord2f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_texture(
self,
mock_glEnd,
mock_glBegin,
mock_glColor4f,
mock_glVertex2f,
mock_glTexCoord2f,
mock_glBindTexture,
mock_glEnable,
mock_glDisable,
):
"""Test ImageData.render() with texture"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img._texture_id = 123
img._img_width = 200
img._img_height = 100
img.render()
# Should enable and bind texture
from pyPhotoAlbum.models import GL_TEXTURE_2D
mock_glEnable.assert_called()
mock_glBindTexture.assert_called_with(GL_TEXTURE_2D, 123)
mock_glColor4f.assert_called_with(1.0, 1.0, 1.0, 1.0)
# Should set texture coordinates and vertices
assert mock_glTexCoord2f.call_count >= 4
assert mock_glVertex2f.call_count >= 4
# Should disable texture after rendering
mock_glDisable.assert_called()
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_from_pending_image_success(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
):
"""Test _create_texture_from_pending_image successfully creates texture"""
img = ImageData(image_path="test.jpg")
# Mock GL context is available
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 456
# Create a pending PIL image
pil_image = Image.new("RGBA", (100, 50), color="green")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
assert img._texture_id == 456
assert img._pending_pil_image is None # Should be cleared
mock_glGenTextures.assert_called_once_with(1)
mock_glTexImage2D.assert_called_once()
@patch("pyPhotoAlbum.models.glGetString")
def test_create_texture_from_pending_image_no_gl_context(self, mock_glGetString):
"""Test _create_texture_from_pending_image defers when no GL context"""
img = ImageData(image_path="test.jpg")
# Mock no GL context available
mock_glGetString.return_value = None
# Create a pending PIL image
pil_image = Image.new("RGBA", (100, 50), color="yellow")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is False
assert img._pending_pil_image is not None # Should keep pending image
assert not hasattr(img, "_texture_id") or img._texture_id is None
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glGetString")
def test_create_texture_from_pending_image_gl_error(self, mock_glGetString, mock_glGenTextures):
"""Test _create_texture_from_pending_image handles GL errors"""
img = ImageData(image_path="test.jpg")
# Mock GL context available but genTextures fails with GL error
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.side_effect = Exception("GLError 1282: Invalid operation")
pil_image = Image.new("RGBA", (100, 50), color="purple")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
# Should return False and keep trying on next render
assert result is False
assert img._pending_pil_image is not None
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glGetString")
def test_create_texture_from_pending_image_other_error(self, mock_glGetString, mock_glGenTextures):
"""Test _create_texture_from_pending_image handles non-GL errors"""
img = ImageData(image_path="test.jpg")
# Mock GL context available but other error occurs
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.side_effect = Exception("Some other error")
pil_image = Image.new("RGBA", (100, 50), color="cyan")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
# Should return False and clear pending image (give up)
assert result is False
assert img._pending_pil_image is None
assert img._texture_id is None
@patch("pyPhotoAlbum.models.glDeleteTextures")
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_deletes_old_texture(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString, mock_glDeleteTextures
):
"""Test _create_texture_from_pending_image deletes old texture"""
img = ImageData(image_path="test.jpg")
img._texture_id = 789 # Old texture
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 999
pil_image = Image.new("RGBA", (100, 50), color="red")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
mock_glDeleteTextures.assert_called_once_with([789])
assert img._texture_id == 999
def test_create_texture_from_pending_image_no_pending(self):
"""Test _create_texture_from_pending_image returns False when no pending image"""
img = ImageData(image_path="test.jpg")
# No pending image set
result = img._create_texture_from_pending_image()
assert result is False
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_converts_non_rgba(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
):
"""Test _create_texture_from_pending_image converts non-RGBA images"""
img = ImageData(image_path="test.jpg")
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 456
# Create RGB image (not RGBA)
pil_image = Image.new("RGB", (100, 50), color="blue")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
# Image should have been converted to RGBA
assert img._texture_id == 456
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_clears_warning_flag(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
):
"""Test _create_texture_from_pending_image clears GL context warning flag on success"""
img = ImageData(image_path="test.jpg")
img._gl_context_warned = True # Set warning flag
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 456
pil_image = Image.new("RGBA", (100, 50), color="green")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
# Warning flag should be cleared
assert not hasattr(img, "_gl_context_warned")
@patch("pyPhotoAlbum.models.calculate_center_crop_coords")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexCoord2f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_texture_using_image_dimensions(
self,
mock_glEnd,
mock_glBegin,
mock_glColor3f,
mock_glColor4f,
mock_glVertex2f,
mock_glTexCoord2f,
mock_glBindTexture,
mock_glEnable,
mock_glDisable,
mock_calculate_coords,
):
"""Test render() with texture but no _img_width/_img_height, using image_dimensions"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img._texture_id = 123
img.image_dimensions = (800, 600) # Has dimensions but not _img_width/_img_height
mock_calculate_coords.return_value = (0, 0, 1, 1)
img.render()
# Should use image_dimensions for crop calculation
mock_calculate_coords.assert_called_once()
args = mock_calculate_coords.call_args[0]
assert args[0] == 800 # img_width from image_dimensions
assert args[1] == 600 # img_height from image_dimensions
@patch("pyPhotoAlbum.models.calculate_center_crop_coords")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexCoord2f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_texture_no_dimensions(
self,
mock_glEnd,
mock_glBegin,
mock_glColor3f,
mock_glColor4f,
mock_glVertex2f,
mock_glTexCoord2f,
mock_glBindTexture,
mock_glEnable,
mock_glDisable,
mock_calculate_coords,
):
"""Test render() with texture but no dimensions at all"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img._texture_id = 123
# No _img_width/_img_height and no image_dimensions
mock_calculate_coords.return_value = (0, 0, 1, 1)
img.render()
# Should use element size as fallback
mock_calculate_coords.assert_called_once()
args = mock_calculate_coords.call_args[0]
assert args[0] == 100 # Uses width as img_width
assert args[1] == 50 # Uses height as img_height
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glDisable")
def test_render_calls_create_texture_from_pending(
self,
mock_glDisable,
mock_glEnable,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glTexImage2D,
mock_glTexParameteri,
mock_glBindTexture,
mock_glGenTextures,
mock_glGetString,
):
"""Test render() calls _create_texture_from_pending_image when pending image exists"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
# Set up pending image
pil_image = Image.new("RGBA", (100, 50), color="orange")
img._pending_pil_image = pil_image
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 999
img.render()
# Should have created texture from pending image
assert img._texture_id == 999
assert img._pending_pil_image is None # Should be cleared
class TestPlaceholderData:
"""Tests for PlaceholderData class"""
@ -240,6 +867,89 @@ class TestPlaceholderData:
assert restored.rotation == original.rotation
assert restored.z_index == original.z_index
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glLineStipple")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_without_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glLineStipple,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test PlaceholderData.render() without rotation"""
placeholder = PlaceholderData(x=10, y=20, width=100, height=50, rotation=0)
placeholder.render()
# Should draw light gray rectangle
mock_glColor3f.assert_any_call(0.9, 0.9, 0.9)
# Should draw gray dashed border
mock_glColor3f.assert_any_call(0.5, 0.5, 0.5)
# Should enable and disable line stipple
from pyPhotoAlbum.models import GL_LINE_STIPPLE
mock_glEnable.assert_called_with(GL_LINE_STIPPLE)
mock_glLineStipple.assert_called_once_with(1, 0x00FF)
mock_glDisable.assert_called_with(GL_LINE_STIPPLE)
# Should NOT push/pop matrix when rotation is 0
mock_glPushMatrix.assert_not_called()
mock_glPopMatrix.assert_not_called()
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glLineStipple")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glLineStipple,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test PlaceholderData.render() with rotation"""
placeholder = PlaceholderData(x=10, y=20, width=100, height=50, rotation=45)
placeholder.render()
# Should push/pop matrix for rotation
mock_glPushMatrix.assert_called_once()
mock_glPopMatrix.assert_called_once()
# Should translate to center and rotate
assert mock_glTranslatef.call_count == 2
mock_glRotatef.assert_called_once_with(45, 0, 0, 1)
class TestTextBoxData:
"""Tests for TextBoxData class"""
@ -383,6 +1093,95 @@ class TestTextBoxData:
textbox.alignment = "right"
assert textbox.alignment == "right"
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBlendFunc")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_without_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glColor4f,
mock_glBlendFunc,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test TextBoxData.render() without rotation"""
textbox = TextBoxData(text_content="Test", x=10, y=20, width=100, height=50, rotation=0)
textbox.render()
# Should enable and disable blending
from pyPhotoAlbum.models import GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA
mock_glEnable.assert_called_with(GL_BLEND)
mock_glBlendFunc.assert_called_once_with(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
mock_glDisable.assert_called_with(GL_BLEND)
# Should draw semi-transparent yellow background
mock_glColor4f.assert_called_with(1.0, 1.0, 0.7, 0.3)
# Should draw black border
mock_glColor3f.assert_called_with(0.0, 0.0, 0.0)
# Should NOT push/pop matrix when rotation is 0
mock_glPushMatrix.assert_not_called()
mock_glPopMatrix.assert_not_called()
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBlendFunc")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glColor4f,
mock_glBlendFunc,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test TextBoxData.render() with rotation"""
textbox = TextBoxData(text_content="Test", x=10, y=20, width=100, height=50, rotation=30)
textbox.render()
# Should push/pop matrix for rotation
mock_glPushMatrix.assert_called_once()
mock_glPopMatrix.assert_called_once()
# Should translate to center and rotate
assert mock_glTranslatef.call_count == 2
mock_glRotatef.assert_called_once_with(30, 0, 0, 1)
class TestElementComparison:
"""Tests comparing different element types"""
@ -413,3 +1212,167 @@ class TestElementComparison:
assert sorted_elements[0].z_index == 1
assert sorted_elements[1].z_index == 3
assert sorted_elements[2].z_index == 5
class TestAssetResolution:
"""Tests for asset resolution functions"""
def test_set_and_get_asset_resolution_context(self, temp_dir):
"""Test setting and getting asset resolution context"""
additional_paths = ["/path1", "/path2"]
set_asset_resolution_context(temp_dir, additional_paths)
project_folder, search_paths = get_asset_search_paths()
assert project_folder == temp_dir
assert search_paths == additional_paths
def test_set_asset_resolution_context_no_additional_paths(self, temp_dir):
"""Test setting context without additional search paths"""
set_asset_resolution_context(temp_dir)
project_folder, search_paths = get_asset_search_paths()
assert project_folder == temp_dir
assert search_paths == []
def test_get_asset_search_paths_default(self):
"""Test getting asset search paths when not set"""
# This depends on global state, so we just verify it returns a tuple
result = get_asset_search_paths()
assert isinstance(result, tuple)
assert len(result) == 2
class TestGhostPageData:
"""Tests for GhostPageData class"""
def test_initialization_default(self):
"""Test GhostPageData initialization with default values"""
ghost = GhostPageData()
assert ghost.page_size == (210, 297) # A4 size in mm
assert ghost.is_ghost is True
assert ghost.position == (0, 0)
assert ghost.size == (100, 100)
def test_initialization_with_custom_page_size(self):
"""Test GhostPageData initialization with custom page size"""
custom_size = (200, 250)
ghost = GhostPageData(page_size=custom_size, x=10, y=20)
assert ghost.page_size == custom_size
assert ghost.position == (10, 20)
assert ghost.is_ghost is True
def test_serialization(self):
"""Test GhostPageData serialization"""
ghost = GhostPageData(page_size=(200, 280), x=5, y=10)
data = ghost.serialize()
assert data["type"] == "ghostpage"
assert data["page_size"] == (200, 280)
assert data["position"] == (5, 10)
# Check base fields
assert "uuid" in data
assert "created" in data
assert "last_modified" in data
def test_deserialization(self):
"""Test GhostPageData deserialization"""
ghost = GhostPageData()
data = {
"position": (15, 25),
"size": (150, 200),
"page_size": (220, 300),
}
ghost.deserialize(data)
assert ghost.position == (15, 25)
assert ghost.size == (150, 200)
assert ghost.page_size == (220, 300)
def test_deserialization_with_defaults(self):
"""Test GhostPageData deserialization with missing fields"""
ghost = GhostPageData()
data = {}
ghost.deserialize(data)
assert ghost.position == (0, 0)
assert ghost.size == (100, 100)
assert ghost.page_size == (210, 297)
def test_serialize_deserialize_roundtrip(self):
"""Test that serialize and deserialize are inverse operations"""
original = GhostPageData(page_size=(200, 250), x=20, y=30)
data = original.serialize()
restored = GhostPageData()
restored.deserialize(data)
assert restored.page_size == original.page_size
assert restored.position == original.position
assert restored.is_ghost is True
def test_get_page_rect(self):
"""Test get_page_rect returns correct bounding box"""
ghost = GhostPageData(page_size=(210, 297))
rect = ghost.get_page_rect()
assert len(rect) == 4
x, y, w, h = rect
assert x == 0
assert y == 0
# Width and height should be calculated from page_size at 300 DPI
# 210mm * 300 DPI / 25.4 mm/inch ≈ 2480px
assert w > 0
assert h > 0
def test_page_size_modification(self):
"""Test modifying page_size after initialization"""
ghost = GhostPageData()
ghost.page_size = (250, 350)
assert ghost.page_size == (250, 350)
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBlendFunc")
@patch("pyPhotoAlbum.models.glLineStipple")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glColor4f,
mock_glLineStipple,
mock_glBlendFunc,
mock_glEnable,
mock_glDisable,
):
"""Test GhostPageData.render()"""
ghost = GhostPageData(page_size=(210, 297))
ghost.render()
# Should enable and disable blending
from pyPhotoAlbum.models import GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_LINE_STIPPLE
mock_glEnable.assert_any_call(GL_BLEND)
mock_glBlendFunc.assert_called_once_with(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Should draw semi-transparent grey background
mock_glColor4f.assert_called_with(0.8, 0.8, 0.8, 0.5)
# Should draw grey dashed border
mock_glColor3f.assert_called_with(0.5, 0.5, 0.5)
mock_glEnable.assert_any_call(GL_LINE_STIPPLE)
mock_glLineStipple.assert_called_once_with(2, 0x0F0F)
mock_glDisable.assert_any_call(GL_LINE_STIPPLE)
# Should have drawn quads and line loop
assert mock_glBegin.call_count >= 2
assert mock_glEnd.call_count >= 2
assert mock_glVertex2f.call_count >= 8 # 4 vertices for quad + 4 for border

View File

@ -518,3 +518,468 @@ class TestWheelEvent:
# Should pan viewport down (decrease pan_offset[1])
assert widget.pan_offset[1] < initial_pan
assert widget.update.called
class TestRotationMode:
"""Test rotation mode functionality"""
def test_click_in_rotation_mode_starts_rotation(self, qtbot):
"""Test clicking on element in rotation mode starts rotation"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget.rotation_mode = True
# Create and select element
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
widget.selected_elements.add(element)
# Mock the _begin_rotate method
widget._begin_rotate = Mock()
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(150, 150))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget._get_element_at = Mock(return_value=element)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should start rotation
assert widget.is_dragging is True
assert widget.drag_start_pos == (150, 150)
assert hasattr(widget, "rotation_start_angle")
widget._begin_rotate.assert_called_once_with(element)
def test_mouse_move_in_rotation_mode(self, qtbot):
"""Test mouse move in rotation mode rotates element"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget._update_page_status = Mock()
widget.rotation_mode = True
# Create element with page renderer
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
element.rotation = 0
widget.selected_elements.add(element)
# Create mock renderer
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(150, 150)) # Center of element
element._page_renderer = mock_renderer
# Start dragging in rotation mode
widget.drag_start_pos = (150, 150)
widget.is_dragging = True
widget.rotation_start_angle = 0
# Mock window with show_status
mock_window = Mock()
mock_window.show_status = Mock()
widget.window = Mock(return_value=mock_window)
event = Mock()
event.position = Mock(return_value=QPointF(200, 150)) # Mouse to the right
widget.mouseMoveEvent(event)
# Rotation should be updated (0 degrees for right side)
assert element.rotation == 0 # Snapped to nearest 15 degrees
assert widget.update.called
mock_window.show_status.assert_called()
class TestResizeMode:
"""Test resize mode functionality"""
def test_click_on_resize_handle_starts_resize(self, qtbot):
"""Test clicking on resize handle starts resize"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget.rotation_mode = False
# Create and select element
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
widget.selected_elements.add(element)
# Mock the _begin_resize method and _get_resize_handle_at
widget._begin_resize = Mock()
widget._get_resize_handle_at = Mock(return_value="bottom-right")
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(200, 200)) # Bottom-right corner
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget._get_element_at = Mock(return_value=element)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should start resize
assert widget.is_dragging is True
assert widget.resize_handle == "bottom-right"
assert widget.drag_start_pos == (200, 200)
widget._begin_resize.assert_called_once_with(element)
def test_mouse_move_in_resize_mode(self, qtbot):
"""Test mouse move in resize mode resizes element"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget._update_page_status = Mock()
# Create and select element
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
widget.selected_elements.add(element)
# Start dragging in resize mode
widget.drag_start_pos = (200, 200)
widget.is_dragging = True
widget.resize_handle = "bottom-right"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (100, 100)
# Mock _resize_element
widget._resize_element = Mock()
event = Mock()
event.position = Mock(return_value=QPointF(220, 220))
widget.mouseMoveEvent(event)
# Should call _resize_element with deltas
widget._resize_element.assert_called_once()
assert widget.update.called
class TestMultiSelect:
"""Test multi-select functionality"""
def test_ctrl_click_non_image_adds_to_selection(self, qtbot):
"""Test Ctrl+click on non-ImageData adds to multi-selection"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create and add first element
element1 = TextBoxData(
text_content="Test 1",
x=100,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
widget.selected_elements.add(element1)
# Create second element
element2 = TextBoxData(
text_content="Test 2",
x=250,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(300, 125))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ControlModifier)
widget._get_element_at = Mock(return_value=element2)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should add to selection
assert element1 in widget.selected_elements
assert element2 in widget.selected_elements
assert len(widget.selected_elements) == 2
def test_ctrl_click_selected_element_removes_from_selection(self, qtbot):
"""Test Ctrl+click on already selected element removes it"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create and select two elements
element1 = TextBoxData(
text_content="Test 1",
x=100,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
element2 = TextBoxData(
text_content="Test 2",
x=250,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
widget.selected_elements.add(element1)
widget.selected_elements.add(element2)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(150, 125))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ControlModifier)
widget._get_element_at = Mock(return_value=element1)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should remove from selection
assert element1 not in widget.selected_elements
assert element2 in widget.selected_elements
assert len(widget.selected_elements) == 1
def test_shift_click_adds_to_selection(self, qtbot):
"""Test Shift+click adds element to selection"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create and select first element
element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100)
widget.selected_elements.add(element1)
# Create second element
element2 = ImageData(image_path="/test2.jpg", x=250, y=100, width=100, height=100)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(300, 150))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ShiftModifier)
widget._get_element_at = Mock(return_value=element2)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should add to selection
assert element1 in widget.selected_elements
assert element2 in widget.selected_elements
assert len(widget.selected_elements) == 2
def test_shift_click_selected_element_removes_it(self, qtbot):
"""Test Shift+click on selected element removes it"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create and select two elements
element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100)
element2 = ImageData(image_path="/test2.jpg", x=250, y=100, width=100, height=100)
widget.selected_elements.add(element1)
widget.selected_elements.add(element2)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(150, 150))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ShiftModifier)
widget._get_element_at = Mock(return_value=element1)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should remove from selection
assert element1 not in widget.selected_elements
assert element2 in widget.selected_elements
assert len(widget.selected_elements) == 1
class TestElementPositioningWithoutParentPage:
"""Test element positioning when element has no parent page"""
def test_drag_element_without_parent_page(self, qtbot):
"""Test dragging element that has no _parent_page attribute"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget._update_page_status = Mock()
# Create element without _parent_page
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
widget.selected_elements.add(element)
# Start dragging
widget.drag_start_pos = (150, 150)
widget.is_dragging = True
widget.drag_start_element_pos = (100, 100)
# Mock page detection to return a page
mock_page = Mock()
mock_renderer = Mock()
widget._get_page_at = Mock(return_value=(mock_page, 0, mock_renderer))
event = Mock()
event.position = Mock(return_value=QPointF(180, 180))
widget.mouseMoveEvent(event)
# Element position should be updated (without snapping since no parent page)
assert element.position == (130, 130) # Moved by 30 pixels / zoom_level (1.0)
assert widget.update.called
class TestWheelEventWhileDragging:
"""Test wheel events during drag operations"""
def test_ctrl_scroll_while_dragging_adjusts_drag_start_pos(self, qtbot):
"""Test Ctrl+scroll while dragging adjusts drag_start_pos for zoom"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget.clamp_pan_offset = Mock()
# Setup dragging state
widget.is_dragging = True
widget.drag_start_pos = (100, 100)
event = Mock()
event.angleDelta = Mock(return_value=Mock(y=Mock(return_value=120))) # Zoom in
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ControlModifier)
event.position = Mock(return_value=QPointF(150, 150))
old_drag_pos = widget.drag_start_pos
widget.wheelEvent(event)
# drag_start_pos should be adjusted
assert widget.drag_start_pos != old_drag_pos
assert widget.is_dragging is True
def test_scroll_while_dragging_adjusts_drag_start_pos(self, qtbot):
"""Test scrolling while dragging adjusts drag_start_pos for pan"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget.clamp_pan_offset = Mock()
# Setup dragging state
widget.is_dragging = True
widget.drag_start_pos = (100, 100)
initial_drag_y = widget.drag_start_pos[1]
event = Mock()
event.angleDelta = Mock(return_value=Mock(y=Mock(return_value=120))) # Scroll up
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget.wheelEvent(event)
# drag_start_pos y should be adjusted for pan
assert widget.drag_start_pos[1] != initial_drag_y
assert widget.is_dragging is True
class TestEditTextElement:
"""Test _edit_text_element dialog functionality"""
def test_edit_text_element_accepted(self, qtbot):
"""Test editing text element when dialog is accepted"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create text element
text_element = TextBoxData(
text_content="Original",
x=100,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
alignment="left",
)
# Mock TextEditDialog - patch where it's imported
with patch("pyPhotoAlbum.text_edit_dialog.TextEditDialog") as MockDialog:
# Create mock instance
mock_instance = Mock()
MockDialog.return_value = mock_instance
# Mock DialogCode.Accepted (needed for comparison in code)
mock_dialog_code = Mock()
mock_dialog_code.Accepted = 1
MockDialog.DialogCode = mock_dialog_code
# Set up the dialog to return Accepted (1)
mock_instance.exec.return_value = 1
mock_instance.get_values.return_value = {
"text_content": "Updated",
"font_settings": {"family": "Helvetica", "size": 14, "color": (0, 0, 0)},
"alignment": "center",
}
widget._edit_text_element(text_element)
# Verify dialog was created and methods called
MockDialog.assert_called_once_with(text_element, widget)
mock_instance.exec.assert_called_once()
mock_instance.get_values.assert_called_once()
# Should update element
assert text_element.text_content == "Updated"
assert text_element.font_settings["family"] == "Helvetica"
assert text_element.alignment == "center"
assert widget.update.called
def test_edit_text_element_rejected(self, qtbot):
"""Test editing text element when dialog is rejected"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create text element
text_element = TextBoxData(
text_content="Original",
x=100,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
alignment="left",
)
original_content = text_element.text_content
# Mock TextEditDialog
with patch("pyPhotoAlbum.text_edit_dialog.TextEditDialog") as MockDialog:
mock_dialog = MockDialog.return_value
mock_dialog.exec = Mock(return_value=0) # Rejected
widget._edit_text_element(text_element)
# Should not update element
assert text_element.text_content == original_content
assert not widget.update.called

View File

@ -0,0 +1,655 @@
"""
Extended tests for PageLayout to improve coverage
Tests rendering logic, snapping, and edge cases
"""
import pytest
from unittest.mock import Mock, MagicMock, patch, call
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.snapping import SnappingSystem
class TestPageLayoutRendering:
"""Test PageLayout rendering methods"""
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_basic_empty_page(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering empty page calls OpenGL functions correctly"""
layout = PageLayout(width=210, height=297)
# Render with default DPI
layout.render(dpi=300)
# Verify OpenGL depth test was disabled then re-enabled
assert mock_disable.call_count >= 1
assert mock_enable.call_count >= 1
# Verify colors were set (shadow, background, border)
assert mock_color3f.call_count >= 3
# Verify vertices were drawn (quads for shadow, background, line loop for border)
assert mock_vertex.call_count >= 8
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_facing_page_draws_center_line(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering facing page draws center divider line"""
layout = PageLayout(width=210, height=297, is_facing_page=True)
layout.render(dpi=300)
# Verify line width was set for center line
assert any(call(1.5) in mock_linewidth.call_args_list for call in mock_linewidth.call_args_list)
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_with_elements_calls_element_render(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering page with elements calls render on each element"""
layout = PageLayout()
# Add elements with mock render methods
elem1 = ImageData(image_path="test1.jpg", x=10, y=20)
elem2 = PlaceholderData(x=30, y=40)
elem3 = TextBoxData(text_content="Hello", x=50, y=60)
elem1.render = Mock()
elem2.render = Mock()
elem3.render = Mock()
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
layout.render(dpi=300)
# Verify each element's render was called
elem1.render.assert_called_once()
elem2.render.assert_called_once()
elem3.render.assert_called_once()
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_with_image_async_loading_requested(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering image without texture triggers async load request"""
layout = PageLayout()
# Create mock parent widget with async loader
mock_parent = Mock()
mock_parent.async_image_loader = Mock()
mock_parent.request_image_load = Mock()
layout._parent_widget = mock_parent
# Add image element without texture
elem = ImageData(image_path="test.jpg", x=10, y=20)
elem.render = Mock()
elem._async_load_requested = False
layout.add_element(elem)
layout.render(dpi=300)
# Verify async load was requested
assert elem._async_load_requested is True
assert elem._async_loading is True
mock_parent.request_image_load.assert_called_once()
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_with_different_dpi_scales_correctly(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering with different DPI values"""
layout = PageLayout(width=210, height=297)
# Render with different DPIs
layout.render(dpi=72)
layout.render(dpi=150)
layout.render(dpi=600)
# Should not raise any errors
assert mock_vertex.call_count > 0
class TestPageLayoutSnapLines:
"""Test snap line rendering"""
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_snap_lines_with_project_settings(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test snap lines use project settings when available"""
layout = PageLayout()
# Create mock project with global snapping settings
mock_project = Mock()
mock_project.snap_to_grid = True
mock_project.snap_to_edges = True
mock_project.snap_to_guides = True
mock_project.grid_size_mm = 10.0
mock_project.snap_threshold_mm = 5.0
mock_project.show_grid = True
mock_project.show_snap_lines = True
layout.render(dpi=300, project=mock_project)
# Verify blending was enabled for transparent lines
# Check that glEnable was called (for GL_BLEND)
assert mock_enable.call_count > 0
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_snap_lines_fallback_to_local_settings(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test snap lines fall back to local settings when no project"""
layout = PageLayout()
layout.snapping_system.snap_to_grid = True
layout.snapping_system.grid_size_mm = 15.0
# Render without project
layout.render(dpi=300, project=None)
# Should complete without error
assert mock_vertex.call_count > 0
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_snap_lines_with_guides(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering snap lines with custom guides"""
layout = PageLayout()
# Add custom guides using the proper method (position, orientation)
layout.snapping_system.add_guide(100, "vertical")
layout.snapping_system.add_guide(150, "horizontal")
mock_project = Mock()
mock_project.snap_to_grid = False
mock_project.snap_to_edges = False
mock_project.snap_to_guides = True
mock_project.grid_size_mm = 10.0
mock_project.snap_threshold_mm = 5.0
mock_project.show_grid = False
mock_project.show_snap_lines = True
layout.render(dpi=300, project=mock_project)
# Cyan color should be used for guides (0.0, 0.7, 0.9, 0.8)
# Check if cyan color was set (at least once)
assert mock_color4f.call_count > 0
class TestPageLayoutEdgeCases:
"""Test edge cases and error conditions"""
def test_page_layout_with_zero_dimensions(self):
"""Test page layout with very small dimensions"""
layout = PageLayout(width=0.1, height=0.1)
assert layout.size == (0.1, 0.1)
def test_page_layout_with_large_dimensions(self):
"""Test page layout with very large dimensions"""
layout = PageLayout(width=10000, height=10000)
assert layout.size == (10000, 10000)
def test_multiple_grid_layout_changes(self):
"""Test changing grid layout multiple times"""
layout = PageLayout()
grid1 = GridLayout(rows=2, columns=2)
grid2 = GridLayout(rows=3, columns=3)
grid3 = GridLayout(rows=4, columns=4)
layout.set_grid_layout(grid1)
assert layout.grid_layout == grid1
layout.set_grid_layout(grid2)
assert layout.grid_layout == grid2
layout.set_grid_layout(grid3)
assert layout.grid_layout == grid3
def test_deserialize_with_missing_snapping_system(self):
"""Test deserialization handles missing snapping_system gracefully"""
layout = PageLayout()
data = {
"size": (210, 297),
"elements": [],
# No snapping_system key
}
layout.deserialize(data)
# Should use existing snapping system
assert layout.snapping_system is not None
def test_deserialize_element_with_invalid_crop_info(self):
"""Test deserialization handles elements with missing optional fields"""
layout = PageLayout()
data = {
"size": (210, 297),
"elements": [
{
"type": "image",
"image_path": "test.jpg",
"position": (10, 20),
"size": (100, 100),
"rotation": 0,
"z_index": 0,
# Missing crop_info - should use default
}
],
}
layout.deserialize(data)
assert len(layout.elements) == 1
assert isinstance(layout.elements[0], ImageData)
def test_element_z_index_handling(self):
"""Test that elements maintain correct z-order"""
layout = PageLayout()
elem1 = ImageData(image_path="test1.jpg", x=10, y=20)
elem2 = ImageData(image_path="test2.jpg", x=30, y=40)
elem3 = ImageData(image_path="test3.jpg", x=50, y=60)
elem1.z_index = 10
elem2.z_index = 5
elem3.z_index = 15
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Elements added in order, z_index is just metadata
assert layout.elements[0] == elem1
assert layout.elements[1] == elem2
assert layout.elements[2] == elem3
class TestGridLayoutEdgeCases:
"""Test GridLayout edge cases"""
def test_grid_with_single_cell(self):
"""Test grid layout with single cell (1x1)"""
grid = GridLayout(rows=1, columns=1, spacing=5.0)
pos = grid.get_cell_position(0, 0, page_width=100, page_height=100)
size = grid.get_cell_size(page_width=100, page_height=100)
assert pos == (5.0, 5.0)
# (100 - 10) / 1 = 90
assert size == (90.0, 90.0)
def test_grid_with_no_spacing(self):
"""Test grid layout with zero spacing"""
grid = GridLayout(rows=2, columns=2, spacing=0.0)
pos = grid.get_cell_position(0, 0, page_width=100, page_height=100)
size = grid.get_cell_size(page_width=100, page_height=100)
assert pos == (0.0, 0.0)
assert size == (50.0, 50.0)
def test_grid_with_large_spacing(self):
"""Test grid layout with large spacing"""
grid = GridLayout(rows=2, columns=2, spacing=20.0)
size = grid.get_cell_size(page_width=200, page_height=200)
# (200 - 60) / 2 = 70
assert size == (70.0, 70.0)
def test_grid_merge_multiple_cells(self):
"""Test merging many cells"""
grid = GridLayout(rows=5, columns=5)
for row in range(3):
for col in range(3):
grid.merge_cells(row, col)
assert len(grid.merged_cells) == 9
def test_grid_cell_position_at_boundaries(self):
"""Test cell positions at grid boundaries"""
grid = GridLayout(rows=3, columns=3, spacing=10.0)
# Top-left corner
pos_tl = grid.get_cell_position(0, 0, page_width=300, page_height=300)
assert pos_tl[0] == 10.0
assert pos_tl[1] == 10.0
# Bottom-right corner (2, 2)
pos_br = grid.get_cell_position(2, 2, page_width=300, page_height=300)
# (300 - 40) / 3 = 86.67, x = 10 + 2 * (86.67 + 10) = 203.33
assert pos_br[0] == pytest.approx(203.333, rel=0.01)
assert pos_br[1] == pytest.approx(203.333, rel=0.01)
class TestPageLayoutSnappingIntegration:
"""Test integration with snapping system"""
def test_snapping_system_initialized(self):
"""Test that PageLayout initializes with snapping system"""
layout = PageLayout()
assert isinstance(layout.snapping_system, SnappingSystem)
def test_snapping_system_serialization(self):
"""Test that snapping system is included in serialization"""
layout = PageLayout()
layout.snapping_system.snap_to_grid = True
layout.snapping_system.grid_size_mm = 20.0
data = layout.serialize()
assert "snapping_system" in data
assert data["snapping_system"]["snap_to_grid"] is True
assert data["snapping_system"]["grid_size_mm"] == 20.0
def test_snapping_system_deserialization(self):
"""Test that snapping system is restored from serialization"""
layout = PageLayout()
# First create a layout with snapping settings
layout.snapping_system.snap_to_grid = True
layout.snapping_system.snap_to_edges = False
layout.snapping_system.snap_to_guides = True
layout.snapping_system.grid_size_mm = 25.0
layout.snapping_system.snap_threshold_mm = 3.0
layout.snapping_system.add_guide(100, "vertical")
layout.snapping_system.add_guide(150, "horizontal")
# Serialize and deserialize
data = layout.serialize()
restored = PageLayout()
restored.deserialize(data)
assert restored.snapping_system.snap_to_grid is True
assert restored.snapping_system.snap_to_edges is False
assert restored.snapping_system.snap_to_guides is True
assert restored.snapping_system.grid_size_mm == 25.0
assert restored.snapping_system.snap_threshold_mm == 3.0
assert len(restored.snapping_system.guides) == 2
class TestPageLayoutBackwardCompatibility:
"""Test backward compatibility with older data formats"""
def test_deserialize_without_base_width(self):
"""Test deserializing data without base_width field"""
layout = PageLayout()
data = {
"size": (200, 280),
# No base_width field
"elements": [],
}
layout.deserialize(data)
# Should default to width from size
assert layout.base_width == 200
def test_deserialize_without_is_facing_page(self):
"""Test deserializing data without is_facing_page field"""
layout = PageLayout()
data = {
"size": (210, 297),
# No is_facing_page field
"elements": [],
}
layout.deserialize(data)
# Should default to False
assert layout.is_facing_page is False
def test_deserialize_without_show_snap_lines(self):
"""Test deserializing data without show_snap_lines field"""
layout = PageLayout()
data = {
"size": (210, 297),
"elements": [],
# No show_snap_lines field
}
layout.deserialize(data)
# Should default to True
assert layout.show_snap_lines is True
class TestPageLayoutComplexScenarios:
"""Test complex real-world scenarios"""
def test_layout_with_many_elements(self):
"""Test layout with large number of elements"""
layout = PageLayout()
# Add 100 elements
for i in range(100):
elem = ImageData(image_path=f"test{i}.jpg", x=i * 10, y=i * 10)
elem.z_index = i
layout.add_element(elem)
assert len(layout.elements) == 100
# Serialize and deserialize
data = layout.serialize()
restored = PageLayout()
restored.deserialize(data)
assert len(restored.elements) == 100
# Elements should be sorted by z_index
for i in range(100):
assert restored.elements[i].z_index == i
def test_layout_with_mixed_element_types(self):
"""Test layout with all element types mixed"""
layout = PageLayout()
for i in range(10):
if i % 3 == 0:
elem = ImageData(image_path=f"img{i}.jpg", x=i * 20, y=i * 20)
elif i % 3 == 1:
elem = PlaceholderData(x=i * 20, y=i * 20)
else:
elem = TextBoxData(text_content=f"Text {i}", x=i * 20, y=i * 20)
elem.z_index = i
layout.add_element(elem)
data = layout.serialize()
restored = PageLayout()
restored.deserialize(data)
# Count element types
images = sum(1 for e in restored.elements if isinstance(e, ImageData))
placeholders = sum(1 for e in restored.elements if isinstance(e, PlaceholderData))
textboxes = sum(1 for e in restored.elements if isinstance(e, TextBoxData))
assert images == 4 # 0, 3, 6, 9
assert placeholders == 3 # 1, 4, 7
assert textboxes == 3 # 2, 5, 8
@patch("pyPhotoAlbum.page_layout.glDisable")
@patch("pyPhotoAlbum.page_layout.glEnable")
@patch("pyPhotoAlbum.page_layout.glColor3f")
@patch("pyPhotoAlbum.page_layout.glColor4f")
@patch("pyPhotoAlbum.page_layout.glBegin")
@patch("pyPhotoAlbum.page_layout.glEnd")
@patch("pyPhotoAlbum.page_layout.glVertex2f")
@patch("pyPhotoAlbum.page_layout.glLineWidth")
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
def test_render_facing_page_with_elements(
self,
mock_blend,
mock_linewidth,
mock_vertex,
mock_end,
mock_begin,
mock_color4f,
mock_color3f,
mock_enable,
mock_disable,
):
"""Test rendering facing page with elements"""
layout = PageLayout(width=210, height=297, is_facing_page=True)
# Add elements on both sides
left_elem = ImageData(image_path="left.jpg", x=50, y=100)
right_elem = ImageData(image_path="right.jpg", x=250, y=100)
left_elem.render = Mock()
right_elem.render = Mock()
layout.add_element(left_elem)
layout.add_element(right_elem)
layout.render(dpi=300)
# Both elements should be rendered
left_elem.render.assert_called_once()
right_elem.render.assert_called_once()
# Center line should be drawn
assert any(call(1.5) in mock_linewidth.call_args_list for call in mock_linewidth.call_args_list)

View File

@ -0,0 +1,672 @@
"""
Tests for RenderingMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtWidgets import QApplication
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from PyQt6.QtCore import Qt
from pyPhotoAlbum.mixins.rendering import RenderingMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData, TextBoxData
# Create a minimal test widget class that combines necessary mixins
class TestRenderingWidget(ViewportMixin, RenderingMixin, QOpenGLWidget):
"""Test widget combining rendering and viewport mixins with QOpenGLWidget"""
def __init__(self):
super().__init__()
# Initialize attributes needed by rendering
self.selected_elements = []
self.rotation_mode = False
def _get_page_positions(self):
"""Mock method to return page positions"""
main_window = self.window()
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
return []
positions = []
y_offset = 50 # PAGE_MARGIN
for page in main_window.project.pages:
positions.append(("page", page, y_offset))
# Calculate page height
page_width_mm, page_height_mm = page.layout.size
dpi = main_window.project.working_dpi
page_height_px = page_height_mm * dpi / 25.4
y_offset += page_height_px + 50 # PAGE_SPACING
return positions
class TestRenderingInitialization:
"""Test rendering mixin setup"""
def test_rendering_mixin_exists(self, qtbot):
"""Test that rendering mixin can be instantiated"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
assert hasattr(widget, "paintGL")
assert callable(widget.paintGL)
assert hasattr(widget, "_draw_selection_handles")
assert callable(widget._draw_selection_handles)
class TestPaintGL:
"""Test paintGL method"""
def test_paintGL_no_project(self, qtbot):
"""Test paintGL with no project does nothing"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window without project
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
# Should not raise exception
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
widget.paintGL()
def test_paintGL_empty_project(self, qtbot):
"""Test paintGL with empty project does nothing"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window with empty project
mock_window = Mock()
mock_window.project = Project(name="Empty")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
# Should not raise exception
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
widget.paintGL()
def test_paintGL_sets_initial_zoom(self, qtbot):
"""Test paintGL sets initial zoom on first render"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.update_scrollbars = Mock()
# A4 page
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Ensure initial_zoom_set is False
widget.initial_zoom_set = False
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
with patch("pyPhotoAlbum.page_renderer.PageRenderer"):
widget.paintGL()
# Should have set initial zoom
assert widget.initial_zoom_set is True
assert widget.zoom_level > 0
assert widget.zoom_level <= 1.0
def test_paintGL_renders_page(self, qtbot):
"""Test paintGL renders a page"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.update_scrollbars = Mock()
# A4 page
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
widget.initial_zoom_set = True
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Mock PageRenderer
mock_renderer = Mock()
mock_renderer.begin_render = Mock()
mock_renderer.end_render = Mock()
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
with patch("pyPhotoAlbum.page_renderer.PageRenderer", return_value=mock_renderer):
widget.paintGL()
# Should have created page renderers
assert hasattr(widget, "_page_renderers")
assert len(widget._page_renderers) == 1
# Should have called renderer methods
mock_renderer.begin_render.assert_called_once()
mock_renderer.end_render.assert_called_once()
def test_paintGL_renders_multiple_pages(self, qtbot):
"""Test paintGL renders multiple pages"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.update_scrollbars = Mock()
# Create 3 A4 pages
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=i)
for i in range(1, 4)
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.initial_zoom_set = True
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Mock PageRenderer
mock_renderer = Mock()
mock_renderer.begin_render = Mock()
mock_renderer.end_render = Mock()
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
with patch("pyPhotoAlbum.page_renderer.PageRenderer", return_value=mock_renderer):
widget.paintGL()
# Should have created page renderers for all pages
assert hasattr(widget, "_page_renderers")
assert len(widget._page_renderers) == 3
def test_paintGL_updates_selected_elements_renderer(self, qtbot):
"""Test paintGL updates page renderer references for selected elements"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.update_scrollbars = Mock()
# A4 page
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Create a mock element
mock_element = Mock()
mock_element._parent_page = page
widget.selected_elements = [mock_element]
widget.window = Mock(return_value=mock_window)
widget.initial_zoom_set = True
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Mock PageRenderer
mock_renderer = Mock()
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
with patch("pyPhotoAlbum.page_renderer.PageRenderer", return_value=mock_renderer):
with patch.object(widget, "_draw_selection_handles"):
widget.paintGL()
# Element should have renderer reference
assert hasattr(mock_element, "_page_renderer")
assert mock_element._page_renderer == mock_renderer
class TestDrawSelectionHandles:
"""Test _draw_selection_handles method"""
def test_draw_selection_handles_no_element(self, qtbot):
"""Test drawing handles with no element does nothing"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
# Should not raise exception
widget._draw_selection_handles(None)
def test_draw_selection_handles_no_project(self, qtbot):
"""Test drawing handles with no project does nothing"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
# Mock window without project
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
mock_element = Mock()
mock_element._page_renderer = Mock()
# Should not raise exception
widget._draw_selection_handles(mock_element)
def test_draw_selection_handles_no_renderer(self, qtbot):
"""Test drawing handles without renderer does nothing"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Element without renderer
mock_element = Mock(spec=[]) # No _page_renderer attribute
# Should not raise exception
widget._draw_selection_handles(mock_element)
def test_draw_selection_handles_normal_mode(self, qtbot):
"""Test drawing handles in normal (non-rotation) mode"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.rotation_mode = False
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Create element with renderer
mock_element = Mock()
mock_element.position = (100, 100)
mock_element.size = (200, 150)
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(150, 150))
mock_renderer.zoom = 1.0
mock_element._page_renderer = mock_renderer
# Mock OpenGL calls
with patch("pyPhotoAlbum.mixins.rendering.glColor3f"):
with patch("pyPhotoAlbum.mixins.rendering.glLineWidth"):
with patch("pyPhotoAlbum.mixins.rendering.glBegin"):
with patch("pyPhotoAlbum.mixins.rendering.glVertex2f"):
with patch("pyPhotoAlbum.mixins.rendering.glEnd"):
widget._draw_selection_handles(mock_element)
# Should have converted position
mock_renderer.page_to_screen.assert_called_once_with(100, 100)
def test_draw_selection_handles_rotation_mode(self, qtbot):
"""Test drawing handles in rotation mode"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.rotation_mode = True
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Create element with renderer
mock_element = Mock()
mock_element.position = (100, 100)
mock_element.size = (200, 150)
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(150, 150))
mock_renderer.zoom = 1.0
mock_element._page_renderer = mock_renderer
# Mock OpenGL calls
with patch("pyPhotoAlbum.mixins.rendering.glColor3f"):
with patch("pyPhotoAlbum.mixins.rendering.glLineWidth"):
with patch("pyPhotoAlbum.mixins.rendering.glBegin"):
with patch("pyPhotoAlbum.mixins.rendering.glVertex2f"):
with patch("pyPhotoAlbum.mixins.rendering.glEnd"):
widget._draw_selection_handles(mock_element)
# Should have converted position
mock_renderer.page_to_screen.assert_called_once_with(100, 100)
class TestRenderTextOverlays:
"""Test _render_text_overlays method"""
def test_render_text_overlays_no_renderers(self, qtbot):
"""Test rendering text overlays with no page renderers"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
# Should not raise exception
widget._render_text_overlays()
def test_render_text_overlays_empty_renderers(self, qtbot):
"""Test rendering text overlays with empty renderers list"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget._page_renderers = []
# Should not raise exception
widget._render_text_overlays()
def test_render_text_overlays_no_text_elements(self, qtbot):
"""Test rendering text overlays with no text elements"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock page with no text elements
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.elements = []
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
widget._page_renderers = [(mock_renderer, page)]
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_text_overlays()
# Painter should have been created and ended
mock_painter_class.assert_called_once_with(widget)
mock_painter.end.assert_called_once()
def test_render_text_overlays_with_text_element(self, qtbot):
"""Test rendering text overlays with text element"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Create text element
text_element = TextBoxData(
x=50, y=50, width=200, height=100,
text_content="Test Text",
font_settings={
"family": "Arial",
"size": 12,
"color": (0, 0, 0)
},
alignment="left"
)
# Mock page with text element
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.elements = [text_element]
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
widget._page_renderers = [(mock_renderer, page)]
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_text_overlays()
# Painter should have been used
mock_painter_class.assert_called_once_with(widget)
mock_painter.setFont.assert_called()
mock_painter.setPen.assert_called()
mock_painter.drawText.assert_called()
mock_painter.end.assert_called_once()
def test_render_text_overlays_with_rotated_text(self, qtbot):
"""Test rendering text overlays with rotated text"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Create rotated text element
text_element = TextBoxData(
x=50, y=50, width=200, height=100, rotation=45,
text_content="Rotated Text",
font_settings={
"family": "Arial",
"size": 14,
"color": (0, 0, 0)
},
alignment="center"
)
# Mock page with text element
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.elements = [text_element]
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
widget._page_renderers = [(mock_renderer, page)]
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_text_overlays()
# Painter should have used save/restore for rotation
mock_painter.save.assert_called()
mock_painter.rotate.assert_called_with(45)
mock_painter.restore.assert_called()
def test_render_text_overlays_different_alignments(self, qtbot):
"""Test rendering text with different alignments"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
for alignment in ["left", "center", "right"]:
# Create text element with alignment
text_element = TextBoxData(
x=50, y=50, width=200, height=100,
text_content=f"{alignment} aligned",
font_settings={
"family": "Arial",
"size": 12,
"color": (0, 0, 0)
},
alignment=alignment
)
# Mock page with text element
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.elements = [text_element]
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
widget._page_renderers = [(mock_renderer, page)]
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_text_overlays()
# Should have drawn text
mock_painter.drawText.assert_called()
def test_render_text_overlays_normalized_color(self, qtbot):
"""Test rendering text with normalized color values (0-1 range)"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Create text element with normalized colors
text_element = TextBoxData(
x=50, y=50, width=200, height=100,
text_content="Normalized Color",
font_settings={
"family": "Arial",
"size": 12,
"color": (0.5, 0.5, 0.5) # Normalized (0-1 range)
},
alignment="left"
)
# Mock page with text element
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.elements = [text_element]
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
widget._page_renderers = [(mock_renderer, page)]
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_text_overlays()
# Should have converted normalized colors to 0-255 range
mock_painter.setPen.assert_called()
def test_render_text_overlays_empty_text(self, qtbot):
"""Test rendering text element with no content"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Create text element with no content
text_element = TextBoxData(
x=50, y=50, width=200, height=100,
text_content="", # Empty
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
alignment="left"
)
# Mock page with text element
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.elements = [text_element]
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
widget._page_renderers = [(mock_renderer, page)]
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_text_overlays()
# Should not draw empty text
mock_painter.drawText.assert_not_called()
class TestRenderGhostPage:
"""Test _render_ghost_page method"""
def test_render_ghost_page(self, qtbot):
"""Test rendering a ghost page"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Create mock ghost data
ghost_data = Mock()
ghost_data.page_size = (210, 297)
ghost_data.render = Mock()
ghost_data.get_page_rect = Mock(return_value=(0, 0, 794, 1123))
# Create mock renderer
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
mock_renderer.begin_render = Mock()
mock_renderer.end_render = Mock()
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_ghost_page(ghost_data, mock_renderer)
# Should have called renderer methods
mock_renderer.begin_render.assert_called_once()
ghost_data.render.assert_called_once()
mock_renderer.end_render.assert_called_once()
# Should have drawn text overlay
mock_painter.drawText.assert_called()
# Check that "Click to Add Page" was drawn
call_args = mock_painter.drawText.call_args
assert "Click to Add Page" in str(call_args)
def test_render_ghost_page_painter_cleanup(self, qtbot):
"""Test that ghost page rendering cleans up QPainter"""
widget = TestRenderingWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Create mock ghost data
ghost_data = Mock()
ghost_data.page_size = (210, 297)
ghost_data.render = Mock()
ghost_data.get_page_rect = Mock(return_value=(0, 0, 794, 1123))
# Create mock renderer
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
mock_renderer.zoom = 1.0
mock_renderer.begin_render = Mock()
mock_renderer.end_render = Mock()
# Mock QPainter
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
mock_painter = Mock()
mock_painter_class.return_value = mock_painter
widget._render_ghost_page(ghost_data, mock_renderer)
# Painter should be ended (cleanup)
mock_painter.end.assert_called_once()

View File

@ -0,0 +1,459 @@
"""
Tests for TemplateOperationsMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtWidgets import QMainWindow, QDialog
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations.template_ops import TemplateOperationsMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.commands import CommandHistory
class TestTemplateOpsWindow(TemplateOperationsMixin, ApplicationStateMixin, QMainWindow):
"""Test window with template operations mixin"""
def __init__(self):
super().__init__()
self._gl_widget = Mock()
self._gl_widget.current_page_index = 0
self._gl_widget.zoom_level = 1.0
self._gl_widget.pan_offset = [0, 0]
self._gl_widget._page_renderers = []
self._gl_widget.width = Mock(return_value=800)
self._gl_widget.height = Mock(return_value=600)
self._project = Project(name="Test")
self._project.page_size_mm = (210, 297)
self._project.working_dpi = 96
self._project.history = CommandHistory()
self._template_manager = Mock()
self._update_view_called = False
self._status_message = None
self._info_title = None
self._info_message = None
self._warning_title = None
self._warning_message = None
self._error_title = None
self._error_message = None
def update_view(self):
self._update_view_called = True
def show_status(self, message, timeout=0):
self._status_message = message
def show_info(self, title, message):
self._info_title = title
self._info_message = message
def show_warning(self, title, message):
self._warning_title = title
self._warning_message = message
def show_error(self, title, message):
self._error_title = title
self._error_message = message
class TestSavePageAsTemplate:
"""Test save_page_as_template method"""
def test_save_template_no_current_page(self, qtbot):
"""Test returns early when no current page"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
window.project.pages = []
window.save_page_as_template()
# Should return early without showing dialogs
assert not window._update_view_called
def test_save_template_empty_page(self, qtbot):
"""Test shows warning when page has no elements"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create empty page
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.save_page_as_template()
# Should show warning about empty page
assert window._warning_title == "Empty Page"
assert "Cannot save an empty page" in window._warning_message
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
def test_save_template_user_cancels_name(self, mock_get_text, qtbot):
"""Test returns when user cancels name dialog"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create page with elements
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = []
# Mock user canceling the name dialog
mock_get_text.return_value = ("", False)
window.save_page_as_template()
# Should return without saving
assert not window.template_manager.save_template.called
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
def test_save_template_user_cancels_description(self, mock_get_text, qtbot):
"""Test continues with empty description when user cancels description dialog"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create page with elements
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = []
# Mock template creation
mock_template = Mock()
window.template_manager.create_template_from_page.return_value = mock_template
# Mock dialogs: name OK, description canceled
mock_get_text.side_effect = [
("My Template", True), # Name dialog
("Some description", False) # Description dialog canceled
]
window.save_page_as_template()
# Should still save with empty description
window.template_manager.create_template_from_page.assert_called_once()
call_args = window.template_manager.create_template_from_page.call_args
assert call_args[0][0] == page
assert call_args[0][1] == "My Template"
assert call_args[0][2] == "" # Empty description
window.template_manager.save_template.assert_called_once_with(mock_template)
assert window._info_title == "Template Saved"
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
def test_save_template_success(self, mock_get_text, qtbot):
"""Test successfully saving a template"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create page with elements
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = ["Template_1", "Template_2"]
# Mock template creation
mock_template = Mock()
window.template_manager.create_template_from_page.return_value = mock_template
# Mock dialogs
mock_get_text.side_effect = [
("My Template", True), # Name dialog
("A description", True) # Description dialog
]
window.save_page_as_template()
# Verify template creation with correct parameters
window.template_manager.create_template_from_page.assert_called_once_with(
page, "My Template", "A description"
)
# Verify template was saved
window.template_manager.save_template.assert_called_once_with(mock_template)
# Verify success message
assert window._info_title == "Template Saved"
assert "My Template" in window._info_message
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
def test_save_template_default_name_numbering(self, mock_get_text, qtbot):
"""Test default template name includes correct numbering"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create page with elements
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
# Mock 3 existing templates
window.template_manager.list_templates.return_value = ["Template_1", "Template_2", "Template_3"]
mock_get_text.return_value = ("", False) # User cancels
window.save_page_as_template()
# Check that the default name offered was "Template_4"
call_args = mock_get_text.call_args
assert call_args[1]["text"] == "Template_4"
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
def test_save_template_exception_handling(self, mock_get_text, qtbot):
"""Test handles exceptions during template save"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create page with elements
layout = PageLayout(width=210, height=297)
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = []
# Mock dialogs
mock_get_text.side_effect = [
("My Template", True),
("Description", True)
]
# Mock template creation to raise exception
window.template_manager.create_template_from_page.side_effect = Exception("Template error")
window.save_page_as_template()
# Should show error message
assert window._error_title == "Error"
assert "Failed to save template" in window._error_message
assert "Template error" in window._error_message
class TestNewPageFromTemplate:
"""Test new_page_from_template method"""
def test_new_page_no_templates(self, qtbot):
"""Test shows info when no templates available"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
window.template_manager.list_templates.return_value = []
window.new_page_from_template()
assert window._info_title == "No Templates"
assert "No templates available" in window._info_message
def test_new_page_user_cancels_dialog(self, qtbot):
"""Test returns when user cancels dialog"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
window.template_manager.list_templates.return_value = ["Template_1"]
# Patch QDialog.exec to return rejected
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
window.new_page_from_template()
# Should not create page
assert not window.template_manager.load_template.called
def _mock_dialog_exec(self, template_name="Template_1", scale_id=1, margin=2.5):
"""Helper to mock dialog exec with specific values"""
original_exec = QDialog.exec
def mock_exec(self):
# Find the widgets that were added to the dialog
combo = self.findChild(Mock.__class__.__bases__[0], "") # This won't work, need different approach
# Set values on widgets before returning
return QDialog.DialogCode.Accepted
return mock_exec
def test_new_page_stretch_mode(self, qtbot):
"""Test creates page with stretch scaling mode"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
window.template_manager.list_templates.return_value = ["Template_1"]
# Create initial page
layout = PageLayout(width=210, height=297)
page1 = Page(layout=layout, page_number=1)
window.project.pages = [page1]
# Mock template
mock_template = Mock()
window.template_manager.load_template.return_value = mock_template
# Mock new page
mock_new_page = Mock()
window.template_manager.create_page_from_template.return_value = mock_new_page
# Capture the dialog and manipulate it
captured_dialog = None
original_exec = QDialog.exec
def mock_exec(dialog_self):
nonlocal captured_dialog
captured_dialog = dialog_self
# Find and set widget values
for child in dialog_self.children():
# Template combo - first one should be selected by default (index 0)
if hasattr(child, 'currentText'):
pass # Already set to first item
# Button groups - stretch radio should already be checked (it's default)
# Margin spinbox - already set to 2.5 (default)
return QDialog.DialogCode.Accepted
with patch.object(QDialog, "exec", mock_exec):
window.new_page_from_template()
# Verify template was loaded
window.template_manager.load_template.assert_called_once_with("Template_1")
# Verify page creation with correct parameters (defaults)
window.template_manager.create_page_from_template.assert_called_once()
call_args = window.template_manager.create_page_from_template.call_args
assert call_args[1]["page_number"] == 2
assert call_args[1]["target_size_mm"] == (210, 297)
assert call_args[1]["scale_mode"] == "stretch" # Default checked
assert call_args[1]["margin_percent"] == 2.5 # Default value
# Verify page was added to project
assert len(window.project.pages) == 2
assert window._update_view_called
def test_new_page_exception_handling(self, qtbot):
"""Test handles exceptions during page creation"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
window.template_manager.list_templates.return_value = ["Template_1"]
window.project.pages = []
# Mock template loading to raise exception
window.template_manager.load_template.side_effect = Exception("Template load error")
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
window.new_page_from_template()
# Should show error message
assert window._error_title == "Error"
assert "Failed to create page from template" in window._error_message
assert "Template load error" in window._error_message
class TestApplyTemplateToPage:
"""Test apply_template_to_page method"""
def test_apply_template_no_current_page(self, qtbot):
"""Test returns early when no current page"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
window.project.pages = []
window.apply_template_to_page()
# Should return early without showing dialogs
assert not window._update_view_called
def test_apply_template_no_templates(self, qtbot):
"""Test shows info when no templates available"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
# Create page
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = []
window.apply_template_to_page()
assert window._info_title == "No Templates"
assert "No templates available" in window._info_message
def test_apply_template_user_cancels_dialog(self, qtbot):
"""Test returns when user cancels dialog"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = ["Template_1"]
# Mock dialog rejection
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
window.apply_template_to_page()
# Should not apply template
assert not window.template_manager.load_template.called
assert not window._update_view_called
def test_apply_template_replace_mode(self, qtbot):
"""Test applies template in replace mode"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = ["Template_1"]
# Mock template
mock_template = Mock()
window.template_manager.load_template.return_value = mock_template
# Use default values: replace mode (checked by default), stretch mode (checked by default), margin=2.5
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
window.apply_template_to_page()
# Verify template application with default values
window.template_manager.apply_template_to_page.assert_called_once()
call_args = window.template_manager.apply_template_to_page.call_args
assert call_args[0][0] == mock_template
assert call_args[0][1] == page
assert call_args[1]["mode"] == "replace" # Default
assert call_args[1]["scale_mode"] == "stretch" # Default
assert call_args[1]["margin_percent"] == 2.5 # Default
assert window._update_view_called
assert "Template_1" in window._status_message
def test_apply_template_exception_handling(self, qtbot):
"""Test handles exceptions during template application"""
window = TestTemplateOpsWindow()
qtbot.addWidget(window)
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
window.project.pages = [page]
window.template_manager.list_templates.return_value = ["Template_1"]
# Mock template loading to raise exception
window.template_manager.load_template.side_effect = Exception("Template error")
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
window.apply_template_to_page()
# Should show error message
assert window._error_title == "Error"
assert "Failed to apply template" in window._error_message
assert "Template error" in window._error_message

View File

@ -499,3 +499,476 @@ class TestViewportOpenGL:
assert hasattr(widget, "resizeGL")
assert callable(widget.resizeGL)
class TestContentBounds:
"""Test get_content_bounds method"""
def test_get_content_bounds_no_project(self, qtbot):
"""Test content bounds with no project returns defaults"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window without project
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
bounds = widget.get_content_bounds()
assert bounds["min_x"] == 0
assert bounds["max_x"] == 800
assert bounds["min_y"] == 0
assert bounds["max_y"] == 600
assert bounds["width"] == 800
assert bounds["height"] == 600
def test_get_content_bounds_empty_project(self, qtbot):
"""Test content bounds with empty project returns defaults"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window with empty project
mock_window = Mock()
mock_window.project = Project(name="Empty")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
bounds = widget.get_content_bounds()
assert bounds["min_x"] == 0
assert bounds["max_x"] == 800
assert bounds["min_y"] == 0
assert bounds["max_y"] == 600
def test_get_content_bounds_single_page(self, qtbot):
"""Test content bounds with single A4 page"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project and A4 page
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
bounds = widget.get_content_bounds()
# Should include page dimensions plus margins and spacing
# A4 at 96 DPI: width=794px, height=1123px
# Total width = 794 + (2 * 50 margin) = 894
# Total height = 1123 + (50 margin top) + (50 margin bottom) + (50 spacing) = 1273
assert bounds["min_x"] == 0
assert bounds["min_y"] == 0
assert 890 < bounds["width"] < 900 # ~894
assert 1260 < bounds["height"] < 1280 # ~1273
def test_get_content_bounds_multiple_pages(self, qtbot):
"""Test content bounds with multiple pages"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project and 3 pages
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Create 3 A4 pages
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=i)
for i in range(1, 4)
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
bounds = widget.get_content_bounds()
# 3 pages vertically: 3 * 1123 + margins + spacings
# Height = 3369 + 50 (top margin) + 50 (bottom margin) + 3*50 (spacings) = 3619
assert bounds["min_x"] == 0
assert bounds["min_y"] == 0
assert 890 < bounds["width"] < 900 # Same width as single page
assert 3600 < bounds["height"] < 3640 # ~3619
def test_get_content_bounds_with_zoom(self, qtbot):
"""Test content bounds respects zoom level"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Test at 50% zoom
widget.zoom_level = 0.5
bounds_50 = widget.get_content_bounds()
# Test at 100% zoom
widget.zoom_level = 1.0
bounds_100 = widget.get_content_bounds()
# Test at 200% zoom
widget.zoom_level = 2.0
bounds_200 = widget.get_content_bounds()
# Bounds should scale with zoom
assert bounds_50["width"] < bounds_100["width"] < bounds_200["width"]
assert bounds_50["height"] < bounds_100["height"] < bounds_200["height"]
def test_get_content_bounds_different_page_sizes(self, qtbot):
"""Test content bounds with pages of different sizes"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Create pages of different sizes
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=1), # A4
Page(layout=PageLayout(width=152.4, height=101.6), page_number=2), # 6x4 photo
Page(layout=PageLayout(width=210, height=297), page_number=3), # A4
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
bounds = widget.get_content_bounds()
# Width should be based on the widest page (A4)
# Height should be sum of all pages
assert bounds["min_x"] == 0
assert bounds["min_y"] == 0
assert 890 < bounds["width"] < 900 # Based on A4 width
assert bounds["height"] > 2000 # Sum of all pages
class TestPanClamping:
"""Test clamp_pan_offset method"""
def test_clamp_pan_offset_no_project(self, qtbot):
"""Test clamping with no project does nothing"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window without project
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
widget.pan_offset = [100, 50]
widget.clamp_pan_offset()
# Should remain unchanged
assert widget.pan_offset == [100, 50]
def test_clamp_pan_offset_empty_project(self, qtbot):
"""Test clamping with empty project does nothing"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window with empty project
mock_window = Mock()
mock_window.project = Project(name="Empty")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
widget.pan_offset = [100, 50]
widget.clamp_pan_offset()
# Should remain unchanged
assert widget.pan_offset == [100, 50]
def test_clamp_pan_offset_vertical_limits_tall_content(self, qtbot):
"""Test vertical clamping when content is taller than viewport"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 600) # Short viewport
# Mock window with project and tall page
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# A4 page at 100% zoom is 1123px tall
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Try to pan way beyond top
widget.pan_offset = [0, 1000]
widget.clamp_pan_offset()
# Should be clamped to top (max_pan_up = 0)
assert widget.pan_offset[1] == 0
# Try to pan way beyond bottom
widget.pan_offset = [0, -2000]
widget.clamp_pan_offset()
# Should be clamped to bottom
# min_pan_up = -(content_height - viewport_height)
assert widget.pan_offset[1] > -2000
assert widget.pan_offset[1] < 0
def test_clamp_pan_offset_vertical_no_clamp_when_fits(self, qtbot):
"""Test vertical clamping preserves position when content fits"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 2000) # Tall viewport
# Mock window with project and small page
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Small page that fits in viewport
page = Page(layout=PageLayout(width=152.4, height=101.6), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Set a specific pan offset
widget.pan_offset = [0, 200]
widget.clamp_pan_offset()
# When content fits, offset should be preserved
assert widget.pan_offset[1] == 200
def test_clamp_pan_offset_horizontal_single_page(self, qtbot):
"""Test horizontal clamping for single page"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# A4 page
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Try to pan too far left
widget.pan_offset = [1000, 0]
original_x = widget.pan_offset[0]
widget.clamp_pan_offset()
# Should be clamped
assert widget.pan_offset[0] < original_x
# Try to pan too far right
widget.pan_offset = [-1000, 0]
original_x = widget.pan_offset[0]
widget.clamp_pan_offset()
# Should be clamped
assert widget.pan_offset[0] > original_x
def test_clamp_pan_offset_horizontal_multiple_pages(self, qtbot):
"""Test horizontal clamping with multiple pages"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Create multiple pages
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=i)
for i in range(1, 4)
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Set pan offset and clamp
widget.pan_offset = [500, -200]
widget.clamp_pan_offset()
# Should be clamped within reasonable bounds
assert -1000 < widget.pan_offset[0] < 1000
def test_clamp_pan_offset_with_zoom_changes(self, qtbot):
"""Test clamping behavior at different zoom levels"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Test at different zoom levels
for zoom in [0.5, 1.0, 2.0]:
widget.zoom_level = zoom
widget.pan_offset = [500, -500]
widget.clamp_pan_offset()
# Should clamp appropriately for each zoom
assert isinstance(widget.pan_offset[0], (int, float))
assert isinstance(widget.pan_offset[1], (int, float))
def test_clamp_pan_offset_preserves_original_pan_y(self, qtbot):
"""Test that clamping uses original pan_y for page selection"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 600)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Multiple pages
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=i)
for i in range(1, 3)
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Set pan offset that will be clamped vertically
widget.pan_offset = [0, -800]
original_y = widget.pan_offset[1]
widget.clamp_pan_offset()
# Horizontal clamping should have used original Y for page selection
# even if vertical was clamped
assert isinstance(widget.pan_offset[0], (int, float))
assert isinstance(widget.pan_offset[1], (int, float))
def test_clamp_pan_offset_different_page_widths(self, qtbot):
"""Test horizontal clamping with pages of different widths"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Pages of different widths
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=1), # A4
Page(layout=PageLayout(width=152.4, height=101.6), page_number=2), # 6x4
Page(layout=PageLayout(width=210, height=297), page_number=3), # A4
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Test clamping while viewing different pages
widget.pan_offset = [200, 0] # Viewing first page
widget.clamp_pan_offset()
first_page_x = widget.pan_offset[0]
widget.pan_offset = [200, -800] # Viewing second page
widget.clamp_pan_offset()
second_page_x = widget.pan_offset[0]
# Both should be clamped appropriately
assert isinstance(first_page_x, (int, float))
assert isinstance(second_page_x, (int, float))
def test_clamp_pan_offset_page_wider_than_viewport(self, qtbot):
"""Test horizontal clamping when page is wider than viewport"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(400, 600) # Narrow viewport
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# A4 page is 794px wide at 96 DPI, wider than 400px viewport
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Should allow panning to see different parts of the page
widget.pan_offset = [100, 0]
widget.clamp_pan_offset()
# Should allow reasonable panning range
assert -500 < widget.pan_offset[0] < 500
def test_clamp_pan_offset_interpolation_between_pages(self, qtbot):
"""Test that horizontal clamping interpolates between page centers"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
# Mock window with project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
# Two pages of different widths
pages = [
Page(layout=PageLayout(width=210, height=297), page_number=1), # Wide
Page(layout=PageLayout(width=100, height=150), page_number=2), # Narrow
]
mock_window.project.pages = pages
widget.window = Mock(return_value=mock_window)
widget.zoom_level = 1.0
# Test clamping at position between pages
# Position viewport center between the two pages
widget.pan_offset = [0, -700] # Between first and second page
widget.clamp_pan_offset()
# Should interpolate horizontal clamping between the two page widths
assert isinstance(widget.pan_offset[0], (int, float))