pyPhotoAlbum/tests/test_interaction_undo_mixin.py
2025-11-11 16:02:02 +00:00

508 lines
17 KiB
Python

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