big refactor to use mixin architecture
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user