pyPhotoAlbum/tests/test_snapping_system.py
Duncan Tourolle f0aa005d8c
All checks were successful
Python CI / test (push) Successful in 1m41s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m46s
Tests / test (3.12) (push) Successful in 1m50s
Tests / test (3.13) (push) Successful in 1m43s
Tests / test (3.14) (push) Successful in 1m26s
refactor to reduce complexity of nesting
2026-01-01 18:42:58 +01:00

586 lines
18 KiB
Python

"""
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 == []