additional testing
This commit is contained in:
parent
7f32858baf
commit
47e5ea4b3e
657
tests/test_commands.py
Normal file
657
tests/test_commands.py
Normal file
@ -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
|
||||
387
tests/test_gl_widget_integration.py
Normal file
387
tests/test_gl_widget_integration.py
Normal file
@ -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
|
||||
507
tests/test_interaction_undo_mixin.py
Normal file
507
tests/test_interaction_undo_mixin.py
Normal file
@ -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
|
||||
506
tests/test_mouse_interaction_mixin.py
Normal file
506
tests/test_mouse_interaction_mixin.py
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user