Improved snapping
This commit is contained in:
parent
5257c6654a
commit
795c0c531c
@ -67,7 +67,7 @@ class SnappingSystem:
|
|||||||
page_size: Tuple[float, float],
|
page_size: Tuple[float, float],
|
||||||
dpi: int = 300) -> Tuple[float, float]:
|
dpi: int = 300) -> Tuple[float, float]:
|
||||||
"""
|
"""
|
||||||
Apply snapping to a position (per-axis)
|
Apply snapping to a position using combined distance threshold
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
position: Current position (x, y) in pixels
|
position: Current position (x, y) in pixels
|
||||||
@ -78,6 +78,8 @@ class SnappingSystem:
|
|||||||
Returns:
|
Returns:
|
||||||
Snapped position (x, y) in pixels
|
Snapped position (x, y) in pixels
|
||||||
"""
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
x, y = position
|
x, y = position
|
||||||
width, height = size
|
width, height = size
|
||||||
page_width_mm, page_height_mm = page_size
|
page_width_mm, page_height_mm = page_size
|
||||||
@ -85,17 +87,84 @@ class SnappingSystem:
|
|||||||
# Convert threshold from mm to pixels
|
# Convert threshold from mm to pixels
|
||||||
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4
|
snap_threshold_px = self.snap_threshold_mm * dpi / 25.4
|
||||||
|
|
||||||
# Snap X axis
|
# Collect all potential snap points for both edges of the element
|
||||||
snapped_x = self._snap_axis(
|
snap_points = []
|
||||||
x, width, page_width_mm, dpi, snap_threshold_px, 'vertical'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Snap Y axis
|
# 1. Page edge snap points
|
||||||
snapped_y = self._snap_axis(
|
if self.snap_to_edges:
|
||||||
y, height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
|
page_width_px = page_width_mm * dpi / 25.4
|
||||||
)
|
page_height_px = page_height_mm * dpi / 25.4
|
||||||
|
|
||||||
return (snapped_x, snapped_y)
|
# Corners where element's top-left can snap
|
||||||
|
snap_points.extend([
|
||||||
|
(0, 0), # Top-left corner
|
||||||
|
(page_width_px - width, 0), # Top-right corner
|
||||||
|
(0, page_height_px - height), # Bottom-left corner
|
||||||
|
(page_width_px - width, page_height_px - height), # Bottom-right corner
|
||||||
|
])
|
||||||
|
|
||||||
|
# Edge positions (element aligned to edge on one axis)
|
||||||
|
snap_points.extend([
|
||||||
|
(0, y), # Left edge
|
||||||
|
(page_width_px - width, y), # Right edge
|
||||||
|
(x, 0), # Top edge
|
||||||
|
(x, page_height_px - height), # Bottom edge
|
||||||
|
])
|
||||||
|
|
||||||
|
# 2. Grid snap points
|
||||||
|
if self.snap_to_grid:
|
||||||
|
grid_size_px = self.grid_size_mm * dpi / 25.4
|
||||||
|
page_width_px = page_width_mm * dpi / 25.4
|
||||||
|
page_height_px = page_height_mm * dpi / 25.4
|
||||||
|
|
||||||
|
# Calculate grid intersection points within range
|
||||||
|
x_start = max(0, int((x - snap_threshold_px) / grid_size_px)) * grid_size_px
|
||||||
|
x_end = min(page_width_px, int((x + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
||||||
|
y_start = max(0, int((y - snap_threshold_px) / grid_size_px)) * grid_size_px
|
||||||
|
y_end = min(page_height_px, int((y + snap_threshold_px) / grid_size_px + 1) * grid_size_px)
|
||||||
|
|
||||||
|
grid_x = x_start
|
||||||
|
while grid_x <= x_end:
|
||||||
|
grid_y = y_start
|
||||||
|
while grid_y <= y_end:
|
||||||
|
snap_points.append((grid_x, grid_y))
|
||||||
|
# Also snap element's far edge to grid
|
||||||
|
if grid_x >= width:
|
||||||
|
snap_points.append((grid_x - width, grid_y))
|
||||||
|
if grid_y >= height:
|
||||||
|
snap_points.append((grid_x, grid_y - height))
|
||||||
|
grid_y += grid_size_px
|
||||||
|
grid_x += grid_size_px
|
||||||
|
|
||||||
|
# 3. Guide snap points
|
||||||
|
if self.snap_to_guides:
|
||||||
|
vertical_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'vertical']
|
||||||
|
horizontal_guides = [g.position * dpi / 25.4 for g in self.guides if g.orientation == 'horizontal']
|
||||||
|
|
||||||
|
# Guide intersections (when both vertical and horizontal guides exist)
|
||||||
|
for vg in vertical_guides:
|
||||||
|
for hg in horizontal_guides:
|
||||||
|
snap_points.append((vg, hg))
|
||||||
|
# Also snap element's far edge to intersections
|
||||||
|
snap_points.append((vg - width, hg))
|
||||||
|
snap_points.append((vg, hg - height))
|
||||||
|
snap_points.append((vg - width, hg - height))
|
||||||
|
|
||||||
|
# Find the nearest snap point using Euclidean distance
|
||||||
|
best_snap_point = None
|
||||||
|
best_distance = snap_threshold_px
|
||||||
|
|
||||||
|
for snap_x, snap_y in snap_points:
|
||||||
|
distance = math.sqrt((x - snap_x) ** 2 + (y - snap_y) ** 2)
|
||||||
|
if distance < best_distance:
|
||||||
|
best_snap_point = (snap_x, snap_y)
|
||||||
|
best_distance = distance
|
||||||
|
|
||||||
|
# Return snapped position or original position
|
||||||
|
if best_snap_point:
|
||||||
|
return best_snap_point
|
||||||
|
else:
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
def snap_resize(self,
|
def snap_resize(self,
|
||||||
position: Tuple[float, float],
|
position: Tuple[float, float],
|
||||||
@ -151,16 +220,19 @@ class SnappingSystem:
|
|||||||
new_width = width + dx
|
new_width = width + dx
|
||||||
|
|
||||||
# Now apply snapping to the edges that are being moved
|
# Now apply snapping to the edges that are being moved
|
||||||
|
# Use _snap_edge_to_targets consistently for all edges
|
||||||
|
|
||||||
# Snap left edge (for nw, w, sw handles)
|
# Snap left edge (for nw, w, sw handles)
|
||||||
if resize_handle in ['nw', 'w', 'sw']:
|
if resize_handle in ['nw', 'w', 'sw']:
|
||||||
snapped_left = self._snap_axis(
|
# Try to snap the left edge
|
||||||
new_x, new_width, page_width_mm, dpi, snap_threshold_px, 'vertical'
|
snapped_left = self._snap_edge_to_targets(
|
||||||
|
new_x, page_width_mm, dpi, snap_threshold_px, 'vertical'
|
||||||
)
|
)
|
||||||
# Adjust width to compensate for position change
|
if snapped_left is not None:
|
||||||
width_adjustment = new_x - snapped_left
|
# Adjust width to compensate for position change
|
||||||
new_x = snapped_left
|
width_adjustment = new_x - snapped_left
|
||||||
new_width += width_adjustment
|
new_x = snapped_left
|
||||||
|
new_width += width_adjustment
|
||||||
|
|
||||||
# Snap right edge (for ne, e, se handles)
|
# Snap right edge (for ne, e, se handles)
|
||||||
if resize_handle in ['ne', 'e', 'se']:
|
if resize_handle in ['ne', 'e', 'se']:
|
||||||
@ -175,13 +247,15 @@ class SnappingSystem:
|
|||||||
|
|
||||||
# Snap top edge (for nw, n, ne handles)
|
# Snap top edge (for nw, n, ne handles)
|
||||||
if resize_handle in ['nw', 'n', 'ne']:
|
if resize_handle in ['nw', 'n', 'ne']:
|
||||||
snapped_top = self._snap_axis(
|
# Try to snap the top edge
|
||||||
new_y, new_height, page_height_mm, dpi, snap_threshold_px, 'horizontal'
|
snapped_top = self._snap_edge_to_targets(
|
||||||
|
new_y, page_height_mm, dpi, snap_threshold_px, 'horizontal'
|
||||||
)
|
)
|
||||||
# Adjust height to compensate for position change
|
if snapped_top is not None:
|
||||||
height_adjustment = new_y - snapped_top
|
# Adjust height to compensate for position change
|
||||||
new_y = snapped_top
|
height_adjustment = new_y - snapped_top
|
||||||
new_height += height_adjustment
|
new_y = snapped_top
|
||||||
|
new_height += height_adjustment
|
||||||
|
|
||||||
# Snap bottom edge (for sw, s, se handles)
|
# Snap bottom edge (for sw, s, se handles)
|
||||||
if resize_handle in ['sw', 's', 'se']:
|
if resize_handle in ['sw', 's', 'se']:
|
||||||
|
|||||||
421
tests/test_alignment.py
Normal file
421
tests/test_alignment.py
Normal file
@ -0,0 +1,421 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for pyPhotoAlbum alignment system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyPhotoAlbum.alignment import AlignmentManager
|
||||||
|
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlignmentManager:
|
||||||
|
"""Tests for AlignmentManager class"""
|
||||||
|
|
||||||
|
def test_get_bounds_empty_list(self):
|
||||||
|
"""Test get_bounds with empty list"""
|
||||||
|
elements = []
|
||||||
|
bounds = AlignmentManager.get_bounds(elements)
|
||||||
|
assert bounds == (0, 0, 0, 0)
|
||||||
|
|
||||||
|
def test_get_bounds_single_element(self):
|
||||||
|
"""Test get_bounds with single element"""
|
||||||
|
elem = ImageData(x=10, y=20, width=100, height=50)
|
||||||
|
bounds = AlignmentManager.get_bounds([elem])
|
||||||
|
|
||||||
|
# min_x, min_y, max_x, max_y
|
||||||
|
assert bounds == (10, 20, 110, 70)
|
||||||
|
|
||||||
|
def test_get_bounds_multiple_elements(self):
|
||||||
|
"""Test get_bounds with multiple elements"""
|
||||||
|
elem1 = ImageData(x=10, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=50, y=10, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=5, y=30, width=90, height=40)
|
||||||
|
|
||||||
|
bounds = AlignmentManager.get_bounds([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# min_x = 5, min_y = 10, max_x = 130 (50+80), max_y = 70 (10+60 or 20+50)
|
||||||
|
assert bounds[0] == 5 # min_x
|
||||||
|
assert bounds[1] == 10 # min_y
|
||||||
|
assert bounds[2] == 130 # max_x
|
||||||
|
assert bounds[3] == 70 # max_y
|
||||||
|
|
||||||
|
def test_align_left_empty_list(self):
|
||||||
|
"""Test align_left with empty list"""
|
||||||
|
changes = AlignmentManager.align_left([])
|
||||||
|
assert changes == []
|
||||||
|
|
||||||
|
def test_align_left_single_element(self):
|
||||||
|
"""Test align_left with single element"""
|
||||||
|
elem = ImageData(x=50, y=50, width=100, height=100)
|
||||||
|
changes = AlignmentManager.align_left([elem])
|
||||||
|
assert changes == []
|
||||||
|
assert elem.position == (50, 50) # Should not change
|
||||||
|
|
||||||
|
def test_align_left_multiple_elements(self):
|
||||||
|
"""Test align_left with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All should align to x=30 (leftmost)
|
||||||
|
assert elem1.position == (30, 20)
|
||||||
|
assert elem2.position == (30, 40)
|
||||||
|
assert elem3.position == (30, 60)
|
||||||
|
|
||||||
|
# Check undo information
|
||||||
|
assert len(changes) == 3
|
||||||
|
assert changes[0] == (elem1, (50, 20))
|
||||||
|
assert changes[1] == (elem2, (30, 40))
|
||||||
|
assert changes[2] == (elem3, (70, 60))
|
||||||
|
|
||||||
|
def test_align_right_multiple_elements(self):
|
||||||
|
"""Test align_right with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50) # right edge at 150
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60) # right edge at 110
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40) # right edge at 160
|
||||||
|
|
||||||
|
changes = AlignmentManager.align_right([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All right edges should align to x=160 (rightmost)
|
||||||
|
assert elem1.position[0] == 60 # 160 - 100
|
||||||
|
assert elem2.position[0] == 80 # 160 - 80
|
||||||
|
assert elem3.position[0] == 70 # 160 - 90
|
||||||
|
|
||||||
|
# Y positions should not change
|
||||||
|
assert elem1.position[1] == 20
|
||||||
|
assert elem2.position[1] == 40
|
||||||
|
assert elem3.position[1] == 60
|
||||||
|
|
||||||
|
def test_align_top_multiple_elements(self):
|
||||||
|
"""Test align_top with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=30, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=20, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=40, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.align_top([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All should align to y=20 (topmost)
|
||||||
|
assert elem1.position[1] == 20
|
||||||
|
assert elem2.position[1] == 20
|
||||||
|
assert elem3.position[1] == 20
|
||||||
|
|
||||||
|
# X positions should not change
|
||||||
|
assert elem1.position[0] == 50
|
||||||
|
assert elem2.position[0] == 30
|
||||||
|
assert elem3.position[0] == 70
|
||||||
|
|
||||||
|
def test_align_bottom_multiple_elements(self):
|
||||||
|
"""Test align_bottom with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=30, width=100, height=50) # bottom at 80
|
||||||
|
elem2 = ImageData(x=30, y=20, width=80, height=60) # bottom at 80
|
||||||
|
elem3 = ImageData(x=70, y=40, width=90, height=50) # bottom at 90
|
||||||
|
|
||||||
|
changes = AlignmentManager.align_bottom([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All bottom edges should align to y=90 (bottommost)
|
||||||
|
assert elem1.position[1] == 40 # 90 - 50
|
||||||
|
assert elem2.position[1] == 30 # 90 - 60
|
||||||
|
assert elem3.position[1] == 40 # 90 - 50
|
||||||
|
|
||||||
|
# X positions should not change
|
||||||
|
assert elem1.position[0] == 50
|
||||||
|
assert elem2.position[0] == 30
|
||||||
|
assert elem3.position[0] == 70
|
||||||
|
|
||||||
|
def test_align_horizontal_center_multiple_elements(self):
|
||||||
|
"""Test align_horizontal_center with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 100
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||||
|
elem3 = ImageData(x=70, y=60, width=60, height=40) # center at 100
|
||||||
|
|
||||||
|
changes = AlignmentManager.align_horizontal_center([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Average center = (100 + 70 + 100) / 3 = 90
|
||||||
|
# All elements should center at x=90
|
||||||
|
assert abs(elem1.position[0] + elem1.size[0]/2 - 90) < 0.01
|
||||||
|
assert abs(elem2.position[0] + elem2.size[0]/2 - 90) < 0.01
|
||||||
|
assert abs(elem3.position[0] + elem3.size[0]/2 - 90) < 0.01
|
||||||
|
|
||||||
|
# Y positions should not change
|
||||||
|
assert elem1.position[1] == 20
|
||||||
|
assert elem2.position[1] == 40
|
||||||
|
assert elem3.position[1] == 60
|
||||||
|
|
||||||
|
def test_align_vertical_center_multiple_elements(self):
|
||||||
|
"""Test align_vertical_center with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50) # center at 45
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60) # center at 70
|
||||||
|
elem3 = ImageData(x=70, y=30, width=60, height=40) # center at 50
|
||||||
|
|
||||||
|
changes = AlignmentManager.align_vertical_center([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Average center = (45 + 70 + 50) / 3 = 55
|
||||||
|
# All elements should center at y=55
|
||||||
|
assert abs(elem1.position[1] + elem1.size[1]/2 - 55) < 0.01
|
||||||
|
assert abs(elem2.position[1] + elem2.size[1]/2 - 55) < 0.01
|
||||||
|
assert abs(elem3.position[1] + elem3.size[1]/2 - 55) < 0.01
|
||||||
|
|
||||||
|
# X positions should not change
|
||||||
|
assert elem1.position[0] == 50
|
||||||
|
assert elem2.position[0] == 30
|
||||||
|
assert elem3.position[0] == 70
|
||||||
|
|
||||||
|
def test_make_same_size_empty_list(self):
|
||||||
|
"""Test make_same_size with empty list"""
|
||||||
|
changes = AlignmentManager.make_same_size([])
|
||||||
|
assert changes == []
|
||||||
|
|
||||||
|
def test_make_same_size_single_element(self):
|
||||||
|
"""Test make_same_size with single element"""
|
||||||
|
elem = ImageData(x=50, y=50, width=100, height=100)
|
||||||
|
changes = AlignmentManager.make_same_size([elem])
|
||||||
|
assert changes == []
|
||||||
|
assert elem.size == (100, 100) # Should not change
|
||||||
|
|
||||||
|
def test_make_same_size_multiple_elements(self):
|
||||||
|
"""Test make_same_size with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All should match elem1's size
|
||||||
|
assert elem1.size == (100, 50)
|
||||||
|
assert elem2.size == (100, 50)
|
||||||
|
assert elem3.size == (100, 50)
|
||||||
|
|
||||||
|
# Check undo information (only elem2 and elem3 change)
|
||||||
|
assert len(changes) == 2
|
||||||
|
assert changes[0][0] == elem2
|
||||||
|
assert changes[0][2] == (80, 60) # old size
|
||||||
|
assert changes[1][0] == elem3
|
||||||
|
assert changes[1][2] == (90, 40) # old size
|
||||||
|
|
||||||
|
def test_make_same_width_multiple_elements(self):
|
||||||
|
"""Test make_same_width with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.make_same_width([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All widths should match elem1
|
||||||
|
assert elem1.size[0] == 100
|
||||||
|
assert elem2.size[0] == 100
|
||||||
|
assert elem3.size[0] == 100
|
||||||
|
|
||||||
|
# Heights should not change
|
||||||
|
assert elem1.size[1] == 50
|
||||||
|
assert elem2.size[1] == 60
|
||||||
|
assert elem3.size[1] == 40
|
||||||
|
|
||||||
|
def test_make_same_height_multiple_elements(self):
|
||||||
|
"""Test make_same_height with multiple elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.make_same_height([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# All heights should match elem1
|
||||||
|
assert elem1.size[1] == 50
|
||||||
|
assert elem2.size[1] == 50
|
||||||
|
assert elem3.size[1] == 50
|
||||||
|
|
||||||
|
# Widths should not change
|
||||||
|
assert elem1.size[0] == 100
|
||||||
|
assert elem2.size[0] == 80
|
||||||
|
assert elem3.size[0] == 90
|
||||||
|
|
||||||
|
def test_distribute_horizontally_too_few_elements(self):
|
||||||
|
"""Test distribute_horizontally with less than 3 elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
|
||||||
|
changes = AlignmentManager.distribute_horizontally([elem1, elem2])
|
||||||
|
assert changes == []
|
||||||
|
|
||||||
|
def test_distribute_horizontally_multiple_elements(self):
|
||||||
|
"""Test distribute_horizontally with multiple elements"""
|
||||||
|
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=50, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.distribute_horizontally([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Elements should be distributed evenly by their left edges
|
||||||
|
# min_x = 0, max_x = 200, span = 200
|
||||||
|
# spacing = 200 / (3-1) = 100
|
||||||
|
positions = [elem.position[0] for elem in [elem1, elem2, elem3]]
|
||||||
|
sorted_positions = sorted(positions)
|
||||||
|
|
||||||
|
assert sorted_positions[0] == 0
|
||||||
|
assert sorted_positions[1] == 100
|
||||||
|
assert sorted_positions[2] == 200
|
||||||
|
|
||||||
|
def test_distribute_vertically_multiple_elements(self):
|
||||||
|
"""Test distribute_vertically with multiple elements"""
|
||||||
|
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=40, y=50, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=60, y=300, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.distribute_vertically([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Elements should be distributed evenly by their top edges
|
||||||
|
# min_y = 0, max_y = 300, span = 300
|
||||||
|
# spacing = 300 / (3-1) = 150
|
||||||
|
positions = [elem.position[1] for elem in [elem1, elem2, elem3]]
|
||||||
|
sorted_positions = sorted(positions)
|
||||||
|
|
||||||
|
assert sorted_positions[0] == 0
|
||||||
|
assert sorted_positions[1] == 150
|
||||||
|
assert sorted_positions[2] == 300
|
||||||
|
|
||||||
|
def test_space_horizontally_too_few_elements(self):
|
||||||
|
"""Test space_horizontally with less than 3 elements"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=200, y=40, width=80, height=60)
|
||||||
|
|
||||||
|
changes = AlignmentManager.space_horizontally([elem1, elem2])
|
||||||
|
assert changes == []
|
||||||
|
|
||||||
|
def test_space_horizontally_multiple_elements(self):
|
||||||
|
"""Test space_horizontally with multiple elements"""
|
||||||
|
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=150, y=40, width=50, height=60)
|
||||||
|
elem3 = ImageData(x=250, y=60, width=100, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Total width = 100 + 50 + 100 = 250
|
||||||
|
# Span = 0 to 350 (250 + 100 from elem3)
|
||||||
|
# Available space = 350 - 0 - 250 = 100
|
||||||
|
# Spacing = 100 / (3-1) = 50
|
||||||
|
|
||||||
|
# After sorting by x: elem1 at 0, elem2 after 100+50=150, elem3 after 150+50+50=250
|
||||||
|
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
||||||
|
|
||||||
|
assert sorted_elements[0].position[0] == 0
|
||||||
|
assert sorted_elements[1].position[0] == 150 # 0 + 100 + 50
|
||||||
|
assert sorted_elements[2].position[0] == 250 # 150 + 50 + 50
|
||||||
|
|
||||||
|
def test_space_vertically_multiple_elements(self):
|
||||||
|
"""Test space_vertically with multiple elements"""
|
||||||
|
elem1 = ImageData(x=20, y=0, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=40, y=100, width=80, height=30)
|
||||||
|
elem3 = ImageData(x=60, y=200, width=90, height=50)
|
||||||
|
|
||||||
|
changes = AlignmentManager.space_vertically([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Total height = 50 + 30 + 50 = 130
|
||||||
|
# Span = 0 to 250 (200 + 50 from elem3)
|
||||||
|
# Available space = 250 - 0 - 130 = 120
|
||||||
|
# Spacing = 120 / (3-1) = 60
|
||||||
|
|
||||||
|
# After sorting by y: elem1 at 0, elem2 after 50+60=110, elem3 after 110+30+60=200
|
||||||
|
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[1])
|
||||||
|
|
||||||
|
assert sorted_elements[0].position[1] == 0
|
||||||
|
assert sorted_elements[1].position[1] == 110 # 0 + 50 + 60
|
||||||
|
assert sorted_elements[2].position[1] == 200 # 110 + 30 + 60
|
||||||
|
|
||||||
|
def test_alignment_with_different_element_types(self):
|
||||||
|
"""Test alignment works with different element types"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = PlaceholderData(placeholder_type="image", x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = TextBoxData(text_content="Test", x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
# Test align_left
|
||||||
|
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
assert elem1.position[0] == 30
|
||||||
|
assert elem2.position[0] == 30
|
||||||
|
assert elem3.position[0] == 30
|
||||||
|
|
||||||
|
def test_undo_information_completeness(self):
|
||||||
|
"""Test that undo information contains all necessary data"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
# Test position changes
|
||||||
|
changes = AlignmentManager.align_left([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
for change in changes:
|
||||||
|
assert len(change) == 2 # (element, old_position)
|
||||||
|
assert isinstance(change[0], ImageData)
|
||||||
|
assert isinstance(change[1], tuple)
|
||||||
|
assert len(change[1]) == 2 # (x, y)
|
||||||
|
|
||||||
|
# Test size changes
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60)
|
||||||
|
elem3 = ImageData(x=70, y=60, width=90, height=40)
|
||||||
|
|
||||||
|
changes = AlignmentManager.make_same_size([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
for change in changes:
|
||||||
|
assert len(change) == 3 # (element, old_position, old_size)
|
||||||
|
assert isinstance(change[0], ImageData)
|
||||||
|
assert isinstance(change[1], tuple)
|
||||||
|
assert len(change[1]) == 2 # (x, y)
|
||||||
|
assert isinstance(change[2], tuple)
|
||||||
|
assert len(change[2]) == 2 # (width, height)
|
||||||
|
|
||||||
|
def test_alignment_preserves_unaffected_properties(self):
|
||||||
|
"""Test that alignment operations only change intended properties"""
|
||||||
|
elem1 = ImageData(x=50, y=20, width=100, height=50, rotation=45, z_index=5)
|
||||||
|
elem2 = ImageData(x=30, y=40, width=80, height=60, rotation=90, z_index=3)
|
||||||
|
|
||||||
|
AlignmentManager.align_left([elem1, elem2])
|
||||||
|
|
||||||
|
# Rotation and z_index should not change
|
||||||
|
assert elem1.rotation == 45
|
||||||
|
assert elem1.z_index == 5
|
||||||
|
assert elem2.rotation == 90
|
||||||
|
assert elem2.z_index == 3
|
||||||
|
|
||||||
|
# Heights should not change
|
||||||
|
assert elem1.size[1] == 50
|
||||||
|
assert elem2.size[1] == 60
|
||||||
|
|
||||||
|
def test_distribute_with_unsorted_elements(self):
|
||||||
|
"""Test distribution works correctly with unsorted input"""
|
||||||
|
# Create elements in random order
|
||||||
|
elem3 = ImageData(x=200, y=60, width=90, height=40)
|
||||||
|
elem1 = ImageData(x=0, y=20, width=100, height=50)
|
||||||
|
elem2 = ImageData(x=100, y=40, width=80, height=60)
|
||||||
|
|
||||||
|
# Pass in random order
|
||||||
|
changes = AlignmentManager.distribute_horizontally([elem3, elem1, elem2])
|
||||||
|
|
||||||
|
# Should still distribute correctly
|
||||||
|
positions = sorted([elem1.position[0], elem2.position[0], elem3.position[0]])
|
||||||
|
assert positions[0] == 0
|
||||||
|
assert positions[1] == 100
|
||||||
|
assert positions[2] == 200
|
||||||
|
|
||||||
|
def test_space_with_varying_sizes(self):
|
||||||
|
"""Test spacing works correctly with elements of varying sizes"""
|
||||||
|
elem1 = ImageData(x=0, y=0, width=50, height=50)
|
||||||
|
elem2 = ImageData(x=100, y=0, width=100, height=50)
|
||||||
|
elem3 = ImageData(x=250, y=0, width=75, height=50)
|
||||||
|
|
||||||
|
changes = AlignmentManager.space_horizontally([elem1, elem2, elem3])
|
||||||
|
|
||||||
|
# Calculate expected spacing
|
||||||
|
# Total width = 50 + 100 + 75 = 225
|
||||||
|
# rightmost edge = 250 + 75 = 325
|
||||||
|
# Available space = 325 - 0 - 225 = 100
|
||||||
|
# Spacing = 100 / 2 = 50
|
||||||
|
|
||||||
|
sorted_elements = sorted([elem1, elem2, elem3], key=lambda e: e.position[0])
|
||||||
|
|
||||||
|
# Verify spacing between elements is equal
|
||||||
|
gap1 = sorted_elements[1].position[0] - (sorted_elements[0].position[0] + sorted_elements[0].size[0])
|
||||||
|
gap2 = sorted_elements[2].position[0] - (sorted_elements[1].position[0] + sorted_elements[1].size[0])
|
||||||
|
|
||||||
|
assert abs(gap1 - 50) < 0.01
|
||||||
|
assert abs(gap2 - 50) < 0.01
|
||||||
496
tests/test_snapping.py
Normal file
496
tests/test_snapping.py
Normal file
@ -0,0 +1,496 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for pyPhotoAlbum snapping system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyPhotoAlbum.snapping import SnappingSystem, Guide
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuide:
|
||||||
|
"""Tests for Guide class"""
|
||||||
|
|
||||||
|
def test_guide_initialization(self):
|
||||||
|
"""Test Guide initialization"""
|
||||||
|
guide = Guide(position=50.0, orientation='vertical')
|
||||||
|
assert guide.position == 50.0
|
||||||
|
assert guide.orientation == 'vertical'
|
||||||
|
|
||||||
|
def test_guide_serialization(self):
|
||||||
|
"""Test Guide serialization to dictionary"""
|
||||||
|
guide = Guide(position=75.5, orientation='horizontal')
|
||||||
|
data = guide.serialize()
|
||||||
|
|
||||||
|
assert data['position'] == 75.5
|
||||||
|
assert data['orientation'] == 'horizontal'
|
||||||
|
|
||||||
|
def test_guide_deserialization(self):
|
||||||
|
"""Test Guide deserialization from dictionary"""
|
||||||
|
data = {'position': 100.0, 'orientation': 'vertical'}
|
||||||
|
guide = Guide.deserialize(data)
|
||||||
|
|
||||||
|
assert guide.position == 100.0
|
||||||
|
assert guide.orientation == 'vertical'
|
||||||
|
|
||||||
|
def test_guide_deserialization_with_defaults(self):
|
||||||
|
"""Test Guide deserialization with missing fields uses defaults"""
|
||||||
|
data = {}
|
||||||
|
guide = Guide.deserialize(data)
|
||||||
|
|
||||||
|
assert guide.position == 0
|
||||||
|
assert guide.orientation == 'vertical'
|
||||||
|
|
||||||
|
|
||||||
|
class TestSnappingSystem:
|
||||||
|
"""Tests for SnappingSystem class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test SnappingSystem initialization with default values"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
|
||||||
|
assert system.snap_threshold_mm == 5.0
|
||||||
|
assert system.grid_size_mm == 10.0
|
||||||
|
assert system.snap_to_grid == False
|
||||||
|
assert system.snap_to_edges == True
|
||||||
|
assert system.snap_to_guides == True
|
||||||
|
assert len(system.guides) == 0
|
||||||
|
|
||||||
|
def test_initialization_with_threshold(self):
|
||||||
|
"""Test SnappingSystem initialization with custom threshold"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=3.0)
|
||||||
|
assert system.snap_threshold_mm == 3.0
|
||||||
|
|
||||||
|
def test_add_guide(self):
|
||||||
|
"""Test adding a guide"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
guide = system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
|
||||||
|
assert len(system.guides) == 1
|
||||||
|
assert guide.position == 50.0
|
||||||
|
assert guide.orientation == 'vertical'
|
||||||
|
assert guide in system.guides
|
||||||
|
|
||||||
|
def test_add_multiple_guides(self):
|
||||||
|
"""Test adding multiple guides"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
guide1 = system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
guide2 = system.add_guide(position=100.0, orientation='horizontal')
|
||||||
|
guide3 = system.add_guide(position=150.0, orientation='vertical')
|
||||||
|
|
||||||
|
assert len(system.guides) == 3
|
||||||
|
assert guide1 in system.guides
|
||||||
|
assert guide2 in system.guides
|
||||||
|
assert guide3 in system.guides
|
||||||
|
|
||||||
|
def test_remove_guide(self):
|
||||||
|
"""Test removing a guide"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
guide = system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
|
||||||
|
system.remove_guide(guide)
|
||||||
|
assert len(system.guides) == 0
|
||||||
|
assert guide not in system.guides
|
||||||
|
|
||||||
|
def test_remove_guide_not_in_list(self):
|
||||||
|
"""Test removing a guide that's not in the list does nothing"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
guide1 = system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
guide2 = Guide(position=100.0, orientation='horizontal')
|
||||||
|
|
||||||
|
# Should not raise an error
|
||||||
|
system.remove_guide(guide2)
|
||||||
|
assert len(system.guides) == 1
|
||||||
|
assert guide1 in system.guides
|
||||||
|
|
||||||
|
def test_clear_guides(self):
|
||||||
|
"""Test clearing all guides"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
system.add_guide(position=100.0, orientation='horizontal')
|
||||||
|
system.add_guide(position=150.0, orientation='vertical')
|
||||||
|
|
||||||
|
system.clear_guides()
|
||||||
|
assert len(system.guides) == 0
|
||||||
|
|
||||||
|
def test_snap_position_no_snapping_enabled(self):
|
||||||
|
"""Test snap_position with all snapping disabled"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
system.snap_to_grid = False
|
||||||
|
system.snap_to_edges = False
|
||||||
|
system.snap_to_guides = False
|
||||||
|
|
||||||
|
position = (25.0, 35.0)
|
||||||
|
size = (100.0, 100.0)
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
snapped = system.snap_position(position, size, page_size, dpi=300)
|
||||||
|
assert snapped == position # Should not snap
|
||||||
|
|
||||||
|
def test_snap_position_to_edges(self):
|
||||||
|
"""Test snap_position snapping to page edges"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_grid = False
|
||||||
|
system.snap_to_edges = True
|
||||||
|
system.snap_to_guides = False
|
||||||
|
|
||||||
|
# Position near left edge (should snap to 0)
|
||||||
|
position = (10.0, 50.0) # Close to 0 in pixels
|
||||||
|
size = (100.0, 100.0)
|
||||||
|
page_size = (210.0, 297.0) # A4 size in mm
|
||||||
|
|
||||||
|
snapped = system.snap_position(position, size, page_size, dpi=300)
|
||||||
|
assert snapped[0] == 0 # Should snap to left edge
|
||||||
|
|
||||||
|
def test_snap_position_to_grid(self):
|
||||||
|
"""Test snap_position snapping to grid"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_grid = True
|
||||||
|
system.snap_to_edges = False
|
||||||
|
system.snap_to_guides = False
|
||||||
|
system.grid_size_mm = 10.0
|
||||||
|
|
||||||
|
# Position near a grid line
|
||||||
|
dpi = 300
|
||||||
|
grid_size_px = 10.0 * dpi / 25.4 # ~118 pixels
|
||||||
|
|
||||||
|
position = (grid_size_px + 5, grid_size_px + 5) # Close to a grid point
|
||||||
|
size = (100.0, 100.0)
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
snapped = system.snap_position(position, size, page_size, dpi=dpi)
|
||||||
|
|
||||||
|
# Should snap to nearest grid line
|
||||||
|
assert abs(snapped[0] - grid_size_px) < 1 # Allow small floating point error
|
||||||
|
assert abs(snapped[1] - grid_size_px) < 1
|
||||||
|
|
||||||
|
def test_snap_position_to_guides(self):
|
||||||
|
"""Test snap_position snapping to guides"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_grid = False
|
||||||
|
system.snap_to_edges = False
|
||||||
|
system.snap_to_guides = True
|
||||||
|
|
||||||
|
dpi = 300
|
||||||
|
guide_pos_mm = 50.0
|
||||||
|
guide_pos_px = guide_pos_mm * dpi / 25.4
|
||||||
|
|
||||||
|
system.add_guide(position=guide_pos_mm, orientation='vertical')
|
||||||
|
system.add_guide(position=guide_pos_mm, orientation='horizontal')
|
||||||
|
|
||||||
|
# Position near the guides
|
||||||
|
position = (guide_pos_px + 5, guide_pos_px + 5)
|
||||||
|
size = (100.0, 100.0)
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
snapped = system.snap_position(position, size, page_size, dpi=dpi)
|
||||||
|
|
||||||
|
# Should snap to guides
|
||||||
|
assert abs(snapped[0] - guide_pos_px) < 1
|
||||||
|
assert abs(snapped[1] - guide_pos_px) < 1
|
||||||
|
|
||||||
|
def test_snap_position_outside_threshold(self):
|
||||||
|
"""Test snap_position when position is outside snap threshold"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=2.0) # Small threshold
|
||||||
|
system.snap_to_edges = True
|
||||||
|
|
||||||
|
# Position far from edges
|
||||||
|
position = (500.0, 600.0)
|
||||||
|
size = (100.0, 100.0)
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
snapped = system.snap_position(position, size, page_size, dpi=300)
|
||||||
|
assert snapped == position # Should not snap when too far
|
||||||
|
|
||||||
|
def test_snap_resize_bottom_right_handle(self):
|
||||||
|
"""Test snap_resize with bottom-right handle"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_grid = True
|
||||||
|
system.grid_size_mm = 10.0
|
||||||
|
|
||||||
|
position = (100.0, 100.0)
|
||||||
|
size = (200.0, 200.0)
|
||||||
|
dx = 10.0
|
||||||
|
dy = 10.0
|
||||||
|
resize_handle = 'se'
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
new_pos, new_size = system.snap_resize(
|
||||||
|
position, size, dx, dy, resize_handle, page_size, dpi=300
|
||||||
|
)
|
||||||
|
|
||||||
|
# Position shouldn't change for bottom-right handle
|
||||||
|
assert new_pos == position
|
||||||
|
# Size should change
|
||||||
|
assert new_size[0] > size[0]
|
||||||
|
assert new_size[1] > size[1]
|
||||||
|
|
||||||
|
def test_snap_resize_top_left_handle(self):
|
||||||
|
"""Test snap_resize with top-left handle"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_edges = True
|
||||||
|
|
||||||
|
position = (150.0, 150.0)
|
||||||
|
size = (200.0, 200.0)
|
||||||
|
dx = -10.0
|
||||||
|
dy = -10.0
|
||||||
|
resize_handle = 'nw'
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
new_pos, new_size = system.snap_resize(
|
||||||
|
position, size, dx, dy, resize_handle, page_size, dpi=300
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both position and size should change for top-left handle
|
||||||
|
assert new_pos != position
|
||||||
|
assert new_size != size
|
||||||
|
|
||||||
|
def test_snap_resize_top_handle(self):
|
||||||
|
"""Test snap_resize with top handle only"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_edges = True
|
||||||
|
|
||||||
|
position = (100.0, 100.0)
|
||||||
|
size = (200.0, 200.0)
|
||||||
|
dx = 0.0
|
||||||
|
dy = -10.0
|
||||||
|
resize_handle = 'n'
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
new_pos, new_size = system.snap_resize(
|
||||||
|
position, size, dx, dy, resize_handle, page_size, dpi=300
|
||||||
|
)
|
||||||
|
|
||||||
|
# X position should stay same, Y should change
|
||||||
|
assert new_pos[0] == position[0]
|
||||||
|
assert new_pos[1] != position[1]
|
||||||
|
# Width should stay same, height should change
|
||||||
|
assert new_size[0] == size[0]
|
||||||
|
assert new_size[1] != size[1]
|
||||||
|
|
||||||
|
def test_snap_resize_right_handle(self):
|
||||||
|
"""Test snap_resize with right handle only"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_edges = True
|
||||||
|
|
||||||
|
position = (100.0, 100.0)
|
||||||
|
size = (200.0, 200.0)
|
||||||
|
dx = 10.0
|
||||||
|
dy = 0.0
|
||||||
|
resize_handle = 'e'
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
new_pos, new_size = system.snap_resize(
|
||||||
|
position, size, dx, dy, resize_handle, page_size, dpi=300
|
||||||
|
)
|
||||||
|
|
||||||
|
# Position should stay same
|
||||||
|
assert new_pos == position
|
||||||
|
# Width should change, height should stay same
|
||||||
|
assert new_size[0] != size[0]
|
||||||
|
assert new_size[1] == size[1]
|
||||||
|
|
||||||
|
def test_snap_resize_minimum_size(self):
|
||||||
|
"""Test snap_resize enforces minimum size"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_edges = False
|
||||||
|
|
||||||
|
position = (100.0, 100.0)
|
||||||
|
size = (50.0, 50.0)
|
||||||
|
dx = -100.0 # Try to make it very small
|
||||||
|
dy = -100.0
|
||||||
|
resize_handle = 'se'
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
new_pos, new_size = system.snap_resize(
|
||||||
|
position, size, dx, dy, resize_handle, page_size, dpi=300
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should enforce minimum size of 10 pixels
|
||||||
|
assert new_size[0] >= 10
|
||||||
|
assert new_size[1] >= 10
|
||||||
|
|
||||||
|
def test_snap_resize_all_handles(self):
|
||||||
|
"""Test snap_resize works with all handle types"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=5.0)
|
||||||
|
system.snap_to_edges = False
|
||||||
|
|
||||||
|
position = (100.0, 100.0)
|
||||||
|
size = (200.0, 200.0)
|
||||||
|
dx = 10.0
|
||||||
|
dy = 10.0
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
|
||||||
|
handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
|
||||||
|
|
||||||
|
for handle in handles:
|
||||||
|
new_pos, new_size = system.snap_resize(
|
||||||
|
position, size, dx, dy, handle, page_size, dpi=300
|
||||||
|
)
|
||||||
|
# Should return valid position and size
|
||||||
|
assert isinstance(new_pos, tuple)
|
||||||
|
assert len(new_pos) == 2
|
||||||
|
assert isinstance(new_size, tuple)
|
||||||
|
assert len(new_size) == 2
|
||||||
|
assert new_size[0] >= 10 # Minimum size
|
||||||
|
assert new_size[1] >= 10
|
||||||
|
|
||||||
|
def test_get_snap_lines_empty(self):
|
||||||
|
"""Test get_snap_lines with no snapping enabled"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
system.snap_to_grid = False
|
||||||
|
system.snap_to_edges = False
|
||||||
|
system.snap_to_guides = False
|
||||||
|
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
lines = system.get_snap_lines(page_size, dpi=300)
|
||||||
|
|
||||||
|
assert lines['grid'] == []
|
||||||
|
assert lines['edges'] == []
|
||||||
|
assert lines['guides'] == []
|
||||||
|
|
||||||
|
def test_get_snap_lines_with_grid(self):
|
||||||
|
"""Test get_snap_lines with grid enabled"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
system.snap_to_grid = True
|
||||||
|
system.grid_size_mm = 10.0
|
||||||
|
|
||||||
|
page_size = (30.0, 30.0) # Small page for easier testing
|
||||||
|
lines = system.get_snap_lines(page_size, dpi=300)
|
||||||
|
|
||||||
|
# Should have grid lines
|
||||||
|
assert len(lines['grid']) > 0
|
||||||
|
|
||||||
|
# Should have both vertical and horizontal grid lines
|
||||||
|
vertical_lines = [line for line in lines['grid'] if line[0] == 'vertical']
|
||||||
|
horizontal_lines = [line for line in lines['grid'] if line[0] == 'horizontal']
|
||||||
|
assert len(vertical_lines) > 0
|
||||||
|
assert len(horizontal_lines) > 0
|
||||||
|
|
||||||
|
def test_get_snap_lines_with_edges(self):
|
||||||
|
"""Test get_snap_lines with edge snapping enabled"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
system.snap_to_edges = True
|
||||||
|
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
lines = system.get_snap_lines(page_size, dpi=300)
|
||||||
|
|
||||||
|
# Should have exactly 4 edge lines (left, right, top, bottom)
|
||||||
|
assert len(lines['edges']) == 4
|
||||||
|
|
||||||
|
# Check for vertical edges
|
||||||
|
vertical_edges = [line for line in lines['edges'] if line[0] == 'vertical']
|
||||||
|
assert len(vertical_edges) == 2
|
||||||
|
|
||||||
|
# Check for horizontal edges
|
||||||
|
horizontal_edges = [line for line in lines['edges'] if line[0] == 'horizontal']
|
||||||
|
assert len(horizontal_edges) == 2
|
||||||
|
|
||||||
|
def test_get_snap_lines_with_guides(self):
|
||||||
|
"""Test get_snap_lines with guides"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
system.snap_to_guides = True
|
||||||
|
|
||||||
|
system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
system.add_guide(position=100.0, orientation='horizontal')
|
||||||
|
system.add_guide(position=150.0, orientation='vertical')
|
||||||
|
|
||||||
|
page_size = (210.0, 297.0)
|
||||||
|
lines = system.get_snap_lines(page_size, dpi=300)
|
||||||
|
|
||||||
|
# Should have guide lines
|
||||||
|
assert len(lines['guides']) == 3
|
||||||
|
|
||||||
|
# Check orientations
|
||||||
|
vertical_guides = [line for line in lines['guides'] if line[0] == 'vertical']
|
||||||
|
horizontal_guides = [line for line in lines['guides'] if line[0] == 'horizontal']
|
||||||
|
assert len(vertical_guides) == 2
|
||||||
|
assert len(horizontal_guides) == 1
|
||||||
|
|
||||||
|
def test_serialization(self):
|
||||||
|
"""Test SnappingSystem serialization to dictionary"""
|
||||||
|
system = SnappingSystem(snap_threshold_mm=3.0)
|
||||||
|
system.grid_size_mm = 15.0
|
||||||
|
system.snap_to_grid = True
|
||||||
|
system.snap_to_edges = False
|
||||||
|
system.snap_to_guides = True
|
||||||
|
|
||||||
|
system.add_guide(position=50.0, orientation='vertical')
|
||||||
|
system.add_guide(position=100.0, orientation='horizontal')
|
||||||
|
|
||||||
|
data = system.serialize()
|
||||||
|
|
||||||
|
assert data['snap_threshold_mm'] == 3.0
|
||||||
|
assert data['grid_size_mm'] == 15.0
|
||||||
|
assert data['snap_to_grid'] == True
|
||||||
|
assert data['snap_to_edges'] == False
|
||||||
|
assert data['snap_to_guides'] == True
|
||||||
|
assert len(data['guides']) == 2
|
||||||
|
|
||||||
|
def test_deserialization(self):
|
||||||
|
"""Test SnappingSystem deserialization from dictionary"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'snap_threshold_mm': 4.0,
|
||||||
|
'grid_size_mm': 20.0,
|
||||||
|
'snap_to_grid': True,
|
||||||
|
'snap_to_edges': False,
|
||||||
|
'snap_to_guides': True,
|
||||||
|
'guides': [
|
||||||
|
{'position': 50.0, 'orientation': 'vertical'},
|
||||||
|
{'position': 100.0, 'orientation': 'horizontal'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
system.deserialize(data)
|
||||||
|
|
||||||
|
assert system.snap_threshold_mm == 4.0
|
||||||
|
assert system.grid_size_mm == 20.0
|
||||||
|
assert system.snap_to_grid == True
|
||||||
|
assert system.snap_to_edges == False
|
||||||
|
assert system.snap_to_guides == True
|
||||||
|
assert len(system.guides) == 2
|
||||||
|
assert system.guides[0].position == 50.0
|
||||||
|
assert system.guides[0].orientation == 'vertical'
|
||||||
|
assert system.guides[1].position == 100.0
|
||||||
|
assert system.guides[1].orientation == 'horizontal'
|
||||||
|
|
||||||
|
def test_deserialization_with_defaults(self):
|
||||||
|
"""Test SnappingSystem deserialization with missing fields uses defaults"""
|
||||||
|
system = SnappingSystem()
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
system.deserialize(data)
|
||||||
|
|
||||||
|
assert system.snap_threshold_mm == 5.0
|
||||||
|
assert system.grid_size_mm == 10.0
|
||||||
|
assert system.snap_to_grid == False
|
||||||
|
assert system.snap_to_edges == True
|
||||||
|
assert system.snap_to_guides == True
|
||||||
|
assert len(system.guides) == 0
|
||||||
|
|
||||||
|
def test_serialize_deserialize_roundtrip(self):
|
||||||
|
"""Test that serialize and deserialize are inverse operations"""
|
||||||
|
original = SnappingSystem(snap_threshold_mm=7.5)
|
||||||
|
original.grid_size_mm = 12.5
|
||||||
|
original.snap_to_grid = True
|
||||||
|
original.snap_to_edges = True
|
||||||
|
original.snap_to_guides = False
|
||||||
|
|
||||||
|
original.add_guide(position=25.5, orientation='vertical')
|
||||||
|
original.add_guide(position=75.5, orientation='horizontal')
|
||||||
|
original.add_guide(position=125.5, orientation='vertical')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
for orig_guide, rest_guide in zip(original.guides, restored.guides):
|
||||||
|
assert rest_guide.position == orig_guide.position
|
||||||
|
assert rest_guide.orientation == orig_guide.orientation
|
||||||
532
tests/test_template_manager.py
Normal file
532
tests/test_template_manager.py
Normal file
@ -0,0 +1,532 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for pyPhotoAlbum template management system
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from pyPhotoAlbum.template_manager import Template, TemplateManager
|
||||||
|
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
|
||||||
|
from pyPhotoAlbum.page_layout import PageLayout
|
||||||
|
from pyPhotoAlbum.project import Page
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplate:
|
||||||
|
"""Tests for Template class"""
|
||||||
|
|
||||||
|
def test_initialization_default(self):
|
||||||
|
"""Test Template initialization with default values"""
|
||||||
|
template = Template()
|
||||||
|
assert template.name == "Untitled Template"
|
||||||
|
assert template.description == ""
|
||||||
|
assert template.page_size_mm == (210, 297)
|
||||||
|
assert len(template.elements) == 0
|
||||||
|
|
||||||
|
def test_initialization_with_parameters(self):
|
||||||
|
"""Test Template initialization with custom parameters"""
|
||||||
|
template = Template(
|
||||||
|
name="My Template",
|
||||||
|
description="Test template",
|
||||||
|
page_size_mm=(200, 280)
|
||||||
|
)
|
||||||
|
assert template.name == "My Template"
|
||||||
|
assert template.description == "Test template"
|
||||||
|
assert template.page_size_mm == (200, 280)
|
||||||
|
|
||||||
|
def test_add_element(self):
|
||||||
|
"""Test adding elements to template"""
|
||||||
|
template = Template()
|
||||||
|
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
|
||||||
|
|
||||||
|
template.add_element(placeholder)
|
||||||
|
assert len(template.elements) == 1
|
||||||
|
assert template.elements[0] == placeholder
|
||||||
|
|
||||||
|
def test_add_multiple_elements(self):
|
||||||
|
"""Test adding multiple elements"""
|
||||||
|
template = Template()
|
||||||
|
elem1 = PlaceholderData(x=10, y=20, width=100, height=50)
|
||||||
|
elem2 = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60)
|
||||||
|
|
||||||
|
template.add_element(elem1)
|
||||||
|
template.add_element(elem2)
|
||||||
|
|
||||||
|
assert len(template.elements) == 2
|
||||||
|
assert elem1 in template.elements
|
||||||
|
assert elem2 in template.elements
|
||||||
|
|
||||||
|
def test_to_dict(self):
|
||||||
|
"""Test serialization to dictionary"""
|
||||||
|
template = Template(name="Test", description="Desc", page_size_mm=(200, 280))
|
||||||
|
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
|
||||||
|
template.add_element(placeholder)
|
||||||
|
|
||||||
|
data = template.to_dict()
|
||||||
|
|
||||||
|
assert data["name"] == "Test"
|
||||||
|
assert data["description"] == "Desc"
|
||||||
|
assert data["page_size_mm"] == (200, 280)
|
||||||
|
assert len(data["elements"]) == 1
|
||||||
|
assert data["elements"][0]["type"] == "placeholder"
|
||||||
|
|
||||||
|
def test_from_dict(self):
|
||||||
|
"""Test deserialization from dictionary"""
|
||||||
|
data = {
|
||||||
|
"name": "Loaded Template",
|
||||||
|
"description": "Test description",
|
||||||
|
"page_size_mm": [220, 300],
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": (50, 60),
|
||||||
|
"size": (120, 80),
|
||||||
|
"placeholder_type": "image"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "textbox",
|
||||||
|
"position": (70, 90),
|
||||||
|
"size": (140, 100),
|
||||||
|
"text_content": "Test text"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
template = Template.from_dict(data)
|
||||||
|
|
||||||
|
assert template.name == "Loaded Template"
|
||||||
|
assert template.description == "Test description"
|
||||||
|
assert template.page_size_mm == (220, 300)
|
||||||
|
assert len(template.elements) == 2
|
||||||
|
assert isinstance(template.elements[0], PlaceholderData)
|
||||||
|
assert isinstance(template.elements[1], TextBoxData)
|
||||||
|
|
||||||
|
def test_from_dict_skips_image_elements(self):
|
||||||
|
"""Test that from_dict skips image elements"""
|
||||||
|
data = {
|
||||||
|
"name": "Test",
|
||||||
|
"elements": [
|
||||||
|
{"type": "image", "position": (10, 20), "size": (100, 50)},
|
||||||
|
{"type": "placeholder", "position": (30, 40), "size": (120, 60)}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
template = Template.from_dict(data)
|
||||||
|
|
||||||
|
# Should only have the placeholder, not the image
|
||||||
|
assert len(template.elements) == 1
|
||||||
|
assert isinstance(template.elements[0], PlaceholderData)
|
||||||
|
|
||||||
|
def test_save_to_file(self, temp_dir):
|
||||||
|
"""Test saving template to file"""
|
||||||
|
template = Template(name="Save Test", description="Test save")
|
||||||
|
placeholder = PlaceholderData(x=10, y=20, width=100, height=50)
|
||||||
|
template.add_element(placeholder)
|
||||||
|
|
||||||
|
file_path = Path(temp_dir) / "test_template.json"
|
||||||
|
template.save_to_file(str(file_path))
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
assert file_path.exists()
|
||||||
|
|
||||||
|
# Verify content
|
||||||
|
with open(file_path, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["name"] == "Save Test"
|
||||||
|
assert data["description"] == "Test save"
|
||||||
|
|
||||||
|
def test_load_from_file(self, temp_dir):
|
||||||
|
"""Test loading template from file"""
|
||||||
|
# Create a test file
|
||||||
|
data = {
|
||||||
|
"name": "Load Test",
|
||||||
|
"description": "Test load",
|
||||||
|
"page_size_mm": [210, 297],
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "placeholder",
|
||||||
|
"position": (10, 20),
|
||||||
|
"size": (100, 50),
|
||||||
|
"placeholder_type": "image"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
file_path = Path(temp_dir) / "load_test.json"
|
||||||
|
with open(file_path, 'w') as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
# Load template
|
||||||
|
template = Template.load_from_file(str(file_path))
|
||||||
|
|
||||||
|
assert template.name == "Load Test"
|
||||||
|
assert template.description == "Test load"
|
||||||
|
assert len(template.elements) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestTemplateManager:
|
||||||
|
"""Tests for TemplateManager class"""
|
||||||
|
|
||||||
|
def test_initialization(self):
|
||||||
|
"""Test TemplateManager initialization"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
assert manager.templates_dir is not None
|
||||||
|
assert isinstance(manager.templates_dir, Path)
|
||||||
|
|
||||||
|
def test_get_templates_directory(self):
|
||||||
|
"""Test getting templates directory"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
templates_dir = manager._get_templates_directory()
|
||||||
|
|
||||||
|
assert templates_dir.name == "templates"
|
||||||
|
assert ".pyphotoalbum" in str(templates_dir)
|
||||||
|
|
||||||
|
def test_get_builtin_templates_directory(self):
|
||||||
|
"""Test getting built-in templates directory"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
builtin_dir = manager._get_builtin_templates_directory()
|
||||||
|
|
||||||
|
assert builtin_dir.name == "templates"
|
||||||
|
assert "pyPhotoAlbum" in str(builtin_dir)
|
||||||
|
|
||||||
|
def test_list_templates_empty(self, tmp_path, monkeypatch):
|
||||||
|
"""Test listing templates when directory is empty"""
|
||||||
|
# Create temporary directories
|
||||||
|
user_dir = tmp_path / "user_templates"
|
||||||
|
builtin_dir = tmp_path / "builtin_templates"
|
||||||
|
user_dir.mkdir()
|
||||||
|
builtin_dir.mkdir()
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, 'templates_dir', user_dir)
|
||||||
|
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
|
||||||
|
|
||||||
|
templates = manager.list_templates()
|
||||||
|
assert templates == []
|
||||||
|
|
||||||
|
def test_list_templates_with_files(self, tmp_path, monkeypatch):
|
||||||
|
"""Test listing templates with template files"""
|
||||||
|
user_dir = tmp_path / "user_templates"
|
||||||
|
builtin_dir = tmp_path / "builtin_templates"
|
||||||
|
user_dir.mkdir()
|
||||||
|
builtin_dir.mkdir()
|
||||||
|
|
||||||
|
# Create user template
|
||||||
|
user_template = user_dir / "My_Template.json"
|
||||||
|
user_template.write_text('{"name": "My Template"}')
|
||||||
|
|
||||||
|
# Create built-in template
|
||||||
|
builtin_template = builtin_dir / "Grid_2x2.json"
|
||||||
|
builtin_template.write_text('{"name": "Grid 2x2"}')
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, 'templates_dir', user_dir)
|
||||||
|
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
|
||||||
|
|
||||||
|
templates = manager.list_templates()
|
||||||
|
|
||||||
|
assert "[Built-in] Grid_2x2" in templates
|
||||||
|
assert "My_Template" in templates
|
||||||
|
assert len(templates) == 2
|
||||||
|
|
||||||
|
def test_save_template(self, tmp_path, monkeypatch):
|
||||||
|
"""Test saving a template"""
|
||||||
|
user_dir = tmp_path / "user_templates"
|
||||||
|
user_dir.mkdir()
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, 'templates_dir', user_dir)
|
||||||
|
|
||||||
|
template = Template(name="Test Template")
|
||||||
|
manager.save_template(template)
|
||||||
|
|
||||||
|
# Verify file was created
|
||||||
|
template_file = user_dir / "Test Template.json"
|
||||||
|
assert template_file.exists()
|
||||||
|
|
||||||
|
def test_load_template_user(self, tmp_path, monkeypatch):
|
||||||
|
"""Test loading a user template"""
|
||||||
|
user_dir = tmp_path / "user_templates"
|
||||||
|
user_dir.mkdir()
|
||||||
|
|
||||||
|
# Create template file
|
||||||
|
data = {"name": "User Template", "description": "Test", "page_size_mm": [210, 297], "elements": []}
|
||||||
|
template_file = user_dir / "User Template.json"
|
||||||
|
with open(template_file, 'w') as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, 'templates_dir', user_dir)
|
||||||
|
|
||||||
|
template = manager.load_template("User Template")
|
||||||
|
assert template.name == "User Template"
|
||||||
|
|
||||||
|
def test_load_template_builtin(self, tmp_path, monkeypatch):
|
||||||
|
"""Test loading a built-in template"""
|
||||||
|
builtin_dir = tmp_path / "builtin_templates"
|
||||||
|
builtin_dir.mkdir()
|
||||||
|
|
||||||
|
# Create built-in template file
|
||||||
|
data = {"name": "Grid 2x2", "description": "Built-in grid", "page_size_mm": [210, 297], "elements": []}
|
||||||
|
template_file = builtin_dir / "Grid 2x2.json"
|
||||||
|
with open(template_file, 'w') as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
|
||||||
|
|
||||||
|
template = manager.load_template("[Built-in] Grid 2x2")
|
||||||
|
assert template.name == "Grid 2x2"
|
||||||
|
|
||||||
|
def test_load_template_not_found(self, tmp_path, monkeypatch):
|
||||||
|
"""Test loading non-existent template raises error"""
|
||||||
|
user_dir = tmp_path / "user_templates"
|
||||||
|
user_dir.mkdir()
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, 'templates_dir', user_dir)
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
manager.load_template("NonExistent")
|
||||||
|
|
||||||
|
def test_delete_template(self, tmp_path, monkeypatch):
|
||||||
|
"""Test deleting a user template"""
|
||||||
|
user_dir = tmp_path / "user_templates"
|
||||||
|
user_dir.mkdir()
|
||||||
|
|
||||||
|
# Create template file
|
||||||
|
template_file = user_dir / "DeleteMe.json"
|
||||||
|
template_file.write_text('{"name": "DeleteMe"}')
|
||||||
|
|
||||||
|
manager = TemplateManager()
|
||||||
|
monkeypatch.setattr(manager, 'templates_dir', user_dir)
|
||||||
|
|
||||||
|
manager.delete_template("DeleteMe")
|
||||||
|
assert not template_file.exists()
|
||||||
|
|
||||||
|
def test_delete_builtin_template_raises_error(self):
|
||||||
|
"""Test deleting built-in template raises error"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
manager.delete_template("[Built-in] Grid_2x2")
|
||||||
|
|
||||||
|
def test_create_template_from_page(self):
|
||||||
|
"""Test creating template from a page"""
|
||||||
|
# Create a page with various elements
|
||||||
|
layout = PageLayout(width=210, height=297)
|
||||||
|
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
|
||||||
|
text = TextBoxData(text_content="Test", x=30, y=40, width=150, height=60)
|
||||||
|
placeholder = PlaceholderData(x=50, y=60, width=120, height=70)
|
||||||
|
|
||||||
|
layout.add_element(img)
|
||||||
|
layout.add_element(text)
|
||||||
|
layout.add_element(placeholder)
|
||||||
|
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
|
||||||
|
# Create template
|
||||||
|
manager = TemplateManager()
|
||||||
|
template = manager.create_template_from_page(
|
||||||
|
page,
|
||||||
|
name="Test Template",
|
||||||
|
description="Created from page"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert template.name == "Test Template"
|
||||||
|
assert template.description == "Created from page"
|
||||||
|
assert len(template.elements) == 3
|
||||||
|
|
||||||
|
# Image should be converted to placeholder
|
||||||
|
assert isinstance(template.elements[0], PlaceholderData)
|
||||||
|
assert isinstance(template.elements[1], TextBoxData)
|
||||||
|
assert isinstance(template.elements[2], PlaceholderData)
|
||||||
|
|
||||||
|
def test_scale_template_elements_proportional(self):
|
||||||
|
"""Test scaling template elements proportionally"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
# Create elements at 200x200 size
|
||||||
|
elem = PlaceholderData(x=50, y=50, width=100, height=100)
|
||||||
|
elements = [elem]
|
||||||
|
|
||||||
|
# Scale to 400x400 (2x scale)
|
||||||
|
scaled = manager.scale_template_elements(
|
||||||
|
elements,
|
||||||
|
from_size=(200, 200),
|
||||||
|
to_size=(400, 400),
|
||||||
|
scale_mode="proportional"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(scaled) == 1
|
||||||
|
# With proportional scaling and centering
|
||||||
|
# scale = min(400/200, 400/200) = 2.0
|
||||||
|
# offset = (400 - 200*2) / 2 = 0
|
||||||
|
assert scaled[0].position == (100, 100) # 50 * 2 + 0
|
||||||
|
assert scaled[0].size == (200, 200) # 100 * 2
|
||||||
|
|
||||||
|
def test_scale_template_elements_stretch(self):
|
||||||
|
"""Test scaling template elements with stretch mode"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
elem = PlaceholderData(x=50, y=50, width=100, height=100)
|
||||||
|
elements = [elem]
|
||||||
|
|
||||||
|
# Scale to 400x200 (2x width, 1x height)
|
||||||
|
scaled = manager.scale_template_elements(
|
||||||
|
elements,
|
||||||
|
from_size=(200, 200),
|
||||||
|
to_size=(400, 200),
|
||||||
|
scale_mode="stretch"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(scaled) == 1
|
||||||
|
assert scaled[0].position == (100, 50) # 50 * 2, 50 * 1
|
||||||
|
assert scaled[0].size == (200, 100) # 100 * 2, 100 * 1
|
||||||
|
|
||||||
|
def test_scale_template_elements_center(self):
|
||||||
|
"""Test scaling template elements with center mode"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
elem = PlaceholderData(x=50, y=50, width=100, height=100)
|
||||||
|
elements = [elem]
|
||||||
|
|
||||||
|
# Center in larger space without scaling
|
||||||
|
scaled = manager.scale_template_elements(
|
||||||
|
elements,
|
||||||
|
from_size=(200, 200),
|
||||||
|
to_size=(400, 400),
|
||||||
|
scale_mode="center"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(scaled) == 1
|
||||||
|
# offset = (400 - 200) / 2 = 100
|
||||||
|
assert scaled[0].position == (150, 150) # 50 + 100
|
||||||
|
assert scaled[0].size == (100, 100) # No scaling
|
||||||
|
|
||||||
|
def test_scale_template_preserves_properties(self):
|
||||||
|
"""Test that scaling preserves element properties"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
elem = PlaceholderData(x=50, y=50, width=100, height=100)
|
||||||
|
elem.rotation = 45
|
||||||
|
elem.z_index = 5
|
||||||
|
elem.placeholder_type = "image"
|
||||||
|
|
||||||
|
scaled = manager.scale_template_elements(
|
||||||
|
[elem],
|
||||||
|
from_size=(200, 200),
|
||||||
|
to_size=(400, 400),
|
||||||
|
scale_mode="proportional"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scaled[0].rotation == 45
|
||||||
|
assert scaled[0].z_index == 5
|
||||||
|
assert scaled[0].placeholder_type == "image"
|
||||||
|
|
||||||
|
def test_apply_template_to_page_replace(self):
|
||||||
|
"""Test applying template with replace mode"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
# Create template
|
||||||
|
template = Template(page_size_mm=(200, 200))
|
||||||
|
template.add_element(PlaceholderData(x=10, y=20, width=80, height=60))
|
||||||
|
|
||||||
|
# Create page with existing content
|
||||||
|
layout = PageLayout(width=200, height=200)
|
||||||
|
layout.add_element(ImageData(x=100, y=100, width=50, height=50))
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
|
||||||
|
# Apply template
|
||||||
|
manager.apply_template_to_page(template, page, mode="replace")
|
||||||
|
|
||||||
|
# Page should have only template elements
|
||||||
|
assert len(page.layout.elements) == 1
|
||||||
|
assert isinstance(page.layout.elements[0], PlaceholderData)
|
||||||
|
|
||||||
|
def test_apply_template_to_page_reflow(self):
|
||||||
|
"""Test applying template with reflow mode"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
# Create template with 2 placeholders
|
||||||
|
template = Template(page_size_mm=(200, 200))
|
||||||
|
template.add_element(PlaceholderData(x=10, y=20, width=80, height=60))
|
||||||
|
template.add_element(PlaceholderData(x=100, y=100, width=80, height=60))
|
||||||
|
|
||||||
|
# Create page with 1 image
|
||||||
|
layout = PageLayout(width=200, height=200)
|
||||||
|
img = ImageData(image_path="test.jpg", x=50, y=50, width=50, height=50)
|
||||||
|
layout.add_element(img)
|
||||||
|
page = Page(layout=layout, page_number=1)
|
||||||
|
|
||||||
|
# Apply template with reflow
|
||||||
|
manager.apply_template_to_page(template, page, mode="reflow")
|
||||||
|
|
||||||
|
# Should have 1 image (reflowed) + 1 placeholder
|
||||||
|
assert len(page.layout.elements) == 2
|
||||||
|
# First should be the reflowed image
|
||||||
|
assert isinstance(page.layout.elements[0], ImageData)
|
||||||
|
# Second should be placeholder (no image to fill it)
|
||||||
|
assert isinstance(page.layout.elements[1], PlaceholderData)
|
||||||
|
|
||||||
|
def test_create_page_from_template_default_size(self):
|
||||||
|
"""Test creating page from template with default size"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
# Create template
|
||||||
|
template = Template(page_size_mm=(210, 297))
|
||||||
|
template.add_element(PlaceholderData(x=10, y=20, width=100, height=50))
|
||||||
|
|
||||||
|
# Create page
|
||||||
|
page = manager.create_page_from_template(template, page_number=5)
|
||||||
|
|
||||||
|
assert page.page_number == 5
|
||||||
|
assert page.layout.size == (210, 297)
|
||||||
|
assert len(page.layout.elements) == 1
|
||||||
|
assert isinstance(page.layout.elements[0], PlaceholderData)
|
||||||
|
|
||||||
|
def test_create_page_from_template_custom_size(self):
|
||||||
|
"""Test creating page from template with custom size"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
# Create template at 200x200
|
||||||
|
template = Template(page_size_mm=(200, 200))
|
||||||
|
template.add_element(PlaceholderData(x=50, y=50, width=100, height=100))
|
||||||
|
|
||||||
|
# Create page at 400x400
|
||||||
|
page = manager.create_page_from_template(
|
||||||
|
template,
|
||||||
|
page_number=1,
|
||||||
|
target_size_mm=(400, 400),
|
||||||
|
scale_mode="proportional"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert page.layout.size == (400, 400)
|
||||||
|
assert len(page.layout.elements) == 1
|
||||||
|
# Element should be scaled
|
||||||
|
assert page.layout.elements[0].size == (200, 200) # 100 * 2
|
||||||
|
|
||||||
|
def test_scale_with_textbox_preserves_font_settings(self):
|
||||||
|
"""Test that scaling preserves text box font settings"""
|
||||||
|
manager = TemplateManager()
|
||||||
|
|
||||||
|
font_settings = {"family": "Arial", "size": 12, "color": (0, 0, 0)}
|
||||||
|
text = TextBoxData(
|
||||||
|
text_content="Test",
|
||||||
|
font_settings=font_settings,
|
||||||
|
x=50,
|
||||||
|
y=50,
|
||||||
|
width=100,
|
||||||
|
height=50
|
||||||
|
)
|
||||||
|
|
||||||
|
scaled = manager.scale_template_elements(
|
||||||
|
[text],
|
||||||
|
from_size=(200, 200),
|
||||||
|
to_size=(400, 400),
|
||||||
|
scale_mode="proportional"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert scaled[0].text_content == "Test"
|
||||||
|
assert scaled[0].font_settings == font_settings
|
||||||
|
assert scaled[0].alignment == text.alignment
|
||||||
Loading…
x
Reference in New Issue
Block a user