""" 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 object assert hasattr(widget, "_interaction_state") assert hasattr(widget, "_command_factory") # State should be clear initially assert widget._interaction_state.element is None assert widget._interaction_state.interaction_type is None assert widget._interaction_state.position is None assert widget._interaction_state.size is None assert widget._interaction_state.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_state.element is element assert widget._interaction_state.interaction_type == "move" assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.size is None assert widget._interaction_state.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_state.element is element2 assert widget._interaction_state.position == (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_state.element is element assert widget._interaction_state.interaction_type == "resize" assert widget._interaction_state.position == (100, 100) assert widget._interaction_state.size == (200, 150) assert widget._interaction_state.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_state.element is element assert widget._interaction_state.interaction_type == "rotate" assert widget._interaction_state.position is None assert widget._interaction_state.size is None assert widget._interaction_state.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_state.element is element assert widget._interaction_state.interaction_type == "image_pan" assert widget._interaction_state.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_state.element is None assert widget._interaction_state.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_state.element is None assert widget._interaction_state.interaction_type is None assert widget._interaction_state.position 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_state.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_state.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_state.element is not None # Clear it widget._clear_interaction_state() # Everything should be None assert widget._interaction_state.element is None assert widget._interaction_state.interaction_type is None assert widget._interaction_state.position is None assert widget._interaction_state.size is None assert widget._interaction_state.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=(0.0, 0.0, 1.0, 1.0)) widget._begin_image_pan(element) # After begin_image_pan, crop_info should be stored assert widget._interaction_state.crop_info is not None widget._clear_interaction_state() # Crop info should be cleared assert widget._interaction_state.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_state.element is None assert widget._interaction_state.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_state.interaction_type == "rotate" assert widget._interaction_state.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