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