From b18a780a33146861a1e532e1df8bcae6ca64419c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 28 Nov 2025 19:54:41 +0100 Subject: [PATCH] increase test coverage --- pyPhotoAlbum/async_backend.py | 6 +- tests/test_asset_heal_dialog.py | 596 +++++++++++++++ tests/test_async_backend.py | 824 ++++++++++++++++++++ tests/test_file_ops_mixin.py | 824 ++++++++++++++++++++ tests/test_keyboard_navigation_mixin.py | 803 ++++++++++++++++++++ tests/test_loading_widget.py | 391 ++++++++++ tests/test_merge_dialog.py | 605 +++++++++++++++ tests/test_merge_ops_mixin.py | 547 ++++++++++++++ tests/test_models.py | 965 +++++++++++++++++++++++- tests/test_mouse_interaction_mixin.py | 465 ++++++++++++ tests/test_page_layout_extended.py | 655 ++++++++++++++++ tests/test_rendering_mixin.py | 672 +++++++++++++++++ tests/test_template_ops_mixin.py | 459 +++++++++++ tests/test_viewport_mixin.py | 473 ++++++++++++ 14 files changed, 8281 insertions(+), 4 deletions(-) create mode 100644 tests/test_asset_heal_dialog.py create mode 100644 tests/test_async_backend.py create mode 100644 tests/test_file_ops_mixin.py create mode 100644 tests/test_keyboard_navigation_mixin.py create mode 100644 tests/test_loading_widget.py create mode 100644 tests/test_merge_dialog.py create mode 100644 tests/test_merge_ops_mixin.py create mode 100644 tests/test_page_layout_extended.py create mode 100644 tests/test_rendering_mixin.py create mode 100644 tests/test_template_ops_mixin.py diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py index 9790b4e..0b38d20 100644 --- a/pyPhotoAlbum/async_backend.py +++ b/pyPhotoAlbum/async_backend.py @@ -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 diff --git a/tests/test_asset_heal_dialog.py b/tests/test_asset_heal_dialog.py new file mode 100644 index 0000000..e2bbe3a --- /dev/null +++ b/tests/test_asset_heal_dialog.py @@ -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() diff --git a/tests/test_async_backend.py b/tests/test_async_backend.py new file mode 100644 index 0000000..c7640b9 --- /dev/null +++ b/tests/test_async_backend.py @@ -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" diff --git a/tests/test_file_ops_mixin.py b/tests/test_file_ops_mixin.py new file mode 100644 index 0000000..d190bb5 --- /dev/null +++ b/tests/test_file_ops_mixin.py @@ -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() diff --git a/tests/test_keyboard_navigation_mixin.py b/tests/test_keyboard_navigation_mixin.py new file mode 100644 index 0000000..09405b0 --- /dev/null +++ b/tests/test_keyboard_navigation_mixin.py @@ -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) diff --git a/tests/test_loading_widget.py b/tests/test_loading_widget.py new file mode 100644 index 0000000..15c8e22 --- /dev/null +++ b/tests/test_loading_widget.py @@ -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 diff --git a/tests/test_merge_dialog.py b/tests/test_merge_dialog.py new file mode 100644 index 0000000..5ecd774 --- /dev/null +++ b/tests/test_merge_dialog.py @@ -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" diff --git a/tests/test_merge_ops_mixin.py b/tests/test_merge_ops_mixin.py new file mode 100644 index 0000000..a5ece0c --- /dev/null +++ b/tests/test_merge_ops_mixin.py @@ -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' diff --git a/tests/test_models.py b/tests/test_models.py index 264a02e..51212c6 100755 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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 diff --git a/tests/test_mouse_interaction_mixin.py b/tests/test_mouse_interaction_mixin.py index de57774..7632eab 100755 --- a/tests/test_mouse_interaction_mixin.py +++ b/tests/test_mouse_interaction_mixin.py @@ -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 diff --git a/tests/test_page_layout_extended.py b/tests/test_page_layout_extended.py new file mode 100644 index 0000000..c40171d --- /dev/null +++ b/tests/test_page_layout_extended.py @@ -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) diff --git a/tests/test_rendering_mixin.py b/tests/test_rendering_mixin.py new file mode 100644 index 0000000..e6eba7d --- /dev/null +++ b/tests/test_rendering_mixin.py @@ -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() diff --git a/tests/test_template_ops_mixin.py b/tests/test_template_ops_mixin.py new file mode 100644 index 0000000..def8070 --- /dev/null +++ b/tests/test_template_ops_mixin.py @@ -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 diff --git a/tests/test_viewport_mixin.py b/tests/test_viewport_mixin.py index 26dcc18..acc4a25 100755 --- a/tests/test_viewport_mixin.py +++ b/tests/test_viewport_mixin.py @@ -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))