pyPhotoAlbum/tests/test_mouse_interaction_mixin.py
Duncan Tourolle 47e5ea4b3e
All checks were successful
Python CI / test (push) Successful in 1m7s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Successful in 51s
Tests / test (3.11) (push) Successful in 52s
Tests / test (3.9) (push) Successful in 49s
additional testing
2025-11-11 11:49:16 +01:00

507 lines
17 KiB
Python

"""
Tests for MouseInteractionMixin
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
from PyQt6.QtCore import Qt, QPoint, QPointF
from PyQt6.QtGui import QMouseEvent, QWheelEvent
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData, TextBoxData
# Create test widget combining necessary mixins
class TestMouseInteractionWidget(
MouseInteractionMixin,
PageNavigationMixin,
ImagePanMixin,
ElementManipulationMixin,
ElementSelectionMixin,
ViewportMixin,
UndoableInteractionMixin,
QOpenGLWidget
):
"""Test widget combining mouse interaction with other required mixins"""
def __init__(self):
super().__init__()
# Initialize additional state not covered by mixins
self.current_page_index = 0
self.resize_handle = None
self.rotation_snap_angle = 15
class TestMouseInteractionInitialization:
"""Test MouseInteractionMixin initialization"""
def test_widget_initializes_state(self, qtbot):
"""Test that widget initializes mouse interaction state"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
# Should have initialized state
assert hasattr(widget, 'drag_start_pos')
assert hasattr(widget, 'is_dragging')
assert hasattr(widget, 'is_panning')
assert widget.drag_start_pos is None
assert widget.is_dragging is False
assert widget.is_panning is False
class TestMousePressEvent:
"""Test mousePressEvent method"""
def test_left_click_starts_drag(self, qtbot):
"""Test left click starts drag operation - clears selection on empty click"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
# Mock update method
widget.update = Mock()
# Create left click event
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(100, 100))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
# Mock element selection and ghost page check
widget._get_element_at = Mock(return_value=None)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should clear selection when clicking on empty space
assert len(widget.selected_elements) == 0
assert widget.update.called
def test_left_click_selects_element(self, qtbot):
"""Test left click on element selects it"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Create mock element
mock_element = ImageData(image_path="/test.jpg", x=50, y=50, width=100, height=100)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(75, 75))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
# Mock element selection to return the element
widget._get_element_at = Mock(return_value=mock_element)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should select the element
assert mock_element in widget.selected_elements
assert widget.drag_start_pos == (75, 75)
assert widget.is_dragging is True
def test_ctrl_click_image_enters_image_pan_mode(self, qtbot):
"""Test Ctrl+click on ImageData enters image pan mode"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget.setCursor = Mock()
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
x=50, y=50, width=100, height=100,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(75, 75))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ControlModifier)
# Mock element selection to return the image element
widget._get_element_at = Mock(return_value=element)
widget._get_page_at = Mock(return_value=(None, -1, None))
widget._check_ghost_page_click = Mock(return_value=False)
widget.mousePressEvent(event)
# Should enter image pan mode
assert element in widget.selected_elements
assert widget.image_pan_mode is True
assert widget.is_dragging is True
assert widget.setCursor.called
def test_middle_click_starts_panning(self, qtbot):
"""Test middle mouse button starts panning"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.MiddleButton)
event.position = Mock(return_value=QPointF(150, 150))
widget.mousePressEvent(event)
# Should start panning
assert widget.is_panning is True
assert widget.drag_start_pos == (150, 150)
def test_click_on_ghost_page_adds_page(self, qtbot):
"""Test clicking on ghost page calls check method"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Mock ghost page click check to return True (handled ghost click)
widget._check_ghost_page_click = Mock(return_value=True)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
event.position = Mock(return_value=QPointF(100, 100))
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget.mousePressEvent(event)
# Should have called check_ghost_page_click
assert widget._check_ghost_page_click.called
# Ghost click returns early, so update should not be called
assert not widget.update.called
class TestMouseMoveEvent:
"""Test mouseMoveEvent method"""
def test_hover_shows_resize_cursor(self, qtbot):
"""Test hovering over resize handle (not dragging)"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget._update_page_status = Mock()
# Create selected element
element = ImageData(image_path="/test.jpg", x=50, y=50, width=100, height=100)
widget.selected_elements.add(element)
event = Mock()
event.position = Mock(return_value=QPointF(150, 150)) # Bottom-right corner
event.buttons = Mock(return_value=Qt.MouseButton.NoButton)
# Mock resize handle detection
widget._get_resize_handle_at = Mock(return_value='bottom-right')
widget._get_element_at = Mock(return_value=element)
widget.mouseMoveEvent(event)
# Should call _update_page_status but not update (no drag)
assert widget._update_page_status.called
# No dragging, so no update call
assert not widget.update.called
def test_drag_moves_element(self, qtbot):
"""Test dragging moves selected element"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget._update_page_status = Mock()
# Setup project
mock_window = Mock()
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
# Create and select element
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
page.layout.add_element(element)
widget.selected_elements.add(element)
element._parent_page = page
# Start drag
widget.drag_start_pos = (150, 150)
widget.is_dragging = True
widget.drag_start_element_pos = (100, 100)
# Mock page detection
mock_renderer = Mock()
mock_renderer.screen_to_page = Mock(return_value=(180, 180))
widget._get_page_at = Mock(return_value=(page, 0, mock_renderer))
event = Mock()
event.position = Mock(return_value=QPointF(180, 180))
event.buttons = Mock(return_value=Qt.MouseButton.LeftButton)
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget.mouseMoveEvent(event)
# Element should have moved (with snapping, so position should change)
assert element.position != (100, 100)
assert widget.update.called
def test_middle_button_panning(self, qtbot):
"""Test middle button drag pans viewport"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Start panning
widget.is_panning = True
widget.drag_start_pos = (100, 100)
initial_pan = widget.pan_offset.copy()
event = Mock()
event.position = Mock(return_value=QPointF(150, 150))
event.buttons = Mock(return_value=Qt.MouseButton.MiddleButton)
widget.mouseMoveEvent(event)
# Pan offset should have changed
assert widget.pan_offset != initial_pan
assert widget.update.called
def test_ctrl_drag_pans_image_in_frame(self, qtbot):
"""Test Ctrl+drag pans image within frame"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
widget._update_page_status = Mock()
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=100, height=100,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
)
widget.selected_elements.add(element)
# Start image pan drag
widget.drag_start_pos = (150, 150)
widget.is_dragging = True
widget.image_pan_mode = True
widget._image_pan_start = (0.0, 0.0)
event = Mock()
event.position = Mock(return_value=QPointF(160, 160))
event.buttons = Mock(return_value=Qt.MouseButton.LeftButton)
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ControlModifier)
# Mock _handle_image_pan_move method
widget._handle_image_pan_move = Mock()
widget.mouseMoveEvent(event)
# Should call _handle_image_pan_move
assert widget._handle_image_pan_move.called
class TestMouseReleaseEvent:
"""Test mouseReleaseEvent method"""
def test_release_clears_drag_state(self, qtbot):
"""Test mouse release clears drag state"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.setCursor = Mock()
# Setup drag state
widget.is_dragging = True
widget.drag_start_pos = (100, 100)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.LeftButton)
widget.mouseReleaseEvent(event)
# Should clear drag state
assert widget.is_dragging is False
assert widget.drag_start_pos is None
assert widget.setCursor.called
def test_release_clears_panning_state(self, qtbot):
"""Test mouse release clears panning state"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
# Setup panning state
widget.is_panning = True
widget.drag_start_pos = (100, 100)
event = Mock()
event.button = Mock(return_value=Qt.MouseButton.MiddleButton)
widget.mouseReleaseEvent(event)
# Should clear panning state
assert widget.is_panning is False
assert widget.drag_start_pos is None
class TestMouseDoubleClickEvent:
"""Test mouseDoubleClickEvent method"""
def test_double_click_text_starts_editing(self, qtbot):
"""Test double-clicking text element starts editing"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
# Create text element with correct constructor
text_element = TextBoxData(
text_content="Test",
x=100, y=100,
width=100, height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)}
)
# Mock _edit_text_element method
widget._edit_text_element = Mock()
# Mock element selection to return text element
widget._get_element_at = Mock(return_value=text_element)
# Create real event (not Mock) for button()
event = QMouseEvent(
QMouseEvent.Type.MouseButtonDblClick,
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
)
widget.mouseDoubleClickEvent(event)
# Should call _edit_text_element
widget._edit_text_element.assert_called_once_with(text_element)
def test_double_click_non_text_does_nothing(self, qtbot):
"""Test double-clicking non-text element does nothing"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
# Create image element (not text)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=100, height=100)
widget._edit_text_element = Mock()
widget._get_element_at = Mock(return_value=element)
# Create real event (not Mock) for button()
event = QMouseEvent(
QMouseEvent.Type.MouseButtonDblClick,
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
)
widget.mouseDoubleClickEvent(event)
# Should not call _edit_text_element
assert not widget._edit_text_element.called
class TestWheelEvent:
"""Test wheelEvent method"""
def test_scroll_pans_viewport(self, qtbot):
"""Test scroll wheel pans viewport without Ctrl"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
initial_pan = widget.pan_offset[1]
event = Mock()
event.angleDelta = Mock(return_value=Mock(y=Mock(return_value=-120))) # Scroll down
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget.wheelEvent(event)
# Should pan viewport
assert widget.pan_offset[1] != initial_pan
assert widget.update.called
def test_ctrl_scroll_zooms(self, qtbot):
"""Test Ctrl+scroll zooms viewport"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
initial_zoom = widget.zoom_level
event = Mock()
event.angleDelta = Mock(return_value=Mock(y=Mock(return_value=120))) # Scroll up
event.modifiers = Mock(return_value=Qt.KeyboardModifier.ControlModifier)
event.position = Mock(return_value=QPointF(100, 100))
widget.wheelEvent(event)
# Should zoom in
assert widget.zoom_level > initial_zoom
assert widget.update.called
def test_scroll_up_pans_viewport_up(self, qtbot):
"""Test scrolling up pans viewport upward"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
initial_pan = widget.pan_offset[1]
event = Mock()
event.angleDelta = Mock(return_value=Mock(y=Mock(return_value=120))) # Scroll up
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget.wheelEvent(event)
# Should pan viewport up (increase pan_offset[1])
assert widget.pan_offset[1] > initial_pan
assert widget.update.called
def test_scroll_down_pans_viewport_down(self, qtbot):
"""Test scrolling down pans viewport downward"""
widget = TestMouseInteractionWidget()
qtbot.addWidget(widget)
widget.update = Mock()
initial_pan = widget.pan_offset[1]
event = Mock()
event.angleDelta = Mock(return_value=Mock(y=Mock(return_value=-120))) # Scroll down
event.modifiers = Mock(return_value=Qt.KeyboardModifier.NoModifier)
widget.wheelEvent(event)
# Should pan viewport down (decrease pan_offset[1])
assert widget.pan_offset[1] < initial_pan
assert widget.update.called