diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..b78b32c --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,657 @@ +""" +Tests for Command pattern implementation +""" + +import pytest +from unittest.mock import Mock, MagicMock +from pyPhotoAlbum.commands import ( + AddElementCommand, + DeleteElementCommand, + MoveElementCommand, + ResizeElementCommand, + RotateElementCommand, + AdjustImageCropCommand, + AlignElementsCommand, + ResizeElementsCommand, + ChangeZOrderCommand, + StateChangeCommand, + CommandHistory, + _normalize_asset_path +) +from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData +from pyPhotoAlbum.page_layout import PageLayout +from pyPhotoAlbum.project import Project, Page + + +class TestNormalizeAssetPath: + """Test _normalize_asset_path helper function""" + + def test_normalize_absolute_path(self): + """Test converting absolute path to relative""" + mock_manager = Mock() + mock_manager.project_folder = "/project" + + result = _normalize_asset_path("/project/assets/image.jpg", mock_manager) + assert result == "assets/image.jpg" + + def test_normalize_relative_path_unchanged(self): + """Test relative path stays unchanged""" + mock_manager = Mock() + mock_manager.project_folder = "/project" + + result = _normalize_asset_path("assets/image.jpg", mock_manager) + assert result == "assets/image.jpg" + + def test_normalize_no_asset_manager(self): + """Test with no asset manager returns unchanged""" + result = _normalize_asset_path("/path/to/image.jpg", None) + assert result == "/path/to/image.jpg" + + def test_normalize_empty_path(self): + """Test with empty path""" + mock_manager = Mock() + result = _normalize_asset_path("", mock_manager) + assert result == "" + + +class TestAddElementCommand: + """Test AddElementCommand""" + + def test_add_element_execute(self): + """Test adding element to layout""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + assert len(layout.elements) == 0 + + cmd.execute() + + assert len(layout.elements) == 1 + assert element in layout.elements + + def test_add_element_undo(self): + """Test undoing element addition""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + cmd.execute() + assert len(layout.elements) == 1 + + cmd.undo() + + assert len(layout.elements) == 0 + + def test_add_element_redo(self): + """Test redoing element addition""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + cmd.execute() + cmd.undo() + + cmd.redo() + + assert len(layout.elements) == 1 + assert element in layout.elements + + def test_add_element_serialization(self): + """Test serializing add element command""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + cmd.execute() + + data = cmd.serialize() + + assert data['type'] == 'add_element' + assert 'element' in data + assert data['executed'] is True + + def test_add_element_with_asset_manager(self): + """Test add element with asset manager reference""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + mock_asset_manager = Mock() + mock_asset_manager.project_folder = "/project" + mock_asset_manager.acquire_reference = Mock() + + cmd = AddElementCommand(layout, element, asset_manager=mock_asset_manager) + + # Should acquire reference on creation + assert mock_asset_manager.acquire_reference.called + + +class TestDeleteElementCommand: + """Test DeleteElementCommand""" + + def test_delete_element_execute(self): + """Test deleting element from layout""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + layout.add_element(element) + + cmd = DeleteElementCommand(layout, element) + cmd.execute() + + assert len(layout.elements) == 0 + assert element not in layout.elements + + def test_delete_element_undo(self): + """Test undoing element deletion""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + layout.add_element(element) + + cmd = DeleteElementCommand(layout, element) + cmd.execute() + + cmd.undo() + + assert len(layout.elements) == 1 + assert element in layout.elements + + def test_delete_element_serialization(self): + """Test serializing delete element command""" + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + layout.add_element(element) + + cmd = DeleteElementCommand(layout, element) + data = cmd.serialize() + + assert data['type'] == 'delete_element' + assert 'element' in data + + +class TestMoveElementCommand: + """Test MoveElementCommand""" + + def test_move_element_execute(self): + """Test moving element""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) + cmd.execute() + + assert element.position == (200, 200) + + def test_move_element_undo(self): + """Test undoing element move""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) + cmd.execute() + + cmd.undo() + + assert element.position == (100, 100) + + def test_move_element_serialization(self): + """Test serializing move command""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200)) + data = cmd.serialize() + + assert data['type'] == 'move_element' + assert data['old_position'] == (100, 100) + assert data['new_position'] == (200, 200) + + +class TestResizeElementCommand: + """Test ResizeElementCommand""" + + def test_resize_element_execute(self): + """Test resizing element""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = ResizeElementCommand( + element, + old_position=(100, 100), + old_size=(200, 150), + new_position=(100, 100), + new_size=(300, 225) + ) + cmd.execute() + + assert element.size == (300, 225) + + def test_resize_element_undo(self): + """Test undoing element resize""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = ResizeElementCommand( + element, + old_position=(100, 100), + old_size=(200, 150), + new_position=(100, 100), + new_size=(300, 225) + ) + cmd.execute() + + cmd.undo() + + assert element.size == (200, 150) + + def test_resize_changes_position(self): + """Test resize that also changes position""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = ResizeElementCommand( + element, + old_position=(100, 100), + old_size=(200, 150), + new_position=(90, 90), + new_size=(220, 165) + ) + cmd.execute() + + assert element.position == (90, 90) + assert element.size == (220, 165) + + +class TestRotateElementCommand: + """Test RotateElementCommand""" + + def test_rotate_element_execute(self): + """Test rotating element""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + element.rotation = 0 + + cmd = RotateElementCommand(element, old_rotation=0, new_rotation=90) + cmd.execute() + + assert element.rotation == 90 + + def test_rotate_element_undo(self): + """Test undoing element rotation""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + element.rotation = 0 + + cmd = RotateElementCommand(element, old_rotation=0, new_rotation=90) + cmd.execute() + + cmd.undo() + + assert element.rotation == 0 + + def test_rotate_element_serialization(self): + """Test serializing rotate command""" + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45) + data = cmd.serialize() + + assert data['type'] == 'rotate_element' + assert data['old_rotation'] == 0 + assert data['new_rotation'] == 45 + + +class TestAdjustImageCropCommand: + """Test AdjustImageCropCommand""" + + def test_adjust_crop_execute(self): + """Test adjusting image crop""" + element = ImageData( + image_path="/test.jpg", + x=100, y=100, + width=200, height=150, + crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} + ) + + new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} + cmd = AdjustImageCropCommand( + element, + old_crop_info=element.crop_info.copy(), + new_crop_info=new_crop + ) + cmd.execute() + + assert element.crop_info == new_crop + + def test_adjust_crop_undo(self): + """Test undoing crop adjustment""" + old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} + element = ImageData( + image_path="/test.jpg", + x=100, y=100, + width=200, height=150, + crop_info=old_crop.copy() + ) + + new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8} + cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop) + cmd.execute() + + cmd.undo() + + assert element.crop_info == old_crop + + +class TestAlignElementsCommand: + """Test AlignElementsCommand""" + + def test_align_elements_execute(self): + """Test aligning elements""" + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=200, y=150, width=100, height=100) + + # Set new positions before creating command + element2.position = (100, 150) # Align left + + # Command expects list of (element, old_position) tuples + changes = [(element1, (100, 100)), (element2, (200, 150))] + + cmd = AlignElementsCommand(changes) + cmd.execute() + + # Execute does nothing (positions already set), check they remain + assert element1.position == (100, 100) + assert element2.position == (100, 150) + + def test_align_elements_undo(self): + """Test undoing alignment""" + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=200, y=150, width=100, height=100) + + # Set new positions before creating command + element2.position = (100, 150) # Align left + + # Command expects list of (element, old_position) tuples + changes = [(element1, (100, 100)), (element2, (200, 150))] + + cmd = AlignElementsCommand(changes) + cmd.execute() + + cmd.undo() + + # Should restore old positions + assert element1.position == (100, 100) + assert element2.position == (200, 150) + + +class TestResizeElementsCommand: + """Test ResizeElementsCommand""" + + def test_resize_elements_execute(self): + """Test resizing multiple elements""" + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=150, height=150) + + # Set new sizes before creating command + element1.size = (200, 200) + element2.size = (300, 300) + + # Command expects list of (element, old_position, old_size) tuples + changes = [ + (element1, (100, 100), (100, 100)), + (element2, (200, 200), (150, 150)) + ] + + cmd = ResizeElementsCommand(changes) + cmd.execute() + + # Execute does nothing (sizes already set), check they remain + assert element1.size == (200, 200) + assert element2.size == (300, 300) + + def test_resize_elements_undo(self): + """Test undoing multiple element resize""" + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=150, height=150) + + # Set new sizes before creating command + element1.size = (200, 200) + element2.size = (300, 300) + + # Command expects list of (element, old_position, old_size) tuples + changes = [ + (element1, (100, 100), (100, 100)), + (element2, (200, 200), (150, 150)) + ] + + cmd = ResizeElementsCommand(changes) + cmd.execute() + + cmd.undo() + + # Should restore old sizes + assert element1.size == (100, 100) + assert element2.size == (150, 150) + + +class TestChangeZOrderCommand: + """Test ChangeZOrderCommand""" + + def test_change_zorder_execute(self): + """Test changing z-order""" + layout = PageLayout(width=210, height=297) + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=120, y=120, width=100, height=100) + + layout.add_element(element1) + layout.add_element(element2) + + # Move element1 to front (swap order) + cmd = ChangeZOrderCommand(layout, element1, 0, 1) + cmd.execute() + + assert layout.elements[1] == element1 + + def test_change_zorder_undo(self): + """Test undoing z-order change""" + layout = PageLayout(width=210, height=297) + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=120, y=120, width=100, height=100) + + layout.add_element(element1) + layout.add_element(element2) + + cmd = ChangeZOrderCommand(layout, element1, 0, 1) + cmd.execute() + + cmd.undo() + + assert layout.elements[0] == element1 + + +class TestStateChangeCommand: + """Test StateChangeCommand for generic state changes""" + + def test_state_change_undo(self): + """Test undoing state change""" + element = TextBoxData( + text_content="Old Text", + x=100, y=100, + width=200, height=100 + ) + + # Define restore function + def restore_state(state): + element.text_content = state['text_content'] + + old_state = {'text_content': 'Old Text'} + new_state = {'text_content': 'New Text'} + + # Apply new state first + element.text_content = 'New Text' + + cmd = StateChangeCommand( + description="Change text", + restore_func=restore_state, + before_state=old_state, + after_state=new_state + ) + + # Undo should restore old state + cmd.undo() + assert element.text_content == 'Old Text' + + def test_state_change_redo(self): + """Test redoing state change""" + element = TextBoxData( + text_content="Old Text", + x=100, y=100, + width=200, height=100 + ) + + # Define restore function + def restore_state(state): + element.text_content = state['text_content'] + + old_state = {'text_content': 'Old Text'} + new_state = {'text_content': 'New Text'} + + # Apply new state first + element.text_content = 'New Text' + + cmd = StateChangeCommand( + description="Change text", + restore_func=restore_state, + before_state=old_state, + after_state=new_state + ) + + # Undo then redo + cmd.undo() + assert element.text_content == 'Old Text' + + cmd.redo() + assert element.text_content == 'New Text' + + def test_state_change_serialization(self): + """Test serializing state change command""" + def restore_func(state): + pass + + cmd = StateChangeCommand( + description="Test operation", + restore_func=restore_func, + before_state={'test': 'before'}, + after_state={'test': 'after'} + ) + + data = cmd.serialize() + + assert data['type'] == 'state_change' + assert data['description'] == 'Test operation' + + +class TestCommandHistory: + """Test CommandHistory for undo/redo management""" + + def test_history_execute_command(self): + """Test executing command through history""" + history = CommandHistory() + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + history.execute(cmd) + + assert len(layout.elements) == 1 + assert history.can_undo() + assert not history.can_redo() + + def test_history_undo(self): + """Test undo through history""" + history = CommandHistory() + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + history.execute(cmd) + + history.undo() + + assert len(layout.elements) == 0 + assert not history.can_undo() + assert history.can_redo() + + def test_history_redo(self): + """Test redo through history""" + history = CommandHistory() + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + cmd = AddElementCommand(layout, element) + history.execute(cmd) + history.undo() + + history.redo() + + assert len(layout.elements) == 1 + assert history.can_undo() + assert not history.can_redo() + + def test_history_multiple_commands(self): + """Test history with multiple commands""" + history = CommandHistory() + layout = PageLayout(width=210, height=297) + + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=100, height=100) + + history.execute(AddElementCommand(layout, element1)) + history.execute(AddElementCommand(layout, element2)) + + assert len(layout.elements) == 2 + + history.undo() + assert len(layout.elements) == 1 + + history.undo() + assert len(layout.elements) == 0 + + def test_history_clears_redo_on_new_command(self): + """Test that new command clears redo stack""" + history = CommandHistory() + layout = PageLayout(width=210, height=297) + + element1 = ImageData(image_path="/test1.jpg", x=100, y=100, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=200, y=200, width=100, height=100) + + history.execute(AddElementCommand(layout, element1)) + history.undo() + + assert history.can_redo() + + # Execute new command should clear redo stack + history.execute(AddElementCommand(layout, element2)) + + assert not history.can_redo() + + def test_history_clear(self): + """Test clearing history""" + history = CommandHistory() + layout = PageLayout(width=210, height=297) + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + history.execute(AddElementCommand(layout, element)) + + history.clear() + + assert not history.can_undo() + assert not history.can_redo() + + def test_history_max_size(self): + """Test history respects max size limit""" + history = CommandHistory(max_history=3) + layout = PageLayout(width=210, height=297) + + for i in range(5): + element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100) + history.execute(AddElementCommand(layout, element)) + + # Should only have 3 commands in history (max_history) + undo_count = 0 + while history.can_undo(): + history.undo() + undo_count += 1 + + assert undo_count == 3 diff --git a/tests/test_gl_widget_integration.py b/tests/test_gl_widget_integration.py new file mode 100644 index 0000000..4274118 --- /dev/null +++ b/tests/test_gl_widget_integration.py @@ -0,0 +1,387 @@ +""" +Integration tests for GLWidget - verifying mixin composition +""" + +import pytest +from unittest.mock import Mock, patch +from PyQt6.QtCore import Qt, QPointF +from PyQt6.QtGui import QMouseEvent +from pyPhotoAlbum.gl_widget import GLWidget +from pyPhotoAlbum.project import Project, Page +from pyPhotoAlbum.page_layout import PageLayout +from pyPhotoAlbum.models import ImageData, TextBoxData + + +class TestGLWidgetInitialization: + """Test GLWidget initialization and mixin integration""" + + def test_gl_widget_initializes(self, qtbot): + """Test GLWidget can be instantiated with all mixins""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Verify mixin state is initialized + assert hasattr(widget, 'zoom_level') + assert hasattr(widget, 'pan_offset') + assert hasattr(widget, 'selected_elements') + assert hasattr(widget, 'drag_start_pos') + assert hasattr(widget, 'is_dragging') + assert hasattr(widget, 'is_panning') + assert hasattr(widget, 'rotation_mode') + + def test_gl_widget_accepts_drops(self, qtbot): + """Test GLWidget is configured to accept drops""" + widget = GLWidget() + qtbot.addWidget(widget) + + assert widget.acceptDrops() is True + + def test_gl_widget_tracks_mouse(self, qtbot): + """Test GLWidget has mouse tracking enabled""" + widget = GLWidget() + qtbot.addWidget(widget) + + assert widget.hasMouseTracking() is True + + +class TestGLWidgetMixinIntegration: + """Test that mixins work together correctly""" + + def test_viewport_and_rendering_integration(self, qtbot): + """Test viewport state affects rendering""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Set zoom level + initial_zoom = widget.zoom_level + widget.zoom_level = 2.0 + + assert widget.zoom_level == 2.0 + assert widget.zoom_level != initial_zoom + + # Pan offset + initial_pan = widget.pan_offset.copy() + widget.pan_offset[0] += 100 + widget.pan_offset[1] += 50 + + assert widget.pan_offset != initial_pan + + def test_selection_and_manipulation_integration(self, qtbot): + """Test element selection works with manipulation""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Create an element + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + # Select it + widget.selected_elements.add(element) + + # Verify selection + assert element in widget.selected_elements + assert widget.selected_element == element + + # Clear selection + widget.selected_elements.clear() + assert len(widget.selected_elements) == 0 + assert widget.selected_element is None + + def test_mouse_interaction_with_selection(self, qtbot): + """Test mouse events trigger selection changes""" + widget = GLWidget() + qtbot.addWidget(widget) + + widget.update = Mock() + + # Mock element at position + test_element = ImageData(image_path="/test.jpg", x=50, y=50, width=100, height=100) + widget._get_element_at = Mock(return_value=test_element) + widget._get_page_at = Mock(return_value=(None, -1, None)) + widget._check_ghost_page_click = Mock(return_value=False) + + # Create mouse press event + event = QMouseEvent( + QMouseEvent.Type.MouseButtonPress, + QPointF(75, 75), + Qt.MouseButton.LeftButton, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier + ) + + widget.mousePressEvent(event) + + # Should select the element + assert test_element in widget.selected_elements + + def test_undo_integration_with_operations(self, qtbot): + """Test undo/redo integration with element operations""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Create element + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + widget.selected_elements.add(element) + + # Begin operation (should be tracked for undo) + widget._begin_move(element) + assert widget._interaction_element is not None + assert widget._interaction_type == 'move' + assert widget._interaction_start_pos == (100, 100) + + # End operation + widget._end_interaction() + # Interaction state should be cleared after operation + assert widget._interaction_element is None + assert widget._interaction_type is None + + +class TestGLWidgetKeyEvents: + """Test keyboard event handling""" + + def test_escape_clears_selection(self, qtbot): + """Test Escape key clears selection and rotation mode""" + widget = GLWidget() + qtbot.addWidget(widget) + + widget.update = Mock() + + # Set up selection and rotation mode + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + widget.selected_elements.add(element) + widget.rotation_mode = True + + # Create key press event for Escape + from PyQt6.QtGui import QKeyEvent + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Escape, + Qt.KeyboardModifier.NoModifier + ) + + widget.keyPressEvent(event) + + # Should clear selection and rotation mode + assert widget.selected_element is None + assert widget.rotation_mode is False + assert widget.update.called + + def test_tab_toggles_rotation_mode(self, qtbot): + """Test Tab key toggles rotation mode when element is selected""" + widget = GLWidget() + qtbot.addWidget(widget) + + widget.update = Mock() + + # Set up mock window for status message + mock_window = Mock() + mock_window.show_status = Mock() + widget.window = Mock(return_value=mock_window) + + # Select an element + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + widget.selected_elements.add(element) + + # Initially not in rotation mode + assert widget.rotation_mode is False + + # Create key press event for Tab + from PyQt6.QtGui import QKeyEvent + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Tab, + Qt.KeyboardModifier.NoModifier + ) + + widget.keyPressEvent(event) + + # Should toggle rotation mode + assert widget.rotation_mode is True + assert widget.update.called + + # Press Tab again + widget.keyPressEvent(event) + + # Should toggle back + assert widget.rotation_mode is False + + def test_delete_key_requires_main_window(self, qtbot): + """Test Delete key calls main window's delete method""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Set up mock window + mock_window = Mock() + mock_window.delete_selected_element = Mock() + widget.window = Mock(return_value=mock_window) + + # Select an element + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + widget.selected_elements.add(element) + + # Create key press event for Delete + from PyQt6.QtGui import QKeyEvent + event = QKeyEvent( + QKeyEvent.Type.KeyPress, + Qt.Key.Key_Delete, + Qt.KeyboardModifier.NoModifier + ) + + widget.keyPressEvent(event) + + # Should call main window's delete method + assert mock_window.delete_selected_element.called + + +class TestGLWidgetWithProject: + """Test GLWidget with a full project setup""" + + def test_gl_widget_with_project(self, qtbot): + """Test GLWidget can work with a project and pages""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Create a mock main window with project + mock_window = Mock() + mock_window.project = Project(name="Test Project") + mock_window.project.working_dpi = 96 + + # Add a page + page = Page(layout=PageLayout(width=210, height=297), page_number=1) + mock_window.project.pages.append(page) + + # Add an element to the page + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + page.layout.add_element(element) + + widget.window = Mock(return_value=mock_window) + + # Verify we can access project through widget + main_window = widget.window() + assert hasattr(main_window, 'project') + assert main_window.project.name == "Test Project" + assert len(main_window.project.pages) == 1 + assert len(main_window.project.pages[0].layout.elements) == 1 + + def test_fit_to_screen_zoom_calculation(self, qtbot): + """Test fit-to-screen zoom calculation with project""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Create mock window with project + mock_window = Mock() + mock_window.project = Project(name="Test") + mock_window.project.working_dpi = 96 + page = Page(layout=PageLayout(width=210, height=297), page_number=1) + mock_window.project.pages.append(page) + + widget.window = Mock(return_value=mock_window) + + # Mock widget dimensions + widget.width = Mock(return_value=800) + widget.height = Mock(return_value=600) + + # Calculate fit-to-screen zoom + zoom = widget._calculate_fit_to_screen_zoom() + + # Should return a valid zoom level + assert isinstance(zoom, float) + assert zoom > 0 + + def test_gl_widget_without_project(self, qtbot): + """Test GLWidget handles missing project gracefully""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Create mock window without project + mock_window = Mock() + del mock_window.project + widget.window = Mock(return_value=mock_window) + + # Should not crash when calculating zoom + zoom = widget._calculate_fit_to_screen_zoom() + assert zoom == 1.0 + + +class TestGLWidgetOpenGL: + """Test OpenGL-specific functionality""" + + def test_gl_widget_has_opengl_format(self, qtbot): + """Test GLWidget has OpenGL format configured""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Should have a format + format = widget.format() + assert format is not None + + def test_gl_widget_update_behavior(self, qtbot): + """Test GLWidget update behavior is configured""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Should have NoPartialUpdate set + from PyQt6.QtOpenGLWidgets import QOpenGLWidget + assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate + + +class TestGLWidgetStateManagement: + """Test state management across mixins""" + + def test_rotation_mode_state(self, qtbot): + """Test rotation mode state is properly managed""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Initial state + assert widget.rotation_mode is False + + # Toggle rotation mode + widget.rotation_mode = True + assert widget.rotation_mode is True + + # Toggle back + widget.rotation_mode = False + assert widget.rotation_mode is False + + def test_drag_state_management(self, qtbot): + """Test drag state is properly managed""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Initial state + assert widget.is_dragging is False + assert widget.drag_start_pos is None + + # Start drag + widget.is_dragging = True + widget.drag_start_pos = (100, 100) + + assert widget.is_dragging is True + assert widget.drag_start_pos == (100, 100) + + # End drag + widget.is_dragging = False + widget.drag_start_pos = None + + assert widget.is_dragging is False + assert widget.drag_start_pos is None + + def test_pan_state_management(self, qtbot): + """Test pan state is properly managed""" + widget = GLWidget() + qtbot.addWidget(widget) + + # Initial state + assert widget.is_panning is False + + # Start panning + widget.is_panning = True + widget.drag_start_pos = (200, 200) + + assert widget.is_panning is True + + # End panning + widget.is_panning = False + widget.drag_start_pos = None + + assert widget.is_panning is False diff --git a/tests/test_interaction_undo_mixin.py b/tests/test_interaction_undo_mixin.py new file mode 100644 index 0000000..0ef54d3 --- /dev/null +++ b/tests/test_interaction_undo_mixin.py @@ -0,0 +1,507 @@ +""" +Tests for UndoableInteractionMixin +""" + +import pytest +from unittest.mock import Mock, patch +from PyQt6.QtOpenGLWidgets import QOpenGLWidget +from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin +from pyPhotoAlbum.models import ImageData, TextBoxData +from pyPhotoAlbum.project import Project, Page +from pyPhotoAlbum.page_layout import PageLayout +from pyPhotoAlbum.commands import CommandHistory + + +# Create test widget with UndoableInteractionMixin +class TestUndoableWidget(UndoableInteractionMixin, QOpenGLWidget): + """Test widget with undoable interaction mixin""" + + def __init__(self): + super().__init__() + + +class TestUndoableInteractionInitialization: + """Test UndoableInteractionMixin initialization""" + + def test_widget_initializes_state(self, qtbot): + """Test that widget initializes interaction tracking state""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + # Should have initialized tracking state + assert hasattr(widget, '_interaction_element') + assert hasattr(widget, '_interaction_type') + assert hasattr(widget, '_interaction_start_pos') + assert hasattr(widget, '_interaction_start_size') + assert hasattr(widget, '_interaction_start_rotation') + + # All should be None initially + assert widget._interaction_element is None + assert widget._interaction_type is None + assert widget._interaction_start_pos is None + assert widget._interaction_start_size is None + assert widget._interaction_start_rotation is None + + +class TestBeginMove: + """Test _begin_move method""" + + def test_begin_move_captures_state(self, qtbot): + """Test that begin_move captures initial position""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + + assert widget._interaction_element is element + assert widget._interaction_type == 'move' + assert widget._interaction_start_pos == (100, 100) + assert widget._interaction_start_size is None + assert widget._interaction_start_rotation is None + + def test_begin_move_updates_existing_state(self, qtbot): + """Test that begin_move overwrites previous interaction state""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element1 = ImageData(image_path="/test1.jpg", x=50, y=50, width=100, height=100) + element2 = ImageData(image_path="/test2.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element1) + widget._begin_move(element2) + + # Should have element2's state + assert widget._interaction_element is element2 + assert widget._interaction_start_pos == (100, 100) + + +class TestBeginResize: + """Test _begin_resize method""" + + def test_begin_resize_captures_state(self, qtbot): + """Test that begin_resize captures initial position and size""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_resize(element) + + assert widget._interaction_element is element + assert widget._interaction_type == 'resize' + assert widget._interaction_start_pos == (100, 100) + assert widget._interaction_start_size == (200, 150) + assert widget._interaction_start_rotation is None + + +class TestBeginRotate: + """Test _begin_rotate method""" + + def test_begin_rotate_captures_state(self, qtbot): + """Test that begin_rotate captures initial rotation""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + element.rotation = 45.0 + + widget._begin_rotate(element) + + assert widget._interaction_element is element + assert widget._interaction_type == 'rotate' + assert widget._interaction_start_pos is None + assert widget._interaction_start_size is None + assert widget._interaction_start_rotation == 45.0 + + +class TestBeginImagePan: + """Test _begin_image_pan method""" + + def test_begin_image_pan_captures_crop_info(self, qtbot): + """Test that begin_image_pan captures initial crop info""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData( + image_path="/test.jpg", + x=100, y=100, + width=200, height=150, + crop_info=(0.1, 0.2, 0.8, 0.7) + ) + + widget._begin_image_pan(element) + + assert widget._interaction_element is element + assert widget._interaction_type == 'image_pan' + assert widget._interaction_start_crop_info == (0.1, 0.2, 0.8, 0.7) + + def test_begin_image_pan_ignores_non_image(self, qtbot): + """Test that begin_image_pan ignores non-ImageData elements""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = TextBoxData(text_content="Test", x=100, y=100, width=200, height=100) + + widget._begin_image_pan(element) + + # Should not set any state for non-ImageData + assert widget._interaction_element is None + assert widget._interaction_type is None + + +class TestEndInteraction: + """Test _end_interaction method""" + + @patch('pyPhotoAlbum.commands.MoveElementCommand') + def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot): + """Test that ending move interaction creates MoveElementCommand""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + # Setup mock project + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + + # Move the element + element.position = (150, 160) + + widget._end_interaction() + + # Should have created and executed command + assert mock_cmd_class.called + mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160)) + assert mock_window.project.history.execute.called + + @patch('pyPhotoAlbum.commands.ResizeElementCommand') + def test_end_interaction_creates_resize_command(self, mock_cmd_class, qtbot): + """Test that ending resize interaction creates ResizeElementCommand""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_resize(element) + + # Resize the element + element.position = (90, 90) + element.size = (250, 200) + + widget._end_interaction() + + # Should have created and executed command + assert mock_cmd_class.called + mock_cmd_class.assert_called_once_with( + element, + (100, 100), # old position + (200, 150), # old size + (90, 90), # new position + (250, 200) # new size + ) + assert mock_window.project.history.execute.called + + @patch('pyPhotoAlbum.commands.RotateElementCommand') + def test_end_interaction_creates_rotate_command(self, mock_cmd_class, qtbot): + """Test that ending rotate interaction creates RotateElementCommand""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + element.rotation = 0 + + widget._begin_rotate(element) + + # Rotate the element + element.rotation = 90 + + widget._end_interaction() + + # Should have created and executed command + assert mock_cmd_class.called + mock_cmd_class.assert_called_once_with(element, 0, 90) + assert mock_window.project.history.execute.called + + @patch('pyPhotoAlbum.commands.AdjustImageCropCommand') + def test_end_interaction_creates_crop_command(self, mock_cmd_class, qtbot): + """Test that ending image pan interaction creates AdjustImageCropCommand""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData( + image_path="/test.jpg", + x=100, y=100, + width=200, height=150, + crop_info=(0.0, 0.0, 1.0, 1.0) # Tuple format used in code + ) + + widget._begin_image_pan(element) + + # Pan the image + element.crop_info = (0.1, 0.1, 0.8, 0.8) + + widget._end_interaction() + + # Should have created and executed command + assert mock_cmd_class.called + assert mock_window.project.history.execute.called + + def test_end_interaction_ignores_insignificant_move(self, qtbot): + """Test that tiny moves (< 0.1 units) don't create commands""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + + # Move element by very small amount + element.position = (100.05, 100.05) + + widget._end_interaction() + + # Should NOT have executed any command + assert not mock_window.project.history.execute.called + + def test_end_interaction_ignores_no_change(self, qtbot): + """Test that interactions with no change don't create commands""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + element.rotation = 45 + + widget._begin_rotate(element) + + # Don't change rotation + + widget._end_interaction() + + # Should NOT have executed any command + assert not mock_window.project.history.execute.called + + def test_end_interaction_clears_state(self, qtbot): + """Test that end_interaction clears tracking state""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + element.position = (150, 150) + widget._end_interaction() + + # State should be cleared + assert widget._interaction_element is None + assert widget._interaction_type is None + assert widget._interaction_start_pos is None + + def test_end_interaction_no_project(self, qtbot): + """Test that end_interaction handles missing project gracefully""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + # No project attribute on window + mock_window = Mock() + del mock_window.project + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + element.position = (150, 150) + + # Should not crash + widget._end_interaction() + + # State should be cleared + assert widget._interaction_element is None + + def test_end_interaction_no_element(self, qtbot): + """Test that end_interaction handles no element gracefully""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + # Call without beginning any interaction + widget._end_interaction() + + # Should not crash, state should remain clear + assert widget._interaction_element is None + + +class TestClearInteractionState: + """Test _clear_interaction_state method""" + + def test_clear_interaction_state(self, qtbot): + """Test that clear_interaction_state resets all tracking""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + # Set up some state + widget._begin_move(element) + assert widget._interaction_element is not None + + # Clear it + widget._clear_interaction_state() + + # Everything should be None + assert widget._interaction_element is None + assert widget._interaction_type is None + assert widget._interaction_start_pos is None + assert widget._interaction_start_size is None + assert widget._interaction_start_rotation is None + + def test_clear_interaction_state_with_crop_info(self, qtbot): + """Test that clear_interaction_state handles crop info""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData( + image_path="/test.jpg", + x=100, y=100, + width=200, height=150, + crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0} + ) + + widget._begin_image_pan(element) + assert hasattr(widget, '_interaction_start_crop_info') + + widget._clear_interaction_state() + + # Crop info should be cleared + if hasattr(widget, '_interaction_start_crop_info'): + assert widget._interaction_start_crop_info is None + + +class TestCancelInteraction: + """Test _cancel_interaction method""" + + def test_cancel_interaction_clears_state(self, qtbot): + """Test that cancel_interaction clears state without creating command""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + element.position = (150, 150) + + # Cancel instead of ending + widget._cancel_interaction() + + # Should NOT have created any command + assert not mock_window.project.history.execute.called + + # State should be cleared + assert widget._interaction_element is None + assert widget._interaction_type is None + + +class TestInteractionEdgeCases: + """Test edge cases and error conditions""" + + def test_multiple_begin_calls(self, qtbot): + """Test that multiple begin calls overwrite state correctly""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_move(element) + widget._begin_resize(element) + widget._begin_rotate(element) + + # Should have rotate state (last call wins) + assert widget._interaction_type == 'rotate' + assert widget._interaction_start_rotation == 0 + + @patch('pyPhotoAlbum.commands.ResizeElementCommand') + def test_resize_with_only_size_change(self, mock_cmd_class, qtbot): + """Test resize command when only size changes (position same)""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_resize(element) + + # Only change size + element.size = (250, 200) + + widget._end_interaction() + + # Should still create command + assert mock_cmd_class.called + assert mock_window.project.history.execute.called + + @patch('pyPhotoAlbum.commands.ResizeElementCommand') + def test_resize_with_only_position_change(self, mock_cmd_class, qtbot): + """Test resize command when only position changes (size same)""" + widget = TestUndoableWidget() + qtbot.addWidget(widget) + + mock_window = Mock() + mock_window.project = Mock() + mock_window.project.history = Mock() + widget.window = Mock(return_value=mock_window) + + element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150) + + widget._begin_resize(element) + + # Only change position + element.position = (90, 90) + + widget._end_interaction() + + # Should still create command + assert mock_cmd_class.called + assert mock_window.project.history.execute.called diff --git a/tests/test_mouse_interaction_mixin.py b/tests/test_mouse_interaction_mixin.py new file mode 100644 index 0000000..cb4f765 --- /dev/null +++ b/tests/test_mouse_interaction_mixin.py @@ -0,0 +1,506 @@ +""" +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