increase test coverage
All checks were successful
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
All checks were successful
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
This commit is contained in:
parent
558b7be01a
commit
b18a780a33
@ -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
|
||||
|
||||
596
tests/test_asset_heal_dialog.py
Normal file
596
tests/test_asset_heal_dialog.py
Normal file
@ -0,0 +1,596 @@
|
||||
"""
|
||||
Tests for asset_heal_dialog module
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch, call
|
||||
from PyQt6.QtWidgets import QMessageBox, QFileDialog
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
|
||||
class TestAssetHealDialog:
|
||||
"""Tests for AssetHealDialog class"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project(self, tmp_path):
|
||||
"""Create a mock project with folder_path"""
|
||||
project = Mock()
|
||||
project.folder_path = str(tmp_path / "project")
|
||||
os.makedirs(project.folder_path, exist_ok=True)
|
||||
|
||||
# Create assets folder
|
||||
assets_path = os.path.join(project.folder_path, "assets")
|
||||
os.makedirs(assets_path, exist_ok=True)
|
||||
|
||||
project.asset_manager = Mock()
|
||||
project.pages = []
|
||||
return project
|
||||
|
||||
@pytest.fixture
|
||||
def mock_page_with_image(self):
|
||||
"""Create a mock page with an image element"""
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
page = Mock()
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "assets/image.jpg"
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
return page
|
||||
|
||||
def test_init(self, qtbot, mock_project):
|
||||
"""Test AssetHealDialog initialization"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.project is mock_project
|
||||
assert dialog.search_paths == []
|
||||
assert dialog.missing_assets == set()
|
||||
assert dialog.windowTitle() == "Heal Missing Assets"
|
||||
|
||||
def test_init_ui(self, qtbot, mock_project):
|
||||
"""Test UI initialization"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Check that UI elements exist
|
||||
assert dialog.missing_list is not None
|
||||
assert dialog.search_list is not None
|
||||
|
||||
def test_scan_missing_assets_no_missing(self, qtbot, mock_project, tmp_path):
|
||||
"""Test scanning when no assets are missing"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create a valid image in assets folder
|
||||
img_path = os.path.join(mock_project.folder_path, "assets", "image.jpg")
|
||||
Path(img_path).touch()
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "assets/image.jpg"
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert len(dialog.missing_assets) == 0
|
||||
assert dialog.missing_list.count() == 1 # "No missing assets found!" message
|
||||
item = dialog.missing_list.item(0)
|
||||
assert "No missing assets" in item.text()
|
||||
|
||||
def test_scan_missing_assets_absolute_path(self, qtbot, mock_project):
|
||||
"""Test scanning detects absolute paths as needing healing"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "/absolute/path/to/image.jpg"
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert "/absolute/path/to/image.jpg" in dialog.missing_assets
|
||||
|
||||
def test_scan_missing_assets_not_in_assets_folder(self, qtbot, mock_project):
|
||||
"""Test scanning detects paths not in assets/ folder"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "images/photo.jpg" # Not in assets/
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert "images/photo.jpg" in dialog.missing_assets
|
||||
|
||||
def test_scan_missing_assets_file_missing(self, qtbot, mock_project):
|
||||
"""Test scanning detects missing files in assets/ folder"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "assets/missing.jpg" # File doesn't exist
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert "assets/missing.jpg" in dialog.missing_assets
|
||||
|
||||
def test_scan_missing_assets_non_image_elements_ignored(self, qtbot, mock_project):
|
||||
"""Test that non-ImageData elements are ignored"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
# TextBox element (not ImageData)
|
||||
element = Mock()
|
||||
element.image_path = None
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert len(dialog.missing_assets) == 0
|
||||
|
||||
def test_scan_missing_assets_empty_image_path(self, qtbot, mock_project):
|
||||
"""Test that elements with empty image_path are ignored"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = ""
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert len(dialog.missing_assets) == 0
|
||||
|
||||
def test_add_search_path(self, qtbot, mock_project, tmp_path):
|
||||
"""Test adding a search path"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
search_path = str(tmp_path / "search")
|
||||
os.makedirs(search_path, exist_ok=True)
|
||||
|
||||
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
|
||||
dialog._add_search_path()
|
||||
|
||||
assert search_path in dialog.search_paths
|
||||
assert dialog.search_list.count() == 1
|
||||
|
||||
def test_add_search_path_duplicate(self, qtbot, mock_project, tmp_path):
|
||||
"""Test adding duplicate search path is ignored"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
search_path = str(tmp_path / "search")
|
||||
os.makedirs(search_path, exist_ok=True)
|
||||
|
||||
with patch.object(QFileDialog, 'getExistingDirectory', return_value=search_path):
|
||||
dialog._add_search_path()
|
||||
dialog._add_search_path()
|
||||
|
||||
assert dialog.search_paths.count(search_path) == 1
|
||||
assert dialog.search_list.count() == 1
|
||||
|
||||
def test_add_search_path_cancelled(self, qtbot, mock_project):
|
||||
"""Test cancelling search path dialog"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(QFileDialog, 'getExistingDirectory', return_value=""):
|
||||
dialog._add_search_path()
|
||||
|
||||
assert len(dialog.search_paths) == 0
|
||||
|
||||
def test_remove_search_path(self, qtbot, mock_project, tmp_path):
|
||||
"""Test removing a search path"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
search_path = str(tmp_path / "search")
|
||||
dialog.search_paths.append(search_path)
|
||||
dialog.search_list.addItem(search_path)
|
||||
|
||||
dialog.search_list.setCurrentRow(0)
|
||||
dialog._remove_search_path()
|
||||
|
||||
assert len(dialog.search_paths) == 0
|
||||
assert dialog.search_list.count() == 0
|
||||
|
||||
def test_remove_search_path_none_selected(self, qtbot, mock_project):
|
||||
"""Test removing when no path is selected"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.search_paths.append("/some/path")
|
||||
dialog.search_list.addItem("/some/path")
|
||||
|
||||
dialog.search_list.setCurrentRow(-1) # No selection
|
||||
dialog._remove_search_path()
|
||||
|
||||
# Should not remove anything
|
||||
assert len(dialog.search_paths) == 1
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_no_missing_assets(self, mock_set_context, qtbot, mock_project):
|
||||
"""Test healing when there are no missing assets"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
mock_project.pages = []
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
dialog._attempt_healing()
|
||||
|
||||
mock_info.assert_called_once()
|
||||
args = mock_info.call_args[0]
|
||||
assert "Assets found: 0" in args[2]
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_resolve_relative_path(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing by resolving relative path"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create the actual image file outside project
|
||||
external_img = tmp_path / "external" / "image.jpg"
|
||||
external_img.parent.mkdir(exist_ok=True)
|
||||
external_img.touch()
|
||||
|
||||
# Element with relative path that resolves to external image
|
||||
element = Mock(spec=ImageData)
|
||||
rel_path = os.path.relpath(str(external_img), mock_project.folder_path)
|
||||
element.image_path = rel_path
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Mock the import_asset method
|
||||
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
dialog._attempt_healing()
|
||||
|
||||
# Should have imported the asset
|
||||
mock_project.asset_manager.import_asset.assert_called()
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_absolute_path_exists(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing absolute path that exists"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create image at absolute path
|
||||
abs_img = tmp_path / "image.jpg"
|
||||
abs_img.touch()
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = str(abs_img)
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
dialog._attempt_healing()
|
||||
|
||||
# Should import the asset
|
||||
mock_project.asset_manager.import_asset.assert_called_with(str(abs_img))
|
||||
# Should update element path
|
||||
assert element.image_path == "assets/image.jpg"
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_search_path_by_filename(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing by finding file in search path by filename"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create search path with image
|
||||
search_dir = tmp_path / "search"
|
||||
search_dir.mkdir()
|
||||
found_img = search_dir / "photo.jpg"
|
||||
found_img.touch()
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "/missing/path/photo.jpg"
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.missing_assets.add("/missing/path/photo.jpg")
|
||||
dialog.search_paths.append(str(search_dir))
|
||||
|
||||
mock_project.asset_manager.import_asset.return_value = "assets/photo.jpg"
|
||||
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
dialog._attempt_healing()
|
||||
|
||||
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_search_path_by_relative_structure(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing by finding file in search path with same relative structure"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create search path with subdirectory structure
|
||||
search_dir = tmp_path / "search"
|
||||
subdir = search_dir / "photos"
|
||||
subdir.mkdir(parents=True)
|
||||
found_img = subdir / "image.jpg"
|
||||
found_img.touch()
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "photos/image.jpg"
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.missing_assets.add("photos/image.jpg")
|
||||
dialog.search_paths.append(str(search_dir))
|
||||
|
||||
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
dialog._attempt_healing()
|
||||
|
||||
mock_project.asset_manager.import_asset.assert_called_with(str(found_img))
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_restore_to_assets_folder(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing by restoring file to assets folder"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create source image
|
||||
source_img = tmp_path / "source" / "image.jpg"
|
||||
source_img.parent.mkdir()
|
||||
source_img.touch()
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "assets/image.jpg" # Already correct path, just missing file
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
with patch.object(AssetHealDialog, '_scan_missing_assets'):
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog.missing_assets.add("assets/image.jpg")
|
||||
dialog.search_paths.append(str(source_img.parent))
|
||||
|
||||
with patch('shutil.copy2') as mock_copy:
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
dialog._attempt_healing()
|
||||
|
||||
# Should copy file, not import
|
||||
mock_copy.assert_called_once()
|
||||
assert not mock_project.asset_manager.import_asset.called
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_not_found(self, mock_set_context, qtbot, mock_project):
|
||||
"""Test healing when asset cannot be found"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = "/missing/image.jpg"
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
dialog._attempt_healing()
|
||||
|
||||
args = mock_info.call_args[0]
|
||||
assert "Still missing: 1" in args[2]
|
||||
assert "/missing/image.jpg" in args[2]
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_import_error(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing when import_asset raises an error"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create image
|
||||
img = tmp_path / "image.jpg"
|
||||
img.touch()
|
||||
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = str(img)
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Mock import to raise error
|
||||
mock_project.asset_manager.import_asset.side_effect = Exception("Import failed")
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
dialog._attempt_healing()
|
||||
|
||||
# Should be in still_missing list
|
||||
args = mock_info.call_args[0]
|
||||
assert "Still missing: 1" in args[2]
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_many_missing(self, mock_set_context, qtbot, mock_project):
|
||||
"""Test healing with more than 10 missing assets (tests truncation)"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
pages = []
|
||||
for i in range(15):
|
||||
element = Mock(spec=ImageData)
|
||||
element.image_path = f"/missing/image{i}.jpg"
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element]
|
||||
pages.append(page)
|
||||
|
||||
mock_project.pages = pages
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
dialog._attempt_healing()
|
||||
|
||||
args = mock_info.call_args[0]
|
||||
# Should show "... and X more"
|
||||
assert "and 5 more" in args[2]
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_multiple_elements_same_path(self, mock_set_context, qtbot, mock_project, tmp_path):
|
||||
"""Test healing when multiple elements reference the same missing path"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
|
||||
# Create image
|
||||
img = tmp_path / "image.jpg"
|
||||
img.touch()
|
||||
|
||||
# Two elements with same path
|
||||
element1 = Mock(spec=ImageData)
|
||||
element1.image_path = str(img)
|
||||
element2 = Mock(spec=ImageData)
|
||||
element2.image_path = str(img)
|
||||
|
||||
page = Mock()
|
||||
page.layout = Mock()
|
||||
page.layout.elements = [element1, element2]
|
||||
|
||||
mock_project.pages = [page]
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
mock_project.asset_manager.import_asset.return_value = "assets/image.jpg"
|
||||
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
dialog._attempt_healing()
|
||||
|
||||
# Both elements should be updated
|
||||
assert element1.image_path == "assets/image.jpg"
|
||||
assert element2.image_path == "assets/image.jpg"
|
||||
|
||||
@patch('pyPhotoAlbum.models.set_asset_resolution_context')
|
||||
def test_attempt_healing_rescans_after(self, mock_set_context, qtbot, mock_project):
|
||||
"""Test that _scan_missing_assets is called after healing"""
|
||||
from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
|
||||
|
||||
mock_project.pages = []
|
||||
|
||||
dialog = AssetHealDialog(mock_project)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
with patch.object(dialog, '_scan_missing_assets') as mock_scan:
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
dialog._attempt_healing()
|
||||
|
||||
# Should rescan after healing
|
||||
mock_scan.assert_called_once()
|
||||
824
tests/test_async_backend.py
Normal file
824
tests/test_async_backend.py
Normal file
@ -0,0 +1,824 @@
|
||||
"""
|
||||
Tests for async_backend module
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, MagicMock, patch, call
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
|
||||
class TestLoadPriority:
|
||||
"""Tests for LoadPriority enum"""
|
||||
|
||||
def test_load_priority_values(self):
|
||||
"""Test that LoadPriority enum has correct values"""
|
||||
from pyPhotoAlbum.async_backend import LoadPriority
|
||||
|
||||
assert LoadPriority.LOW.value == 0
|
||||
assert LoadPriority.NORMAL.value == 1
|
||||
assert LoadPriority.HIGH.value == 2
|
||||
assert LoadPriority.URGENT.value == 3
|
||||
|
||||
def test_load_priority_ordering(self):
|
||||
"""Test that LoadPriority values are ordered correctly"""
|
||||
from pyPhotoAlbum.async_backend import LoadPriority
|
||||
|
||||
assert LoadPriority.LOW.value < LoadPriority.NORMAL.value
|
||||
assert LoadPriority.NORMAL.value < LoadPriority.HIGH.value
|
||||
assert LoadPriority.HIGH.value < LoadPriority.URGENT.value
|
||||
|
||||
|
||||
class TestGetImageDimensions:
|
||||
"""Tests for get_image_dimensions function"""
|
||||
|
||||
def test_get_image_dimensions_simple(self, tmp_path):
|
||||
"""Test getting dimensions of a simple image"""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
# Create a test image
|
||||
img = Image.new("RGB", (800, 600), color="red")
|
||||
img_path = tmp_path / "test.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
dims = get_image_dimensions(str(img_path))
|
||||
|
||||
assert dims == (800, 600)
|
||||
|
||||
def test_get_image_dimensions_with_max_size_width_larger(self, tmp_path):
|
||||
"""Test dimensions scaled down when width is larger"""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
# Create a wide image
|
||||
img = Image.new("RGB", (1000, 500), color="blue")
|
||||
img_path = tmp_path / "wide.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
dims = get_image_dimensions(str(img_path), max_size=300)
|
||||
|
||||
# Should be scaled down to fit within 300
|
||||
assert dims == (300, 150)
|
||||
|
||||
def test_get_image_dimensions_with_max_size_height_larger(self, tmp_path):
|
||||
"""Test dimensions scaled down when height is larger"""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
# Create a tall image
|
||||
img = Image.new("RGB", (500, 1000), color="green")
|
||||
img_path = tmp_path / "tall.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
dims = get_image_dimensions(str(img_path), max_size=300)
|
||||
|
||||
# Should be scaled down to fit within 300
|
||||
assert dims == (150, 300)
|
||||
|
||||
def test_get_image_dimensions_already_smaller_than_max(self, tmp_path):
|
||||
"""Test dimensions not scaled when already smaller than max"""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
img = Image.new("RGB", (200, 150), color="yellow")
|
||||
img_path = tmp_path / "small.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
dims = get_image_dimensions(str(img_path), max_size=300)
|
||||
|
||||
# Should remain the same
|
||||
assert dims == (200, 150)
|
||||
|
||||
def test_get_image_dimensions_invalid_file(self):
|
||||
"""Test get_image_dimensions with invalid file returns None"""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
dims = get_image_dimensions("/nonexistent/file.jpg")
|
||||
|
||||
assert dims is None
|
||||
|
||||
def test_get_image_dimensions_not_an_image(self, tmp_path):
|
||||
"""Test get_image_dimensions with non-image file returns None"""
|
||||
from pyPhotoAlbum.async_backend import get_image_dimensions
|
||||
|
||||
text_file = tmp_path / "not_image.txt"
|
||||
text_file.write_text("This is not an image")
|
||||
|
||||
dims = get_image_dimensions(str(text_file))
|
||||
|
||||
assert dims is None
|
||||
|
||||
|
||||
class TestLoadRequest:
|
||||
"""Tests for LoadRequest dataclass"""
|
||||
|
||||
def test_load_request_creation(self):
|
||||
"""Test creating a LoadRequest"""
|
||||
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
|
||||
|
||||
request = LoadRequest(
|
||||
priority=LoadPriority.HIGH,
|
||||
request_id=1,
|
||||
path=Path("/test/image.jpg"),
|
||||
target_size=(300, 300),
|
||||
callback=None,
|
||||
user_data={"test": "data"},
|
||||
)
|
||||
|
||||
assert request.priority == LoadPriority.HIGH
|
||||
assert request.request_id == 1
|
||||
assert request.path == Path("/test/image.jpg")
|
||||
assert request.target_size == (300, 300)
|
||||
assert request.user_data == {"test": "data"}
|
||||
|
||||
def test_load_request_ordering_by_priority(self):
|
||||
"""Test that LoadRequests are ordered by priority (fixed with IntEnum)"""
|
||||
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
|
||||
|
||||
req1 = LoadRequest(priority=LoadPriority.LOW, request_id=1, path=Path("/a.jpg"))
|
||||
req2 = LoadRequest(priority=LoadPriority.HIGH, request_id=2, path=Path("/b.jpg"))
|
||||
|
||||
# LOW priority (value 0) should be < HIGH priority (value 2) in the priority queue
|
||||
# This means LOW will be processed before HIGH (priority queue uses min-heap)
|
||||
assert req1 < req2
|
||||
|
||||
def test_load_request_ordering_by_id_when_same_priority(self):
|
||||
"""Test that LoadRequests with same priority are ordered by request_id"""
|
||||
from pyPhotoAlbum.async_backend import LoadRequest, LoadPriority
|
||||
|
||||
req1 = LoadRequest(priority=LoadPriority.NORMAL, request_id=1, path=Path("/a.jpg"))
|
||||
req2 = LoadRequest(priority=LoadPriority.NORMAL, request_id=2, path=Path("/b.jpg"))
|
||||
|
||||
assert req1 < req2
|
||||
|
||||
|
||||
class TestImageCache:
|
||||
"""Tests for ImageCache class"""
|
||||
|
||||
def test_image_cache_init(self):
|
||||
"""Test ImageCache initialization"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache(max_memory_mb=256)
|
||||
|
||||
assert cache.max_memory_bytes == 256 * 1024 * 1024
|
||||
assert cache.current_memory_bytes == 0
|
||||
|
||||
def test_image_cache_estimate_image_size_rgba(self):
|
||||
"""Test estimating RGBA image size"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img = Image.new("RGBA", (100, 100))
|
||||
|
||||
size = cache._estimate_image_size(img)
|
||||
|
||||
# 100 * 100 * 4 bytes (RGBA)
|
||||
assert size == 40000
|
||||
|
||||
def test_image_cache_estimate_image_size_rgb(self):
|
||||
"""Test estimating RGB image size"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img = Image.new("RGB", (100, 100))
|
||||
|
||||
size = cache._estimate_image_size(img)
|
||||
|
||||
# 100 * 100 * 3 bytes (RGB)
|
||||
assert size == 30000
|
||||
|
||||
def test_image_cache_make_key_without_size(self):
|
||||
"""Test making cache key without target size"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
key = cache._make_key(Path("/test/image.jpg"))
|
||||
|
||||
assert key == "/test/image.jpg"
|
||||
|
||||
def test_image_cache_make_key_with_size(self):
|
||||
"""Test making cache key with target size"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
key = cache._make_key(Path("/test/image.jpg"), (300, 300))
|
||||
|
||||
assert key == "/test/image.jpg:300x300"
|
||||
|
||||
def test_image_cache_put_and_get(self):
|
||||
"""Test putting and getting image from cache"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
path = Path("/test/image.jpg")
|
||||
|
||||
cache.put(path, img)
|
||||
cached_img = cache.get(path)
|
||||
|
||||
assert cached_img is not None
|
||||
assert cached_img.size == img.size
|
||||
assert cached_img.mode == img.mode
|
||||
|
||||
def test_image_cache_get_returns_copy(self):
|
||||
"""Test that get returns a copy of the image"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img = Image.new("RGB", (100, 100))
|
||||
path = Path("/test/image.jpg")
|
||||
|
||||
cache.put(path, img)
|
||||
cached_img = cache.get(path)
|
||||
|
||||
# Modify the cached image
|
||||
cached_img.putpixel((0, 0), (255, 0, 0))
|
||||
|
||||
# Get it again - should be unchanged
|
||||
cached_img2 = cache.get(path)
|
||||
assert cached_img2.getpixel((0, 0)) != (255, 0, 0)
|
||||
|
||||
def test_image_cache_miss(self):
|
||||
"""Test cache miss returns None"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
cached_img = cache.get(Path("/nonexistent.jpg"))
|
||||
|
||||
assert cached_img is None
|
||||
|
||||
def test_image_cache_different_sizes_different_keys(self):
|
||||
"""Test that different target sizes use different cache keys"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img1 = Image.new("RGB", (100, 100), color="red")
|
||||
img2 = Image.new("RGB", (50, 50), color="blue")
|
||||
path = Path("/test/image.jpg")
|
||||
|
||||
cache.put(path, img1, target_size=None)
|
||||
cache.put(path, img2, target_size=(50, 50))
|
||||
|
||||
cached_full = cache.get(path, target_size=None)
|
||||
cached_small = cache.get(path, target_size=(50, 50))
|
||||
|
||||
assert cached_full.size == (100, 100)
|
||||
assert cached_small.size == (50, 50)
|
||||
|
||||
def test_image_cache_lru_eviction(self):
|
||||
"""Test that LRU items are evicted when cache is full"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
# Small cache that can hold only 1 small image
|
||||
cache = ImageCache(max_memory_mb=1)
|
||||
|
||||
# Create images that will fill the cache
|
||||
img1 = Image.new("RGB", (500, 500)) # ~750KB
|
||||
img2 = Image.new("RGB", (500, 500)) # ~750KB
|
||||
|
||||
# Add img1
|
||||
cache.put(Path("/img1.jpg"), img1)
|
||||
assert cache.get(Path("/img1.jpg")) is not None
|
||||
|
||||
# Add img2 - should evict img1 due to memory limit
|
||||
cache.put(Path("/img2.jpg"), img2)
|
||||
|
||||
# img1 should be evicted to make room for img2
|
||||
assert cache.get(Path("/img1.jpg")) is None
|
||||
# img2 should be there
|
||||
assert cache.get(Path("/img2.jpg")) is not None
|
||||
|
||||
def test_image_cache_update_existing(self):
|
||||
"""Test updating an existing cache entry"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img1 = Image.new("RGB", (100, 100), color="red")
|
||||
img2 = Image.new("RGB", (200, 200), color="blue")
|
||||
path = Path("/test/image.jpg")
|
||||
|
||||
cache.put(path, img1)
|
||||
cache.put(path, img2) # Update
|
||||
|
||||
cached = cache.get(path)
|
||||
assert cached.size == (200, 200)
|
||||
|
||||
def test_image_cache_clear(self):
|
||||
"""Test clearing the cache"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img = Image.new("RGB", (100, 100))
|
||||
|
||||
cache.put(Path("/img1.jpg"), img)
|
||||
cache.put(Path("/img2.jpg"), img)
|
||||
|
||||
cache.clear()
|
||||
|
||||
assert cache.current_memory_bytes == 0
|
||||
assert cache.get(Path("/img1.jpg")) is None
|
||||
assert cache.get(Path("/img2.jpg")) is None
|
||||
|
||||
def test_image_cache_get_stats(self):
|
||||
"""Test getting cache statistics"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache(max_memory_mb=100)
|
||||
img = Image.new("RGB", (100, 100))
|
||||
|
||||
cache.put(Path("/img1.jpg"), img)
|
||||
cache.put(Path("/img2.jpg"), img)
|
||||
|
||||
stats = cache.get_stats()
|
||||
|
||||
assert stats["items"] == 2
|
||||
assert stats["memory_mb"] > 0
|
||||
assert stats["max_memory_mb"] == 100
|
||||
assert 0 <= stats["utilization"] <= 100
|
||||
|
||||
def test_image_cache_thread_safety(self):
|
||||
"""Test that cache operations are thread-safe"""
|
||||
from pyPhotoAlbum.async_backend import ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
img = Image.new("RGB", (50, 50))
|
||||
|
||||
def put_images(start):
|
||||
for i in range(start, start + 10):
|
||||
cache.put(Path(f"/img{i}.jpg"), img)
|
||||
|
||||
def get_images(start):
|
||||
for i in range(start, start + 10):
|
||||
cache.get(Path(f"/img{i}.jpg"))
|
||||
|
||||
threads = []
|
||||
for i in range(5):
|
||||
t1 = threading.Thread(target=put_images, args=(i * 10,))
|
||||
t2 = threading.Thread(target=get_images, args=(i * 10,))
|
||||
threads.extend([t1, t2])
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Should not crash
|
||||
assert cache.current_memory_bytes >= 0
|
||||
|
||||
|
||||
class TestAsyncImageLoader:
|
||||
"""Tests for AsyncImageLoader class"""
|
||||
|
||||
def test_async_image_loader_init(self):
|
||||
"""Test AsyncImageLoader initialization"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
loader = AsyncImageLoader(cache=cache, max_workers=2)
|
||||
|
||||
assert loader.cache is cache
|
||||
assert loader.max_workers == 2
|
||||
assert loader._shutdown is False
|
||||
|
||||
def test_async_image_loader_init_creates_cache(self):
|
||||
"""Test AsyncImageLoader creates cache if not provided"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
|
||||
assert loader.cache is not None
|
||||
|
||||
def test_async_image_loader_start(self):
|
||||
"""Test starting AsyncImageLoader"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
loader.start()
|
||||
|
||||
# Give it time to start
|
||||
time.sleep(0.1)
|
||||
|
||||
assert loader._loop is not None
|
||||
assert loader._loop_thread is not None
|
||||
assert loader._loop_thread.is_alive()
|
||||
|
||||
loader.stop()
|
||||
|
||||
def test_async_image_loader_start_twice(self):
|
||||
"""Test starting AsyncImageLoader twice doesn't create multiple threads"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
loader.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
thread1 = loader._loop_thread
|
||||
|
||||
loader.start() # Should warn but not create new thread
|
||||
time.sleep(0.1)
|
||||
|
||||
assert loader._loop_thread is thread1
|
||||
|
||||
loader.stop()
|
||||
|
||||
def test_async_image_loader_stop(self):
|
||||
"""Test stopping AsyncImageLoader"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
loader.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
loader.stop()
|
||||
time.sleep(0.2)
|
||||
|
||||
assert loader._shutdown is True
|
||||
|
||||
def test_async_image_loader_load_and_process_image(self, tmp_path):
|
||||
"""Test _load_and_process_image method"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
# Create test image
|
||||
img = Image.new("RGB", (800, 600), color="blue")
|
||||
img_path = tmp_path / "test.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
result = loader._load_and_process_image(img_path, None)
|
||||
|
||||
assert result is not None
|
||||
assert result.mode == "RGBA" # Should be converted to RGBA
|
||||
|
||||
def test_async_image_loader_load_and_process_image_with_resize(self, tmp_path):
|
||||
"""Test _load_and_process_image with target size"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
# Create large test image
|
||||
img = Image.new("RGB", (2000, 1500), color="green")
|
||||
img_path = tmp_path / "large.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
result = loader._load_and_process_image(img_path, (500, 500))
|
||||
|
||||
assert result is not None
|
||||
# Should be resized to fit within 500x500
|
||||
assert result.size[0] <= 500
|
||||
assert result.size[1] <= 500
|
||||
|
||||
def test_async_image_loader_emit_loaded(self, qtbot):
|
||||
"""Test _emit_loaded signal"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_loaded(path, img, user_data):
|
||||
signal_received.append((path, img, user_data))
|
||||
|
||||
loader.image_loaded.connect(on_loaded)
|
||||
|
||||
mock_img = Mock()
|
||||
user_data = {"test": "data"}
|
||||
|
||||
loader._emit_loaded(Path("/test.jpg"), mock_img, user_data)
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == Path("/test.jpg")
|
||||
assert signal_received[0][2] == user_data
|
||||
|
||||
def test_async_image_loader_emit_failed(self, qtbot):
|
||||
"""Test _emit_failed signal"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_failed(path, error, user_data):
|
||||
signal_received.append((path, error, user_data))
|
||||
|
||||
loader.load_failed.connect(on_failed)
|
||||
|
||||
user_data = {"test": "data"}
|
||||
|
||||
loader._emit_failed(Path("/test.jpg"), "Error message", user_data)
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0][0] == Path("/test.jpg")
|
||||
assert signal_received[0][1] == "Error message"
|
||||
|
||||
def test_async_image_loader_request_load_not_started(self):
|
||||
"""Test request_load when loader not started"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
|
||||
result = loader.request_load(Path("/test.jpg"), priority=LoadPriority.HIGH)
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_async_image_loader_request_load_success(self, tmp_path):
|
||||
"""Test successful request_load"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||
|
||||
# Create test image
|
||||
img = Image.new("RGB", (100, 100), color="red")
|
||||
img_path = tmp_path / "test.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
loader.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
result = loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||
|
||||
assert result is True
|
||||
|
||||
loader.stop()
|
||||
|
||||
def test_async_image_loader_request_load_duplicate(self, tmp_path):
|
||||
"""Test requesting same image twice returns False"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||
|
||||
img = Image.new("RGB", (100, 100))
|
||||
img_path = tmp_path / "test.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
loader.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
result1 = loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||
result2 = loader.request_load(img_path, priority=LoadPriority.HIGH)
|
||||
|
||||
assert result1 is True
|
||||
assert result2 is False # Already pending
|
||||
|
||||
loader.stop()
|
||||
|
||||
def test_async_image_loader_cancel_pending(self, tmp_path):
|
||||
"""Test canceling a pending load request"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader, LoadPriority
|
||||
|
||||
img = Image.new("RGB", (100, 100))
|
||||
img_path = tmp_path / "test.jpg"
|
||||
img.save(img_path)
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
loader.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
loader.request_load(img_path, priority=LoadPriority.LOW)
|
||||
result = loader.cancel_load(img_path)
|
||||
|
||||
assert result is True
|
||||
|
||||
loader.stop()
|
||||
|
||||
def test_async_image_loader_cancel_nonexistent(self):
|
||||
"""Test canceling a non-existent load request"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
|
||||
result = loader.cancel_load(Path("/nonexistent.jpg"))
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_async_image_loader_get_stats(self):
|
||||
"""Test getting loader statistics"""
|
||||
from pyPhotoAlbum.async_backend import AsyncImageLoader
|
||||
|
||||
loader = AsyncImageLoader()
|
||||
|
||||
stats = loader.get_stats()
|
||||
|
||||
assert "pending" in stats
|
||||
assert "active" in stats
|
||||
assert "cache" in stats
|
||||
|
||||
|
||||
class TestAsyncPDFGenerator:
|
||||
"""Tests for AsyncPDFGenerator class"""
|
||||
|
||||
def test_async_pdf_generator_init(self):
|
||||
"""Test AsyncPDFGenerator initialization"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator, ImageCache
|
||||
|
||||
cache = ImageCache()
|
||||
generator = AsyncPDFGenerator(image_cache=cache, max_workers=1)
|
||||
|
||||
assert generator.image_cache is cache
|
||||
assert generator.max_workers == 1
|
||||
assert generator._shutdown is False
|
||||
|
||||
def test_async_pdf_generator_init_creates_cache(self):
|
||||
"""Test AsyncPDFGenerator creates cache if not provided"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
assert generator.image_cache is not None
|
||||
|
||||
def test_async_pdf_generator_start(self):
|
||||
"""Test starting AsyncPDFGenerator"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
generator.start()
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
assert generator._loop is not None
|
||||
assert generator._loop_thread is not None
|
||||
assert generator._loop_thread.is_alive()
|
||||
|
||||
generator.stop()
|
||||
|
||||
def test_async_pdf_generator_start_twice(self):
|
||||
"""Test starting AsyncPDFGenerator twice doesn't create multiple threads"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
generator.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
thread1 = generator._loop_thread
|
||||
|
||||
generator.start() # Should warn
|
||||
time.sleep(0.1)
|
||||
|
||||
assert generator._loop_thread is thread1
|
||||
|
||||
generator.stop()
|
||||
|
||||
def test_async_pdf_generator_stop(self):
|
||||
"""Test stopping AsyncPDFGenerator"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
generator.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
generator.stop()
|
||||
time.sleep(0.2)
|
||||
|
||||
assert generator._shutdown is True
|
||||
|
||||
def test_async_pdf_generator_export_not_started(self):
|
||||
"""Test export_pdf when generator not started"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
mock_project = Mock()
|
||||
|
||||
result = generator.export_pdf(mock_project, "/output.pdf")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_async_pdf_generator_export_already_exporting(self):
|
||||
"""Test export_pdf when already exporting"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
generator.start()
|
||||
time.sleep(0.1)
|
||||
|
||||
mock_project = Mock()
|
||||
|
||||
# Start first export
|
||||
generator._current_export = Mock()
|
||||
generator._current_export.done.return_value = False
|
||||
|
||||
result = generator.export_pdf(mock_project, "/output.pdf")
|
||||
|
||||
assert result is False
|
||||
|
||||
generator.stop()
|
||||
|
||||
def test_async_pdf_generator_cancel_export(self):
|
||||
"""Test cancel_export method"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
# Mock an active export
|
||||
generator._current_export = Mock()
|
||||
generator._current_export.done.return_value = False
|
||||
|
||||
generator.cancel_export()
|
||||
|
||||
assert generator._cancel_requested is True
|
||||
generator._current_export.cancel.assert_called_once()
|
||||
|
||||
def test_async_pdf_generator_is_exporting_true(self):
|
||||
"""Test is_exporting returns True when exporting"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
generator._current_export = Mock()
|
||||
generator._current_export.done.return_value = False
|
||||
|
||||
assert generator.is_exporting() is True
|
||||
|
||||
def test_async_pdf_generator_is_exporting_false(self):
|
||||
"""Test is_exporting returns False when not exporting"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
assert generator.is_exporting() is False
|
||||
|
||||
def test_async_pdf_generator_get_stats(self):
|
||||
"""Test getting generator statistics"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
stats = generator.get_stats()
|
||||
|
||||
assert "exporting" in stats
|
||||
assert "cache" in stats
|
||||
|
||||
def test_async_pdf_generator_export_with_cache_uses_cache(self):
|
||||
"""Test _export_with_cache uses cached images"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
from PIL import Image
|
||||
from unittest.mock import patch
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
# Mock exporter that tries to open an image
|
||||
mock_exporter = Mock()
|
||||
mock_exporter.export.return_value = (True, [])
|
||||
|
||||
def mock_progress(current, total, msg):
|
||||
return True
|
||||
|
||||
# Run export (just verify the method exists and can be called)
|
||||
with patch('PIL.Image.open') as mock_open:
|
||||
mock_img = Image.new("RGBA", (50, 50), color="blue")
|
||||
mock_open.return_value = mock_img
|
||||
|
||||
success, warnings = generator._export_with_cache(mock_exporter, "/fake/output.pdf", mock_progress)
|
||||
|
||||
assert success is True
|
||||
mock_exporter.export.assert_called_once()
|
||||
|
||||
def test_async_pdf_generator_progress_signal(self, qtbot):
|
||||
"""Test progress_updated signal"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_progress(current, total, message):
|
||||
signal_received.append((current, total, message))
|
||||
|
||||
generator.progress_updated.connect(on_progress)
|
||||
|
||||
generator.progress_updated.emit(5, 10, "Processing page 5")
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == (5, 10, "Processing page 5")
|
||||
|
||||
def test_async_pdf_generator_complete_signal(self, qtbot):
|
||||
"""Test export_complete signal"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_complete(success, warnings):
|
||||
signal_received.append((success, warnings))
|
||||
|
||||
generator.export_complete.connect(on_complete)
|
||||
|
||||
generator.export_complete.emit(True, ["warning1"])
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == (True, ["warning1"])
|
||||
|
||||
def test_async_pdf_generator_failed_signal(self, qtbot):
|
||||
"""Test export_failed signal"""
|
||||
from pyPhotoAlbum.async_backend import AsyncPDFGenerator
|
||||
|
||||
generator = AsyncPDFGenerator()
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_failed(error_msg):
|
||||
signal_received.append(error_msg)
|
||||
|
||||
generator.export_failed.connect(on_failed)
|
||||
|
||||
generator.export_failed.emit("Export failed")
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == "Export failed"
|
||||
824
tests/test_file_ops_mixin.py
Normal file
824
tests/test_file_ops_mixin.py
Normal file
@ -0,0 +1,824 @@
|
||||
"""
|
||||
Tests for FileOperationsMixin
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch, call
|
||||
from PyQt6.QtWidgets import QMainWindow, QDialog
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.operations.file_ops import FileOperationsMixin
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.commands import CommandHistory
|
||||
|
||||
|
||||
class TestFileOpsWindow(FileOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||
"""Test window with file operations mixin"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._gl_widget = Mock()
|
||||
self._gl_widget.current_page_index = 0
|
||||
self._gl_widget.zoom_level = 1.0
|
||||
self._gl_widget.pan_offset = [0, 0]
|
||||
self._gl_widget._page_renderers = []
|
||||
self._gl_widget.width = Mock(return_value=800)
|
||||
self._gl_widget.height = Mock(return_value=600)
|
||||
self._gl_widget.export_pdf_async = Mock(return_value=True)
|
||||
self._project = Project(name="Test")
|
||||
self._project.page_size_mm = (210, 297)
|
||||
self._project.working_dpi = 96
|
||||
self._project.export_dpi = 300
|
||||
self._project.history = CommandHistory()
|
||||
self._update_view_called = False
|
||||
self._status_message = None
|
||||
self._status_timeout = None
|
||||
self._info_title = None
|
||||
self._info_message = None
|
||||
self._warning_title = None
|
||||
self._warning_message = None
|
||||
self._error_title = None
|
||||
self._error_message = None
|
||||
|
||||
@property
|
||||
def gl_widget(self):
|
||||
return self._gl_widget
|
||||
|
||||
@property
|
||||
def project(self):
|
||||
return self._project
|
||||
|
||||
@project.setter
|
||||
def project(self, value):
|
||||
self._project = value
|
||||
|
||||
def update_view(self):
|
||||
self._update_view_called = True
|
||||
|
||||
def show_status(self, message, timeout=0):
|
||||
self._status_message = message
|
||||
self._status_timeout = timeout
|
||||
|
||||
def show_info(self, title, message):
|
||||
self._info_title = title
|
||||
self._info_message = message
|
||||
|
||||
def show_warning(self, title, message):
|
||||
self._warning_title = title
|
||||
self._warning_message = message
|
||||
|
||||
def show_error(self, title, message):
|
||||
self._error_title = title
|
||||
self._error_message = message
|
||||
|
||||
def resolve_asset_path(self, path):
|
||||
"""Mock asset path resolution"""
|
||||
if path.startswith("assets/") and path.endswith("exists.jpg"):
|
||||
return "/fake/path/exists.jpg"
|
||||
return None
|
||||
|
||||
|
||||
class TestNewProject:
|
||||
"""Test new_project method"""
|
||||
|
||||
def test_new_project_dialog_cancelled(self, qtbot):
|
||||
"""Test returns when user cancels dialog"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
old_project = window.project
|
||||
|
||||
# Patch QDialog.exec to return rejected
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
window.new_project()
|
||||
|
||||
# Should keep old project
|
||||
assert window.project == old_project
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
|
||||
def test_new_project_creates_project_default_values(self, mock_set_context, qtbot):
|
||||
"""Test creates new project with default dialog values"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
old_project = window.project
|
||||
old_project.cleanup = Mock()
|
||||
|
||||
# Patch QDialog.exec to accept with default values (140x140, 300 DPI)
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
||||
window.new_project()
|
||||
|
||||
# Verify new project was created with default values
|
||||
assert window.project != old_project
|
||||
assert window.project.name == "New Project" # Default name
|
||||
assert window.project.page_size_mm == (140.0, 140.0) # Default size
|
||||
assert window.project.working_dpi == 300 # Default working DPI
|
||||
assert window.project.export_dpi == 300 # Default export DPI
|
||||
assert window._update_view_called
|
||||
mock_set_context.assert_called_once()
|
||||
old_project.cleanup.assert_called_once()
|
||||
|
||||
|
||||
class TestOpenProject:
|
||||
"""Test open_project method"""
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
|
||||
def test_open_project_dialog_cancelled(self, mock_file_dialog, qtbot):
|
||||
"""Test returns when user cancels file dialog"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Mock dialog to return empty path
|
||||
mock_file_dialog.return_value = ("", "")
|
||||
|
||||
window.open_project()
|
||||
|
||||
# Should not create loader
|
||||
assert not hasattr(window, "_project_loader")
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.AsyncProjectLoader")
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.LoadingWidget")
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getOpenFileName")
|
||||
def test_open_project_starts_async_loading(self, mock_file_dialog, mock_loading_widget, mock_loader, qtbot):
|
||||
"""Test starts async loading when file selected"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Mock dialog to return file path
|
||||
mock_file_dialog.return_value = ("/path/to/project.ppz", "")
|
||||
|
||||
# Mock loader
|
||||
mock_loader_instance = Mock()
|
||||
mock_loader.return_value = mock_loader_instance
|
||||
|
||||
# Mock loading widget
|
||||
mock_loading_instance = Mock()
|
||||
mock_loading_widget.return_value = mock_loading_instance
|
||||
|
||||
window.open_project()
|
||||
|
||||
# Verify loader was created and started
|
||||
mock_loader.assert_called_once_with("/path/to/project.ppz")
|
||||
mock_loader_instance.start.assert_called_once()
|
||||
mock_loading_instance.show_loading.assert_called_once()
|
||||
|
||||
|
||||
class TestLoadCallbacks:
|
||||
"""Test async loading callback methods"""
|
||||
|
||||
def test_on_load_progress(self, qtbot):
|
||||
"""Test progress callback updates loading widget"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create mock loading widget
|
||||
window._loading_widget = Mock()
|
||||
|
||||
window._on_load_progress(5, 10, "Loading page 5...")
|
||||
|
||||
window._loading_widget.set_progress.assert_called_once_with(5, 10)
|
||||
window._loading_widget.set_status.assert_called_once_with("Loading page 5...")
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
|
||||
def test_on_load_complete_success(self, mock_set_context, qtbot):
|
||||
"""Test successful load callback"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create mock loading widget
|
||||
window._loading_widget = Mock()
|
||||
window._opening_file_path = "/path/to/project.ppz"
|
||||
|
||||
# Create mock project
|
||||
new_project = Mock()
|
||||
new_project.name = "Loaded Project"
|
||||
new_project.mark_clean = Mock()
|
||||
|
||||
# Mock check missing assets
|
||||
window._check_missing_assets = Mock(return_value=[])
|
||||
|
||||
old_project = window.project
|
||||
old_project.cleanup = Mock()
|
||||
|
||||
window._on_load_complete(new_project)
|
||||
|
||||
# Verify old project was cleaned up
|
||||
old_project.cleanup.assert_called_once()
|
||||
|
||||
# Verify new project was set
|
||||
assert window.project == new_project
|
||||
assert window.project.file_path == "/path/to/project.ppz"
|
||||
new_project.mark_clean.assert_called_once()
|
||||
|
||||
# Verify UI was updated
|
||||
window._loading_widget.hide_loading.assert_called_once()
|
||||
assert window._update_view_called
|
||||
assert "Project opened" in window._status_message
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.set_asset_resolution_context")
|
||||
def test_on_load_complete_with_missing_assets(self, mock_set_context, qtbot):
|
||||
"""Test load callback with missing assets"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window._loading_widget = Mock()
|
||||
window._opening_file_path = "/path/to/project.ppz"
|
||||
|
||||
new_project = Mock()
|
||||
new_project.name = "Project with Missing"
|
||||
new_project.mark_clean = Mock()
|
||||
|
||||
# Mock missing assets
|
||||
window._check_missing_assets = Mock(return_value=["/missing/image1.jpg", "/missing/image2.jpg"])
|
||||
window._show_missing_assets_warning = Mock()
|
||||
|
||||
window._on_load_complete(new_project)
|
||||
|
||||
# Verify warning was shown
|
||||
window._show_missing_assets_warning.assert_called_once_with(["/missing/image1.jpg", "/missing/image2.jpg"])
|
||||
assert "2 missing images" in window._status_message
|
||||
|
||||
def test_on_load_failed(self, qtbot):
|
||||
"""Test load failure callback"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window._loading_widget = Mock()
|
||||
|
||||
window._on_load_failed("File corrupted")
|
||||
|
||||
# Verify error was shown
|
||||
window._loading_widget.hide_loading.assert_called_once()
|
||||
assert "Failed to open project" in window._error_message
|
||||
assert "File corrupted" in window._error_message
|
||||
|
||||
|
||||
class TestSaveProject:
|
||||
"""Test save_project method"""
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
|
||||
def test_save_project_with_existing_path(self, mock_save, qtbot):
|
||||
"""Test saves to existing file path"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.file_path = "/path/to/existing.ppz"
|
||||
mock_save.return_value = (True, None)
|
||||
|
||||
window.save_project()
|
||||
|
||||
# Verify save was called with existing path
|
||||
mock_save.assert_called_once_with(window.project, "/path/to/existing.ppz")
|
||||
assert "Project saved" in window._status_message
|
||||
assert not window.project.is_dirty() # is_dirty() is a method
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_save_project_prompts_for_path(self, mock_file_dialog, mock_save, qtbot):
|
||||
"""Test prompts for path when none exists"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# No existing file path
|
||||
window.project.file_path = None
|
||||
|
||||
# Mock dialog to return path
|
||||
mock_file_dialog.return_value = ("/path/to/new.ppz", "")
|
||||
mock_save.return_value = (True, None)
|
||||
|
||||
window.save_project()
|
||||
|
||||
# Verify dialog was shown and save was called
|
||||
mock_file_dialog.assert_called_once()
|
||||
mock_save.assert_called_once_with(window.project, "/path/to/new.ppz")
|
||||
assert window.project.file_path == "/path/to/new.ppz"
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_save_project_user_cancels(self, mock_file_dialog, mock_save, qtbot):
|
||||
"""Test returns when user cancels file dialog"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.file_path = None
|
||||
mock_file_dialog.return_value = ("", "")
|
||||
|
||||
window.save_project()
|
||||
|
||||
# Should not call save
|
||||
mock_save.assert_not_called()
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.save_to_zip")
|
||||
def test_save_project_handles_error(self, mock_save, qtbot):
|
||||
"""Test handles save errors"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.file_path = "/path/to/project.ppz"
|
||||
mock_save.return_value = (False, "Disk full")
|
||||
|
||||
window.save_project()
|
||||
|
||||
# Verify error was shown
|
||||
assert "Failed to save project" in window._status_message
|
||||
assert "Disk full" in window._status_message
|
||||
|
||||
|
||||
class TestHealAssets:
|
||||
"""Test heal_assets method"""
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
||||
def test_heal_assets_opens_dialog(self, mock_dialog, qtbot):
|
||||
"""Test opens asset heal dialog"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
mock_dialog_instance = Mock()
|
||||
mock_dialog.return_value = mock_dialog_instance
|
||||
|
||||
window.heal_assets()
|
||||
|
||||
# Verify dialog was created and executed
|
||||
mock_dialog.assert_called_once_with(window.project, window)
|
||||
mock_dialog_instance.exec.assert_called_once()
|
||||
assert window._update_view_called
|
||||
|
||||
|
||||
class TestCheckMissingAssets:
|
||||
"""Test _check_missing_assets method"""
|
||||
|
||||
def test_check_missing_assets_absolute_path(self, qtbot):
|
||||
"""Test detects absolute path as missing"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page with absolute path
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="/absolute/path/image.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
missing = window._check_missing_assets()
|
||||
|
||||
assert len(missing) == 1
|
||||
assert "/absolute/path/image.jpg" in missing
|
||||
|
||||
def test_check_missing_assets_non_assets_path(self, qtbot):
|
||||
"""Test detects non-assets path as missing"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="relative/path/image.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
missing = window._check_missing_assets()
|
||||
|
||||
assert len(missing) == 1
|
||||
assert "relative/path/image.jpg" in missing
|
||||
|
||||
def test_check_missing_assets_not_found(self, qtbot):
|
||||
"""Test detects assets that don't exist"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="assets/missing.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
missing = window._check_missing_assets()
|
||||
|
||||
assert len(missing) == 1
|
||||
assert "assets/missing.jpg" in missing
|
||||
|
||||
def test_check_missing_assets_found(self, qtbot):
|
||||
"""Test ignores assets that exist"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="assets/exists.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
missing = window._check_missing_assets()
|
||||
|
||||
assert len(missing) == 0
|
||||
|
||||
def test_check_missing_assets_removes_duplicates(self, qtbot):
|
||||
"""Test removes duplicate missing paths"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout1 = PageLayout(width=210, height=297)
|
||||
layout1.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
|
||||
page1 = Page(layout=layout1, page_number=1)
|
||||
|
||||
layout2 = PageLayout(width=210, height=297)
|
||||
layout2.elements = [ImageData(image_path="/missing/image.jpg", x=0, y=0, width=100, height=100)]
|
||||
page2 = Page(layout=layout2, page_number=2)
|
||||
|
||||
window.project.pages = [page1, page2]
|
||||
|
||||
missing = window._check_missing_assets()
|
||||
|
||||
assert len(missing) == 1
|
||||
assert "/missing/image.jpg" in missing
|
||||
|
||||
|
||||
class TestShowMissingAssetsWarning:
|
||||
"""Test _show_missing_assets_warning method"""
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
||||
def test_show_warning_few_assets(self, mock_heal_dialog, qtbot):
|
||||
"""Test shows all assets when count is small"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Patch QMessageBox.exec to return Ok (not Open)
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
|
||||
missing = ["/path1.jpg", "/path2.jpg", "/path3.jpg"]
|
||||
window._show_missing_assets_warning(missing)
|
||||
|
||||
# Heal dialog should not be opened for Ok
|
||||
mock_heal_dialog.assert_not_called()
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
||||
def test_show_warning_many_assets(self, mock_heal_dialog, qtbot):
|
||||
"""Test truncates list when many assets missing"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Ok):
|
||||
missing = [f"/path{i}.jpg" for i in range(10)]
|
||||
window._show_missing_assets_warning(missing)
|
||||
|
||||
# Verify it completed without error
|
||||
mock_heal_dialog.assert_not_called()
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.AssetHealDialog")
|
||||
def test_show_warning_opens_heal_dialog(self, mock_heal_dialog, qtbot):
|
||||
"""Test opens heal dialog when user clicks Open"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
from PyQt6.QtWidgets import QMessageBox
|
||||
|
||||
mock_heal_instance = Mock()
|
||||
mock_heal_dialog.return_value = mock_heal_instance
|
||||
|
||||
# Patch QMessageBox.exec to return Open
|
||||
with patch.object(QMessageBox, "exec", return_value=QMessageBox.StandardButton.Open):
|
||||
missing = ["/path1.jpg"]
|
||||
window._show_missing_assets_warning(missing)
|
||||
|
||||
# Verify heal dialog was opened
|
||||
mock_heal_dialog.assert_called_once()
|
||||
mock_heal_instance.exec.assert_called_once()
|
||||
|
||||
|
||||
class TestProjectSettings:
|
||||
"""Test project_settings method"""
|
||||
|
||||
def test_project_settings_cancelled(self, qtbot):
|
||||
"""Test returns when user cancels"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
old_size = window.project.page_size_mm
|
||||
|
||||
# Patch QDialog.exec to return rejected
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
window.project_settings()
|
||||
|
||||
# Size should not change
|
||||
assert window.project.page_size_mm == old_size
|
||||
|
||||
def test_project_settings_updates_values_default(self, qtbot):
|
||||
"""Test updates project settings with dialog defaults"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Set initial values
|
||||
window.project.page_size_mm = (210, 297)
|
||||
window.project.working_dpi = 96
|
||||
window.project.export_dpi = 300
|
||||
|
||||
# Patch QDialog.exec to accept (will use current project values as defaults)
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
||||
window.project_settings()
|
||||
|
||||
# Values should remain the same (since dialog uses current values as defaults)
|
||||
assert window.project.page_size_mm == (210, 297)
|
||||
assert window.project.working_dpi == 96
|
||||
assert window.project.export_dpi == 300
|
||||
assert window._update_view_called
|
||||
|
||||
|
||||
class TestApplyPageSizeToProject:
|
||||
"""Test _apply_page_size_to_project method"""
|
||||
|
||||
def test_apply_page_size_skips_manual_pages(self, qtbot):
|
||||
"""Test skips manually sized pages"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
page.manually_sized = True
|
||||
window.project.pages = [page]
|
||||
|
||||
window._apply_page_size_to_project((210, 297), (200, 200), "proportional")
|
||||
|
||||
# Page size should not change
|
||||
assert page.layout.size == (210, 297)
|
||||
|
||||
def test_apply_page_size_proportional_scaling(self, qtbot):
|
||||
"""Test proportional scaling mode"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
page.manually_sized = False
|
||||
window.project.pages = [page]
|
||||
|
||||
# Double the size
|
||||
window._apply_page_size_to_project((100, 100), (200, 200), "proportional")
|
||||
|
||||
# Page should be resized
|
||||
assert page.layout.size == (200, 200)
|
||||
# Elements should be scaled uniformly
|
||||
assert layout.elements[0].position == (20.0, 20.0)
|
||||
assert layout.elements[0].size == (100.0, 100.0)
|
||||
|
||||
def test_apply_page_size_stretch_scaling(self, qtbot):
|
||||
"""Test stretch scaling mode"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
page.manually_sized = False
|
||||
window.project.pages = [page]
|
||||
|
||||
# Stretch to different aspect ratio
|
||||
window._apply_page_size_to_project((100, 100), (200, 150), "stretch")
|
||||
|
||||
# Page should be resized
|
||||
assert page.layout.size == (200, 150)
|
||||
# Elements should be scaled independently
|
||||
assert layout.elements[0].position == (20.0, 15.0) # x*2, y*1.5
|
||||
assert layout.elements[0].size == (100.0, 75.0) # w*2, h*1.5
|
||||
|
||||
def test_apply_page_size_reposition_mode(self, qtbot):
|
||||
"""Test reposition mode"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
page.manually_sized = False
|
||||
window.project.pages = [page]
|
||||
|
||||
# Increase size
|
||||
window._apply_page_size_to_project((100, 100), (150, 150), "reposition")
|
||||
|
||||
# Page should be resized
|
||||
assert page.layout.size == (150, 150)
|
||||
# Elements should be offset to center
|
||||
assert layout.elements[0].position == (35.0, 35.0) # +25, +25
|
||||
assert layout.elements[0].size == (50, 50) # unchanged
|
||||
|
||||
def test_apply_page_size_none_mode(self, qtbot):
|
||||
"""Test none mode (no element changes)"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=10, width=50, height=50)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
page.manually_sized = False
|
||||
window.project.pages = [page]
|
||||
|
||||
window._apply_page_size_to_project((100, 100), (200, 200), "none")
|
||||
|
||||
# Page should be resized
|
||||
assert page.layout.size == (200, 200)
|
||||
# Elements should not change
|
||||
assert layout.elements[0].position == (10, 10)
|
||||
assert layout.elements[0].size == (50, 50)
|
||||
|
||||
def test_apply_page_size_double_spread(self, qtbot):
|
||||
"""Test handles double spread pages correctly"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=200, height=100)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
page.manually_sized = False
|
||||
page.is_double_spread = True
|
||||
window.project.pages = [page]
|
||||
|
||||
window._apply_page_size_to_project((100, 100), (150, 150), "none")
|
||||
|
||||
# Double spread should be 2x width
|
||||
assert page.layout.size == (300, 150)
|
||||
|
||||
|
||||
class TestScalePageElements:
|
||||
"""Test _scale_page_elements method"""
|
||||
|
||||
def test_scale_elements_uniform(self, qtbot):
|
||||
"""Test uniform scaling"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [
|
||||
ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
|
||||
ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
|
||||
]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
|
||||
window._scale_page_elements(page, 2.0, 2.0)
|
||||
|
||||
# Check first element
|
||||
assert layout.elements[0].position == (20.0, 40.0)
|
||||
assert layout.elements[0].size == (100.0, 120.0)
|
||||
|
||||
# Check second element
|
||||
assert layout.elements[1].position == (10.0, 20.0)
|
||||
assert layout.elements[1].size == (60.0, 80.0)
|
||||
|
||||
def test_scale_elements_non_uniform(self, qtbot):
|
||||
"""Test non-uniform scaling"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
|
||||
window._scale_page_elements(page, 2.0, 1.5)
|
||||
|
||||
assert layout.elements[0].position == (20.0, 30.0)
|
||||
assert layout.elements[0].size == (100.0, 90.0)
|
||||
|
||||
|
||||
class TestRepositionPageElements:
|
||||
"""Test _reposition_page_elements method"""
|
||||
|
||||
def test_reposition_elements_larger_page(self, qtbot):
|
||||
"""Test repositioning on larger page"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [
|
||||
ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60),
|
||||
ImageData(image_path="test2.jpg", x=5, y=10, width=30, height=40),
|
||||
]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
|
||||
window._reposition_page_elements(page, (100, 100), (150, 130))
|
||||
|
||||
# Offset should be (25, 15)
|
||||
assert layout.elements[0].position == (35.0, 35.0)
|
||||
assert layout.elements[1].position == (30.0, 25.0)
|
||||
|
||||
def test_reposition_elements_smaller_page(self, qtbot):
|
||||
"""Test repositioning on smaller page"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=100, height=100)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=10, y=20, width=50, height=60)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
|
||||
window._reposition_page_elements(page, (100, 100), (80, 90))
|
||||
|
||||
# Offset should be (-10, -5)
|
||||
assert layout.elements[0].position == (0.0, 15.0)
|
||||
|
||||
|
||||
class TestExportPdf:
|
||||
"""Test export_pdf method"""
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_export_pdf_no_pages(self, mock_file_dialog, qtbot):
|
||||
"""Test returns early when no pages"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.pages = []
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
# Should not show file dialog
|
||||
mock_file_dialog.assert_not_called()
|
||||
assert "No pages to export" in window._status_message
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_export_pdf_user_cancels(self, mock_file_dialog, qtbot):
|
||||
"""Test returns when user cancels"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
mock_file_dialog.return_value = ("", "")
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
# Should not call export
|
||||
window.gl_widget.export_pdf_async.assert_not_called()
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_export_pdf_success(self, mock_file_dialog, qtbot):
|
||||
"""Test successful PDF export"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
||||
window.gl_widget.export_pdf_async.return_value = True
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
# Verify export was called
|
||||
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
|
||||
assert "PDF export started" in window._status_message
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_export_pdf_adds_extension(self, mock_file_dialog, qtbot):
|
||||
"""Test adds .pdf extension if missing"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
mock_file_dialog.return_value = ("/path/to/output", "")
|
||||
window.gl_widget.export_pdf_async.return_value = True
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
# Verify .pdf was added
|
||||
window.gl_widget.export_pdf_async.assert_called_once_with(window.project, "/path/to/output.pdf", export_dpi=300)
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QFileDialog.getSaveFileName")
|
||||
def test_export_pdf_failed_to_start(self, mock_file_dialog, qtbot):
|
||||
"""Test handles export failure"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
mock_file_dialog.return_value = ("/path/to/output.pdf", "")
|
||||
window.gl_widget.export_pdf_async.return_value = False
|
||||
|
||||
window.export_pdf()
|
||||
|
||||
assert "PDF export failed to start" in window._status_message
|
||||
|
||||
|
||||
class TestShowAbout:
|
||||
"""Test show_about method"""
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.format_version_info")
|
||||
@patch("pyPhotoAlbum.mixins.operations.file_ops.QDialog")
|
||||
def test_show_about_displays_dialog(self, mock_dialog_class, mock_format_version, qtbot):
|
||||
"""Test shows about dialog with version info"""
|
||||
window = TestFileOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
mock_dialog = Mock()
|
||||
mock_dialog_class.return_value = mock_dialog
|
||||
mock_format_version.return_value = "Version 1.0.0\nData Format: v2"
|
||||
|
||||
window.show_about()
|
||||
|
||||
# Verify dialog was created and executed
|
||||
mock_dialog_class.assert_called_once()
|
||||
mock_dialog.exec.assert_called_once()
|
||||
mock_format_version.assert_called_once()
|
||||
803
tests/test_keyboard_navigation_mixin.py
Normal file
803
tests/test_keyboard_navigation_mixin.py
Normal file
@ -0,0 +1,803 @@
|
||||
"""
|
||||
Tests for KeyboardNavigationMixin
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
|
||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.models import ImageData, TextBoxData
|
||||
|
||||
|
||||
# Create test widget combining necessary mixins
|
||||
class KeyboardNavWidget(KeyboardNavigationMixin, ViewportMixin, QOpenGLWidget):
|
||||
"""Test widget combining keyboard navigation and viewport mixins"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.selected_elements = set()
|
||||
|
||||
|
||||
class TestNavigateToNextPage:
|
||||
"""Test _navigate_to_next_page method"""
|
||||
|
||||
def test_navigate_next_no_project_attribute(self, qtbot):
|
||||
"""Test does nothing when main window has no project attribute"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
del mock_window.project
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Should not raise exception
|
||||
widget._navigate_to_next_page()
|
||||
|
||||
def test_navigate_next_no_project(self, qtbot):
|
||||
"""Test does nothing when project is None"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = None
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget._navigate_to_next_page()
|
||||
|
||||
def test_navigate_next_empty_pages(self, qtbot):
|
||||
"""Test does nothing when project has no pages"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.pages = []
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget._navigate_to_next_page()
|
||||
|
||||
def test_navigate_next_at_last_page(self, qtbot):
|
||||
"""Test does nothing when already at last page"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window._get_most_visible_page_index = Mock(return_value=1) # Last page
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
initial_pan = widget.pan_offset.copy()
|
||||
widget._navigate_to_next_page()
|
||||
|
||||
# Should not navigate (already at last page)
|
||||
|
||||
def test_navigate_next_to_next_page(self, qtbot):
|
||||
"""Test navigates to next page successfully"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
# Need to set widget size for calculations
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
|
||||
mock_window.project.pages = [page1, page2, page3]
|
||||
mock_window._get_most_visible_page_index = Mock(return_value=0) # First page
|
||||
mock_window.project.get_page_display_name = Mock(return_value="Page 2")
|
||||
mock_window.show_status = Mock()
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Mock update method
|
||||
widget.update = Mock()
|
||||
|
||||
widget._navigate_to_next_page()
|
||||
|
||||
# Should have scrolled to page 2
|
||||
mock_window.show_status.assert_called_once_with("Navigated to Page 2", 2000)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_navigate_next_without_status_bar(self, qtbot):
|
||||
"""Test navigation works even without show_status method"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window._get_most_visible_page_index = Mock(return_value=0)
|
||||
mock_window.update_scrollbars = Mock()
|
||||
# No show_status attribute
|
||||
del mock_window.show_status
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
# Should not raise exception
|
||||
widget._navigate_to_next_page()
|
||||
widget.update.assert_called()
|
||||
|
||||
|
||||
class TestNavigateToPreviousPage:
|
||||
"""Test _navigate_to_previous_page method"""
|
||||
|
||||
def test_navigate_prev_no_project_attribute(self, qtbot):
|
||||
"""Test does nothing when main window has no project attribute"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
del mock_window.project
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget._navigate_to_previous_page()
|
||||
|
||||
def test_navigate_prev_no_project(self, qtbot):
|
||||
"""Test does nothing when project is None"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = None
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget._navigate_to_previous_page()
|
||||
|
||||
def test_navigate_prev_empty_pages(self, qtbot):
|
||||
"""Test does nothing when project has no pages"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.pages = []
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget._navigate_to_previous_page()
|
||||
|
||||
def test_navigate_prev_at_first_page(self, qtbot):
|
||||
"""Test does nothing when already at first page"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window._get_most_visible_page_index = Mock(return_value=0) # First page
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget._navigate_to_previous_page()
|
||||
|
||||
# Should not navigate (already at first page)
|
||||
|
||||
def test_navigate_prev_to_previous_page(self, qtbot):
|
||||
"""Test navigates to previous page successfully"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
page3 = Page(layout=PageLayout(width=210, height=297), page_number=3)
|
||||
mock_window.project.pages = [page1, page2, page3]
|
||||
mock_window._get_most_visible_page_index = Mock(return_value=2) # Third page
|
||||
mock_window.project.get_page_display_name = Mock(return_value="Page 2")
|
||||
mock_window.show_status = Mock()
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
widget._navigate_to_previous_page()
|
||||
|
||||
# Should have scrolled to page 2
|
||||
mock_window.show_status.assert_called_once_with("Navigated to Page 2", 2000)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_navigate_prev_without_status_bar(self, qtbot):
|
||||
"""Test navigation works even without show_status method"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window._get_most_visible_page_index = Mock(return_value=1)
|
||||
mock_window.update_scrollbars = Mock()
|
||||
del mock_window.show_status
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
widget._navigate_to_previous_page()
|
||||
widget.update.assert_called()
|
||||
|
||||
|
||||
class TestScrollToPage:
|
||||
"""Test _scroll_to_page method"""
|
||||
|
||||
def test_scroll_to_page_no_project_attribute(self, qtbot):
|
||||
"""Test does nothing when main window has no project attribute"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
del mock_window.project
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
widget._scroll_to_page(page, 0)
|
||||
|
||||
def test_scroll_to_page_first_page(self, qtbot):
|
||||
"""Test scrolling to first page"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
widget._scroll_to_page(page1, 0)
|
||||
|
||||
# Should have updated pan offset
|
||||
assert widget.pan_offset[1] != 0 # Y offset should be set
|
||||
widget.update.assert_called()
|
||||
mock_window.update_scrollbars.assert_called()
|
||||
|
||||
def test_scroll_to_page_second_page(self, qtbot):
|
||||
"""Test scrolling to second page accounts for first page height"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
# Scroll to first page
|
||||
widget._scroll_to_page(page1, 0)
|
||||
first_page_offset = widget.pan_offset[1]
|
||||
|
||||
# Scroll to second page
|
||||
widget._scroll_to_page(page2, 1)
|
||||
second_page_offset = widget.pan_offset[1]
|
||||
|
||||
# Second page offset should be different (accounting for first page height + spacing)
|
||||
assert second_page_offset != first_page_offset
|
||||
|
||||
def test_scroll_to_page_with_different_zoom(self, qtbot):
|
||||
"""Test scrolling respects zoom level"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page2 = Page(layout=PageLayout(width=210, height=297), page_number=2)
|
||||
mock_window.project.pages = [page1, page2]
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
# Test at 100% zoom
|
||||
widget.zoom_level = 1.0
|
||||
widget._scroll_to_page(page2, 1)
|
||||
offset_100 = widget.pan_offset[1]
|
||||
|
||||
# Test at 50% zoom
|
||||
widget.zoom_level = 0.5
|
||||
widget._scroll_to_page(page2, 1)
|
||||
offset_50 = widget.pan_offset[1]
|
||||
|
||||
# Offsets should be different due to zoom
|
||||
assert offset_100 != offset_50
|
||||
|
||||
def test_scroll_to_page_calls_clamp_if_available(self, qtbot):
|
||||
"""Test that clamp_pan_offset is called if available"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.clamp_pan_offset = Mock()
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page1]
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
widget._scroll_to_page(page1, 0)
|
||||
|
||||
# clamp_pan_offset should have been called
|
||||
widget.clamp_pan_offset.assert_called()
|
||||
|
||||
def test_scroll_to_page_without_update_scrollbars(self, qtbot):
|
||||
"""Test scrolling works even without update_scrollbars method"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
with patch.object(widget, "height", return_value=800):
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page1 = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page1]
|
||||
del mock_window.update_scrollbars
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
widget.update = Mock()
|
||||
|
||||
# Should not raise exception
|
||||
widget._scroll_to_page(page1, 0)
|
||||
widget.update.assert_called()
|
||||
|
||||
|
||||
class TestMoveViewportWithArrowKeys:
|
||||
"""Test _move_viewport_with_arrow_keys method"""
|
||||
|
||||
def test_move_viewport_up(self, qtbot):
|
||||
"""Test moving viewport up with up arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Pan Y should increase by 50
|
||||
assert widget.pan_offset[1] == 50
|
||||
assert widget.pan_offset[0] == 0
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_viewport_down(self, qtbot):
|
||||
"""Test moving viewport down with down arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Down)
|
||||
|
||||
# Pan Y should decrease by 50
|
||||
assert widget.pan_offset[1] == -50
|
||||
assert widget.pan_offset[0] == 0
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_viewport_left(self, qtbot):
|
||||
"""Test moving viewport left with left arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Left)
|
||||
|
||||
# Pan X should increase by 50
|
||||
assert widget.pan_offset[0] == 50
|
||||
assert widget.pan_offset[1] == 0
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_viewport_right(self, qtbot):
|
||||
"""Test moving viewport right with right arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Right)
|
||||
|
||||
# Pan X should decrease by 50
|
||||
assert widget.pan_offset[0] == -50
|
||||
assert widget.pan_offset[1] == 0
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_viewport_multiple_moves(self, qtbot):
|
||||
"""Test multiple consecutive moves accumulate"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Right)
|
||||
|
||||
# Should have moved up (Y+50) and right (X-50)
|
||||
assert widget.pan_offset[0] == -50
|
||||
assert widget.pan_offset[1] == 50
|
||||
|
||||
def test_move_viewport_calls_clamp_if_available(self, qtbot):
|
||||
"""Test that clamp_pan_offset is called if available"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
widget.clamp_pan_offset = Mock()
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
widget.clamp_pan_offset.assert_called()
|
||||
|
||||
def test_move_viewport_updates_scrollbars_if_available(self, qtbot):
|
||||
"""Test that update_scrollbars is called if available"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.pages = []
|
||||
mock_window.update_scrollbars = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
mock_window.update_scrollbars.assert_called()
|
||||
|
||||
def test_move_viewport_without_scrollbars(self, qtbot):
|
||||
"""Test movement works even without update_scrollbars method"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.pages = []
|
||||
del mock_window.update_scrollbars
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
# Should not raise exception
|
||||
widget._move_viewport_with_arrow_keys(Qt.Key.Key_Up)
|
||||
assert widget.pan_offset[1] == 50
|
||||
|
||||
|
||||
class TestMoveSelectedElementsWithArrowKeys:
|
||||
"""Test _move_selected_elements_with_arrow_keys method"""
|
||||
|
||||
def test_move_elements_no_project_attribute(self, qtbot):
|
||||
"""Test does nothing when main window has no project attribute"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
mock_window = Mock()
|
||||
del mock_window.project
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Should not raise exception
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
def test_move_elements_no_selected_elements(self, qtbot):
|
||||
"""Test works when no elements are selected (just updates view)"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.selected_elements = set()
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Update is called even with no selected elements
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_elements_up(self, qtbot):
|
||||
"""Test moving elements up with up arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Create element without parent page (no snapping)
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Y should decrease by 1mm
|
||||
assert element.position == (5, 4)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_elements_down(self, qtbot):
|
||||
"""Test moving elements down with down arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Down)
|
||||
|
||||
# Y should increase by 1mm
|
||||
assert element.position == (5, 6)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_elements_left(self, qtbot):
|
||||
"""Test moving elements left with left arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Left)
|
||||
|
||||
# X should decrease by 1mm
|
||||
assert element.position == (4, 5)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_elements_right(self, qtbot):
|
||||
"""Test moving elements right with right arrow"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Right)
|
||||
|
||||
# X should increase by 1mm
|
||||
assert element.position == (6, 5)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_multiple_elements(self, qtbot):
|
||||
"""Test moving multiple selected elements"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element1 = ImageData(image_path="test1.jpg", width=10, height=10, x=5, y=5)
|
||||
element2 = TextBoxData(text_content="Test", width=20, height=20, x=10, y=10)
|
||||
widget.selected_elements = {element1, element2}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Both elements should move
|
||||
assert element1.position == (5, 4)
|
||||
assert element2.position == (10, 9)
|
||||
mock_window.show_status.assert_called_once_with("Moved 2 elements", 1000)
|
||||
|
||||
def test_move_elements_with_snapping(self, qtbot):
|
||||
"""Test moving elements with snapping enabled"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Create element with parent page
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
|
||||
# Create mock page with snapping system
|
||||
mock_page = Mock()
|
||||
mock_snapping_system = Mock()
|
||||
mock_snapping_system.snap_position = Mock(return_value=(5.5, 4.5)) # Snapped position
|
||||
mock_layout = Mock()
|
||||
mock_layout.snapping_system = mock_snapping_system
|
||||
mock_layout.size = (210, 297)
|
||||
mock_page.layout = mock_layout
|
||||
|
||||
element._parent_page = mock_page
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Position should be the snapped position
|
||||
assert element.position == (5.5, 4.5)
|
||||
# Snapping system should have been called
|
||||
mock_snapping_system.snap_position.assert_called_once()
|
||||
|
||||
def test_move_elements_without_snapping(self, qtbot):
|
||||
"""Test moving elements without parent page (no snapping)"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
# No _parent_page attribute
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Position should be directly set (no snapping)
|
||||
assert element.position == (5, 4)
|
||||
|
||||
def test_move_elements_shows_status_single_element(self, qtbot):
|
||||
"""Test status message for single element"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
mock_window.show_status.assert_called_once_with("Moved 1 element", 1000)
|
||||
|
||||
def test_move_elements_shows_status_multiple_elements(self, qtbot):
|
||||
"""Test status message for multiple elements"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element1 = ImageData(image_path="test1.jpg", width=10, height=10, x=5, y=5)
|
||||
element2 = ImageData(image_path="test2.jpg", width=10, height=10, x=10, y=10)
|
||||
element3 = ImageData(image_path="test3.jpg", width=10, height=10, x=15, y=15)
|
||||
widget.selected_elements = {element1, element2, element3}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Right)
|
||||
|
||||
mock_window.show_status.assert_called_once_with("Moved 3 elements", 1000)
|
||||
|
||||
def test_move_elements_without_status_bar(self, qtbot):
|
||||
"""Test movement works even without show_status method"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
del mock_window.show_status
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
# Should not raise exception
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
assert element.position == (5, 4)
|
||||
widget.update.assert_called()
|
||||
|
||||
def test_move_elements_element_without_parent_page_attribute(self, qtbot):
|
||||
"""Test moving elements that don't have _parent_page attribute"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
# Ensure no _parent_page attribute exists
|
||||
if hasattr(element, "_parent_page"):
|
||||
delattr(element, "_parent_page")
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Should use direct positioning (no snapping)
|
||||
assert element.position == (5, 4)
|
||||
|
||||
def test_move_elements_with_none_parent_page(self, qtbot):
|
||||
"""Test moving elements with _parent_page set to None"""
|
||||
widget = KeyboardNavWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
element = ImageData(image_path="test.jpg", width=10, height=10, x=5, y=5)
|
||||
element._parent_page = None
|
||||
widget.selected_elements = {element}
|
||||
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.show_status = Mock()
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.update = Mock()
|
||||
|
||||
widget._move_selected_elements_with_arrow_keys(Qt.Key.Key_Up)
|
||||
|
||||
# Should use direct positioning (no snapping)
|
||||
assert element.position == (5, 4)
|
||||
391
tests/test_loading_widget.py
Normal file
391
tests/test_loading_widget.py
Normal file
@ -0,0 +1,391 @@
|
||||
"""
|
||||
Tests for loading_widget module
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
|
||||
class TestLoadingWidget:
|
||||
"""Tests for LoadingWidget class"""
|
||||
|
||||
def test_init(self, qtbot):
|
||||
"""Test LoadingWidget initialization"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.isHidden()
|
||||
assert widget.width() == 280
|
||||
assert widget.height() == 80
|
||||
|
||||
def test_init_with_parent(self, qtbot):
|
||||
"""Test LoadingWidget initialization with parent"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
assert widget.parent() == parent
|
||||
|
||||
def test_opacity_property(self, qtbot):
|
||||
"""Test opacity property getter and setter"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Test getter
|
||||
assert widget.opacity == 1.0
|
||||
|
||||
# Test setter
|
||||
widget.opacity = 0.5
|
||||
assert widget.opacity == 0.5
|
||||
|
||||
def test_show_loading(self, qtbot):
|
||||
"""Test showing the loading widget"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
widget.show_loading("Processing...")
|
||||
|
||||
assert widget.isVisible()
|
||||
assert widget._status_label.text() == "Processing..."
|
||||
assert widget._progress_bar.value() == 0
|
||||
|
||||
def test_show_loading_default_message(self, qtbot):
|
||||
"""Test showing loading widget with default message"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
widget.show_loading()
|
||||
|
||||
assert widget._status_label.text() == "Loading..."
|
||||
|
||||
def test_hide_loading(self, qtbot):
|
||||
"""Test hiding the loading widget"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
# Show first
|
||||
widget.show_loading()
|
||||
assert widget.isVisible()
|
||||
|
||||
# Then hide
|
||||
widget.hide_loading()
|
||||
|
||||
# Animation starts but widget may not be immediately hidden
|
||||
# Just verify the method runs without error
|
||||
|
||||
def test_set_status(self, qtbot):
|
||||
"""Test setting status message"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.set_status("Processing files...")
|
||||
|
||||
assert widget._status_label.text() == "Processing files..."
|
||||
|
||||
def test_set_progress(self, qtbot):
|
||||
"""Test setting progress value"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.set_progress(50)
|
||||
|
||||
assert widget._progress_bar.value() == 50
|
||||
assert widget._progress_bar.maximum() == 100
|
||||
|
||||
def test_set_progress_with_custom_maximum(self, qtbot):
|
||||
"""Test setting progress with custom maximum"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.set_progress(75, maximum=200)
|
||||
|
||||
assert widget._progress_bar.value() == 75
|
||||
assert widget._progress_bar.maximum() == 200
|
||||
|
||||
def test_set_indeterminate_true(self, qtbot):
|
||||
"""Test setting indeterminate mode to True"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget.set_indeterminate(True)
|
||||
|
||||
assert widget._progress_bar.minimum() == 0
|
||||
assert widget._progress_bar.maximum() == 0
|
||||
|
||||
def test_set_indeterminate_false(self, qtbot):
|
||||
"""Test setting indeterminate mode to False"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# First set to indeterminate
|
||||
widget.set_indeterminate(True)
|
||||
|
||||
# Then set back to normal
|
||||
widget.set_indeterminate(False)
|
||||
|
||||
assert widget._progress_bar.minimum() == 0
|
||||
assert widget._progress_bar.maximum() == 100
|
||||
|
||||
def test_reposition_with_parent(self, qtbot):
|
||||
"""Test repositioning in lower-right corner of parent"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
widget._reposition()
|
||||
|
||||
# Should be in lower-right corner with 20px margin
|
||||
expected_x = 800 - 280 - 20
|
||||
expected_y = 600 - 80 - 20
|
||||
|
||||
assert widget.x() == expected_x
|
||||
assert widget.y() == expected_y
|
||||
|
||||
def test_reposition_no_parent(self, qtbot):
|
||||
"""Test repositioning when there's no parent"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not crash
|
||||
widget._reposition()
|
||||
|
||||
def test_show_event(self, qtbot):
|
||||
"""Test show event triggers repositioning"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
with patch.object(widget, '_reposition') as mock_reposition:
|
||||
widget.show()
|
||||
mock_reposition.assert_called()
|
||||
|
||||
def test_resize_parent(self, qtbot):
|
||||
"""Test resizeParent method"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
widget.show_loading()
|
||||
|
||||
# Resize parent
|
||||
parent.resize(1000, 800)
|
||||
|
||||
with patch.object(widget, '_reposition') as mock_reposition:
|
||||
widget.resizeParent()
|
||||
mock_reposition.assert_called_once()
|
||||
|
||||
def test_resize_parent_when_hidden(self, qtbot):
|
||||
"""Test resizeParent when widget is hidden"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
# Widget is hidden by default
|
||||
|
||||
with patch.object(widget, '_reposition') as mock_reposition:
|
||||
widget.resizeParent()
|
||||
# Should not call reposition when hidden
|
||||
mock_reposition.assert_not_called()
|
||||
|
||||
def test_fade_animation_on_show(self, qtbot):
|
||||
"""Test fade animation starts on show_loading"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
widget.show_loading("Test")
|
||||
|
||||
# Animation should be running
|
||||
assert widget._fade_animation.state() != 0 # Not stopped
|
||||
|
||||
def test_fade_animation_on_hide(self, qtbot):
|
||||
"""Test fade animation starts on hide_loading"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
widget.show_loading()
|
||||
|
||||
widget.hide_loading()
|
||||
|
||||
# Animation should be running
|
||||
assert widget._fade_animation.state() != 0 # Not stopped
|
||||
|
||||
def test_progress_bar_format(self, qtbot):
|
||||
"""Test progress bar displays percentage format"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._progress_bar.format() == "%p%"
|
||||
|
||||
def test_progress_bar_text_visible(self, qtbot):
|
||||
"""Test progress bar text is visible"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._progress_bar.isTextVisible() is True
|
||||
|
||||
def test_window_flags(self, qtbot):
|
||||
"""Test widget has correct window flags"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
flags = widget.windowFlags()
|
||||
assert Qt.WindowType.ToolTip in Qt.WindowType(flags)
|
||||
assert Qt.WindowType.FramelessWindowHint in Qt.WindowType(flags)
|
||||
|
||||
def test_fixed_size(self, qtbot):
|
||||
"""Test widget has fixed size"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.minimumWidth() == 280
|
||||
assert widget.maximumWidth() == 280
|
||||
assert widget.minimumHeight() == 80
|
||||
assert widget.maximumHeight() == 80
|
||||
|
||||
def test_sequential_show_hide(self, qtbot):
|
||||
"""Test showing and hiding widget multiple times"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
# First cycle
|
||||
widget.show_loading("Task 1")
|
||||
widget.set_progress(50)
|
||||
widget.hide_loading()
|
||||
|
||||
# Second cycle
|
||||
widget.show_loading("Task 2")
|
||||
widget.set_progress(25)
|
||||
assert widget._status_label.text() == "Task 2"
|
||||
assert widget._progress_bar.value() == 25
|
||||
|
||||
def test_update_progress_while_visible(self, qtbot):
|
||||
"""Test updating progress while widget is visible"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
widget.show_loading("Processing")
|
||||
|
||||
# Update progress multiple times
|
||||
for i in range(0, 101, 10):
|
||||
widget.set_progress(i)
|
||||
assert widget._progress_bar.value() == i
|
||||
|
||||
def test_status_label_alignment(self, qtbot):
|
||||
"""Test status label is left-aligned"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._status_label.alignment() == Qt.AlignmentFlag.AlignLeft
|
||||
|
||||
def test_initial_opacity(self, qtbot):
|
||||
"""Test initial opacity value"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._opacity == 1.0
|
||||
|
||||
def test_fade_animation_duration(self, qtbot):
|
||||
"""Test fade animation has correct duration"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
widget = LoadingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget._fade_animation.duration() == 300
|
||||
|
||||
def test_show_loading_resets_progress(self, qtbot):
|
||||
"""Test that show_loading resets progress to 0"""
|
||||
from pyPhotoAlbum.loading_widget import LoadingWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.resize(800, 600)
|
||||
qtbot.addWidget(parent)
|
||||
|
||||
widget = LoadingWidget(parent)
|
||||
|
||||
# Set some progress
|
||||
widget.set_progress(75)
|
||||
|
||||
# Show loading should reset to 0
|
||||
widget.show_loading("New task")
|
||||
|
||||
assert widget._progress_bar.value() == 0
|
||||
605
tests/test_merge_dialog.py
Normal file
605
tests/test_merge_dialog.py
Normal file
@ -0,0 +1,605 @@
|
||||
"""
|
||||
Tests for merge_dialog module
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import QRadioButton
|
||||
|
||||
|
||||
class TestPagePreviewWidget:
|
||||
"""Tests for PagePreviewWidget class"""
|
||||
|
||||
def test_init(self, qtbot):
|
||||
"""Test PagePreviewWidget initialization"""
|
||||
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
|
||||
|
||||
page_data = {
|
||||
"page_number": 1,
|
||||
"layout": {"elements": []},
|
||||
"last_modified": "2024-01-01 12:00:00",
|
||||
}
|
||||
|
||||
widget = PagePreviewWidget(page_data)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.page_data == page_data
|
||||
assert widget.minimumSize().width() == 200
|
||||
assert widget.minimumSize().height() == 280
|
||||
|
||||
def test_paint_event_basic(self, qtbot):
|
||||
"""Test basic paint event rendering"""
|
||||
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
|
||||
|
||||
page_data = {
|
||||
"page_number": 2,
|
||||
"layout": {"elements": []},
|
||||
"last_modified": "2024-01-01",
|
||||
}
|
||||
|
||||
widget = PagePreviewWidget(page_data)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
|
||||
# Trigger paint event
|
||||
widget.repaint()
|
||||
|
||||
def test_paint_event_with_elements(self, qtbot):
|
||||
"""Test paint event with elements"""
|
||||
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
|
||||
|
||||
page_data = {
|
||||
"page_number": 3,
|
||||
"layout": {
|
||||
"elements": [
|
||||
{"type": "image", "deleted": False},
|
||||
{"type": "textbox", "deleted": False},
|
||||
{"type": "shape", "deleted": True},
|
||||
]
|
||||
},
|
||||
"last_modified": "2024-01-01 10:00:00",
|
||||
}
|
||||
|
||||
widget = PagePreviewWidget(page_data)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
widget.repaint()
|
||||
|
||||
def test_paint_event_many_elements(self, qtbot):
|
||||
"""Test paint event with more than 5 elements (tests truncation)"""
|
||||
from pyPhotoAlbum.merge_dialog import PagePreviewWidget
|
||||
|
||||
elements = [{"type": f"elem{i}", "deleted": False} for i in range(10)]
|
||||
|
||||
page_data = {
|
||||
"page_number": 4,
|
||||
"layout": {"elements": elements},
|
||||
"last_modified": "2024-01-01",
|
||||
}
|
||||
|
||||
widget = PagePreviewWidget(page_data)
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
widget.repaint()
|
||||
|
||||
|
||||
class TestConflictItemWidget:
|
||||
"""Tests for ConflictItemWidget class"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conflict_page(self):
|
||||
"""Create a mock page conflict"""
|
||||
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
|
||||
|
||||
conflict = ConflictInfo(
|
||||
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
|
||||
page_uuid="page-uuid-1",
|
||||
element_uuid=None,
|
||||
description="Page 1 modified in both versions",
|
||||
our_version={
|
||||
"page_number": 1,
|
||||
"layout": {"elements": [{"type": "image"}]},
|
||||
"last_modified": "2024-01-01 10:00:00",
|
||||
},
|
||||
their_version={
|
||||
"page_number": 1,
|
||||
"layout": {"elements": [{"type": "image"}, {"type": "textbox"}]},
|
||||
"last_modified": "2024-01-01 11:00:00",
|
||||
},
|
||||
)
|
||||
return conflict
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conflict_element(self):
|
||||
"""Create a mock element conflict"""
|
||||
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
|
||||
|
||||
conflict = ConflictInfo(
|
||||
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
|
||||
page_uuid="page-uuid-1",
|
||||
element_uuid="element-uuid-1",
|
||||
description="Element modified in both versions",
|
||||
our_version={
|
||||
"type": "image",
|
||||
"position": (10, 20),
|
||||
"size": (100, 100),
|
||||
"deleted": False,
|
||||
"last_modified": "2024-01-01",
|
||||
},
|
||||
their_version={
|
||||
"type": "image",
|
||||
"position": (15, 25),
|
||||
"size": (120, 120),
|
||||
"deleted": False,
|
||||
"last_modified": "2024-01-02",
|
||||
},
|
||||
)
|
||||
return conflict
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conflict_settings(self):
|
||||
"""Create a mock settings conflict"""
|
||||
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
|
||||
|
||||
conflict = ConflictInfo(
|
||||
conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH,
|
||||
page_uuid=None,
|
||||
element_uuid=None,
|
||||
description="Settings modified",
|
||||
our_version={"theme": "light", "font_size": 12, "last_modified": "2024-01-01"},
|
||||
their_version={"theme": "dark", "font_size": 14, "last_modified": "2024-01-02"},
|
||||
)
|
||||
return conflict
|
||||
|
||||
def test_init_page_conflict(self, qtbot, mock_conflict_page):
|
||||
"""Test initialization with page conflict"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_page)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.conflict_index == 0
|
||||
assert widget.conflict == mock_conflict_page
|
||||
|
||||
def test_init_element_conflict(self, qtbot, mock_conflict_element):
|
||||
"""Test initialization with element conflict"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(1, mock_conflict_element)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.conflict_index == 1
|
||||
assert widget.conflict == mock_conflict_element
|
||||
|
||||
def test_init_settings_conflict(self, qtbot, mock_conflict_settings):
|
||||
"""Test initialization with settings conflict"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(2, mock_conflict_settings)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert widget.conflict_index == 2
|
||||
|
||||
def test_resolution_changed_signal_ours(self, qtbot, mock_conflict_page):
|
||||
"""Test resolution_changed signal for 'ours'"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_page)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_resolution_changed(index, choice):
|
||||
signal_received.append((index, choice))
|
||||
|
||||
widget.resolution_changed.connect(on_resolution_changed)
|
||||
|
||||
# First select "theirs", then select "ours" to trigger the signal
|
||||
for button in widget.button_group.buttons():
|
||||
if "Other" in button.text():
|
||||
button.setChecked(True)
|
||||
break
|
||||
|
||||
# Clear the signal list
|
||||
signal_received.clear()
|
||||
|
||||
# Now select "ours" to trigger the signal
|
||||
for button in widget.button_group.buttons():
|
||||
if "Your" in button.text():
|
||||
button.setChecked(True)
|
||||
break
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == (0, "ours")
|
||||
|
||||
def test_resolution_changed_signal_theirs(self, qtbot, mock_conflict_page):
|
||||
"""Test resolution_changed signal for 'theirs'"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_page)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
signal_received = []
|
||||
|
||||
def on_resolution_changed(index, choice):
|
||||
signal_received.append((index, choice))
|
||||
|
||||
widget.resolution_changed.connect(on_resolution_changed)
|
||||
|
||||
# Find "Use Other Version" button and click it
|
||||
for button in widget.button_group.buttons():
|
||||
if "Other" in button.text():
|
||||
button.setChecked(True)
|
||||
break
|
||||
|
||||
assert len(signal_received) == 1
|
||||
assert signal_received[0] == (0, "theirs")
|
||||
|
||||
def test_get_resolution_ours(self, qtbot, mock_conflict_page):
|
||||
"""Test get_resolution returns 'ours' when selected"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_page)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Default is "ours"
|
||||
assert widget.get_resolution() == "ours"
|
||||
|
||||
def test_get_resolution_theirs(self, qtbot, mock_conflict_page):
|
||||
"""Test get_resolution returns 'theirs' when selected"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_page)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Select "theirs"
|
||||
for button in widget.button_group.buttons():
|
||||
if "Other" in button.text():
|
||||
button.setChecked(True)
|
||||
break
|
||||
|
||||
assert widget.get_resolution() == "theirs"
|
||||
|
||||
def test_create_element_details_image(self, qtbot, mock_conflict_element):
|
||||
"""Test creating element details for image element"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_element)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
details = widget._create_element_details(mock_conflict_element.our_version)
|
||||
|
||||
assert details is not None
|
||||
assert "Type: image" in details.toPlainText()
|
||||
|
||||
def test_create_element_details_textbox(self, qtbot):
|
||||
"""Test creating element details for textbox element"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
|
||||
|
||||
element_data = {
|
||||
"type": "textbox",
|
||||
"position": (0, 0),
|
||||
"size": (100, 50),
|
||||
"deleted": False,
|
||||
"last_modified": "2024-01-01",
|
||||
"text_content": "This is a long text that should be truncated after 50 characters for display",
|
||||
}
|
||||
|
||||
conflict = ConflictInfo(
|
||||
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
|
||||
page_uuid="page-uuid-1",
|
||||
element_uuid="element-uuid-2",
|
||||
description="Text element",
|
||||
our_version=element_data,
|
||||
their_version=element_data,
|
||||
)
|
||||
|
||||
widget = ConflictItemWidget(0, conflict)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
details = widget._create_element_details(element_data)
|
||||
text = details.toPlainText()
|
||||
|
||||
assert "Type: textbox" in text
|
||||
assert "Text:" in text
|
||||
|
||||
def test_create_settings_details(self, qtbot, mock_conflict_settings):
|
||||
"""Test creating settings details"""
|
||||
from pyPhotoAlbum.merge_dialog import ConflictItemWidget
|
||||
|
||||
widget = ConflictItemWidget(0, mock_conflict_settings)
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
details = widget._create_settings_details(mock_conflict_settings.our_version)
|
||||
|
||||
assert details is not None
|
||||
text = details.toPlainText()
|
||||
assert "theme: light" in text
|
||||
assert "font_size: 12" in text
|
||||
assert "Modified:" in text
|
||||
|
||||
|
||||
class TestMergeDialog:
|
||||
"""Tests for MergeDialog class"""
|
||||
|
||||
@pytest.fixture
|
||||
def our_project_data(self):
|
||||
"""Create mock 'our' project data"""
|
||||
return {
|
||||
"name": "Our Project",
|
||||
"last_modified": "2024-01-01 10:00:00",
|
||||
"pages": [
|
||||
{
|
||||
"page_number": 1,
|
||||
"layout": {"elements": [{"type": "image"}]},
|
||||
"last_modified": "2024-01-01 10:00:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def their_project_data(self):
|
||||
"""Create mock 'their' project data"""
|
||||
return {
|
||||
"name": "Their Project",
|
||||
"last_modified": "2024-01-01 11:00:00",
|
||||
"pages": [
|
||||
{
|
||||
"page_number": 1,
|
||||
"layout": {"elements": [{"type": "image"}, {"type": "textbox"}]},
|
||||
"last_modified": "2024-01-01 11:00:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conflicts(self):
|
||||
"""Create mock conflicts"""
|
||||
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
|
||||
|
||||
return [
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
|
||||
page_uuid="page-uuid-conflict",
|
||||
element_uuid=None,
|
||||
description="Page conflict",
|
||||
our_version={"page_number": 1, "layout": {"elements": []}},
|
||||
their_version={"page_number": 1, "layout": {"elements": [{"type": "image"}]}},
|
||||
)
|
||||
]
|
||||
|
||||
def test_init(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test MergeDialog initialization"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.our_project_data == our_project_data
|
||||
assert dialog.their_project_data == their_project_data
|
||||
assert len(dialog.conflicts) == 1
|
||||
assert len(dialog.resolutions) == 1
|
||||
assert dialog.resolutions[0] == "ours" # Default
|
||||
|
||||
def test_init_ui(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test UI initialization"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert dialog.windowTitle() == "Merge Projects"
|
||||
assert dialog.strategy_combo is not None
|
||||
assert len(dialog.conflict_widgets) == 1
|
||||
|
||||
def test_on_resolution_changed(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test handling resolution changes"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog._on_resolution_changed(0, "theirs")
|
||||
|
||||
assert dialog.resolutions[0] == "theirs"
|
||||
|
||||
def test_auto_resolve_latest_wins(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test auto-resolve with LATEST_WINS strategy"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
from pyPhotoAlbum.merge_manager import MergeStrategy
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
mock_manager.auto_resolve_conflicts.return_value = {0: "theirs"}
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Set strategy to LATEST_WINS
|
||||
dialog.strategy_combo.setCurrentIndex(0)
|
||||
|
||||
dialog._auto_resolve()
|
||||
|
||||
mock_manager.auto_resolve_conflicts.assert_called_once()
|
||||
assert dialog.resolutions[0] == "theirs"
|
||||
|
||||
def test_auto_resolve_ours(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test auto-resolve with OURS strategy"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
from pyPhotoAlbum.merge_manager import MergeStrategy
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
mock_manager.auto_resolve_conflicts.return_value = {0: "ours"}
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Set strategy to OURS
|
||||
dialog.strategy_combo.setCurrentIndex(1)
|
||||
|
||||
dialog._auto_resolve()
|
||||
|
||||
assert dialog.resolutions[0] == "ours"
|
||||
|
||||
def test_auto_resolve_theirs(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test auto-resolve with THEIRS strategy"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
from pyPhotoAlbum.merge_manager import MergeStrategy
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
mock_manager.auto_resolve_conflicts.return_value = {0: "theirs"}
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# Set strategy to THEIRS
|
||||
dialog.strategy_combo.setCurrentIndex(2)
|
||||
|
||||
dialog._auto_resolve()
|
||||
|
||||
assert dialog.resolutions[0] == "theirs"
|
||||
|
||||
def test_auto_resolve_updates_ui(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test that auto-resolve updates UI radio buttons"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
mock_manager.auto_resolve_conflicts.return_value = {0: "theirs"}
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
dialog._auto_resolve()
|
||||
|
||||
# Check that the "Other Version" button is now checked
|
||||
conflict_widget = dialog.conflict_widgets[0]
|
||||
resolution = conflict_widget.get_resolution()
|
||||
assert resolution == "theirs"
|
||||
|
||||
def test_get_merged_project_data(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test getting merged project data"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
merged_data = {"name": "Merged", "pages": []}
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
mock_manager.apply_resolutions.return_value = merged_data
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
result = dialog.get_merged_project_data()
|
||||
|
||||
mock_manager.apply_resolutions.assert_called_once_with(
|
||||
our_project_data, their_project_data, dialog.resolutions
|
||||
)
|
||||
assert result == merged_data
|
||||
|
||||
def test_accept_button(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test clicking Accept button"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# This should trigger accept without errors
|
||||
dialog.accept()
|
||||
|
||||
def test_reject_button(self, qtbot, our_project_data, their_project_data, mock_conflicts):
|
||||
"""Test clicking Cancel button"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = mock_conflicts
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
# This should trigger reject without errors
|
||||
dialog.reject()
|
||||
|
||||
def test_no_conflicts(self, qtbot, our_project_data, their_project_data):
|
||||
"""Test dialog with no conflicts"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = []
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert len(dialog.conflicts) == 0
|
||||
assert len(dialog.conflict_widgets) == 0
|
||||
|
||||
def test_multiple_conflicts(self, qtbot, our_project_data, their_project_data):
|
||||
"""Test dialog with multiple conflicts"""
|
||||
from pyPhotoAlbum.merge_dialog import MergeDialog
|
||||
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
|
||||
|
||||
conflicts = [
|
||||
ConflictInfo(
|
||||
conflict_type=ConflictType.PAGE_MODIFIED_BOTH,
|
||||
page_uuid=f"page-uuid-{i}",
|
||||
element_uuid=None,
|
||||
description=f"Conflict {i}",
|
||||
our_version={"page_number": i},
|
||||
their_version={"page_number": i},
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
with patch("pyPhotoAlbum.merge_dialog.MergeManager") as MockMergeManager:
|
||||
mock_manager = Mock()
|
||||
mock_manager.detect_conflicts.return_value = conflicts
|
||||
MockMergeManager.return_value = mock_manager
|
||||
|
||||
dialog = MergeDialog(our_project_data, their_project_data)
|
||||
qtbot.addWidget(dialog)
|
||||
|
||||
assert len(dialog.conflicts) == 5
|
||||
assert len(dialog.conflict_widgets) == 5
|
||||
assert len(dialog.resolutions) == 5
|
||||
|
||||
# All should default to "ours"
|
||||
for i in range(5):
|
||||
assert dialog.resolutions[i] == "ours"
|
||||
547
tests/test_merge_ops_mixin.py
Normal file
547
tests/test_merge_ops_mixin.py
Normal file
@ -0,0 +1,547 @@
|
||||
"""
|
||||
Tests for MergeOperationsMixin
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import tempfile
|
||||
import os
|
||||
from unittest.mock import Mock, MagicMock, patch, call
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox, QFileDialog
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.operations.merge_ops import MergeOperationsMixin
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.models import TextBoxData
|
||||
|
||||
|
||||
class MergeOpsWindow(MergeOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||
"""Test window with merge operations mixin"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._project = Project(name="Test Project")
|
||||
self._gl_widget = Mock()
|
||||
self._gl_widget.current_page_index = 0
|
||||
self._autosave_timer = Mock()
|
||||
self._status_bar = Mock()
|
||||
|
||||
# Track calls
|
||||
self._save_project_called = False
|
||||
self._update_view_called = False
|
||||
|
||||
@property
|
||||
def gl_widget(self):
|
||||
return self._gl_widget
|
||||
|
||||
@property
|
||||
def status_bar(self):
|
||||
return self._status_bar
|
||||
|
||||
def save_project(self):
|
||||
self._save_project_called = True
|
||||
|
||||
def update_view(self):
|
||||
self._update_view_called = True
|
||||
|
||||
|
||||
class TestMergeProjects:
|
||||
"""Test merge_projects method"""
|
||||
|
||||
def test_dirty_project_user_cancels(self, qtbot):
|
||||
"""Test user cancels when prompted about unsaved changes"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Make project dirty
|
||||
window.project.mark_dirty()
|
||||
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Cancel):
|
||||
window.merge_projects()
|
||||
|
||||
# Should return early without attempting merge
|
||||
assert not window._save_project_called
|
||||
|
||||
def test_dirty_project_user_saves(self, qtbot):
|
||||
"""Test user chooses to save before merging"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Make project dirty
|
||||
window.project.mark_dirty()
|
||||
|
||||
# Mock QMessageBox.question to return Yes, then mock file dialog to cancel
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')):
|
||||
window.merge_projects()
|
||||
|
||||
# Should have called save_project
|
||||
assert window._save_project_called
|
||||
|
||||
def test_dirty_project_user_skips_save(self, qtbot):
|
||||
"""Test user chooses not to save before merging"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Make project dirty
|
||||
window.project.mark_dirty()
|
||||
|
||||
# Mock QMessageBox.question to return No, then mock file dialog to cancel
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No):
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')):
|
||||
window.merge_projects()
|
||||
|
||||
# Should NOT have called save_project
|
||||
assert not window._save_project_called
|
||||
|
||||
def test_user_cancels_file_selection(self, qtbot):
|
||||
"""Test user cancels the file selection dialog"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Project is clean (not dirty)
|
||||
assert not window.project.is_dirty()
|
||||
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=('', '')):
|
||||
window.merge_projects()
|
||||
|
||||
# Should return early
|
||||
assert not window._update_view_called
|
||||
|
||||
def test_autosave_timer_stopped_and_restarted(self, qtbot, tmp_path):
|
||||
"""Test autosave timer is stopped during merge and restarted after"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create a temporary project file
|
||||
test_project = Project("Other Project")
|
||||
test_file = tmp_path / "test.ppz"
|
||||
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||
save_to_zip(test_project, str(test_file))
|
||||
|
||||
# Track timer calls
|
||||
timer_stop_called = False
|
||||
timer_start_called = False
|
||||
|
||||
def mock_stop():
|
||||
nonlocal timer_stop_called
|
||||
timer_stop_called = True
|
||||
|
||||
def mock_start():
|
||||
nonlocal timer_start_called
|
||||
timer_start_called = True
|
||||
|
||||
window._autosave_timer.stop = mock_stop
|
||||
window._autosave_timer.start = mock_start
|
||||
|
||||
# Mock file dialog to return our test file
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')):
|
||||
# Mock QMessageBox for the concatenation question
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
|
||||
# Mock the information box
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
window.merge_projects()
|
||||
|
||||
# Verify timer was stopped and started
|
||||
assert timer_stop_called
|
||||
assert timer_start_called
|
||||
|
||||
def test_merge_same_project_no_conflicts(self, qtbot, tmp_path):
|
||||
"""Test merging same project with no conflicts"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create base project
|
||||
base_project = Project("Base Project")
|
||||
base_project._project_id = "same-id-123"
|
||||
page = Page(page_number=1)
|
||||
text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50)
|
||||
page.layout.add_element(text)
|
||||
base_project.add_page(page)
|
||||
|
||||
# Set window's project to have same ID
|
||||
window._project = Project("Base Project")
|
||||
window._project._project_id = "same-id-123"
|
||||
page1 = Page(page_number=1)
|
||||
window._project.add_page(page1)
|
||||
|
||||
# Create temporary project file
|
||||
test_file = tmp_path / "test.ppz"
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||
save_to_zip(base_project, str(test_file))
|
||||
|
||||
# Mock file dialog
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')):
|
||||
# Mock QMessageBox to accept auto-merge (no conflicts)
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
|
||||
# Mock the completion information box
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
window.merge_projects()
|
||||
|
||||
# Should show completion message
|
||||
assert mock_info.called
|
||||
|
||||
def test_merge_different_projects_concatenation(self, qtbot, tmp_path):
|
||||
"""Test concatenating different projects"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create another project with different ID
|
||||
other_project = Project("Other Project")
|
||||
page = Page(page_number=1)
|
||||
other_project.add_page(page)
|
||||
|
||||
# Create temporary project file
|
||||
test_file = tmp_path / "test.ppz"
|
||||
from pyPhotoAlbum.project_serializer import save_to_zip
|
||||
save_to_zip(other_project, str(test_file))
|
||||
|
||||
# Mock file dialog
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=(str(test_file), '')):
|
||||
# Mock QMessageBox to accept concatenation
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
|
||||
# Mock the completion information box
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
window.merge_projects()
|
||||
|
||||
# Should show completion message
|
||||
assert mock_info.called
|
||||
|
||||
def test_merge_error_handling(self, qtbot):
|
||||
"""Test error handling during merge"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Mock file dialog to return invalid file
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=('/invalid/path.ppz', '')):
|
||||
# Mock QMessageBox.critical to capture error
|
||||
with patch.object(QMessageBox, 'critical') as mock_critical:
|
||||
window.merge_projects()
|
||||
|
||||
# Should show error message
|
||||
assert mock_critical.called
|
||||
args = mock_critical.call_args[0]
|
||||
assert "Merge Error" in args[1]
|
||||
|
||||
def test_merge_timer_restarted_after_error(self, qtbot):
|
||||
"""Test autosave timer is restarted even after error"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
timer_start_called = False
|
||||
|
||||
def mock_start():
|
||||
nonlocal timer_start_called
|
||||
timer_start_called = True
|
||||
|
||||
window._autosave_timer.start = mock_start
|
||||
|
||||
# Mock file dialog to return invalid file (will cause error)
|
||||
with patch.object(QFileDialog, 'getOpenFileName', return_value=('/invalid/path.ppz', '')):
|
||||
with patch.object(QMessageBox, 'critical'):
|
||||
window.merge_projects()
|
||||
|
||||
# Timer should be restarted even after error
|
||||
assert timer_start_called
|
||||
|
||||
|
||||
class TestPerformMergeWithConflicts:
|
||||
"""Test _perform_merge_with_conflicts method"""
|
||||
|
||||
def test_no_conflicts_user_accepts(self, qtbot):
|
||||
"""Test auto-merge when no conflicts and user accepts"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create mock data
|
||||
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
|
||||
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
|
||||
|
||||
# Mock MergeManager to return no conflicts
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
|
||||
mock_manager = MockMergeManager.return_value
|
||||
mock_manager.detect_conflicts.return_value = []
|
||||
mock_manager.apply_resolutions.return_value = {'pages': [], 'name': 'Merged'}
|
||||
|
||||
# Mock QMessageBox to accept auto-merge
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
window._perform_merge_with_conflicts(our_data, their_data)
|
||||
|
||||
# Should have called apply_resolutions
|
||||
mock_manager.apply_resolutions.assert_called_once()
|
||||
# Should show completion message
|
||||
assert mock_info.called
|
||||
|
||||
def test_no_conflicts_user_rejects(self, qtbot):
|
||||
"""Test user rejects auto-merge when no conflicts"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
|
||||
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
|
||||
|
||||
# Mock MergeManager to return no conflicts
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
|
||||
mock_manager = MockMergeManager.return_value
|
||||
mock_manager.detect_conflicts.return_value = []
|
||||
|
||||
# Mock QMessageBox to reject auto-merge
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No):
|
||||
window._perform_merge_with_conflicts(our_data, their_data)
|
||||
|
||||
# Should NOT have called apply_resolutions
|
||||
mock_manager.apply_resolutions.assert_not_called()
|
||||
|
||||
def test_with_conflicts_user_accepts_dialog(self, qtbot):
|
||||
"""Test merge with conflicts when user accepts dialog"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
|
||||
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
|
||||
|
||||
# Mock MergeManager to return conflicts
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
|
||||
mock_manager = MockMergeManager.return_value
|
||||
mock_manager.detect_conflicts.return_value = [Mock()] # One conflict
|
||||
|
||||
# Mock MergeDialog
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeDialog') as MockDialog:
|
||||
mock_dialog = MockDialog.return_value
|
||||
mock_dialog.exec.return_value = QMessageBox.DialogCode.Accepted
|
||||
mock_dialog.get_merged_project_data.return_value = {'pages': [], 'name': 'Merged'}
|
||||
|
||||
with patch.object(QMessageBox, 'information'):
|
||||
window._perform_merge_with_conflicts(our_data, their_data)
|
||||
|
||||
# Should have shown dialog
|
||||
MockDialog.assert_called_once()
|
||||
# Should have gotten merged data
|
||||
mock_dialog.get_merged_project_data.assert_called_once()
|
||||
|
||||
def test_with_conflicts_user_cancels_dialog(self, qtbot):
|
||||
"""Test merge with conflicts when user cancels dialog"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
our_data = {'pages': [], 'name': 'Our Project', 'project_id': 'test-123'}
|
||||
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'test-123'}
|
||||
|
||||
# Mock MergeManager to return conflicts
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeManager') as MockMergeManager:
|
||||
mock_manager = MockMergeManager.return_value
|
||||
mock_manager.detect_conflicts.return_value = [Mock()] # One conflict
|
||||
|
||||
# Mock MergeDialog
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.MergeDialog') as MockDialog:
|
||||
mock_dialog = MockDialog.return_value
|
||||
mock_dialog.exec.return_value = QMessageBox.DialogCode.Rejected
|
||||
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
window._perform_merge_with_conflicts(our_data, their_data)
|
||||
|
||||
# Should have shown cancellation message
|
||||
assert mock_info.called
|
||||
args = mock_info.call_args[0]
|
||||
assert "Cancelled" in args[1]
|
||||
# Should NOT have gotten merged data
|
||||
mock_dialog.get_merged_project_data.assert_not_called()
|
||||
|
||||
|
||||
class TestPerformConcatenation:
|
||||
"""Test _perform_concatenation method"""
|
||||
|
||||
def test_user_accepts_concatenation(self, qtbot):
|
||||
"""Test concatenation when user accepts"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
our_data = {'pages': [], 'name': 'Project A', 'project_id': 'id-a'}
|
||||
their_data = {'pages': [], 'name': 'Project B', 'project_id': 'id-b'}
|
||||
|
||||
# Mock concatenate_projects
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.concatenate_projects') as mock_concat:
|
||||
mock_concat.return_value = {'pages': [], 'name': 'Combined'}
|
||||
|
||||
# Mock QMessageBox to accept
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.Yes):
|
||||
with patch.object(QMessageBox, 'information') as mock_info:
|
||||
window._perform_concatenation(our_data, their_data)
|
||||
|
||||
# Should have called concatenate_projects
|
||||
mock_concat.assert_called_once_with(our_data, their_data)
|
||||
# Should show completion message
|
||||
assert mock_info.called
|
||||
args = mock_info.call_args[0]
|
||||
assert "Concatenation Complete" in args[1]
|
||||
|
||||
def test_user_rejects_concatenation(self, qtbot):
|
||||
"""Test concatenation when user rejects"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
our_data = {'pages': [], 'name': 'Project A', 'project_id': 'id-a'}
|
||||
their_data = {'pages': [], 'name': 'Project B', 'project_id': 'id-b'}
|
||||
|
||||
# Mock concatenate_projects
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.concatenate_projects') as mock_concat:
|
||||
# Mock QMessageBox to reject
|
||||
with patch.object(QMessageBox, 'question', return_value=QMessageBox.StandardButton.No):
|
||||
window._perform_concatenation(our_data, their_data)
|
||||
|
||||
# Should NOT have called concatenate_projects
|
||||
mock_concat.assert_not_called()
|
||||
|
||||
def test_concatenation_shows_project_names(self, qtbot):
|
||||
"""Test concatenation dialog shows both project names"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
our_data = {'pages': [], 'name': 'My Project', 'project_id': 'id-a'}
|
||||
their_data = {'pages': [], 'name': 'Their Project', 'project_id': 'id-b'}
|
||||
|
||||
# Mock QMessageBox.question to capture the message
|
||||
with patch.object(QMessageBox, 'question') as mock_question:
|
||||
mock_question.return_value = QMessageBox.StandardButton.No
|
||||
|
||||
window._perform_concatenation(our_data, their_data)
|
||||
|
||||
# Check that the dialog message contains both project names
|
||||
args = mock_question.call_args[0]
|
||||
message = args[2] # Third argument is the message
|
||||
assert 'My Project' in message
|
||||
assert 'Their Project' in message
|
||||
|
||||
|
||||
class TestApplyMergedData:
|
||||
"""Test _apply_merged_data method"""
|
||||
|
||||
def test_apply_merged_data_updates_project(self, qtbot):
|
||||
"""Test applying merged data creates new project"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create merged data
|
||||
merged_data = {
|
||||
'pages': [],
|
||||
'name': 'Merged Project',
|
||||
'project_id': 'merged-123'
|
||||
}
|
||||
|
||||
# Mock set_asset_resolution_context
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
|
||||
window._apply_merged_data(merged_data)
|
||||
|
||||
# Should have updated project
|
||||
assert window.project.name == 'Merged Project'
|
||||
# Project should be marked dirty
|
||||
assert window.project.is_dirty()
|
||||
|
||||
def test_apply_merged_data_updates_gl_widget(self, qtbot):
|
||||
"""Test applying merged data updates GL widget"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
merged_data = {
|
||||
'pages': [],
|
||||
'name': 'Merged Project',
|
||||
'project_id': 'merged-123'
|
||||
}
|
||||
|
||||
# Mock set_asset_resolution_context
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
|
||||
window._apply_merged_data(merged_data)
|
||||
|
||||
# Should have updated gl_widget
|
||||
window.gl_widget.set_project.assert_called_once()
|
||||
window.gl_widget.update.assert_called_once()
|
||||
|
||||
def test_apply_merged_data_shows_status(self, qtbot):
|
||||
"""Test applying merged data shows status message"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
merged_data = {
|
||||
'pages': [],
|
||||
'name': 'Merged Project',
|
||||
'project_id': 'merged-123'
|
||||
}
|
||||
|
||||
# Mock set_asset_resolution_context
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
|
||||
window._apply_merged_data(merged_data)
|
||||
|
||||
# Should have shown status message
|
||||
window.status_bar.showMessage.assert_called_once()
|
||||
args = window.status_bar.showMessage.call_args[0]
|
||||
assert "Merge completed successfully" in args[0]
|
||||
|
||||
def test_apply_merged_data_sets_asset_context(self, qtbot, tmp_path):
|
||||
"""Test applying merged data sets asset resolution context"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create project with folder_path
|
||||
test_path = tmp_path / "test"
|
||||
test_path.mkdir()
|
||||
window._project.folder_path = str(test_path)
|
||||
|
||||
merged_path = tmp_path / "merged"
|
||||
merged_path.mkdir()
|
||||
|
||||
merged_data = {
|
||||
'pages': [],
|
||||
'name': 'Merged Project',
|
||||
'project_id': 'merged-123',
|
||||
'folder_path': str(merged_path)
|
||||
}
|
||||
|
||||
# Mock set_asset_resolution_context
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context') as mock_set:
|
||||
window._apply_merged_data(merged_data)
|
||||
|
||||
# Should have called set_asset_resolution_context with new project's folder_path
|
||||
mock_set.assert_called_once()
|
||||
|
||||
def test_apply_merged_data_without_gl_widget(self, qtbot):
|
||||
"""Test applying merged data when gl_widget doesn't exist"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Remove gl_widget
|
||||
delattr(window, '_gl_widget')
|
||||
|
||||
merged_data = {
|
||||
'pages': [],
|
||||
'name': 'Merged Project',
|
||||
'project_id': 'merged-123'
|
||||
}
|
||||
|
||||
# Should not raise error
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
|
||||
window._apply_merged_data(merged_data)
|
||||
|
||||
# Project should still be updated
|
||||
assert window.project.name == 'Merged Project'
|
||||
|
||||
def test_apply_merged_data_without_status_bar(self, qtbot):
|
||||
"""Test applying merged data when status_bar doesn't exist"""
|
||||
window = MergeOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Remove status_bar
|
||||
delattr(window, '_status_bar')
|
||||
|
||||
merged_data = {
|
||||
'pages': [],
|
||||
'name': 'Merged Project',
|
||||
'project_id': 'merged-123'
|
||||
}
|
||||
|
||||
# Should not raise error
|
||||
with patch('pyPhotoAlbum.mixins.operations.merge_ops.set_asset_resolution_context'):
|
||||
window._apply_merged_data(merged_data)
|
||||
|
||||
# Project should still be updated
|
||||
assert window.project.name == 'Merged Project'
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
655
tests/test_page_layout_extended.py
Normal file
655
tests/test_page_layout_extended.py
Normal file
@ -0,0 +1,655 @@
|
||||
"""
|
||||
Extended tests for PageLayout to improve coverage
|
||||
Tests rendering logic, snapping, and edge cases
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch, call
|
||||
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
|
||||
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
|
||||
from pyPhotoAlbum.snapping import SnappingSystem
|
||||
|
||||
|
||||
class TestPageLayoutRendering:
|
||||
"""Test PageLayout rendering methods"""
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_basic_empty_page(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering empty page calls OpenGL functions correctly"""
|
||||
layout = PageLayout(width=210, height=297)
|
||||
|
||||
# Render with default DPI
|
||||
layout.render(dpi=300)
|
||||
|
||||
# Verify OpenGL depth test was disabled then re-enabled
|
||||
assert mock_disable.call_count >= 1
|
||||
assert mock_enable.call_count >= 1
|
||||
|
||||
# Verify colors were set (shadow, background, border)
|
||||
assert mock_color3f.call_count >= 3
|
||||
|
||||
# Verify vertices were drawn (quads for shadow, background, line loop for border)
|
||||
assert mock_vertex.call_count >= 8
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_facing_page_draws_center_line(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering facing page draws center divider line"""
|
||||
layout = PageLayout(width=210, height=297, is_facing_page=True)
|
||||
|
||||
layout.render(dpi=300)
|
||||
|
||||
# Verify line width was set for center line
|
||||
assert any(call(1.5) in mock_linewidth.call_args_list for call in mock_linewidth.call_args_list)
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_with_elements_calls_element_render(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering page with elements calls render on each element"""
|
||||
layout = PageLayout()
|
||||
|
||||
# Add elements with mock render methods
|
||||
elem1 = ImageData(image_path="test1.jpg", x=10, y=20)
|
||||
elem2 = PlaceholderData(x=30, y=40)
|
||||
elem3 = TextBoxData(text_content="Hello", x=50, y=60)
|
||||
|
||||
elem1.render = Mock()
|
||||
elem2.render = Mock()
|
||||
elem3.render = Mock()
|
||||
|
||||
layout.add_element(elem1)
|
||||
layout.add_element(elem2)
|
||||
layout.add_element(elem3)
|
||||
|
||||
layout.render(dpi=300)
|
||||
|
||||
# Verify each element's render was called
|
||||
elem1.render.assert_called_once()
|
||||
elem2.render.assert_called_once()
|
||||
elem3.render.assert_called_once()
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_with_image_async_loading_requested(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering image without texture triggers async load request"""
|
||||
layout = PageLayout()
|
||||
|
||||
# Create mock parent widget with async loader
|
||||
mock_parent = Mock()
|
||||
mock_parent.async_image_loader = Mock()
|
||||
mock_parent.request_image_load = Mock()
|
||||
layout._parent_widget = mock_parent
|
||||
|
||||
# Add image element without texture
|
||||
elem = ImageData(image_path="test.jpg", x=10, y=20)
|
||||
elem.render = Mock()
|
||||
elem._async_load_requested = False
|
||||
layout.add_element(elem)
|
||||
|
||||
layout.render(dpi=300)
|
||||
|
||||
# Verify async load was requested
|
||||
assert elem._async_load_requested is True
|
||||
assert elem._async_loading is True
|
||||
mock_parent.request_image_load.assert_called_once()
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_with_different_dpi_scales_correctly(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering with different DPI values"""
|
||||
layout = PageLayout(width=210, height=297)
|
||||
|
||||
# Render with different DPIs
|
||||
layout.render(dpi=72)
|
||||
layout.render(dpi=150)
|
||||
layout.render(dpi=600)
|
||||
|
||||
# Should not raise any errors
|
||||
assert mock_vertex.call_count > 0
|
||||
|
||||
|
||||
class TestPageLayoutSnapLines:
|
||||
"""Test snap line rendering"""
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_snap_lines_with_project_settings(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test snap lines use project settings when available"""
|
||||
layout = PageLayout()
|
||||
|
||||
# Create mock project with global snapping settings
|
||||
mock_project = Mock()
|
||||
mock_project.snap_to_grid = True
|
||||
mock_project.snap_to_edges = True
|
||||
mock_project.snap_to_guides = True
|
||||
mock_project.grid_size_mm = 10.0
|
||||
mock_project.snap_threshold_mm = 5.0
|
||||
mock_project.show_grid = True
|
||||
mock_project.show_snap_lines = True
|
||||
|
||||
layout.render(dpi=300, project=mock_project)
|
||||
|
||||
# Verify blending was enabled for transparent lines
|
||||
# Check that glEnable was called (for GL_BLEND)
|
||||
assert mock_enable.call_count > 0
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_snap_lines_fallback_to_local_settings(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test snap lines fall back to local settings when no project"""
|
||||
layout = PageLayout()
|
||||
layout.snapping_system.snap_to_grid = True
|
||||
layout.snapping_system.grid_size_mm = 15.0
|
||||
|
||||
# Render without project
|
||||
layout.render(dpi=300, project=None)
|
||||
|
||||
# Should complete without error
|
||||
assert mock_vertex.call_count > 0
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_snap_lines_with_guides(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering snap lines with custom guides"""
|
||||
layout = PageLayout()
|
||||
|
||||
# Add custom guides using the proper method (position, orientation)
|
||||
layout.snapping_system.add_guide(100, "vertical")
|
||||
layout.snapping_system.add_guide(150, "horizontal")
|
||||
|
||||
mock_project = Mock()
|
||||
mock_project.snap_to_grid = False
|
||||
mock_project.snap_to_edges = False
|
||||
mock_project.snap_to_guides = True
|
||||
mock_project.grid_size_mm = 10.0
|
||||
mock_project.snap_threshold_mm = 5.0
|
||||
mock_project.show_grid = False
|
||||
mock_project.show_snap_lines = True
|
||||
|
||||
layout.render(dpi=300, project=mock_project)
|
||||
|
||||
# Cyan color should be used for guides (0.0, 0.7, 0.9, 0.8)
|
||||
# Check if cyan color was set (at least once)
|
||||
assert mock_color4f.call_count > 0
|
||||
|
||||
|
||||
class TestPageLayoutEdgeCases:
|
||||
"""Test edge cases and error conditions"""
|
||||
|
||||
def test_page_layout_with_zero_dimensions(self):
|
||||
"""Test page layout with very small dimensions"""
|
||||
layout = PageLayout(width=0.1, height=0.1)
|
||||
assert layout.size == (0.1, 0.1)
|
||||
|
||||
def test_page_layout_with_large_dimensions(self):
|
||||
"""Test page layout with very large dimensions"""
|
||||
layout = PageLayout(width=10000, height=10000)
|
||||
assert layout.size == (10000, 10000)
|
||||
|
||||
def test_multiple_grid_layout_changes(self):
|
||||
"""Test changing grid layout multiple times"""
|
||||
layout = PageLayout()
|
||||
|
||||
grid1 = GridLayout(rows=2, columns=2)
|
||||
grid2 = GridLayout(rows=3, columns=3)
|
||||
grid3 = GridLayout(rows=4, columns=4)
|
||||
|
||||
layout.set_grid_layout(grid1)
|
||||
assert layout.grid_layout == grid1
|
||||
|
||||
layout.set_grid_layout(grid2)
|
||||
assert layout.grid_layout == grid2
|
||||
|
||||
layout.set_grid_layout(grid3)
|
||||
assert layout.grid_layout == grid3
|
||||
|
||||
def test_deserialize_with_missing_snapping_system(self):
|
||||
"""Test deserialization handles missing snapping_system gracefully"""
|
||||
layout = PageLayout()
|
||||
data = {
|
||||
"size": (210, 297),
|
||||
"elements": [],
|
||||
# No snapping_system key
|
||||
}
|
||||
|
||||
layout.deserialize(data)
|
||||
|
||||
# Should use existing snapping system
|
||||
assert layout.snapping_system is not None
|
||||
|
||||
def test_deserialize_element_with_invalid_crop_info(self):
|
||||
"""Test deserialization handles elements with missing optional fields"""
|
||||
layout = PageLayout()
|
||||
data = {
|
||||
"size": (210, 297),
|
||||
"elements": [
|
||||
{
|
||||
"type": "image",
|
||||
"image_path": "test.jpg",
|
||||
"position": (10, 20),
|
||||
"size": (100, 100),
|
||||
"rotation": 0,
|
||||
"z_index": 0,
|
||||
# Missing crop_info - should use default
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
layout.deserialize(data)
|
||||
|
||||
assert len(layout.elements) == 1
|
||||
assert isinstance(layout.elements[0], ImageData)
|
||||
|
||||
def test_element_z_index_handling(self):
|
||||
"""Test that elements maintain correct z-order"""
|
||||
layout = PageLayout()
|
||||
|
||||
elem1 = ImageData(image_path="test1.jpg", x=10, y=20)
|
||||
elem2 = ImageData(image_path="test2.jpg", x=30, y=40)
|
||||
elem3 = ImageData(image_path="test3.jpg", x=50, y=60)
|
||||
|
||||
elem1.z_index = 10
|
||||
elem2.z_index = 5
|
||||
elem3.z_index = 15
|
||||
|
||||
layout.add_element(elem1)
|
||||
layout.add_element(elem2)
|
||||
layout.add_element(elem3)
|
||||
|
||||
# Elements added in order, z_index is just metadata
|
||||
assert layout.elements[0] == elem1
|
||||
assert layout.elements[1] == elem2
|
||||
assert layout.elements[2] == elem3
|
||||
|
||||
|
||||
class TestGridLayoutEdgeCases:
|
||||
"""Test GridLayout edge cases"""
|
||||
|
||||
def test_grid_with_single_cell(self):
|
||||
"""Test grid layout with single cell (1x1)"""
|
||||
grid = GridLayout(rows=1, columns=1, spacing=5.0)
|
||||
|
||||
pos = grid.get_cell_position(0, 0, page_width=100, page_height=100)
|
||||
size = grid.get_cell_size(page_width=100, page_height=100)
|
||||
|
||||
assert pos == (5.0, 5.0)
|
||||
# (100 - 10) / 1 = 90
|
||||
assert size == (90.0, 90.0)
|
||||
|
||||
def test_grid_with_no_spacing(self):
|
||||
"""Test grid layout with zero spacing"""
|
||||
grid = GridLayout(rows=2, columns=2, spacing=0.0)
|
||||
|
||||
pos = grid.get_cell_position(0, 0, page_width=100, page_height=100)
|
||||
size = grid.get_cell_size(page_width=100, page_height=100)
|
||||
|
||||
assert pos == (0.0, 0.0)
|
||||
assert size == (50.0, 50.0)
|
||||
|
||||
def test_grid_with_large_spacing(self):
|
||||
"""Test grid layout with large spacing"""
|
||||
grid = GridLayout(rows=2, columns=2, spacing=20.0)
|
||||
|
||||
size = grid.get_cell_size(page_width=200, page_height=200)
|
||||
|
||||
# (200 - 60) / 2 = 70
|
||||
assert size == (70.0, 70.0)
|
||||
|
||||
def test_grid_merge_multiple_cells(self):
|
||||
"""Test merging many cells"""
|
||||
grid = GridLayout(rows=5, columns=5)
|
||||
|
||||
for row in range(3):
|
||||
for col in range(3):
|
||||
grid.merge_cells(row, col)
|
||||
|
||||
assert len(grid.merged_cells) == 9
|
||||
|
||||
def test_grid_cell_position_at_boundaries(self):
|
||||
"""Test cell positions at grid boundaries"""
|
||||
grid = GridLayout(rows=3, columns=3, spacing=10.0)
|
||||
|
||||
# Top-left corner
|
||||
pos_tl = grid.get_cell_position(0, 0, page_width=300, page_height=300)
|
||||
assert pos_tl[0] == 10.0
|
||||
assert pos_tl[1] == 10.0
|
||||
|
||||
# Bottom-right corner (2, 2)
|
||||
pos_br = grid.get_cell_position(2, 2, page_width=300, page_height=300)
|
||||
# (300 - 40) / 3 = 86.67, x = 10 + 2 * (86.67 + 10) = 203.33
|
||||
assert pos_br[0] == pytest.approx(203.333, rel=0.01)
|
||||
assert pos_br[1] == pytest.approx(203.333, rel=0.01)
|
||||
|
||||
|
||||
class TestPageLayoutSnappingIntegration:
|
||||
"""Test integration with snapping system"""
|
||||
|
||||
def test_snapping_system_initialized(self):
|
||||
"""Test that PageLayout initializes with snapping system"""
|
||||
layout = PageLayout()
|
||||
assert isinstance(layout.snapping_system, SnappingSystem)
|
||||
|
||||
def test_snapping_system_serialization(self):
|
||||
"""Test that snapping system is included in serialization"""
|
||||
layout = PageLayout()
|
||||
layout.snapping_system.snap_to_grid = True
|
||||
layout.snapping_system.grid_size_mm = 20.0
|
||||
|
||||
data = layout.serialize()
|
||||
|
||||
assert "snapping_system" in data
|
||||
assert data["snapping_system"]["snap_to_grid"] is True
|
||||
assert data["snapping_system"]["grid_size_mm"] == 20.0
|
||||
|
||||
def test_snapping_system_deserialization(self):
|
||||
"""Test that snapping system is restored from serialization"""
|
||||
layout = PageLayout()
|
||||
|
||||
# First create a layout with snapping settings
|
||||
layout.snapping_system.snap_to_grid = True
|
||||
layout.snapping_system.snap_to_edges = False
|
||||
layout.snapping_system.snap_to_guides = True
|
||||
layout.snapping_system.grid_size_mm = 25.0
|
||||
layout.snapping_system.snap_threshold_mm = 3.0
|
||||
layout.snapping_system.add_guide(100, "vertical")
|
||||
layout.snapping_system.add_guide(150, "horizontal")
|
||||
|
||||
# Serialize and deserialize
|
||||
data = layout.serialize()
|
||||
restored = PageLayout()
|
||||
restored.deserialize(data)
|
||||
|
||||
assert restored.snapping_system.snap_to_grid is True
|
||||
assert restored.snapping_system.snap_to_edges is False
|
||||
assert restored.snapping_system.snap_to_guides is True
|
||||
assert restored.snapping_system.grid_size_mm == 25.0
|
||||
assert restored.snapping_system.snap_threshold_mm == 3.0
|
||||
assert len(restored.snapping_system.guides) == 2
|
||||
|
||||
|
||||
class TestPageLayoutBackwardCompatibility:
|
||||
"""Test backward compatibility with older data formats"""
|
||||
|
||||
def test_deserialize_without_base_width(self):
|
||||
"""Test deserializing data without base_width field"""
|
||||
layout = PageLayout()
|
||||
data = {
|
||||
"size": (200, 280),
|
||||
# No base_width field
|
||||
"elements": [],
|
||||
}
|
||||
|
||||
layout.deserialize(data)
|
||||
|
||||
# Should default to width from size
|
||||
assert layout.base_width == 200
|
||||
|
||||
def test_deserialize_without_is_facing_page(self):
|
||||
"""Test deserializing data without is_facing_page field"""
|
||||
layout = PageLayout()
|
||||
data = {
|
||||
"size": (210, 297),
|
||||
# No is_facing_page field
|
||||
"elements": [],
|
||||
}
|
||||
|
||||
layout.deserialize(data)
|
||||
|
||||
# Should default to False
|
||||
assert layout.is_facing_page is False
|
||||
|
||||
def test_deserialize_without_show_snap_lines(self):
|
||||
"""Test deserializing data without show_snap_lines field"""
|
||||
layout = PageLayout()
|
||||
data = {
|
||||
"size": (210, 297),
|
||||
"elements": [],
|
||||
# No show_snap_lines field
|
||||
}
|
||||
|
||||
layout.deserialize(data)
|
||||
|
||||
# Should default to True
|
||||
assert layout.show_snap_lines is True
|
||||
|
||||
|
||||
class TestPageLayoutComplexScenarios:
|
||||
"""Test complex real-world scenarios"""
|
||||
|
||||
def test_layout_with_many_elements(self):
|
||||
"""Test layout with large number of elements"""
|
||||
layout = PageLayout()
|
||||
|
||||
# Add 100 elements
|
||||
for i in range(100):
|
||||
elem = ImageData(image_path=f"test{i}.jpg", x=i * 10, y=i * 10)
|
||||
elem.z_index = i
|
||||
layout.add_element(elem)
|
||||
|
||||
assert len(layout.elements) == 100
|
||||
|
||||
# Serialize and deserialize
|
||||
data = layout.serialize()
|
||||
restored = PageLayout()
|
||||
restored.deserialize(data)
|
||||
|
||||
assert len(restored.elements) == 100
|
||||
# Elements should be sorted by z_index
|
||||
for i in range(100):
|
||||
assert restored.elements[i].z_index == i
|
||||
|
||||
def test_layout_with_mixed_element_types(self):
|
||||
"""Test layout with all element types mixed"""
|
||||
layout = PageLayout()
|
||||
|
||||
for i in range(10):
|
||||
if i % 3 == 0:
|
||||
elem = ImageData(image_path=f"img{i}.jpg", x=i * 20, y=i * 20)
|
||||
elif i % 3 == 1:
|
||||
elem = PlaceholderData(x=i * 20, y=i * 20)
|
||||
else:
|
||||
elem = TextBoxData(text_content=f"Text {i}", x=i * 20, y=i * 20)
|
||||
|
||||
elem.z_index = i
|
||||
layout.add_element(elem)
|
||||
|
||||
data = layout.serialize()
|
||||
restored = PageLayout()
|
||||
restored.deserialize(data)
|
||||
|
||||
# Count element types
|
||||
images = sum(1 for e in restored.elements if isinstance(e, ImageData))
|
||||
placeholders = sum(1 for e in restored.elements if isinstance(e, PlaceholderData))
|
||||
textboxes = sum(1 for e in restored.elements if isinstance(e, TextBoxData))
|
||||
|
||||
assert images == 4 # 0, 3, 6, 9
|
||||
assert placeholders == 3 # 1, 4, 7
|
||||
assert textboxes == 3 # 2, 5, 8
|
||||
|
||||
@patch("pyPhotoAlbum.page_layout.glDisable")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnable")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor3f")
|
||||
@patch("pyPhotoAlbum.page_layout.glColor4f")
|
||||
@patch("pyPhotoAlbum.page_layout.glBegin")
|
||||
@patch("pyPhotoAlbum.page_layout.glEnd")
|
||||
@patch("pyPhotoAlbum.page_layout.glVertex2f")
|
||||
@patch("pyPhotoAlbum.page_layout.glLineWidth")
|
||||
@patch("pyPhotoAlbum.page_layout.glBlendFunc")
|
||||
def test_render_facing_page_with_elements(
|
||||
self,
|
||||
mock_blend,
|
||||
mock_linewidth,
|
||||
mock_vertex,
|
||||
mock_end,
|
||||
mock_begin,
|
||||
mock_color4f,
|
||||
mock_color3f,
|
||||
mock_enable,
|
||||
mock_disable,
|
||||
):
|
||||
"""Test rendering facing page with elements"""
|
||||
layout = PageLayout(width=210, height=297, is_facing_page=True)
|
||||
|
||||
# Add elements on both sides
|
||||
left_elem = ImageData(image_path="left.jpg", x=50, y=100)
|
||||
right_elem = ImageData(image_path="right.jpg", x=250, y=100)
|
||||
|
||||
left_elem.render = Mock()
|
||||
right_elem.render = Mock()
|
||||
|
||||
layout.add_element(left_elem)
|
||||
layout.add_element(right_elem)
|
||||
|
||||
layout.render(dpi=300)
|
||||
|
||||
# Both elements should be rendered
|
||||
left_elem.render.assert_called_once()
|
||||
right_elem.render.assert_called_once()
|
||||
|
||||
# Center line should be drawn
|
||||
assert any(call(1.5) in mock_linewidth.call_args_list for call in mock_linewidth.call_args_list)
|
||||
672
tests/test_rendering_mixin.py
Normal file
672
tests/test_rendering_mixin.py
Normal file
@ -0,0 +1,672 @@
|
||||
"""
|
||||
Tests for RenderingMixin
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
||||
from PyQt6.QtCore import Qt
|
||||
from pyPhotoAlbum.mixins.rendering import RenderingMixin
|
||||
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.models import ImageData, TextBoxData
|
||||
|
||||
|
||||
# Create a minimal test widget class that combines necessary mixins
|
||||
class TestRenderingWidget(ViewportMixin, RenderingMixin, QOpenGLWidget):
|
||||
"""Test widget combining rendering and viewport mixins with QOpenGLWidget"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Initialize attributes needed by rendering
|
||||
self.selected_elements = []
|
||||
self.rotation_mode = False
|
||||
|
||||
def _get_page_positions(self):
|
||||
"""Mock method to return page positions"""
|
||||
main_window = self.window()
|
||||
if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages:
|
||||
return []
|
||||
|
||||
positions = []
|
||||
y_offset = 50 # PAGE_MARGIN
|
||||
for page in main_window.project.pages:
|
||||
positions.append(("page", page, y_offset))
|
||||
# Calculate page height
|
||||
page_width_mm, page_height_mm = page.layout.size
|
||||
dpi = main_window.project.working_dpi
|
||||
page_height_px = page_height_mm * dpi / 25.4
|
||||
y_offset += page_height_px + 50 # PAGE_SPACING
|
||||
|
||||
return positions
|
||||
|
||||
|
||||
class TestRenderingInitialization:
|
||||
"""Test rendering mixin setup"""
|
||||
|
||||
def test_rendering_mixin_exists(self, qtbot):
|
||||
"""Test that rendering mixin can be instantiated"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
assert hasattr(widget, "paintGL")
|
||||
assert callable(widget.paintGL)
|
||||
assert hasattr(widget, "_draw_selection_handles")
|
||||
assert callable(widget._draw_selection_handles)
|
||||
|
||||
|
||||
class TestPaintGL:
|
||||
"""Test paintGL method"""
|
||||
|
||||
def test_paintGL_no_project(self, qtbot):
|
||||
"""Test paintGL with no project does nothing"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(800, 600)
|
||||
|
||||
# Mock window without project
|
||||
mock_window = Mock()
|
||||
mock_window.project = None
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Should not raise exception
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
|
||||
widget.paintGL()
|
||||
|
||||
def test_paintGL_empty_project(self, qtbot):
|
||||
"""Test paintGL with empty project does nothing"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(800, 600)
|
||||
|
||||
# Mock window with empty project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Empty")
|
||||
mock_window.project.pages = []
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Should not raise exception
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
|
||||
widget.paintGL()
|
||||
|
||||
def test_paintGL_sets_initial_zoom(self, qtbot):
|
||||
"""Test paintGL sets initial zoom on first render"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
mock_window.update_scrollbars = Mock()
|
||||
|
||||
# A4 page
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Ensure initial_zoom_set is False
|
||||
widget.initial_zoom_set = False
|
||||
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
|
||||
with patch("pyPhotoAlbum.page_renderer.PageRenderer"):
|
||||
widget.paintGL()
|
||||
|
||||
# Should have set initial zoom
|
||||
assert widget.initial_zoom_set is True
|
||||
assert widget.zoom_level > 0
|
||||
assert widget.zoom_level <= 1.0
|
||||
|
||||
def test_paintGL_renders_page(self, qtbot):
|
||||
"""Test paintGL renders a page"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
mock_window.update_scrollbars = Mock()
|
||||
|
||||
# A4 page
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.initial_zoom_set = True
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
# Mock PageRenderer
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.begin_render = Mock()
|
||||
mock_renderer.end_render = Mock()
|
||||
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
|
||||
with patch("pyPhotoAlbum.page_renderer.PageRenderer", return_value=mock_renderer):
|
||||
widget.paintGL()
|
||||
|
||||
# Should have created page renderers
|
||||
assert hasattr(widget, "_page_renderers")
|
||||
assert len(widget._page_renderers) == 1
|
||||
|
||||
# Should have called renderer methods
|
||||
mock_renderer.begin_render.assert_called_once()
|
||||
mock_renderer.end_render.assert_called_once()
|
||||
|
||||
def test_paintGL_renders_multiple_pages(self, qtbot):
|
||||
"""Test paintGL renders multiple pages"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
mock_window.update_scrollbars = Mock()
|
||||
|
||||
# Create 3 A4 pages
|
||||
pages = [
|
||||
Page(layout=PageLayout(width=210, height=297), page_number=i)
|
||||
for i in range(1, 4)
|
||||
]
|
||||
mock_window.project.pages = pages
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.initial_zoom_set = True
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
# Mock PageRenderer
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.begin_render = Mock()
|
||||
mock_renderer.end_render = Mock()
|
||||
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
|
||||
with patch("pyPhotoAlbum.page_renderer.PageRenderer", return_value=mock_renderer):
|
||||
widget.paintGL()
|
||||
|
||||
# Should have created page renderers for all pages
|
||||
assert hasattr(widget, "_page_renderers")
|
||||
assert len(widget._page_renderers) == 3
|
||||
|
||||
def test_paintGL_updates_selected_elements_renderer(self, qtbot):
|
||||
"""Test paintGL updates page renderer references for selected elements"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
mock_window.update_scrollbars = Mock()
|
||||
|
||||
# A4 page
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
|
||||
# Create a mock element
|
||||
mock_element = Mock()
|
||||
mock_element._parent_page = page
|
||||
widget.selected_elements = [mock_element]
|
||||
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
widget.initial_zoom_set = True
|
||||
widget.zoom_level = 1.0
|
||||
widget.pan_offset = [0, 0]
|
||||
|
||||
# Mock PageRenderer
|
||||
mock_renderer = Mock()
|
||||
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glClear"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLoadIdentity"):
|
||||
with patch("pyPhotoAlbum.page_renderer.PageRenderer", return_value=mock_renderer):
|
||||
with patch.object(widget, "_draw_selection_handles"):
|
||||
widget.paintGL()
|
||||
|
||||
# Element should have renderer reference
|
||||
assert hasattr(mock_element, "_page_renderer")
|
||||
assert mock_element._page_renderer == mock_renderer
|
||||
|
||||
|
||||
class TestDrawSelectionHandles:
|
||||
"""Test _draw_selection_handles method"""
|
||||
|
||||
def test_draw_selection_handles_no_element(self, qtbot):
|
||||
"""Test drawing handles with no element does nothing"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not raise exception
|
||||
widget._draw_selection_handles(None)
|
||||
|
||||
def test_draw_selection_handles_no_project(self, qtbot):
|
||||
"""Test drawing handles with no project does nothing"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Mock window without project
|
||||
mock_window = Mock()
|
||||
mock_window.project = None
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
mock_element = Mock()
|
||||
mock_element._page_renderer = Mock()
|
||||
|
||||
# Should not raise exception
|
||||
widget._draw_selection_handles(mock_element)
|
||||
|
||||
def test_draw_selection_handles_no_renderer(self, qtbot):
|
||||
"""Test drawing handles without renderer does nothing"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Element without renderer
|
||||
mock_element = Mock(spec=[]) # No _page_renderer attribute
|
||||
|
||||
# Should not raise exception
|
||||
widget._draw_selection_handles(mock_element)
|
||||
|
||||
def test_draw_selection_handles_normal_mode(self, qtbot):
|
||||
"""Test drawing handles in normal (non-rotation) mode"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.rotation_mode = False
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Create element with renderer
|
||||
mock_element = Mock()
|
||||
mock_element.position = (100, 100)
|
||||
mock_element.size = (200, 150)
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(150, 150))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
mock_element._page_renderer = mock_renderer
|
||||
|
||||
# Mock OpenGL calls
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glColor3f"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLineWidth"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glBegin"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glVertex2f"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glEnd"):
|
||||
widget._draw_selection_handles(mock_element)
|
||||
|
||||
# Should have converted position
|
||||
mock_renderer.page_to_screen.assert_called_once_with(100, 100)
|
||||
|
||||
def test_draw_selection_handles_rotation_mode(self, qtbot):
|
||||
"""Test drawing handles in rotation mode"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.rotation_mode = True
|
||||
|
||||
# Mock window with project
|
||||
mock_window = Mock()
|
||||
mock_window.project = Project(name="Test")
|
||||
mock_window.project.working_dpi = 96
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
mock_window.project.pages = [page]
|
||||
widget.window = Mock(return_value=mock_window)
|
||||
|
||||
# Create element with renderer
|
||||
mock_element = Mock()
|
||||
mock_element.position = (100, 100)
|
||||
mock_element.size = (200, 150)
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(150, 150))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
mock_element._page_renderer = mock_renderer
|
||||
|
||||
# Mock OpenGL calls
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glColor3f"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glLineWidth"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glBegin"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glVertex2f"):
|
||||
with patch("pyPhotoAlbum.mixins.rendering.glEnd"):
|
||||
widget._draw_selection_handles(mock_element)
|
||||
|
||||
# Should have converted position
|
||||
mock_renderer.page_to_screen.assert_called_once_with(100, 100)
|
||||
|
||||
|
||||
class TestRenderTextOverlays:
|
||||
"""Test _render_text_overlays method"""
|
||||
|
||||
def test_render_text_overlays_no_renderers(self, qtbot):
|
||||
"""Test rendering text overlays with no page renderers"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
# Should not raise exception
|
||||
widget._render_text_overlays()
|
||||
|
||||
def test_render_text_overlays_empty_renderers(self, qtbot):
|
||||
"""Test rendering text overlays with empty renderers list"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
widget._page_renderers = []
|
||||
|
||||
# Should not raise exception
|
||||
widget._render_text_overlays()
|
||||
|
||||
def test_render_text_overlays_no_text_elements(self, qtbot):
|
||||
"""Test rendering text overlays with no text elements"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Mock page with no text elements
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page.layout.elements = []
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_text_overlays()
|
||||
|
||||
# Painter should have been created and ended
|
||||
mock_painter_class.assert_called_once_with(widget)
|
||||
mock_painter.end.assert_called_once()
|
||||
|
||||
def test_render_text_overlays_with_text_element(self, qtbot):
|
||||
"""Test rendering text overlays with text element"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Create text element
|
||||
text_element = TextBoxData(
|
||||
x=50, y=50, width=200, height=100,
|
||||
text_content="Test Text",
|
||||
font_settings={
|
||||
"family": "Arial",
|
||||
"size": 12,
|
||||
"color": (0, 0, 0)
|
||||
},
|
||||
alignment="left"
|
||||
)
|
||||
|
||||
# Mock page with text element
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page.layout.elements = [text_element]
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_text_overlays()
|
||||
|
||||
# Painter should have been used
|
||||
mock_painter_class.assert_called_once_with(widget)
|
||||
mock_painter.setFont.assert_called()
|
||||
mock_painter.setPen.assert_called()
|
||||
mock_painter.drawText.assert_called()
|
||||
mock_painter.end.assert_called_once()
|
||||
|
||||
def test_render_text_overlays_with_rotated_text(self, qtbot):
|
||||
"""Test rendering text overlays with rotated text"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Create rotated text element
|
||||
text_element = TextBoxData(
|
||||
x=50, y=50, width=200, height=100, rotation=45,
|
||||
text_content="Rotated Text",
|
||||
font_settings={
|
||||
"family": "Arial",
|
||||
"size": 14,
|
||||
"color": (0, 0, 0)
|
||||
},
|
||||
alignment="center"
|
||||
)
|
||||
|
||||
# Mock page with text element
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page.layout.elements = [text_element]
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_text_overlays()
|
||||
|
||||
# Painter should have used save/restore for rotation
|
||||
mock_painter.save.assert_called()
|
||||
mock_painter.rotate.assert_called_with(45)
|
||||
mock_painter.restore.assert_called()
|
||||
|
||||
def test_render_text_overlays_different_alignments(self, qtbot):
|
||||
"""Test rendering text with different alignments"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
for alignment in ["left", "center", "right"]:
|
||||
# Create text element with alignment
|
||||
text_element = TextBoxData(
|
||||
x=50, y=50, width=200, height=100,
|
||||
text_content=f"{alignment} aligned",
|
||||
font_settings={
|
||||
"family": "Arial",
|
||||
"size": 12,
|
||||
"color": (0, 0, 0)
|
||||
},
|
||||
alignment=alignment
|
||||
)
|
||||
|
||||
# Mock page with text element
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page.layout.elements = [text_element]
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_text_overlays()
|
||||
|
||||
# Should have drawn text
|
||||
mock_painter.drawText.assert_called()
|
||||
|
||||
def test_render_text_overlays_normalized_color(self, qtbot):
|
||||
"""Test rendering text with normalized color values (0-1 range)"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Create text element with normalized colors
|
||||
text_element = TextBoxData(
|
||||
x=50, y=50, width=200, height=100,
|
||||
text_content="Normalized Color",
|
||||
font_settings={
|
||||
"family": "Arial",
|
||||
"size": 12,
|
||||
"color": (0.5, 0.5, 0.5) # Normalized (0-1 range)
|
||||
},
|
||||
alignment="left"
|
||||
)
|
||||
|
||||
# Mock page with text element
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page.layout.elements = [text_element]
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_text_overlays()
|
||||
|
||||
# Should have converted normalized colors to 0-255 range
|
||||
mock_painter.setPen.assert_called()
|
||||
|
||||
def test_render_text_overlays_empty_text(self, qtbot):
|
||||
"""Test rendering text element with no content"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Create text element with no content
|
||||
text_element = TextBoxData(
|
||||
x=50, y=50, width=200, height=100,
|
||||
text_content="", # Empty
|
||||
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
|
||||
alignment="left"
|
||||
)
|
||||
|
||||
# Mock page with text element
|
||||
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
|
||||
page.layout.elements = [text_element]
|
||||
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
|
||||
widget._page_renderers = [(mock_renderer, page)]
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_text_overlays()
|
||||
|
||||
# Should not draw empty text
|
||||
mock_painter.drawText.assert_not_called()
|
||||
|
||||
|
||||
class TestRenderGhostPage:
|
||||
"""Test _render_ghost_page method"""
|
||||
|
||||
def test_render_ghost_page(self, qtbot):
|
||||
"""Test rendering a ghost page"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Create mock ghost data
|
||||
ghost_data = Mock()
|
||||
ghost_data.page_size = (210, 297)
|
||||
ghost_data.render = Mock()
|
||||
ghost_data.get_page_rect = Mock(return_value=(0, 0, 794, 1123))
|
||||
|
||||
# Create mock renderer
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
mock_renderer.begin_render = Mock()
|
||||
mock_renderer.end_render = Mock()
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_ghost_page(ghost_data, mock_renderer)
|
||||
|
||||
# Should have called renderer methods
|
||||
mock_renderer.begin_render.assert_called_once()
|
||||
ghost_data.render.assert_called_once()
|
||||
mock_renderer.end_render.assert_called_once()
|
||||
|
||||
# Should have drawn text overlay
|
||||
mock_painter.drawText.assert_called()
|
||||
# Check that "Click to Add Page" was drawn
|
||||
call_args = mock_painter.drawText.call_args
|
||||
assert "Click to Add Page" in str(call_args)
|
||||
|
||||
def test_render_ghost_page_painter_cleanup(self, qtbot):
|
||||
"""Test that ghost page rendering cleans up QPainter"""
|
||||
widget = TestRenderingWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(1000, 800)
|
||||
|
||||
# Create mock ghost data
|
||||
ghost_data = Mock()
|
||||
ghost_data.page_size = (210, 297)
|
||||
ghost_data.render = Mock()
|
||||
ghost_data.get_page_rect = Mock(return_value=(0, 0, 794, 1123))
|
||||
|
||||
# Create mock renderer
|
||||
mock_renderer = Mock()
|
||||
mock_renderer.page_to_screen = Mock(return_value=(100, 100))
|
||||
mock_renderer.zoom = 1.0
|
||||
mock_renderer.begin_render = Mock()
|
||||
mock_renderer.end_render = Mock()
|
||||
|
||||
# Mock QPainter
|
||||
with patch("pyPhotoAlbum.mixins.rendering.QPainter") as mock_painter_class:
|
||||
mock_painter = Mock()
|
||||
mock_painter_class.return_value = mock_painter
|
||||
|
||||
widget._render_ghost_page(ghost_data, mock_renderer)
|
||||
|
||||
# Painter should be ended (cleanup)
|
||||
mock_painter.end.assert_called_once()
|
||||
459
tests/test_template_ops_mixin.py
Normal file
459
tests/test_template_ops_mixin.py
Normal file
@ -0,0 +1,459 @@
|
||||
"""
|
||||
Tests for TemplateOperationsMixin
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
from PyQt6.QtWidgets import QMainWindow, QDialog
|
||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||
from pyPhotoAlbum.mixins.operations.template_ops import TemplateOperationsMixin
|
||||
from pyPhotoAlbum.project import Project, Page
|
||||
from pyPhotoAlbum.page_layout import PageLayout
|
||||
from pyPhotoAlbum.models import ImageData
|
||||
from pyPhotoAlbum.commands import CommandHistory
|
||||
|
||||
|
||||
class TestTemplateOpsWindow(TemplateOperationsMixin, ApplicationStateMixin, QMainWindow):
|
||||
"""Test window with template operations mixin"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._gl_widget = Mock()
|
||||
self._gl_widget.current_page_index = 0
|
||||
self._gl_widget.zoom_level = 1.0
|
||||
self._gl_widget.pan_offset = [0, 0]
|
||||
self._gl_widget._page_renderers = []
|
||||
self._gl_widget.width = Mock(return_value=800)
|
||||
self._gl_widget.height = Mock(return_value=600)
|
||||
self._project = Project(name="Test")
|
||||
self._project.page_size_mm = (210, 297)
|
||||
self._project.working_dpi = 96
|
||||
self._project.history = CommandHistory()
|
||||
self._template_manager = Mock()
|
||||
self._update_view_called = False
|
||||
self._status_message = None
|
||||
self._info_title = None
|
||||
self._info_message = None
|
||||
self._warning_title = None
|
||||
self._warning_message = None
|
||||
self._error_title = None
|
||||
self._error_message = None
|
||||
|
||||
def update_view(self):
|
||||
self._update_view_called = True
|
||||
|
||||
def show_status(self, message, timeout=0):
|
||||
self._status_message = message
|
||||
|
||||
def show_info(self, title, message):
|
||||
self._info_title = title
|
||||
self._info_message = message
|
||||
|
||||
def show_warning(self, title, message):
|
||||
self._warning_title = title
|
||||
self._warning_message = message
|
||||
|
||||
def show_error(self, title, message):
|
||||
self._error_title = title
|
||||
self._error_message = message
|
||||
|
||||
|
||||
class TestSavePageAsTemplate:
|
||||
"""Test save_page_as_template method"""
|
||||
|
||||
def test_save_template_no_current_page(self, qtbot):
|
||||
"""Test returns early when no current page"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.pages = []
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Should return early without showing dialogs
|
||||
assert not window._update_view_called
|
||||
|
||||
def test_save_template_empty_page(self, qtbot):
|
||||
"""Test shows warning when page has no elements"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create empty page
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Should show warning about empty page
|
||||
assert window._warning_title == "Empty Page"
|
||||
assert "Cannot save an empty page" in window._warning_message
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
|
||||
def test_save_template_user_cancels_name(self, mock_get_text, qtbot):
|
||||
"""Test returns when user cancels name dialog"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page with elements
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = []
|
||||
|
||||
# Mock user canceling the name dialog
|
||||
mock_get_text.return_value = ("", False)
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Should return without saving
|
||||
assert not window.template_manager.save_template.called
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
|
||||
def test_save_template_user_cancels_description(self, mock_get_text, qtbot):
|
||||
"""Test continues with empty description when user cancels description dialog"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page with elements
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = []
|
||||
|
||||
# Mock template creation
|
||||
mock_template = Mock()
|
||||
window.template_manager.create_template_from_page.return_value = mock_template
|
||||
|
||||
# Mock dialogs: name OK, description canceled
|
||||
mock_get_text.side_effect = [
|
||||
("My Template", True), # Name dialog
|
||||
("Some description", False) # Description dialog canceled
|
||||
]
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Should still save with empty description
|
||||
window.template_manager.create_template_from_page.assert_called_once()
|
||||
call_args = window.template_manager.create_template_from_page.call_args
|
||||
assert call_args[0][0] == page
|
||||
assert call_args[0][1] == "My Template"
|
||||
assert call_args[0][2] == "" # Empty description
|
||||
|
||||
window.template_manager.save_template.assert_called_once_with(mock_template)
|
||||
assert window._info_title == "Template Saved"
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
|
||||
def test_save_template_success(self, mock_get_text, qtbot):
|
||||
"""Test successfully saving a template"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page with elements
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1", "Template_2"]
|
||||
|
||||
# Mock template creation
|
||||
mock_template = Mock()
|
||||
window.template_manager.create_template_from_page.return_value = mock_template
|
||||
|
||||
# Mock dialogs
|
||||
mock_get_text.side_effect = [
|
||||
("My Template", True), # Name dialog
|
||||
("A description", True) # Description dialog
|
||||
]
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Verify template creation with correct parameters
|
||||
window.template_manager.create_template_from_page.assert_called_once_with(
|
||||
page, "My Template", "A description"
|
||||
)
|
||||
|
||||
# Verify template was saved
|
||||
window.template_manager.save_template.assert_called_once_with(mock_template)
|
||||
|
||||
# Verify success message
|
||||
assert window._info_title == "Template Saved"
|
||||
assert "My Template" in window._info_message
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
|
||||
def test_save_template_default_name_numbering(self, mock_get_text, qtbot):
|
||||
"""Test default template name includes correct numbering"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page with elements
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
# Mock 3 existing templates
|
||||
window.template_manager.list_templates.return_value = ["Template_1", "Template_2", "Template_3"]
|
||||
|
||||
mock_get_text.return_value = ("", False) # User cancels
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Check that the default name offered was "Template_4"
|
||||
call_args = mock_get_text.call_args
|
||||
assert call_args[1]["text"] == "Template_4"
|
||||
|
||||
@patch("pyPhotoAlbum.mixins.operations.template_ops.QInputDialog.getText")
|
||||
def test_save_template_exception_handling(self, mock_get_text, qtbot):
|
||||
"""Test handles exceptions during template save"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page with elements
|
||||
layout = PageLayout(width=210, height=297)
|
||||
layout.elements = [ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)]
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = []
|
||||
|
||||
# Mock dialogs
|
||||
mock_get_text.side_effect = [
|
||||
("My Template", True),
|
||||
("Description", True)
|
||||
]
|
||||
|
||||
# Mock template creation to raise exception
|
||||
window.template_manager.create_template_from_page.side_effect = Exception("Template error")
|
||||
|
||||
window.save_page_as_template()
|
||||
|
||||
# Should show error message
|
||||
assert window._error_title == "Error"
|
||||
assert "Failed to save template" in window._error_message
|
||||
assert "Template error" in window._error_message
|
||||
|
||||
|
||||
class TestNewPageFromTemplate:
|
||||
"""Test new_page_from_template method"""
|
||||
|
||||
def test_new_page_no_templates(self, qtbot):
|
||||
"""Test shows info when no templates available"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.template_manager.list_templates.return_value = []
|
||||
|
||||
window.new_page_from_template()
|
||||
|
||||
assert window._info_title == "No Templates"
|
||||
assert "No templates available" in window._info_message
|
||||
|
||||
def test_new_page_user_cancels_dialog(self, qtbot):
|
||||
"""Test returns when user cancels dialog"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1"]
|
||||
|
||||
# Patch QDialog.exec to return rejected
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
window.new_page_from_template()
|
||||
|
||||
# Should not create page
|
||||
assert not window.template_manager.load_template.called
|
||||
|
||||
def _mock_dialog_exec(self, template_name="Template_1", scale_id=1, margin=2.5):
|
||||
"""Helper to mock dialog exec with specific values"""
|
||||
original_exec = QDialog.exec
|
||||
|
||||
def mock_exec(self):
|
||||
# Find the widgets that were added to the dialog
|
||||
combo = self.findChild(Mock.__class__.__bases__[0], "") # This won't work, need different approach
|
||||
# Set values on widgets before returning
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
return mock_exec
|
||||
|
||||
def test_new_page_stretch_mode(self, qtbot):
|
||||
"""Test creates page with stretch scaling mode"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1"]
|
||||
|
||||
# Create initial page
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page1 = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page1]
|
||||
|
||||
# Mock template
|
||||
mock_template = Mock()
|
||||
window.template_manager.load_template.return_value = mock_template
|
||||
|
||||
# Mock new page
|
||||
mock_new_page = Mock()
|
||||
window.template_manager.create_page_from_template.return_value = mock_new_page
|
||||
|
||||
# Capture the dialog and manipulate it
|
||||
captured_dialog = None
|
||||
original_exec = QDialog.exec
|
||||
|
||||
def mock_exec(dialog_self):
|
||||
nonlocal captured_dialog
|
||||
captured_dialog = dialog_self
|
||||
# Find and set widget values
|
||||
for child in dialog_self.children():
|
||||
# Template combo - first one should be selected by default (index 0)
|
||||
if hasattr(child, 'currentText'):
|
||||
pass # Already set to first item
|
||||
# Button groups - stretch radio should already be checked (it's default)
|
||||
# Margin spinbox - already set to 2.5 (default)
|
||||
return QDialog.DialogCode.Accepted
|
||||
|
||||
with patch.object(QDialog, "exec", mock_exec):
|
||||
window.new_page_from_template()
|
||||
|
||||
# Verify template was loaded
|
||||
window.template_manager.load_template.assert_called_once_with("Template_1")
|
||||
|
||||
# Verify page creation with correct parameters (defaults)
|
||||
window.template_manager.create_page_from_template.assert_called_once()
|
||||
call_args = window.template_manager.create_page_from_template.call_args
|
||||
assert call_args[1]["page_number"] == 2
|
||||
assert call_args[1]["target_size_mm"] == (210, 297)
|
||||
assert call_args[1]["scale_mode"] == "stretch" # Default checked
|
||||
assert call_args[1]["margin_percent"] == 2.5 # Default value
|
||||
|
||||
# Verify page was added to project
|
||||
assert len(window.project.pages) == 2
|
||||
assert window._update_view_called
|
||||
|
||||
def test_new_page_exception_handling(self, qtbot):
|
||||
"""Test handles exceptions during page creation"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1"]
|
||||
window.project.pages = []
|
||||
|
||||
# Mock template loading to raise exception
|
||||
window.template_manager.load_template.side_effect = Exception("Template load error")
|
||||
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
||||
window.new_page_from_template()
|
||||
|
||||
# Should show error message
|
||||
assert window._error_title == "Error"
|
||||
assert "Failed to create page from template" in window._error_message
|
||||
assert "Template load error" in window._error_message
|
||||
|
||||
|
||||
class TestApplyTemplateToPage:
|
||||
"""Test apply_template_to_page method"""
|
||||
|
||||
def test_apply_template_no_current_page(self, qtbot):
|
||||
"""Test returns early when no current page"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
window.project.pages = []
|
||||
|
||||
window.apply_template_to_page()
|
||||
|
||||
# Should return early without showing dialogs
|
||||
assert not window._update_view_called
|
||||
|
||||
def test_apply_template_no_templates(self, qtbot):
|
||||
"""Test shows info when no templates available"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
# Create page
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = []
|
||||
|
||||
window.apply_template_to_page()
|
||||
|
||||
assert window._info_title == "No Templates"
|
||||
assert "No templates available" in window._info_message
|
||||
|
||||
def test_apply_template_user_cancels_dialog(self, qtbot):
|
||||
"""Test returns when user cancels dialog"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1"]
|
||||
|
||||
# Mock dialog rejection
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Rejected):
|
||||
window.apply_template_to_page()
|
||||
|
||||
# Should not apply template
|
||||
assert not window.template_manager.load_template.called
|
||||
assert not window._update_view_called
|
||||
|
||||
def test_apply_template_replace_mode(self, qtbot):
|
||||
"""Test applies template in replace mode"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1"]
|
||||
|
||||
# Mock template
|
||||
mock_template = Mock()
|
||||
window.template_manager.load_template.return_value = mock_template
|
||||
|
||||
# Use default values: replace mode (checked by default), stretch mode (checked by default), margin=2.5
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
||||
window.apply_template_to_page()
|
||||
|
||||
# Verify template application with default values
|
||||
window.template_manager.apply_template_to_page.assert_called_once()
|
||||
call_args = window.template_manager.apply_template_to_page.call_args
|
||||
assert call_args[0][0] == mock_template
|
||||
assert call_args[0][1] == page
|
||||
assert call_args[1]["mode"] == "replace" # Default
|
||||
assert call_args[1]["scale_mode"] == "stretch" # Default
|
||||
assert call_args[1]["margin_percent"] == 2.5 # Default
|
||||
|
||||
assert window._update_view_called
|
||||
assert "Template_1" in window._status_message
|
||||
|
||||
def test_apply_template_exception_handling(self, qtbot):
|
||||
"""Test handles exceptions during template application"""
|
||||
window = TestTemplateOpsWindow()
|
||||
qtbot.addWidget(window)
|
||||
|
||||
layout = PageLayout(width=210, height=297)
|
||||
page = Page(layout=layout, page_number=1)
|
||||
window.project.pages = [page]
|
||||
|
||||
window.template_manager.list_templates.return_value = ["Template_1"]
|
||||
|
||||
# Mock template loading to raise exception
|
||||
window.template_manager.load_template.side_effect = Exception("Template error")
|
||||
|
||||
with patch.object(QDialog, "exec", return_value=QDialog.DialogCode.Accepted):
|
||||
window.apply_template_to_page()
|
||||
|
||||
# Should show error message
|
||||
assert window._error_title == "Error"
|
||||
assert "Failed to apply template" in window._error_message
|
||||
assert "Template error" in window._error_message
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user