additional testing
All checks were successful
Python CI / test (push) Successful in 1m7s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Successful in 51s
Tests / test (3.11) (push) Successful in 52s
Tests / test (3.9) (push) Successful in 49s

This commit is contained in:
Duncan Tourolle 2025-11-11 11:49:16 +01:00
parent 7f32858baf
commit 47e5ea4b3e
4 changed files with 2057 additions and 0 deletions

657
tests/test_commands.py Normal file
View 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

View 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

View 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

View 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