big refactor to use mixin architecture
Python CI / test (push) Successful in 1m7s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Successful in 50s
Tests / test (3.11) (push) Successful in 51s
Tests / test (3.9) (push) Successful in 47s

This commit is contained in:
2025-11-11 10:35:24 +01:00
parent 3805b6b913
commit 7f32858baf
17 changed files with 3702 additions and 1334 deletions
+344
View File
@@ -0,0 +1,344 @@
"""
Tests for AssetDropMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import QMimeData, QUrl, QPoint
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
# Create test widget combining necessary mixins
class TestAssetDropWidget(AssetDropMixin, PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining asset drop, page navigation, and viewport mixins"""
def _get_element_at(self, x, y):
"""Mock implementation for testing"""
# Will be overridden in tests that need it
return None
class TestAssetDropInitialization:
"""Test AssetDropMixin initialization"""
def test_widget_accepts_drops(self, qtbot):
"""Test that widget is configured to accept drops"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
# Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin
assert hasattr(widget, 'acceptDrops')
class TestDragEnterEvent:
"""Test dragEnterEvent method"""
def test_accepts_image_urls(self, qtbot):
"""Test accepts drag events with image file URLs"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
# Create mime data with image file
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
# Create drag enter event
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragEnterEvent(event)
# Should accept the event
assert event.acceptProposedAction.called
def test_accepts_png_files(self, qtbot):
"""Test accepts PNG files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.png")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragEnterEvent(event)
assert event.acceptProposedAction.called
def test_rejects_non_image_files(self, qtbot):
"""Test rejects non-image files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
event.ignore = Mock()
widget.dragEnterEvent(event)
# Should not accept PDF files
assert not event.acceptProposedAction.called
def test_rejects_empty_mime_data(self, qtbot):
"""Test rejects events with no URLs"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
# No URLs set
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragEnterEvent(event)
assert not event.acceptProposedAction.called
class TestDragMoveEvent:
"""Test dragMoveEvent method"""
def test_accepts_drag_move_with_image(self, qtbot):
"""Test accepts drag move events with image files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.acceptProposedAction = Mock()
widget.dragMoveEvent(event)
assert event.acceptProposedAction.called
class TestDropEvent:
"""Test dropEvent method"""
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Mock update method
widget.update = Mock()
# Setup project with page
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]
# Mock asset manager
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="/imported/image.jpg")
# Mock history
mock_window.project.history = Mock()
# Mock page renderer
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
# Mock _get_page_at to return tuple
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
# Create drop event
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should have called asset manager
assert mock_window.project.asset_manager.import_asset.called
# Should have created command
assert mock_cmd_class.called
# Should have executed command
assert mock_window.project.history.execute.called
assert widget.update.called
def test_drop_outside_page_does_nothing(self, qtbot):
"""Test dropping outside any page does nothing"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Mock renderer that returns False (not in page)
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=False)
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(5000, 5000))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should not create any elements
assert len(page.layout.elements) == 0
def test_drop_updates_existing_placeholder(self, qtbot):
"""Test dropping on existing placeholder updates it with image"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
widget.update = Mock()
# Setup project with page containing placeholder
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)
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder)
mock_window.project.pages = [page]
# Mock renderer
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(150, 150))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
# Mock element selection to return the placeholder
widget._get_element_at = Mock(return_value=placeholder)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Should replace placeholder with ImageData
assert len(page.layout.elements) == 1
assert isinstance(page.layout.elements[0], ImageData)
assert page.layout.elements[0].image_path == "/path/to/image.jpg"
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
widget.update = Mock()
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]
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(return_value="/imported/image1.jpg")
mock_window.project.history = Mock()
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
mock_renderer.screen_to_page = Mock(return_value=(100, 100))
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
# Create drop event with multiple files (only first is used)
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg")
])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
widget.dropEvent(event)
# Only first image should be processed
assert mock_window.project.asset_manager.import_asset.call_count == 1
def test_drop_no_project_does_nothing(self, qtbot):
"""Test dropping when no project loaded does nothing"""
widget = TestAssetDropWidget()
qtbot.addWidget(widget)
widget.update = Mock()
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
# Mock _get_element_at to return None (no element hit)
widget._get_element_at = Mock(return_value=None)
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile("/path/to/image.jpg")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
event.position = Mock(return_value=QPoint(150, 150))
event.acceptProposedAction = Mock()
# Should not crash
widget.dropEvent(event)
# Should still accept event and call update
assert event.acceptProposedAction.called
assert widget.update.called
+371
View File
@@ -0,0 +1,371 @@
"""
Tests for ElementManipulationMixin
"""
import pytest
from unittest.mock import Mock, MagicMock
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
# Create test widget combining necessary mixins
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining manipulation and selection mixins"""
def __init__(self):
super().__init__()
self._page_renderers = []
self.drag_start_pos = None
self.drag_start_element_pos = None
class TestElementManipulationInitialization:
"""Test ElementManipulationMixin initialization"""
def test_initialization_sets_defaults(self, qtbot):
"""Test that mixin initializes with correct defaults"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
assert widget.resize_handle is None
assert widget.resize_start_pos is None
assert widget.resize_start_size is None
assert widget.rotation_mode is False
assert widget.rotation_start_angle is None
assert widget.rotation_snap_angle == 15
assert widget.snap_state == {
'is_snapped': False,
'last_position': None,
'last_size': None
}
def test_rotation_mode_is_mutable(self, qtbot):
"""Test that rotation mode can be toggled"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.rotation_mode = True
assert widget.rotation_mode is True
widget.rotation_mode = False
assert widget.rotation_mode is False
def test_rotation_snap_angle_is_configurable(self, qtbot):
"""Test that rotation snap angle can be changed"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.rotation_snap_angle = 45
assert widget.rotation_snap_angle == 45
class TestResizeElementNoSnap:
"""Test _resize_element_no_snap method"""
def test_resize_se_handle_increases_size(self, qtbot):
"""Test SE handle resizes from bottom-right"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
# Drag 50 pixels right and down
widget._resize_element_no_snap(50, 30)
assert elem.position == (100, 100) # Position unchanged
assert elem.size == (250, 180) # Size increased
def test_resize_nw_handle_moves_and_resizes(self, qtbot):
"""Test NW handle moves position and adjusts size"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'nw'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
# Drag 20 pixels left and up (negative deltas in local coordinates mean expansion)
widget._resize_element_no_snap(-20, -10)
assert elem.position == (80, 90) # Moved up-left
assert elem.size == (220, 160) # Size increased
def test_resize_ne_handle(self, qtbot):
"""Test NE handle behavior"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'ne'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
# Drag right and up
widget._resize_element_no_snap(30, -20)
assert elem.position == (100, 80) # Y moved up, X unchanged
assert elem.size == (230, 170) # Both dimensions increased
def test_resize_sw_handle(self, qtbot):
"""Test SW handle behavior"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'sw'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
# Drag left and down
widget._resize_element_no_snap(-15, 25)
assert elem.position == (85, 100) # X moved left, Y unchanged
assert elem.size == (215, 175) # Both dimensions increased
def test_resize_enforces_minimum_size(self, qtbot):
"""Test that resize enforces minimum size of 20px"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
# Try to shrink below minimum
widget._resize_element_no_snap(-40, -40)
assert elem.size[0] >= 20 # Width at least 20
assert elem.size[1] >= 20 # Height at least 20
def test_resize_no_op_without_resize_start(self, qtbot):
"""Test resize does nothing without start position/size"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
# Don't set resize_start_pos or resize_start_size
original_pos = elem.position
original_size = elem.size
widget._resize_element_no_snap(50, 50)
# Should be unchanged
assert elem.position == original_pos
assert elem.size == original_size
class TestResizeElementWithSnap:
"""Test _resize_element method with snapping"""
def test_resize_with_snap_calls_snapping_system(self, qtbot):
"""Test resize with snap uses snapping system"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
# Create element with parent page
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.add_element(elem)
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
# Mock window and project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
widget.window = Mock(return_value=mock_window)
# Mock snap_resize to return modified values
mock_snap_sys = page.layout.snapping_system
mock_snap_sys.snap_resize = Mock(return_value=((100, 100), (250, 180)))
widget._resize_element(50, 30)
# Verify snap_resize was called
assert mock_snap_sys.snap_resize.called
call_args = mock_snap_sys.snap_resize.call_args
assert call_args[1]['dx'] == 50
assert call_args[1]['dy'] == 30
assert call_args[1]['resize_handle'] == 'se'
# Verify element was updated
assert elem.size == (250, 180)
def test_resize_without_parent_page_uses_no_snap(self, qtbot):
"""Test resize without parent page falls back to no-snap"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
# No _parent_page attribute
widget._resize_element(50, 30)
# Should use no-snap logic
assert elem.size == (250, 180)
def test_resize_enforces_minimum_size_with_snap(self, qtbot):
"""Test minimum size is enforced even with snapping"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.layout.add_element(elem)
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
# Mock window
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
widget.window = Mock(return_value=mock_window)
# Mock snap to return tiny size
mock_snap_sys = page.layout.snapping_system
mock_snap_sys.snap_resize = Mock(return_value=((100, 100), (5, 5)))
widget._resize_element(-45, -45)
# Should enforce minimum
assert elem.size[0] >= 20
assert elem.size[1] >= 20
class TestTransferElementToPage:
"""Test _transfer_element_to_page method"""
def test_transfer_moves_element_between_pages(self, qtbot):
"""Test element is transferred from source to target page"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
# Create source and target pages
source_page = Page(layout=PageLayout(width=210, height=297), page_number=1)
target_page = Page(layout=PageLayout(width=210, height=297), page_number=2)
# Create element on source page
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
source_page.layout.add_element(elem)
# Mock renderer
mock_renderer = Mock()
mock_renderer.screen_to_page = Mock(return_value=(150, 175))
# Transfer element
widget._transfer_element_to_page(elem, source_page, target_page, 250, 300, mock_renderer)
# Verify element removed from source
assert elem not in source_page.layout.elements
# Verify element added to target
assert elem in target_page.layout.elements
# Verify element references updated
assert elem._parent_page is target_page
assert elem._page_renderer is mock_renderer
def test_transfer_centers_element_on_mouse(self, qtbot):
"""Test transferred element is centered on mouse position"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
source_page = Page(layout=PageLayout(width=210, height=297), page_number=1)
target_page = Page(layout=PageLayout(width=210, height=297), page_number=2)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
source_page.layout.add_element(elem)
# Mock renderer - mouse at (250, 300) screen -> (150, 175) page
mock_renderer = Mock()
mock_renderer.screen_to_page = Mock(return_value=(150, 175))
widget._transfer_element_to_page(elem, source_page, target_page, 250, 300, mock_renderer)
# Element should be centered: (150 - 200/2, 175 - 150/2) = (50, 100)
assert elem.position == (50, 100)
def test_transfer_updates_drag_state(self, qtbot):
"""Test transfer updates drag start position"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
source_page = Page(layout=PageLayout(width=210, height=297), page_number=1)
target_page = Page(layout=PageLayout(width=210, height=297), page_number=2)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
source_page.layout.add_element(elem)
mock_renderer = Mock()
mock_renderer.screen_to_page = Mock(return_value=(150, 175))
widget._transfer_element_to_page(elem, source_page, target_page, 250, 300, mock_renderer)
# Drag state should be updated
assert widget.drag_start_pos == (250, 300)
assert widget.drag_start_element_pos == elem.position
class TestManipulationStateManagement:
"""Test state management"""
def test_snap_state_dictionary_structure(self, qtbot):
"""Test snap state has expected structure"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
assert 'is_snapped' in widget.snap_state
assert 'last_position' in widget.snap_state
assert 'last_size' in widget.snap_state
def test_resize_state_can_be_set(self, qtbot):
"""Test resize state variables can be set"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.resize_handle = 'nw'
widget.resize_start_pos = (10, 20)
widget.resize_start_size = (100, 200)
assert widget.resize_handle == 'nw'
assert widget.resize_start_pos == (10, 20)
assert widget.resize_start_size == (100, 200)
def test_rotation_state_can_be_set(self, qtbot):
"""Test rotation state variables can be set"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.rotation_mode = True
widget.rotation_start_angle = 45.0
assert widget.rotation_mode is True
assert widget.rotation_start_angle == 45.0
+398
View File
@@ -0,0 +1,398 @@
"""
Tests for ElementSelectionMixin
"""
import pytest
from unittest.mock import Mock
from PyQt6.QtWidgets import QApplication
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
@pytest.fixture
def mock_page_renderer():
"""Create a mock PageRenderer"""
renderer = Mock()
renderer.screen_x = 50
renderer.screen_y = 50
renderer.zoom = 1.0
renderer.dpi = 96
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
renderer.is_point_in_page = is_point_in_page
return renderer
# Create a minimal test widget class
class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining ElementSelectionMixin with QOpenGLWidget"""
def __init__(self):
super().__init__()
self._page_renderers = []
class TestElementSelectionInitialization:
"""Test ElementSelectionMixin initialization"""
def test_initialization_creates_empty_selection_set(self, qtbot):
"""Test that mixin initializes with empty selection set"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
assert hasattr(widget, 'selected_elements')
assert isinstance(widget.selected_elements, set)
assert len(widget.selected_elements) == 0
def test_selected_element_property_returns_none_when_empty(self, qtbot):
"""Test that selected_element property returns None when no selection"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
assert widget.selected_element is None
def test_selected_element_property_returns_first_when_populated(self, qtbot):
"""Test that selected_element property returns first element"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem1 = ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)
elem2 = PlaceholderData(x=50, y=50, width=80, height=80)
widget.selected_elements = {elem1, elem2}
# Should return one of them (sets are unordered, but there should be exactly one)
result = widget.selected_element
assert result is not None
assert result in {elem1, elem2}
class TestElementSelectionProperty:
"""Test selected_element property setter/getter"""
def test_set_selected_element_to_single_element(self, qtbot):
"""Test setting selected_element with single element"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)
widget.selected_element = elem
assert len(widget.selected_elements) == 1
assert elem in widget.selected_elements
assert widget.selected_element == elem
def test_set_selected_element_to_none_clears_selection(self, qtbot):
"""Test setting selected_element to None clears selection"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=0, y=0, width=100, height=100)
widget.selected_element = elem
widget.selected_element = None
assert len(widget.selected_elements) == 0
assert widget.selected_element is None
def test_set_selected_element_replaces_previous(self, qtbot):
"""Test setting selected_element replaces previous selection"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem1 = ImageData(image_path="test1.jpg", x=0, y=0, width=100, height=100)
elem2 = PlaceholderData(x=50, y=50, width=80, height=80)
widget.selected_element = elem1
assert widget.selected_element == elem1
widget.selected_element = elem2
assert widget.selected_element == elem2
assert len(widget.selected_elements) == 1
assert elem1 not in widget.selected_elements
class TestGetElementAt:
"""Test _get_element_at method"""
def test_get_element_at_no_renderers(self, qtbot):
"""Test _get_element_at returns None when no renderers"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
widget._page_renderers = []
result = widget._get_element_at(100, 100)
assert result is None
def test_get_element_at_outside_page(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns None when click is outside page"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
widget._page_renderers = [(mock_page_renderer, page)]
# Click way outside page bounds
result = widget._get_element_at(5000, 5000)
assert result is None
def test_get_element_at_finds_element(self, qtbot, mock_page_renderer):
"""Test _get_element_at finds element at position"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
page.layout.add_element(elem)
widget._page_renderers = [(mock_page_renderer, page)]
# Click in middle of element (screen coords: 50 + 150 = 200, 50 + 175 = 225)
result = widget._get_element_at(200, 225)
assert result is not None
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns topmost element when overlapping"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
# Add overlapping elements (higher z-index = on top)
elem1 = ImageData(image_path="bottom.jpg", x=100, y=100, width=200, height=200, z_index=0)
elem2 = PlaceholderData(x=150, y=150, width=100, height=100, z_index=1)
page.layout.add_element(elem1)
page.layout.add_element(elem2)
widget._page_renderers = [(mock_page_renderer, page)]
# Click in overlapping region (screen: 50 + 175 = 225, 50 + 175 = 225)
result = widget._get_element_at(225, 225)
# Should return elem2 (topmost - last in list)
assert result == elem2
def test_get_element_at_handles_empty_page(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns None for empty page"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
widget._page_renderers = [(mock_page_renderer, page)]
# Click inside page but no elements
result = widget._get_element_at(200, 200)
assert result is None
def test_get_element_at_element_at_edge(self, qtbot, mock_page_renderer):
"""Test _get_element_at detects element at exact edge"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
page.layout.add_element(elem)
widget._page_renderers = [(mock_page_renderer, page)]
# Click exactly at element edge (screen: 50 + 100 = 150, 50 + 100 = 150)
result = widget._get_element_at(150, 150)
assert result == elem
# Click just outside element (screen: 50 + 301 = 351, 50 + 251 = 301)
result = widget._get_element_at(351, 301)
assert result is None
class TestGetResizeHandleAt:
"""Test _get_resize_handle_at method"""
def test_get_resize_handle_no_selection(self, qtbot):
"""Test _get_resize_handle_at returns None when no selection"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
result = widget._get_resize_handle_at(100, 100)
assert result is None
def test_get_resize_handle_no_project(self, qtbot):
"""Test _get_resize_handle_at returns None when no project"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
# Mock window without project
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
result = widget._get_resize_handle_at(100, 100)
assert result is None
def test_get_resize_handle_no_renderer(self, qtbot):
"""Test _get_resize_handle_at returns None when element has no renderer"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
result = widget._get_resize_handle_at(100, 100)
assert result is None
def test_get_resize_handle_detects_nw_corner(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at detects northwest corner"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem._page_renderer = mock_page_renderer
widget.selected_element = elem
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Click on NW handle (screen: 50 + 100 = 150, 50 + 100 = 150)
result = widget._get_resize_handle_at(150, 150)
assert result == 'nw'
def test_get_resize_handle_detects_all_corners(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at detects all four corners"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem._page_renderer = mock_page_renderer
widget.selected_element = elem
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# NW corner (screen: 50 + 100 = 150, 50 + 100 = 150)
assert widget._get_resize_handle_at(150, 150) == 'nw'
# NE corner (screen: 50 + 300 = 350, 50 + 100 = 150)
assert widget._get_resize_handle_at(350, 150) == 'ne'
# SW corner (screen: 50 + 100 = 150, 50 + 250 = 300)
assert widget._get_resize_handle_at(150, 300) == 'sw'
# SE corner (screen: 50 + 300 = 350, 50 + 250 = 300)
assert widget._get_resize_handle_at(350, 300) == 'se'
def test_get_resize_handle_returns_none_for_center(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at returns None for element center"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem._page_renderer = mock_page_renderer
widget.selected_element = elem
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Click in center of element (screen: 50 + 200 = 250, 50 + 175 = 225)
result = widget._get_resize_handle_at(250, 225)
assert result is None
class TestMultiSelect:
"""Test multi-selection functionality"""
def test_multi_select_add_elements(self, qtbot):
"""Test adding multiple elements to selection"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem1 = ImageData(image_path="test1.jpg", x=0, y=0, width=100, height=100)
elem2 = PlaceholderData(x=50, y=50, width=80, height=80)
widget.selected_elements.add(elem1)
widget.selected_elements.add(elem2)
assert len(widget.selected_elements) == 2
assert elem1 in widget.selected_elements
assert elem2 in widget.selected_elements
def test_multi_select_remove_element(self, qtbot):
"""Test removing element from multi-selection"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem1 = ImageData(image_path="test1.jpg", x=0, y=0, width=100, height=100)
elem2 = PlaceholderData(x=50, y=50, width=80, height=80)
widget.selected_elements = {elem1, elem2}
widget.selected_elements.remove(elem1)
assert len(widget.selected_elements) == 1
assert elem2 in widget.selected_elements
assert elem1 not in widget.selected_elements
def test_multi_select_clear_all(self, qtbot):
"""Test clearing all selections"""
widget = TestSelectionWidget()
qtbot.addWidget(widget)
elem1 = ImageData(image_path="test1.jpg", x=0, y=0, width=100, height=100)
elem2 = PlaceholderData(x=50, y=50, width=80, height=80)
widget.selected_elements = {elem1, elem2}
widget.selected_elements.clear()
assert len(widget.selected_elements) == 0
+190
View File
@@ -0,0 +1,190 @@
"""
Shared fixtures for GLWidget mixin tests
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import Qt, QPointF
from PyQt6.QtGui import QMouseEvent, QWheelEvent
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
@pytest.fixture
def mock_main_window():
"""Create a mock main window with a basic project"""
window = Mock()
window.project = Project(name="Test Project")
# Add a test page
page = Page(
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
window.project.page_spacing_mm = 10
# Mock status bar
window.status_bar = Mock()
window.status_bar.showMessage = Mock()
window.show_status = Mock()
return window
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
return ImageData(
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
return PlaceholderData(
x=50,
y=50,
width=100,
height=100,
z_index=0
)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
return TextBoxData(
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
@pytest.fixture
def mock_page_renderer():
"""Create a mock PageRenderer"""
renderer = Mock()
renderer.screen_x = 50
renderer.screen_y = 50
renderer.zoom = 1.0
renderer.dpi = 96
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
renderer.is_point_in_page = is_point_in_page
return renderer
@pytest.fixture
def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing
Args:
event_type: QEvent.Type (MouseButtonPress, MouseButtonRelease, MouseMove)
x, y: Position coordinates
button: Mouse button
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
return QMouseEvent(
event_type,
pos,
button,
button,
modifiers
)
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
Args:
x, y: Position coordinates
delta_y: Wheel delta (positive = scroll up, negative = scroll down)
modifiers: Keyboard modifiers (e.g., ControlModifier for zoom)
"""
from PyQt6.QtCore import QPoint
pos = QPointF(x, y)
global_pos = QPoint(int(x), int(y))
angle_delta = QPoint(0, delta_y)
return QWheelEvent(
pos,
global_pos,
QPoint(0, 0),
angle_delta,
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False
)
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
# Add various elements
page.layout.add_element(ImageData(
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(PlaceholderData(
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(TextBoxData(
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
return page
+277
View File
@@ -0,0 +1,277 @@
"""
Tests for ImagePanMixin
"""
import pytest
from unittest.mock import Mock
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.models import ImageData, PlaceholderData
# Create test widget combining necessary mixins
class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining image pan, selection, and viewport mixins"""
def __init__(self):
super().__init__()
self.drag_start_pos = None
class TestImagePanInitialization:
"""Test ImagePanMixin initialization"""
def test_initialization_sets_defaults(self, qtbot):
"""Test that mixin initializes with correct defaults"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
assert widget.image_pan_mode is False
assert widget.image_pan_start_crop is None
def test_image_pan_mode_is_mutable(self, qtbot):
"""Test that image pan mode can be toggled"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
widget.image_pan_mode = True
assert widget.image_pan_mode is True
widget.image_pan_mode = False
assert widget.image_pan_mode is False
class TestHandleImagePanMove:
"""Test _handle_image_pan_move method"""
def test_pan_right_shifts_crop_left(self, qtbot):
"""Test panning mouse right shifts crop window left (shows more of right side)"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.2, 0.2, 0.8, 0.8) # 60% view in center
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.2, 0.2, 0.8, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan mouse 50 pixels right
widget._handle_image_pan_move(150, 100, elem)
# Crop should shift left (x_min increases)
# crop_dx = -50 / (200 * 1.0) = -0.25
# new_x_min = 0.2 + (-0.25) = -0.05 -> clamped to 0.0
# new_x_max = 0.0 + 0.6 = 0.6
assert elem.crop_info[0] == 0.0 # Left edge
assert abs(elem.crop_info[2] - 0.6) < 0.001 # Right edge (floating point tolerance)
def test_pan_down_shifts_crop_up(self, qtbot):
"""Test panning mouse down shifts crop window up"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.2, 0.2, 0.8, 0.8)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.2, 0.2, 0.8, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan mouse 30 pixels down
widget._handle_image_pan_move(100, 130, elem)
# crop_dy = -30 / (150 * 1.0) = -0.2
# new_y_min = 0.2 + (-0.2) = 0.0
# new_y_max = 0.0 + 0.6 = 0.6
assert elem.crop_info[1] == 0.0 # Top edge
assert abs(elem.crop_info[3] - 0.6) < 0.001 # Bottom edge (floating point tolerance)
def test_pan_clamps_to_image_boundaries(self, qtbot):
"""Test panning is clamped to 0-1 range"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.1, 0.1, 0.6, 0.6)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.1, 0.1, 0.6, 0.6)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Try to pan way past boundaries
widget._handle_image_pan_move(500, 500, elem)
# Crop should be clamped to valid 0-1 range
assert 0.0 <= elem.crop_info[0] <= 1.0
assert 0.0 <= elem.crop_info[1] <= 1.0
assert 0.0 <= elem.crop_info[2] <= 1.0
assert 0.0 <= elem.crop_info[3] <= 1.0
# And crop window dimensions should be preserved
crop_width = elem.crop_info[2] - elem.crop_info[0]
crop_height = elem.crop_info[3] - elem.crop_info[1]
assert abs(crop_width - 0.5) < 0.001
assert abs(crop_height - 0.5) < 0.001
def test_pan_respects_zoom_level(self, qtbot):
"""Test panning calculation respects zoom level"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.2, 0.2, 0.8, 0.8)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.2, 0.2, 0.8, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 2.0 # Zoomed in 2x
# Pan 100 pixels right at 2x zoom
widget._handle_image_pan_move(200, 100, elem)
# crop_dx = -100 / (200 * 2.0) = -0.25
# new_x_min = 0.2 + (-0.25) = -0.05 -> clamped to 0.0
assert elem.crop_info[0] == 0.0
def test_pan_no_op_when_not_in_pan_mode(self, qtbot):
"""Test panning does nothing when not in pan mode"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
original_crop = (0.2, 0.2, 0.8, 0.8)
elem.crop_info = original_crop
widget.selected_element = elem
widget.image_pan_mode = False # Not in pan mode
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
widget._handle_image_pan_move(200, 200, elem)
# Crop should be unchanged
assert elem.crop_info == original_crop
def test_pan_no_op_on_non_image_element(self, qtbot):
"""Test panning does nothing on non-ImageData elements"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = PlaceholderData(x=100, y=100, width=200, height=150)
widget.image_pan_mode = True
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Should not crash, just do nothing
widget._handle_image_pan_move(200, 200, elem)
def test_pan_no_op_without_drag_start(self, qtbot):
"""Test panning does nothing without drag start position"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
original_crop = (0.2, 0.2, 0.8, 0.8)
elem.crop_info = original_crop
widget.selected_element = elem
widget.image_pan_mode = True
widget.drag_start_pos = None # No drag start
widget.zoom_level = 1.0
widget._handle_image_pan_move(200, 200, elem)
# Crop should be unchanged
assert elem.crop_info == original_crop
def test_pan_uses_default_crop_when_none(self, qtbot):
"""Test panning uses (0,0,1,1) when start crop is None"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0, 0, 1, 1)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = None # No start crop
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan 100 pixels right
widget._handle_image_pan_move(200, 100, elem)
# Should use full image as start (crop_width = 1.0)
# crop_dx = -100 / 200 = -0.5
# new_x_min = 0 + (-0.5) = -0.5 -> clamped to 0
# new_x_max = 0 + 1.0 = 1.0
assert elem.crop_info[0] == 0.0
assert elem.crop_info[2] == 1.0
def test_pan_maintains_crop_dimensions(self, qtbot):
"""Test panning maintains the crop window dimensions"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
original_crop = (0.2, 0.3, 0.7, 0.8) # width=0.5, height=0.5
elem.crop_info = original_crop
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = original_crop
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan 20 pixels right and 15 pixels down
widget._handle_image_pan_move(120, 115, elem)
# Crop dimensions should remain the same
new_crop = elem.crop_info
new_width = new_crop[2] - new_crop[0]
new_height = new_crop[3] - new_crop[1]
original_width = original_crop[2] - original_crop[0]
original_height = original_crop[3] - original_crop[1]
assert abs(new_width - original_width) < 0.001
assert abs(new_height - original_height) < 0.001
def test_pan_left_boundary_clamping(self, qtbot):
"""Test panning respects left boundary"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.5, 0.2, 1.0, 0.8) # Right half
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.5, 0.2, 1.0, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Try to pan left beyond boundary (pan mouse left = positive crop delta)
widget._handle_image_pan_move(50, 100, elem)
# crop_dx = -(-50) / 200 = 0.25
# new_x_min = 0.5 + 0.25 = 0.75
# But if we go further...
widget.drag_start_pos = (100, 100)
widget._handle_image_pan_move(0, 100, elem)
# crop_dx = -(-100) / 200 = 0.5
# new_x_min = 0.5 + 0.5 = 1.0
# new_x_max = 1.0 + 0.5 = 1.5 -> should clamp
assert elem.crop_info[2] == 1.0 # Right boundary
assert elem.crop_info[0] == 0.5 # 1.0 - crop_width
+345
View File
@@ -0,0 +1,345 @@
"""
Tests for PageNavigationMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import GhostPageData
# Create test widget combining necessary mixins
class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining page navigation and viewport mixins"""
pass
class TestPageNavigationInitialization:
"""Test PageNavigationMixin initialization"""
def test_initialization_sets_defaults(self, qtbot):
"""Test that mixin initializes with correct defaults"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
assert widget.current_page_index == 0
assert widget._page_renderers == []
def test_current_page_index_is_mutable(self, qtbot):
"""Test that current page index can be changed"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
widget.current_page_index = 5
assert widget.current_page_index == 5
class TestGetPageAt:
"""Test _get_page_at method"""
def test_get_page_at_no_renderers(self, qtbot):
"""Test returns None when no renderers"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
result = widget._get_page_at(100, 100)
assert result == (None, -1, None)
def test_get_page_at_no_project(self, qtbot):
"""Test returns None when no project"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
# Set up renderers but no project
mock_renderer = Mock()
widget._page_renderers = [(mock_renderer, Mock())]
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
result = widget._get_page_at(100, 100)
assert result == (None, -1, None)
def test_get_page_at_finds_page(self, qtbot):
"""Test finds page at coordinates"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
# Create project with page
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Create renderer that returns True for is_point_in_page
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
result_page, result_index, result_renderer = widget._get_page_at(100, 100)
assert result_page is page
assert result_index == 0
assert result_renderer is mock_renderer
def test_get_page_at_multiple_pages(self, qtbot):
"""Test finds correct page when multiple pages exist"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = Project(name="Test")
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]
# First renderer returns False, second returns True
renderer1 = Mock()
renderer1.is_point_in_page = Mock(return_value=False)
renderer2 = Mock()
renderer2.is_point_in_page = Mock(return_value=True)
renderer3 = Mock()
renderer3.is_point_in_page = Mock(return_value=False)
widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
widget.window = Mock(return_value=mock_window)
result_page, result_index, result_renderer = widget._get_page_at(100, 100)
assert result_page is page2
assert result_index == 1
assert result_renderer is renderer2
class TestGetPagePositions:
"""Test _get_page_positions method"""
def test_get_page_positions_no_project(self, qtbot):
"""Test returns empty list when no project"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
del mock_window.project # No project attribute
widget.window = Mock(return_value=mock_window)
result = widget._get_page_positions()
assert result == []
def test_get_page_positions_single_page(self, qtbot):
"""Test calculates positions for single page"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.project.page_spacing_mm = 10
mock_window.project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Mock calculate_page_layout_with_ghosts
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
('page', page, 0)
])
widget.window = Mock(return_value=mock_window)
result = widget._get_page_positions()
# Should have one page entry
assert len(result) >= 1
assert result[0][0] == 'page'
assert result[0][1] is page
def test_get_page_positions_includes_ghosts(self, qtbot):
"""Test includes ghost pages in result"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.project.page_spacing_mm = 10
mock_window.project.page_size_mm = (210, 297)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Mock with ghost page
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
('page', page, 0),
('ghost', None, 1)
])
widget.window = Mock(return_value=mock_window)
result = widget._get_page_positions()
# Should have page + ghost
assert len(result) >= 2
page_types = [r[0] for r in result]
assert 'page' in page_types
assert 'ghost' in page_types
class TestCheckGhostPageClick:
"""Test _check_ghost_page_click method"""
def test_check_ghost_page_no_renderers(self, qtbot):
"""Test returns False when no renderers"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
result = widget._check_ghost_page_click(100, 100)
assert result is False
def test_check_ghost_page_no_project(self, qtbot):
"""Test returns False when no project"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
widget._page_renderers = []
mock_window = Mock()
del mock_window.project
widget.window = Mock(return_value=mock_window)
result = widget._check_ghost_page_click(100, 100)
assert result is False
@patch('pyPhotoAlbum.page_renderer.PageRenderer')
def test_check_ghost_page_click_on_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking on ghost page creates new page"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
widget.pan_offset = [0, 0]
# Mock the update method
widget.update = Mock()
# Setup project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
mock_window.project.page_spacing_mm = 10
mock_window.project.page_size_mm = (210, 297)
mock_window.project.pages = []
# Mock _get_page_positions to return a ghost
ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[
('ghost', ghost, 100)
])
# Mock PageRenderer to say click is in page
mock_renderer_instance = Mock()
mock_renderer_instance.is_point_in_page = Mock(return_value=True)
mock_page_renderer_class.return_value = mock_renderer_instance
widget.window = Mock(return_value=mock_window)
# Click on ghost page
result = widget._check_ghost_page_click(150, 150)
# Should return True and create page
assert result is True
assert len(mock_window.project.pages) == 1
assert widget.update.called
@patch('pyPhotoAlbum.page_renderer.PageRenderer')
def test_check_ghost_page_click_outside_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking outside ghost page returns False"""
widget = TestPageNavWidget()
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
mock_window.project.page_spacing_mm = 10
mock_window.project.page_size_mm = (210, 297)
mock_window.project.pages = []
ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[
('ghost', ghost, 100)
])
# Mock renderer to say click is NOT in page
mock_renderer_instance = Mock()
mock_renderer_instance.is_point_in_page = Mock(return_value=False)
mock_page_renderer_class.return_value = mock_renderer_instance
widget.window = Mock(return_value=mock_window)
result = widget._check_ghost_page_click(5000, 5000)
assert result is False
assert len(mock_window.project.pages) == 0
class TestUpdatePageStatus:
"""Test _update_page_status method"""
def test_update_page_status_no_project(self, qtbot):
"""Test does nothing when no project"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
# Should not raise exception
widget._update_page_status(100, 100)
def test_update_page_status_no_renderers(self, qtbot):
"""Test does nothing when no renderers"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
widget._update_page_status(100, 100)
def test_update_page_status_on_page(self, qtbot):
"""Test updates status bar when on a page"""
widget = TestPageNavWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.0
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
page.get_page_count = Mock(return_value=1)
page.is_double_spread = False
mock_window.project.pages = [page]
mock_window.status_bar = Mock()
mock_renderer = Mock()
mock_renderer.is_point_in_page = Mock(return_value=True)
widget._page_renderers = [(mock_renderer, page)]
widget.window = Mock(return_value=mock_window)
widget._update_page_status(100, 100)
# Status bar should be updated
assert mock_window.status_bar.showMessage.called
call_args = mock_window.status_bar.showMessage.call_args[0][0]
assert "Page 1" in call_args
+203
View File
@@ -0,0 +1,203 @@
"""
Tests for ViewportMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtWidgets import QApplication
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class
class TestViewportWidget(ViewportMixin, QOpenGLWidget):
"""Test widget combining ViewportMixin with QOpenGLWidget"""
pass
class TestViewportMixinInitialization:
"""Test ViewportMixin initialization"""
def test_initialization_sets_defaults(self, qtbot):
"""Test that mixin initializes with correct defaults"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
assert widget.zoom_level == 1.0
assert widget.pan_offset == [0, 0]
assert widget.initial_zoom_set is False
def test_zoom_level_is_mutable(self, qtbot):
"""Test that zoom level can be changed"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.zoom_level = 1.5
assert widget.zoom_level == 1.5
def test_pan_offset_is_mutable(self, qtbot):
"""Test that pan offset can be changed"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.pan_offset = [100, 50]
assert widget.pan_offset == [100, 50]
class TestViewportCalculations:
"""Test viewport zoom calculations"""
def test_calculate_fit_to_screen_no_project(self, qtbot):
"""Test fit-to-screen with no project returns 1.0"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window() to return a window without project
mock_window = Mock()
mock_window.project = None
widget.window = Mock(return_value=mock_window)
zoom = widget._calculate_fit_to_screen_zoom()
assert zoom == 1.0
def test_calculate_fit_to_screen_empty_project(self, qtbot):
"""Test fit-to-screen with empty project returns 1.0"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(800, 600)
# Mock window() to return a window with empty project
mock_window = Mock()
mock_window.project = Project(name="Empty")
mock_window.project.pages = []
widget.window = Mock(return_value=mock_window)
zoom = widget._calculate_fit_to_screen_zoom()
assert zoom == 1.0
def test_calculate_fit_to_screen_with_page(self, qtbot):
"""Test fit-to-screen calculates correct zoom for 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)
zoom = widget._calculate_fit_to_screen_zoom()
# Calculate expected zoom
# A4 at 96 DPI: width=794px, height=1123px
# Window: 1000x800, margins: 100px each side
# Available: 800x600
# zoom_w = 800/794 ≈ 1.007, zoom_h = 600/1123 ≈ 0.534
# Should use min(zoom_w, zoom_h, 1.0) = 0.534
assert 0.5 < zoom < 0.6 # Approximately 0.534
assert zoom <= 1.0 # Never zoom beyond 100%
def test_calculate_fit_to_screen_small_window(self, qtbot):
"""Test fit-to-screen with small window returns small zoom"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(400, 300) # Small window
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)
zoom = widget._calculate_fit_to_screen_zoom()
# With 400x300 window and 200px margins, available space is 200x100
# This should produce a very small zoom
assert zoom < 0.3
def test_calculate_fit_to_screen_large_window(self, qtbot):
"""Test fit-to-screen with large window caps at 1.0"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(3000, 2000) # Very large window
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)
zoom = widget._calculate_fit_to_screen_zoom()
# Even with huge window, zoom should not exceed 1.0
assert zoom == 1.0
def test_calculate_fit_to_screen_different_dpi(self, qtbot):
"""Test fit-to-screen respects different DPI values"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
widget.resize(1000, 800)
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 300 # High DPI
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
zoom = widget._calculate_fit_to_screen_zoom()
# At 300 DPI, page is much larger in pixels
# So zoom should be smaller
assert zoom < 0.3
class TestViewportOpenGL:
"""Test OpenGL-related viewport methods"""
def test_initializeGL_sets_clear_color(self, qtbot):
"""Test that initializeGL is callable (actual GL testing is integration)"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
# Just verify the method exists and is callable
assert hasattr(widget, 'initializeGL')
assert callable(widget.initializeGL)
def test_resizeGL_is_callable(self, qtbot):
"""Test that resizeGL is callable"""
widget = TestViewportWidget()
qtbot.addWidget(widget)
assert hasattr(widget, 'resizeGL')
assert callable(widget.resizeGL)