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

278 lines
10 KiB
Python

"""
Tests for ImagePanMixin
"""
import pytest
from unittest.mock import Mock
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
from pyPhotoAlbum.mixins.viewport import ViewportMixin
from pyPhotoAlbum.models import ImageData, PlaceholderData
# Create test widget combining necessary mixins
class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining image pan, selection, and viewport mixins"""
def __init__(self):
super().__init__()
self.drag_start_pos = None
class TestImagePanInitialization:
"""Test ImagePanMixin initialization"""
def test_initialization_sets_defaults(self, qtbot):
"""Test that mixin initializes with correct defaults"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
assert widget.image_pan_mode is False
assert widget.image_pan_start_crop is None
def test_image_pan_mode_is_mutable(self, qtbot):
"""Test that image pan mode can be toggled"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
widget.image_pan_mode = True
assert widget.image_pan_mode is True
widget.image_pan_mode = False
assert widget.image_pan_mode is False
class TestHandleImagePanMove:
"""Test _handle_image_pan_move method"""
def test_pan_right_shifts_crop_left(self, qtbot):
"""Test panning mouse right shifts crop window left (shows more of right side)"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.2, 0.2, 0.8, 0.8) # 60% view in center
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.2, 0.2, 0.8, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan mouse 50 pixels right
widget._handle_image_pan_move(150, 100, elem)
# Crop should shift left (x_min increases)
# crop_dx = -50 / (200 * 1.0) = -0.25
# new_x_min = 0.2 + (-0.25) = -0.05 -> clamped to 0.0
# new_x_max = 0.0 + 0.6 = 0.6
assert elem.crop_info[0] == 0.0 # Left edge
assert abs(elem.crop_info[2] - 0.6) < 0.001 # Right edge (floating point tolerance)
def test_pan_down_shifts_crop_up(self, qtbot):
"""Test panning mouse down shifts crop window up"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.2, 0.2, 0.8, 0.8)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.2, 0.2, 0.8, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan mouse 30 pixels down
widget._handle_image_pan_move(100, 130, elem)
# crop_dy = -30 / (150 * 1.0) = -0.2
# new_y_min = 0.2 + (-0.2) = 0.0
# new_y_max = 0.0 + 0.6 = 0.6
assert elem.crop_info[1] == 0.0 # Top edge
assert abs(elem.crop_info[3] - 0.6) < 0.001 # Bottom edge (floating point tolerance)
def test_pan_clamps_to_image_boundaries(self, qtbot):
"""Test panning is clamped to 0-1 range"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.1, 0.1, 0.6, 0.6)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.1, 0.1, 0.6, 0.6)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Try to pan way past boundaries
widget._handle_image_pan_move(500, 500, elem)
# Crop should be clamped to valid 0-1 range
assert 0.0 <= elem.crop_info[0] <= 1.0
assert 0.0 <= elem.crop_info[1] <= 1.0
assert 0.0 <= elem.crop_info[2] <= 1.0
assert 0.0 <= elem.crop_info[3] <= 1.0
# And crop window dimensions should be preserved
crop_width = elem.crop_info[2] - elem.crop_info[0]
crop_height = elem.crop_info[3] - elem.crop_info[1]
assert abs(crop_width - 0.5) < 0.001
assert abs(crop_height - 0.5) < 0.001
def test_pan_respects_zoom_level(self, qtbot):
"""Test panning calculation respects zoom level"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.2, 0.2, 0.8, 0.8)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.2, 0.2, 0.8, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 2.0 # Zoomed in 2x
# Pan 100 pixels right at 2x zoom
widget._handle_image_pan_move(200, 100, elem)
# crop_dx = -100 / (200 * 2.0) = -0.25
# new_x_min = 0.2 + (-0.25) = -0.05 -> clamped to 0.0
assert elem.crop_info[0] == 0.0
def test_pan_no_op_when_not_in_pan_mode(self, qtbot):
"""Test panning does nothing when not in pan mode"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
original_crop = (0.2, 0.2, 0.8, 0.8)
elem.crop_info = original_crop
widget.selected_element = elem
widget.image_pan_mode = False # Not in pan mode
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
widget._handle_image_pan_move(200, 200, elem)
# Crop should be unchanged
assert elem.crop_info == original_crop
def test_pan_no_op_on_non_image_element(self, qtbot):
"""Test panning does nothing on non-ImageData elements"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = PlaceholderData(x=100, y=100, width=200, height=150)
widget.image_pan_mode = True
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Should not crash, just do nothing
widget._handle_image_pan_move(200, 200, elem)
def test_pan_no_op_without_drag_start(self, qtbot):
"""Test panning does nothing without drag start position"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
original_crop = (0.2, 0.2, 0.8, 0.8)
elem.crop_info = original_crop
widget.selected_element = elem
widget.image_pan_mode = True
widget.drag_start_pos = None # No drag start
widget.zoom_level = 1.0
widget._handle_image_pan_move(200, 200, elem)
# Crop should be unchanged
assert elem.crop_info == original_crop
def test_pan_uses_default_crop_when_none(self, qtbot):
"""Test panning uses (0,0,1,1) when start crop is None"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0, 0, 1, 1)
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = None # No start crop
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan 100 pixels right
widget._handle_image_pan_move(200, 100, elem)
# Should use full image as start (crop_width = 1.0)
# crop_dx = -100 / 200 = -0.5
# new_x_min = 0 + (-0.5) = -0.5 -> clamped to 0
# new_x_max = 0 + 1.0 = 1.0
assert elem.crop_info[0] == 0.0
assert elem.crop_info[2] == 1.0
def test_pan_maintains_crop_dimensions(self, qtbot):
"""Test panning maintains the crop window dimensions"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
original_crop = (0.2, 0.3, 0.7, 0.8) # width=0.5, height=0.5
elem.crop_info = original_crop
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = original_crop
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Pan 20 pixels right and 15 pixels down
widget._handle_image_pan_move(120, 115, elem)
# Crop dimensions should remain the same
new_crop = elem.crop_info
new_width = new_crop[2] - new_crop[0]
new_height = new_crop[3] - new_crop[1]
original_width = original_crop[2] - original_crop[0]
original_height = original_crop[3] - original_crop[1]
assert abs(new_width - original_width) < 0.001
assert abs(new_height - original_height) < 0.001
def test_pan_left_boundary_clamping(self, qtbot):
"""Test panning respects left boundary"""
widget = TestImagePanWidget()
qtbot.addWidget(widget)
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
elem.crop_info = (0.5, 0.2, 1.0, 0.8) # Right half
widget.selected_element = elem
widget.image_pan_mode = True
widget.image_pan_start_crop = (0.5, 0.2, 1.0, 0.8)
widget.drag_start_pos = (100, 100)
widget.zoom_level = 1.0
# Try to pan left beyond boundary (pan mouse left = positive crop delta)
widget._handle_image_pan_move(50, 100, elem)
# crop_dx = -(-50) / 200 = 0.25
# new_x_min = 0.5 + 0.25 = 0.75
# But if we go further...
widget.drag_start_pos = (100, 100)
widget._handle_image_pan_move(0, 100, elem)
# crop_dx = -(-100) / 200 = 0.5
# new_x_min = 0.5 + 0.5 = 1.0
# new_x_max = 1.0 + 0.5 = 1.5 -> should clamp
assert elem.crop_info[2] == 1.0 # Right boundary
assert elem.crop_info[0] == 0.5 # 1.0 - crop_width