507 lines
17 KiB
Python
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
|