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