""" Comprehensive tests for SnappingSystem """ import pytest import math from unittest.mock import Mock from pyPhotoAlbum.snapping import SnappingSystem, Guide, SnapResizeParams class TestGuide: """Tests for Guide dataclass""" def test_guide_creation(self): """Test creating a Guide""" guide = Guide(position=100.0, orientation="vertical") assert guide.position == 100.0 assert guide.orientation == "vertical" def test_guide_serialize(self): """Test Guide serialization""" guide = Guide(position=50.5, orientation="horizontal") data = guide.serialize() assert data["position"] == 50.5 assert data["orientation"] == "horizontal" def test_guide_deserialize(self): """Test Guide deserialization""" data = {"position": 75.0, "orientation": "vertical"} guide = Guide.deserialize(data) assert guide.position == 75.0 assert guide.orientation == "vertical" def test_guide_deserialize_defaults(self): """Test Guide deserialization with missing fields""" guide = Guide.deserialize({}) assert guide.position == 0 assert guide.orientation == "vertical" class TestSnappingSystemInit: """Tests for SnappingSystem initialization""" def test_default_init(self): """Test default initialization""" snap = SnappingSystem() assert snap.snap_threshold_mm == 5.0 assert snap.grid_size_mm == 10.0 assert snap.snap_to_grid is False assert snap.snap_to_edges is True assert snap.snap_to_guides is True assert snap.guides == [] def test_custom_threshold(self): """Test initialization with custom threshold""" snap = SnappingSystem(snap_threshold_mm=10.0) assert snap.snap_threshold_mm == 10.0 class TestGuideManagement: """Tests for guide management methods""" def test_add_guide(self): """Test adding a guide""" snap = SnappingSystem() guide = snap.add_guide(100.0, "vertical") assert len(snap.guides) == 1 assert snap.guides[0].position == 100.0 assert snap.guides[0].orientation == "vertical" def test_add_multiple_guides(self): """Test adding multiple guides""" snap = SnappingSystem() snap.add_guide(50.0, "horizontal") snap.add_guide(100.0, "vertical") snap.add_guide(150.0, "horizontal") assert len(snap.guides) == 3 def test_remove_guide(self): """Test removing a guide""" snap = SnappingSystem() guide = snap.add_guide(100.0, "vertical") snap.remove_guide(guide) assert len(snap.guides) == 0 def test_remove_nonexistent_guide(self): """Test removing a guide that doesn't exist""" snap = SnappingSystem() guide = Guide(position=100.0, orientation="vertical") # Should not raise exception snap.remove_guide(guide) assert len(snap.guides) == 0 def test_clear_guides(self): """Test clearing all guides""" snap = SnappingSystem() snap.add_guide(50.0, "horizontal") snap.add_guide(100.0, "vertical") snap.add_guide(150.0, "horizontal") snap.clear_guides() assert len(snap.guides) == 0 class TestSnapPosition: """Tests for snap_position method""" def test_no_snapping_when_disabled(self): """Test that no snapping occurs when all snapping is disabled""" snap = SnappingSystem() snap.snap_to_grid = False snap.snap_to_edges = False snap.snap_to_guides = False position = (100, 100) size = (50, 50) page_size = (210, 297) # A4 in mm result = snap.snap_position(position, size, page_size) assert result == position def test_snap_to_left_edge(self): """Test snapping to left page edge""" snap = SnappingSystem(snap_threshold_mm=10.0) snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False # Position close to left edge (within threshold) # At 300 DPI, 10mm = ~118 pixels position = (5, 100) # Very close to left edge (0) size = (50, 50) page_size = (210, 297) result = snap.snap_position(position, size, page_size) # Should snap to 0 (left edge) assert result[0] == 0 def test_snap_to_right_edge(self): """Test snapping to right page edge""" snap = SnappingSystem(snap_threshold_mm=10.0) snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False dpi = 300 page_width_mm = 210 page_width_px = page_width_mm * dpi / 25.4 element_width = 50 # Position close to right edge position = (page_width_px - element_width - 5, 100) size = (element_width, 50) page_size = (page_width_mm, 297) result = snap.snap_position(position, size, page_size, dpi) # Should snap so element's right edge aligns with page right edge expected_x = page_width_px - element_width assert abs(result[0] - expected_x) < 1 def test_snap_to_grid(self): """Test snapping to grid""" snap = SnappingSystem(snap_threshold_mm=5.0) snap.snap_to_grid = True snap.snap_to_edges = False snap.snap_to_guides = False snap.grid_size_mm = 10.0 dpi = 300 grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels # Position slightly off grid position = (grid_size_px + 10, grid_size_px + 10) size = (50, 50) page_size = (210, 297) result = snap.snap_position(position, size, page_size, dpi) # Should snap to nearest grid intersection assert abs(result[0] - grid_size_px) < 1 or abs(result[0] - 2 * grid_size_px) < 1 def test_snap_to_guides(self): """Test snapping to guides""" snap = SnappingSystem(snap_threshold_mm=10.0) snap.snap_to_edges = False snap.snap_to_grid = False snap.snap_to_guides = True dpi = 300 guide_pos_mm = 50.0 guide_pos_px = guide_pos_mm * dpi / 25.4 snap.add_guide(guide_pos_mm, "vertical") snap.add_guide(guide_pos_mm, "horizontal") # Position close to guide intersection position = (guide_pos_px + 5, guide_pos_px + 5) size = (50, 50) page_size = (210, 297) result = snap.snap_position(position, size, page_size, dpi) # Should snap to guide intersection assert abs(result[0] - guide_pos_px) < 1 or abs(result[1] - guide_pos_px) < 1 def test_snap_uses_euclidean_distance(self): """Test that snapping uses Euclidean distance for point selection""" snap = SnappingSystem(snap_threshold_mm=20.0) snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False # Position close to origin - should snap to (0, 0) # At 300 DPI, 20mm threshold = ~236 pixels # Position (50, 50) has euclidean distance ~70.7 from (0, 0) # which is well within the threshold position = (50, 50) size = (50, 50) page_size = (210, 297) dpi = 300 result = snap.snap_position(position, size, page_size, dpi) # Should snap to (0, 0) corner as it's closest and within threshold # Note: snap_position considers multiple snap points; check we got one of them assert result[0] == 0 or result[1] == 0, f"Expected at least one axis to snap to 0, got {result}" def test_snap_with_project_settings(self): """Test snapping with project settings override""" snap = SnappingSystem() snap.snap_to_grid = False # Local setting mock_project = Mock() mock_project.snap_to_grid = True mock_project.snap_to_edges = False mock_project.snap_to_guides = False mock_project.grid_size_mm = 10.0 mock_project.snap_threshold_mm = 5.0 dpi = 300 grid_size_px = 10.0 * dpi / 25.4 # Position near grid line position = (grid_size_px + 5, grid_size_px + 5) size = (50, 50) page_size = (210, 297) result = snap.snap_position(position, size, page_size, dpi, mock_project) # Should use project settings and snap to grid # The result should be different from input (snapped) assert result != position class TestSnapResize: """Tests for snap_resize method""" def test_resize_southeast_handle(self): """Test resizing from SE corner""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = False snap.snap_to_guides = False params = SnapResizeParams( position=(100, 100), size=(100, 100), dx=50, dy=50, resize_handle="se", page_size=(210, 297) ) new_pos, new_size = snap.snap_resize(params) # Position should stay same for SE resize assert new_pos == (100, 100) # Size should increase assert new_size == (150, 150) def test_resize_northwest_handle(self): """Test resizing from NW corner""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = False snap.snap_to_guides = False params = SnapResizeParams( position=(100, 100), size=(100, 100), dx=-20, dy=-20, resize_handle="nw", page_size=(210, 297) ) new_pos, new_size = snap.snap_resize(params) # Position should move for NW resize assert new_pos == (80, 80) # Size should increase assert new_size == (120, 120) def test_resize_minimum_size(self): """Test that resize enforces minimum size""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = False snap.snap_to_guides = False params = SnapResizeParams( position=(100, 100), size=(50, 50), dx=-100, # Would make width negative dy=-100, # Would make height negative resize_handle="se", page_size=(210, 297), ) new_pos, new_size = snap.snap_resize(params) # Size should be clamped to minimum assert new_size[0] >= 10 assert new_size[1] >= 10 def test_resize_snap_to_edge(self): """Test that resize snaps edges to page boundaries""" snap = SnappingSystem(snap_threshold_mm=10.0) snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False dpi = 300 page_width_px = 210 * dpi / 25.4 params = SnapResizeParams( position=(100, 100), size=(100, 100), dx=page_width_px - 200 - 5, # Almost to right edge dy=0, resize_handle="e", page_size=(210, 297), dpi=dpi, ) new_pos, new_size = snap.snap_resize(params) # Right edge should snap to page edge right_edge = new_pos[0] + new_size[0] assert abs(right_edge - page_width_px) < 20 # Within snap threshold class TestSnapEdgeToTargets: """Tests for _snap_edge_to_targets method""" def test_snap_to_page_start_edge(self): """Test snapping to page start edge (0)""" snap = SnappingSystem() snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False dpi = 300 threshold_px = 50 result = snap._snap_edge_to_targets( edge_position=10, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical" ) assert result == 0 def test_snap_to_page_end_edge(self): """Test snapping to page end edge""" snap = SnappingSystem() snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False dpi = 300 page_size_mm = 210 page_size_px = page_size_mm * dpi / 25.4 threshold_px = 50 result = snap._snap_edge_to_targets( edge_position=page_size_px - 10, page_size_mm=page_size_mm, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical", ) assert result == page_size_px def test_snap_to_grid_line(self): """Test snapping to grid line""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = True snap.snap_to_guides = False snap.grid_size_mm = 10.0 dpi = 300 grid_size_px = 10.0 * dpi / 25.4 threshold_px = 50 result = snap._snap_edge_to_targets( edge_position=grid_size_px + 5, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical", ) assert result == grid_size_px def test_snap_to_guide(self): """Test snapping to guide""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = False snap.snap_to_guides = True guide_pos_mm = 50.0 snap.add_guide(guide_pos_mm, "vertical") dpi = 300 guide_pos_px = guide_pos_mm * dpi / 25.4 threshold_px = 50 result = snap._snap_edge_to_targets( edge_position=guide_pos_px + 5, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical", ) assert result == guide_pos_px def test_no_snap_when_out_of_threshold(self): """Test no snap when edge is outside threshold""" snap = SnappingSystem() snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False dpi = 300 threshold_px = 10 result = snap._snap_edge_to_targets( edge_position=500, # Far from any edge page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical", ) assert result is None class TestGetSnapLines: """Tests for get_snap_lines method""" def test_get_snap_lines_edges_only(self): """Test getting snap lines with edges only""" snap = SnappingSystem() snap.snap_to_edges = True snap.snap_to_grid = False snap.snap_to_guides = False result = snap.get_snap_lines((210, 297)) assert len(result["edges"]) == 4 # 4 edges assert len(result["grid"]) == 0 assert len(result["guides"]) == 0 def test_get_snap_lines_with_grid(self): """Test getting snap lines with grid enabled""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = True snap.snap_to_guides = False snap.grid_size_mm = 10.0 result = snap.get_snap_lines((100, 100), dpi=300) # Should have multiple grid lines assert len(result["grid"]) > 0 assert len(result["edges"]) == 0 def test_get_snap_lines_with_guides(self): """Test getting snap lines with guides""" snap = SnappingSystem() snap.snap_to_edges = False snap.snap_to_grid = False snap.snap_to_guides = True snap.add_guide(50.0, "vertical") snap.add_guide(100.0, "horizontal") result = snap.get_snap_lines((210, 297)) assert len(result["guides"]) == 2 class TestSerialization: """Tests for serialize/deserialize methods""" def test_serialize(self): """Test serialization""" snap = SnappingSystem(snap_threshold_mm=8.0) snap.grid_size_mm = 15.0 snap.snap_to_grid = True snap.snap_to_edges = False snap.add_guide(50.0, "vertical") snap.add_guide(100.0, "horizontal") data = snap.serialize() assert data["snap_threshold_mm"] == 8.0 assert data["grid_size_mm"] == 15.0 assert data["snap_to_grid"] is True assert data["snap_to_edges"] is False assert data["snap_to_guides"] is True assert len(data["guides"]) == 2 def test_deserialize(self): """Test deserialization""" snap = SnappingSystem() data = { "snap_threshold_mm": 12.0, "grid_size_mm": 20.0, "snap_to_grid": True, "snap_to_edges": False, "snap_to_guides": False, "guides": [{"position": 75.0, "orientation": "vertical"}, {"position": 125.0, "orientation": "horizontal"}], } snap.deserialize(data) assert snap.snap_threshold_mm == 12.0 assert snap.grid_size_mm == 20.0 assert snap.snap_to_grid is True assert snap.snap_to_edges is False assert snap.snap_to_guides is False assert len(snap.guides) == 2 assert snap.guides[0].position == 75.0 assert snap.guides[1].orientation == "horizontal" def test_serialize_deserialize_roundtrip(self): """Test serialize/deserialize roundtrip""" original = SnappingSystem(snap_threshold_mm=7.5) original.grid_size_mm = 12.5 original.snap_to_grid = True original.add_guide(33.0, "vertical") original.add_guide(66.0, "horizontal") data = original.serialize() restored = SnappingSystem() restored.deserialize(data) assert restored.snap_threshold_mm == original.snap_threshold_mm assert restored.grid_size_mm == original.grid_size_mm assert restored.snap_to_grid == original.snap_to_grid assert restored.snap_to_edges == original.snap_to_edges assert restored.snap_to_guides == original.snap_to_guides assert len(restored.guides) == len(original.guides) def test_deserialize_defaults(self): """Test deserialization with missing fields uses defaults""" snap = SnappingSystem() snap.deserialize({}) assert snap.snap_threshold_mm == 5.0 assert snap.grid_size_mm == 10.0 assert snap.snap_to_grid is False assert snap.snap_to_edges is True assert snap.snap_to_guides is True assert snap.guides == []