""" 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