black formatting
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s

This commit is contained in:
2025-11-27 23:07:16 +01:00
parent 3b2301b547
commit f6ed11b0bc
107 changed files with 6940 additions and 4410 deletions
+26 -87
View File
@@ -15,9 +15,9 @@ from pyPhotoAlbum.project import Project, Page
@pytest.fixture
def temp_image_file():
"""Create a temporary test image file"""
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f:
# Create a simple test image
img = Image.new('RGB', (100, 100), color='red')
img = Image.new("RGB", (100, 100), color="red")
img.save(f.name)
yield f.name
# Cleanup
@@ -37,37 +37,19 @@ def temp_dir():
@pytest.fixture
def sample_image_data(temp_image_file):
"""Create a sample ImageData instance"""
return ImageData(
image_path=temp_image_file,
x=10.0,
y=20.0,
width=100.0,
height=150.0
)
return ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=100.0, height=150.0)
@pytest.fixture
def sample_placeholder_data():
"""Create a sample PlaceholderData instance"""
return PlaceholderData(
placeholder_type="image",
x=50.0,
y=60.0,
width=200.0,
height=150.0
)
return PlaceholderData(placeholder_type="image", x=50.0, y=60.0, width=200.0, height=150.0)
@pytest.fixture
def sample_textbox_data():
"""Create a sample TextBoxData instance"""
return TextBoxData(
text_content="Sample Text",
x=30.0,
y=40.0,
width=150.0,
height=50.0
)
return TextBoxData(text_content="Sample Text", x=30.0, y=40.0, width=150.0, height=50.0)
@pytest.fixture
@@ -119,10 +101,7 @@ def mock_main_window():
window.project = Project(name="Test Project")
# Add a test page
page = Page(
layout=PageLayout(width=210, height=297), # A4 size in mm
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1) # A4 size in mm
window.project.pages.append(page)
window.project.working_dpi = 96
window.project.page_size_mm = (210, 297)
@@ -139,39 +118,19 @@ def mock_main_window():
@pytest.fixture
def sample_image_element():
"""Create a sample ImageData element for testing"""
return ImageData(
image_path="test.jpg",
x=100,
y=100,
width=200,
height=150,
z_index=1
)
return ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150, z_index=1)
@pytest.fixture
def sample_placeholder_element():
"""Create a sample PlaceholderData element for testing"""
return PlaceholderData(
x=50,
y=50,
width=100,
height=100,
z_index=0
)
return PlaceholderData(x=50, y=50, width=100, height=100, z_index=0)
@pytest.fixture
def sample_textbox_element():
"""Create a sample TextBoxData element for testing"""
return TextBoxData(
x=10,
y=10,
width=180,
height=50,
text_content="Test Text",
z_index=2
)
return TextBoxData(x=10, y=10, width=180, height=50, text_content="Test Text", z_index=2)
@pytest.fixture
@@ -189,19 +148,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
return (
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
@@ -213,8 +172,8 @@ def mock_page_renderer():
@pytest.fixture
def create_mouse_event():
"""Factory fixture for creating QMouseEvent objects"""
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton,
modifiers=Qt.KeyboardModifier.NoModifier):
def _create_event(event_type, x, y, button=Qt.MouseButton.LeftButton, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QMouseEvent for testing
Args:
@@ -224,19 +183,15 @@ def create_mouse_event():
modifiers: Keyboard modifiers
"""
pos = QPointF(x, y)
return QMouseEvent(
event_type,
pos,
button,
button,
modifiers
)
return QMouseEvent(event_type, pos, button, button, modifiers)
return _create_event
@pytest.fixture
def create_wheel_event():
"""Factory fixture for creating QWheelEvent objects"""
def _create_event(x, y, delta_y=120, modifiers=Qt.KeyboardModifier.NoModifier):
"""Create a QWheelEvent for testing
@@ -257,38 +212,22 @@ def create_wheel_event():
Qt.MouseButton.NoButton,
modifiers,
Qt.ScrollPhase.NoScrollPhase,
False
False,
)
return _create_event
@pytest.fixture
def populated_page():
"""Create a page with multiple elements for testing"""
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
# Add various elements
page.layout.add_element(ImageData(
image_path="img1.jpg",
x=10, y=10,
width=100, height=75,
z_index=0
))
page.layout.add_element(ImageData(image_path="img1.jpg", x=10, y=10, width=100, height=75, z_index=0))
page.layout.add_element(PlaceholderData(
x=120, y=10,
width=80, height=60,
z_index=1
))
page.layout.add_element(PlaceholderData(x=120, y=10, width=80, height=60, z_index=1))
page.layout.add_element(TextBoxData(
x=10, y=100,
width=190, height=40,
text_content="Sample Text",
z_index=2
))
page.layout.add_element(TextBoxData(x=10, y=100, width=190, height=40, text_content="Sample Text", z_index=2))
return page
+87 -91
View File
@@ -20,7 +20,7 @@ class TestAlignmentManager:
"""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)
@@ -29,9 +29,9 @@ class TestAlignmentManager:
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
@@ -55,14 +55,14 @@ class TestAlignmentManager:
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))
@@ -72,16 +72,16 @@ class TestAlignmentManager:
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
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
@@ -92,14 +92,14 @@ class TestAlignmentManager:
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
@@ -108,16 +108,16 @@ class TestAlignmentManager:
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
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
@@ -125,18 +125,18 @@ class TestAlignmentManager:
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
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
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
@@ -144,18 +144,18 @@ class TestAlignmentManager:
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
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
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
@@ -178,14 +178,14 @@ class TestAlignmentManager:
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
@@ -198,14 +198,14 @@ class TestAlignmentManager:
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
@@ -216,14 +216,14 @@ class TestAlignmentManager:
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
@@ -233,7 +233,7 @@ class TestAlignmentManager:
"""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 == []
@@ -242,15 +242,15 @@ class TestAlignmentManager:
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
@@ -260,15 +260,15 @@ class TestAlignmentManager:
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
@@ -277,7 +277,7 @@ class TestAlignmentManager:
"""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 == []
@@ -286,17 +286,17 @@ class TestAlignmentManager:
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
@@ -306,17 +306,17 @@ class TestAlignmentManager:
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
@@ -326,10 +326,10 @@ class TestAlignmentManager:
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
@@ -339,23 +339,23 @@ class TestAlignmentManager:
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)
@@ -368,15 +368,15 @@ class TestAlignmentManager:
"""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
@@ -387,10 +387,10 @@ class TestAlignmentManager:
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
@@ -402,21 +402,21 @@ class TestAlignmentManager:
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
@@ -452,7 +452,7 @@ class TestAlignmentManager:
assert len(changes) == 1
assert changes[0][0] == elem
assert changes[0][1] == (100, 80) # old position
assert changes[0][2] == (20, 15) # old size
assert changes[0][2] == (20, 15) # old size
def test_maximize_pattern_two_elements_horizontal(self):
"""Test maximize_pattern with two elements side by side"""
@@ -469,11 +469,11 @@ class TestAlignmentManager:
# Elements should not overlap (min_gap = 2.0)
gap_x = max(
elem2.position[0] - (elem1.position[0] + elem1.size[0]),
elem1.position[0] - (elem2.position[0] + elem2.size[0])
elem1.position[0] - (elem2.position[0] + elem2.size[0]),
)
gap_y = max(
elem2.position[1] - (elem1.position[1] + elem1.size[1]),
elem1.position[1] - (elem2.position[1] + elem2.size[1])
elem1.position[1] - (elem2.position[1] + elem2.size[1]),
)
# Either horizontal or vertical gap should be >= min_gap
@@ -510,11 +510,11 @@ class TestAlignmentManager:
# Calculate gaps between rectangles
gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
)
gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
)
# At least one gap should be >= min_gap
@@ -564,11 +564,7 @@ class TestAlignmentManager:
elem4 = ImageData(x=140, y=90, width=10, height=10)
page_size = (160, 110)
changes = AlignmentManager.maximize_pattern(
[elem1, elem2, elem3, elem4],
page_size,
min_gap=2.0
)
changes = AlignmentManager.maximize_pattern([elem1, elem2, elem3, elem4], page_size, min_gap=2.0)
# All elements should grow
for elem in [elem1, elem2, elem3, elem4]:
@@ -584,11 +580,11 @@ class TestAlignmentManager:
gap_x = max(
elem_b.position[0] - (elem_a.position[0] + elem_a.size[0]),
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0])
elem_a.position[0] - (elem_b.position[0] + elem_b.size[0]),
)
gap_y = max(
elem_b.position[1] - (elem_a.position[1] + elem_a.size[1]),
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1])
elem_a.position[1] - (elem_b.position[1] + elem_b.size[1]),
)
assert gap_x >= 2.0 or gap_y >= 2.0
+17 -41
View File
@@ -81,7 +81,7 @@ class TestGetSelectedElementsList:
class TestAlignLeft:
"""Test align_left method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_success(self, mock_manager, qtbot):
"""Test aligning elements to the left"""
window = TestAlignmentWindow()
@@ -93,10 +93,7 @@ class TestAlignLeft:
window.gl_widget.selected_elements = {element1, element2}
# Mock AlignmentManager to return changes
mock_manager.align_left.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_left()
@@ -106,7 +103,7 @@ class TestAlignLeft:
assert "aligned" in window._status_message.lower()
assert "left" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_left_no_changes(self, mock_manager, qtbot):
"""Test align left when no changes needed"""
window = TestAlignmentWindow()
@@ -143,7 +140,7 @@ class TestAlignLeft:
class TestAlignRight:
"""Test align_right method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_right_success(self, mock_manager, qtbot):
"""Test aligning elements to the right"""
window = TestAlignmentWindow()
@@ -154,10 +151,7 @@ class TestAlignRight:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_right.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_right.return_value = [(element1, (100, 0)), (element2, (200, 100))]
window.align_right()
@@ -169,7 +163,7 @@ class TestAlignRight:
class TestAlignTop:
"""Test align_top method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_top_success(self, mock_manager, qtbot):
"""Test aligning elements to the top"""
window = TestAlignmentWindow()
@@ -180,10 +174,7 @@ class TestAlignTop:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_top.return_value = [
(element1, (0, 50)),
(element2, (100, 100))
]
mock_manager.align_top.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_top()
@@ -195,7 +186,7 @@ class TestAlignTop:
class TestAlignBottom:
"""Test align_bottom method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_bottom_success(self, mock_manager, qtbot):
"""Test aligning elements to the bottom"""
window = TestAlignmentWindow()
@@ -206,10 +197,7 @@ class TestAlignBottom:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_bottom.return_value = [
(element1, (0, 50)),
(element2, (100, 100))
]
mock_manager.align_bottom.return_value = [(element1, (0, 50)), (element2, (100, 100))]
window.align_bottom()
@@ -221,7 +209,7 @@ class TestAlignBottom:
class TestAlignHorizontalCenter:
"""Test align_horizontal_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_horizontal_center_success(self, mock_manager, qtbot):
"""Test aligning elements to horizontal center"""
window = TestAlignmentWindow()
@@ -232,10 +220,7 @@ class TestAlignHorizontalCenter:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_horizontal_center.return_value = [
(element1, (0, 0)),
(element2, (200, 100))
]
mock_manager.align_horizontal_center.return_value = [(element1, (0, 0)), (element2, (200, 100))]
window.align_horizontal_center()
@@ -247,7 +232,7 @@ class TestAlignHorizontalCenter:
class TestAlignVerticalCenter:
"""Test align_vertical_center method"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_align_vertical_center_success(self, mock_manager, qtbot):
"""Test aligning elements to vertical center"""
window = TestAlignmentWindow()
@@ -258,10 +243,7 @@ class TestAlignVerticalCenter:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_vertical_center.return_value = [
(element1, (0, 0)),
(element2, (100, 200))
]
mock_manager.align_vertical_center.return_value = [(element1, (0, 0)), (element2, (100, 200))]
window.align_vertical_center()
@@ -273,7 +255,7 @@ class TestAlignVerticalCenter:
class TestAlignmentCommandPattern:
"""Test alignment operations with command pattern for undo/redo"""
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_creates_command(self, mock_manager, qtbot):
"""Test that alignment creates a command for undo"""
window = TestAlignmentWindow()
@@ -284,10 +266,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.align_left.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_left.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Should have no commands initially
assert not window.project.history.can_undo()
@@ -297,7 +276,7 @@ class TestAlignmentCommandPattern:
# Should have created a command
assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.alignment_ops.AlignmentManager")
def test_alignment_undo_redo(self, mock_manager, qtbot):
"""Test that alignment can be undone and redone"""
window = TestAlignmentWindow()
@@ -309,10 +288,7 @@ class TestAlignmentCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
# Mock alignment to return changes (command will handle actual moves)
mock_manager.align_top.return_value = [
(element1, (100, 0)),
(element2, (200, 100))
]
mock_manager.align_top.return_value = [(element1, (100, 0)), (element2, (200, 100))]
# Execute alignment - command created
window.align_top()
+25 -31
View File
@@ -28,8 +28,8 @@ class TestAssetDropWidget(AssetDropMixin, AssetPathMixin, PageNavigationMixin, V
def _get_project_folder(self):
"""Override to access project via window mock"""
main_window = self.window()
if hasattr(main_window, 'project') and main_window.project:
return getattr(main_window.project, 'folder_path', None)
if hasattr(main_window, "project") and main_window.project:
return getattr(main_window.project, "folder_path", None)
return None
@@ -43,7 +43,7 @@ class TestAssetDropInitialization:
# Should accept drops (set in GLWidget.__init__)
# This is a property of the widget, not the mixin
assert hasattr(widget, 'acceptDrops')
assert hasattr(widget, "acceptDrops")
class TestDragEnterEvent:
@@ -141,7 +141,7 @@ class TestDragMoveEvent:
class TestDropEvent:
"""Test dropEvent method"""
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_creates_image_element(self, mock_cmd_class, qtbot):
"""Test dropping image file creates ImageData element"""
widget = TestAssetDropWidget()
@@ -239,7 +239,7 @@ class TestDropEvent:
# Create a real test image file
test_image = tmp_path / "test_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100) # Minimal JPEG header
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100) # Minimal JPEG header
# Setup project with page containing placeholder
mock_window = Mock()
@@ -248,6 +248,7 @@ class TestDropEvent:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
page.layout.elements.append(placeholder)
@@ -280,7 +281,7 @@ class TestDropEvent:
# Image path should now be in assets folder (imported)
assert page.layout.elements[0].image_path.startswith("assets/")
@patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand')
@patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand")
def test_drop_multiple_files(self, mock_cmd_class, qtbot):
"""Test dropping first image from multiple files"""
widget = TestAssetDropWidget()
@@ -311,11 +312,13 @@ class TestDropEvent:
# Create drop event with multiple files (only first is used)
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg")
])
mime_data.setUrls(
[
QUrl.fromLocalFile("/path/to/image1.jpg"),
QUrl.fromLocalFile("/path/to/image2.png"),
QUrl.fromLocalFile("/path/to/image3.jpg"),
]
)
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@@ -364,7 +367,7 @@ class TestDropEvent:
# Create a real test image file
test_image = tmp_path / "new_image.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project with page containing existing ImageData
mock_window = Mock()
@@ -372,10 +375,7 @@ class TestDropEvent:
mock_window.project.working_dpi = 96
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(
image_path="assets/old_image.jpg",
x=100, y=100, width=200, height=150
)
existing_image = ImageData(image_path="assets/old_image.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
@@ -407,24 +407,19 @@ class TestDropEvent:
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
existing_image = ImageData(
image_path="assets/old.jpg",
x=100, y=100, width=200, height=150
)
existing_image = ImageData(image_path="assets/old.jpg", x=100, y=100, width=200, height=150)
page.layout.elements.append(existing_image)
mock_window.project.pages = [page]
# Mock asset manager to raise exception
mock_window.project.asset_manager = Mock()
mock_window.project.asset_manager.import_asset = Mock(
side_effect=Exception("Import failed")
)
mock_window.project.asset_manager.import_asset = Mock(side_effect=Exception("Import failed"))
widget.window = Mock(return_value=mock_window)
widget._get_element_at = Mock(return_value=existing_image)
@@ -454,7 +449,7 @@ class TestDropEvent:
# Create a corrupted/invalid image file
corrupted_image = tmp_path / "corrupted.jpg"
corrupted_image.write_bytes(b'not a valid image')
corrupted_image.write_bytes(b"not a valid image")
mock_window = Mock()
mock_window.project = Project(name="Test")
@@ -488,7 +483,8 @@ class TestDropEvent:
# Should use default dimensions (200, 150) from _calculate_image_dimensions
# Check that AddElementCommand was called with an ImageData
from pyPhotoAlbum.commands import AddElementCommand
with patch('pyPhotoAlbum.mixins.asset_drop.AddElementCommand') as mock_cmd:
with patch("pyPhotoAlbum.mixins.asset_drop.AddElementCommand") as mock_cmd:
# Re-run to check the call
widget.dropEvent(event)
assert mock_cmd.called
@@ -527,10 +523,7 @@ class TestExtractImagePathEdgeCases:
widget.update = Mock()
mime_data = QMimeData()
mime_data.setUrls([
QUrl.fromLocalFile("/path/to/document.pdf"),
QUrl.fromLocalFile("/path/to/file.txt")
])
mime_data.setUrls([QUrl.fromLocalFile("/path/to/document.pdf"), QUrl.fromLocalFile("/path/to/file.txt")])
event = Mock()
event.mimeData = Mock(return_value=mime_data)
@@ -576,7 +569,7 @@ class TestPlaceholderReplacementEdgeCases:
widget.update = Mock()
test_image = tmp_path / "test.jpg"
test_image.write_bytes(b'\xFF\xD8\xFF\xE0' + b'\x00' * 100)
test_image.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
# Setup project WITHOUT pages
mock_window = Mock()
@@ -585,6 +578,7 @@ class TestPlaceholderReplacementEdgeCases:
mock_window.project.pages = [] # Empty pages list
from pyPhotoAlbum.models import PlaceholderData
placeholder = PlaceholderData(x=100, y=100, width=200, height=150)
mock_window.project.asset_manager = Mock()
+183
View File
@@ -0,0 +1,183 @@
"""
Tests for asset_path mixin module
"""
import pytest
import os
from unittest.mock import Mock
class TestAssetPathMixin:
"""Tests for AssetPathMixin class"""
def test_resolve_asset_path_empty_path(self, tmp_path):
"""Test resolve_asset_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.resolve_asset_path("") is None
assert obj.resolve_asset_path(None) is None
def test_resolve_asset_path_absolute_exists(self, tmp_path):
"""Test resolve_asset_path with existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create a test file
test_file = tmp_path / "test_image.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path(str(test_file))
assert result == str(test_file)
def test_resolve_asset_path_absolute_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing absolute path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("/nonexistent/path/image.jpg")
assert result is None
def test_resolve_asset_path_relative_exists(self, tmp_path):
"""Test resolve_asset_path with existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
# Create assets folder and test file
assets_dir = tmp_path / "assets"
assets_dir.mkdir()
test_file = assets_dir / "photo.jpg"
test_file.write_text("test")
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result == str(test_file)
def test_resolve_asset_path_relative_not_exists(self, tmp_path):
"""Test resolve_asset_path with non-existing relative path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.resolve_asset_path("assets/nonexistent.jpg")
assert result is None
def test_resolve_asset_path_no_project_folder(self):
"""Test resolve_asset_path when project folder is not available"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.resolve_asset_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_with_project(self, tmp_path):
"""Test get_asset_full_path returns correct path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
expected = os.path.join(str(tmp_path), "assets/photo.jpg")
assert result == expected
def test_get_asset_full_path_no_project(self):
"""Test get_asset_full_path without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = None
obj = TestClass()
result = obj.get_asset_full_path("assets/photo.jpg")
assert result is None
def test_get_asset_full_path_empty_path(self, tmp_path):
"""Test get_asset_full_path with empty path returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
assert obj.get_asset_full_path("") is None
assert obj.get_asset_full_path(None) is None
def test_get_project_folder_with_project(self, tmp_path):
"""Test _get_project_folder returns project folder"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock()
self.project.folder_path = str(tmp_path)
obj = TestClass()
result = obj._get_project_folder()
assert result == str(tmp_path)
def test_get_project_folder_no_project(self):
"""Test _get_project_folder without project returns None"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
pass
obj = TestClass()
result = obj._get_project_folder()
assert result is None
def test_get_project_folder_project_without_folder_path(self):
"""Test _get_project_folder with project missing folder_path"""
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
class TestClass(AssetPathMixin):
def __init__(self):
self.project = Mock(spec=[]) # No folder_path attribute
obj = TestClass()
result = obj._get_project_folder()
assert result is None
+635
View File
@@ -0,0 +1,635 @@
"""
Tests for async_loading mixin module
"""
import pytest
from pathlib import Path
from unittest.mock import Mock, MagicMock, patch, PropertyMock
class TestAsyncLoadingMixinInit:
"""Tests for AsyncLoadingMixin initialization"""
def test_init_async_loading_creates_cache(self):
"""Test that _init_async_loading creates image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_cache.assert_called_once_with(max_memory_mb=512)
assert hasattr(obj, "image_cache")
def test_init_async_loading_creates_image_loader(self):
"""Test that _init_async_loading creates async image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_loader.assert_called_once()
assert hasattr(obj, "async_image_loader")
mock_loader_instance.start.assert_called_once()
def test_init_async_loading_creates_pdf_generator(self):
"""Test that _init_async_loading creates async PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
with (
patch("pyPhotoAlbum.mixins.async_loading.ImageCache") as mock_cache,
patch("pyPhotoAlbum.mixins.async_loading.AsyncImageLoader") as mock_loader,
patch("pyPhotoAlbum.mixins.async_loading.AsyncPDFGenerator") as mock_pdf,
):
mock_loader_instance = Mock()
mock_loader.return_value = mock_loader_instance
mock_pdf_instance = Mock()
mock_pdf.return_value = mock_pdf_instance
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._init_async_loading()
mock_pdf.assert_called_once()
assert hasattr(obj, "async_pdf_generator")
mock_pdf_instance.start.assert_called_once()
class TestAsyncLoadingMixinCleanup:
"""Tests for AsyncLoadingMixin cleanup"""
def test_cleanup_stops_image_loader(self):
"""Test that _cleanup_async_loading stops image loader"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_image_loader.stop.assert_called_once()
def test_cleanup_stops_pdf_generator(self):
"""Test that _cleanup_async_loading stops PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.async_pdf_generator.stop.assert_called_once()
def test_cleanup_clears_cache(self):
"""Test that _cleanup_async_loading clears image cache"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_pdf_generator = Mock()
obj.image_cache = Mock()
obj._cleanup_async_loading()
obj.image_cache.clear.assert_called_once()
def test_cleanup_handles_missing_components(self):
"""Test that _cleanup_async_loading handles missing components gracefully"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Don't set any async components
# Should not raise
obj._cleanup_async_loading()
class TestOnImageLoaded:
"""Tests for _on_image_loaded callback"""
def test_on_image_loaded_calls_element_callback(self):
"""Test that _on_image_loaded calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
mock_image = Mock()
mock_user_data = Mock()
mock_user_data._on_async_image_loaded = Mock()
obj._on_image_loaded(Path("/test/image.jpg"), mock_image, mock_user_data)
mock_user_data._on_async_image_loaded.assert_called_once_with(mock_image)
def test_on_image_loaded_triggers_update(self):
"""Test that _on_image_loaded triggers widget update"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def __init__(self):
self.update_called = False
def update(self):
self.update_called = True
obj = TestClass()
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
assert obj.update_called
def test_on_image_loaded_handles_none_user_data(self):
"""Test that _on_image_loaded handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def update(self):
pass
obj = TestClass()
# Should not raise
obj._on_image_loaded(Path("/test/image.jpg"), Mock(), None)
class TestOnImageLoadFailed:
"""Tests for _on_image_load_failed callback"""
def test_on_image_load_failed_calls_element_callback(self):
"""Test that _on_image_load_failed calls element's callback"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_user_data = Mock()
mock_user_data._on_async_image_load_failed = Mock()
obj._on_image_load_failed(Path("/test/image.jpg"), "Error message", mock_user_data)
mock_user_data._on_async_image_load_failed.assert_called_once_with("Error message")
def test_on_image_load_failed_handles_none_user_data(self):
"""Test that _on_image_load_failed handles None user_data"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# Should not raise
obj._on_image_load_failed(Path("/test/image.jpg"), "Error", None)
class TestOnPdfProgress:
"""Tests for _on_pdf_progress callback"""
def test_on_pdf_progress_updates_dialog(self):
"""Test that _on_pdf_progress updates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj._pdf_progress_dialog = Mock()
obj._on_pdf_progress(5, 10, "Processing page 5")
obj._pdf_progress_dialog.setValue.assert_called_once_with(5)
obj._pdf_progress_dialog.setLabelText.assert_called_once_with("Processing page 5")
def test_on_pdf_progress_handles_no_dialog(self):
"""Test that _on_pdf_progress handles missing dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No _pdf_progress_dialog attribute
# Should not raise
obj._on_pdf_progress(5, 10, "Processing")
class TestOnPdfComplete:
"""Tests for _on_pdf_complete callback"""
def test_on_pdf_complete_closes_dialog(self):
"""Test that _on_pdf_complete closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_complete(True, [])
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_complete_shows_success_status(self):
"""Test that _on_pdf_complete shows success status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "successfully" in call_args[0]
def test_on_pdf_complete_shows_warnings(self):
"""Test that _on_pdf_complete shows warning count"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(True, ["warning1", "warning2"])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "2 warnings" in call_args[0]
def test_on_pdf_complete_shows_failure_status(self):
"""Test that _on_pdf_complete shows failure status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_complete(False, [])
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
class TestOnPdfFailed:
"""Tests for _on_pdf_failed callback"""
def test_on_pdf_failed_closes_dialog(self):
"""Test that _on_pdf_failed closes progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
def window(self):
return Mock(spec=[])
obj = TestClass()
mock_dialog = Mock()
obj._pdf_progress_dialog = mock_dialog
obj._on_pdf_failed("Error occurred")
mock_dialog.close.assert_called_once()
assert obj._pdf_progress_dialog is None
def test_on_pdf_failed_shows_error_status(self):
"""Test that _on_pdf_failed shows error status"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
mock_main_window = Mock()
class TestClass(AsyncLoadingMixin):
def window(self):
return mock_main_window
obj = TestClass()
obj._on_pdf_failed("Something went wrong")
mock_main_window.show_status.assert_called_once()
call_args = mock_main_window.show_status.call_args[0]
assert "failed" in call_args[0]
assert "Something went wrong" in call_args[0]
class TestRequestImageLoad:
"""Tests for request_image_load method"""
def test_request_image_load_no_loader(self):
"""Test request_image_load when loader not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_image_data = Mock()
# Should not raise
obj.request_image_load(mock_image_data)
def test_request_image_load_empty_path(self):
"""Test request_image_load with empty image path"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = ""
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_non_assets_path_skipped(self):
"""Test request_image_load skips paths outside assets folder"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "/absolute/path/image.jpg"
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_path_not_resolved(self):
"""Test request_image_load when path resolution fails"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
mock_image_data = Mock()
mock_image_data.image_path = "assets/missing.jpg"
mock_image_data.resolve_image_path.return_value = None
obj.request_image_load(mock_image_data)
obj.async_image_loader.request_load.assert_not_called()
def test_request_image_load_success(self, tmp_path):
"""Test successful request_image_load"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin, LoadPriority
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
# Create actual file
asset_path = tmp_path / "assets" / "photo.jpg"
asset_path.parent.mkdir(parents=True)
asset_path.write_text("test")
mock_image_data = Mock()
mock_image_data.image_path = "assets/photo.jpg"
mock_image_data.resolve_image_path.return_value = str(asset_path)
obj.request_image_load(mock_image_data, priority=LoadPriority.HIGH)
obj.async_image_loader.request_load.assert_called_once()
call_kwargs = obj.async_image_loader.request_load.call_args[1]
assert call_kwargs["priority"] == LoadPriority.HIGH
assert call_kwargs["user_data"] == mock_image_data
class TestExportPdfAsync:
"""Tests for export_pdf_async method"""
def test_export_pdf_async_no_generator(self):
"""Test export_pdf_async when generator not initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
mock_project = Mock()
result = obj.export_pdf_async(mock_project, "/output.pdf")
assert result is False
def test_export_pdf_async_creates_progress_dialog(self, qtbot):
"""Test export_pdf_async creates progress dialog"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = [Mock(is_cover=False, is_double_spread=False)]
widget.export_pdf_async(mock_project, "/output.pdf")
assert hasattr(widget, "_pdf_progress_dialog")
assert widget._pdf_progress_dialog is not None
def test_export_pdf_async_calls_generator(self, qtbot):
"""Test export_pdf_async calls the PDF generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
from PyQt6.QtWidgets import QWidget
class TestWidget(QWidget, AsyncLoadingMixin):
pass
widget = TestWidget()
qtbot.addWidget(widget)
widget.async_pdf_generator = Mock()
widget.async_pdf_generator.export_pdf.return_value = True
mock_project = Mock()
mock_project.pages = []
result = widget.export_pdf_async(mock_project, "/output.pdf", export_dpi=150)
widget.async_pdf_generator.export_pdf.assert_called_once_with(mock_project, "/output.pdf", 150)
assert result is True
class TestOnPdfCancel:
"""Tests for _on_pdf_cancel callback"""
def test_on_pdf_cancel_cancels_export(self):
"""Test that _on_pdf_cancel cancels the export"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj._on_pdf_cancel()
obj.async_pdf_generator.cancel_export.assert_called_once()
def test_on_pdf_cancel_handles_no_generator(self):
"""Test that _on_pdf_cancel handles missing generator"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
# No async_pdf_generator
# Should not raise
obj._on_pdf_cancel()
class TestGetAsyncStats:
"""Tests for get_async_stats method"""
def test_get_async_stats_empty(self):
"""Test get_async_stats with no components initialized"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
stats = obj.get_async_stats()
assert stats == {}
def test_get_async_stats_with_loader(self):
"""Test get_async_stats includes loader stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert stats["image_loader"]["loaded"] == 10
def test_get_async_stats_with_pdf_generator(self):
"""Test get_async_stats includes PDF generator stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "pdf_generator" in stats
assert stats["pdf_generator"]["exports"] == 5
def test_get_async_stats_with_all_components(self):
"""Test get_async_stats includes all component stats"""
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
class TestClass(AsyncLoadingMixin):
pass
obj = TestClass()
obj.async_image_loader = Mock()
obj.async_image_loader.get_stats.return_value = {"loaded": 10}
obj.async_pdf_generator = Mock()
obj.async_pdf_generator.get_stats.return_value = {"exports": 5}
stats = obj.get_async_stats()
assert "image_loader" in stats
assert "pdf_generator" in stats
+511
View File
@@ -0,0 +1,511 @@
"""
Tests for AutosaveManager
"""
import pytest
import json
import tempfile
import shutil
from pathlib import Path
from datetime import datetime, timedelta
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.autosave_manager import AutosaveManager
class TestAutosaveManagerInit:
"""Tests for AutosaveManager initialization"""
def test_init_creates_checkpoint_directory(self, tmp_path, monkeypatch):
"""Test that init creates the checkpoint directory"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
def test_init_with_existing_directory(self, tmp_path, monkeypatch):
"""Test init when checkpoint directory already exists"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert checkpoint_dir.exists()
class TestGetCheckpointPath:
"""Tests for _get_checkpoint_path method"""
def test_get_checkpoint_path_basic(self, tmp_path, monkeypatch):
"""Test basic checkpoint path generation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("MyProject")
assert path.parent == checkpoint_dir
assert path.suffix == ".ppz"
assert "checkpoint_MyProject_" in path.name
def test_get_checkpoint_path_with_timestamp(self, tmp_path, monkeypatch):
"""Test checkpoint path with specific timestamp"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
timestamp = datetime(2024, 1, 15, 10, 30, 45)
path = manager._get_checkpoint_path("TestProject", timestamp)
assert "20240115_103045" in path.name
def test_get_checkpoint_path_sanitizes_name(self, tmp_path, monkeypatch):
"""Test that special characters in project name are sanitized"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
path = manager._get_checkpoint_path("My Project!@#$%")
# Should not contain special characters except - and _
name_without_ext = path.stem
for char in name_without_ext:
assert char.isalnum() or char in "-_", f"Invalid char: {char}"
class TestCreateCheckpoint:
"""Tests for create_checkpoint method"""
def test_create_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint creation"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
# Mock save_to_zip - note the return value format
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (True, "Success")
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/project.ppz"
success, message = manager.create_checkpoint(mock_project)
assert success is True
assert "Checkpoint created" in message
mock_save.assert_called_once()
def test_create_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint creation failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.return_value = (False, "Disk full")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint failed" in message
def test_create_checkpoint_exception(self, tmp_path, monkeypatch):
"""Test checkpoint creation with exception"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.save_to_zip") as mock_save:
mock_save.side_effect = Exception("IO Error")
mock_project = Mock()
mock_project.name = "TestProject"
success, message = manager.create_checkpoint(mock_project)
assert success is False
assert "Checkpoint error" in message
class TestSaveCheckpointMetadata:
"""Tests for _save_checkpoint_metadata method"""
def test_save_metadata(self, tmp_path, monkeypatch):
"""Test saving checkpoint metadata"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
mock_project = Mock()
mock_project.name = "TestProject"
mock_project.file_path = "/path/to/original.ppz"
checkpoint_path = checkpoint_dir / "checkpoint_TestProject_20240115_103045.ppz"
checkpoint_path.touch()
manager._save_checkpoint_metadata(mock_project, checkpoint_path)
metadata_path = checkpoint_path.with_suffix(".json")
assert metadata_path.exists()
with open(metadata_path, "r") as f:
metadata = json.load(f)
assert metadata["project_name"] == "TestProject"
assert metadata["original_path"] == "/path/to/original.ppz"
assert "timestamp" in metadata
class TestListCheckpoints:
"""Tests for list_checkpoints method"""
def test_list_checkpoints_empty(self, tmp_path, monkeypatch):
"""Test listing checkpoints when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert checkpoints == []
def test_list_checkpoints_with_files(self, tmp_path, monkeypatch):
"""Test listing checkpoints with existing files"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create some checkpoint files
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
# Create metadata for first checkpoint
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
assert len(checkpoints) == 2
def test_list_checkpoints_filter_by_project(self, tmp_path, monkeypatch):
"""Test listing checkpoints filtered by project name"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint files with metadata
cp1 = checkpoint_dir / "checkpoint_Project1_20240115_100000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project2_20240115_110000.ppz"
cp1.touch()
cp2.touch()
metadata1 = {"project_name": "Project1", "timestamp": "2024-01-15T10:00:00"}
metadata2 = {"project_name": "Project2", "timestamp": "2024-01-15T11:00:00"}
with open(cp1.with_suffix(".json"), "w") as f:
json.dump(metadata1, f)
with open(cp2.with_suffix(".json"), "w") as f:
json.dump(metadata2, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints("Project1")
assert len(checkpoints) == 1
assert checkpoints[0][1]["project_name"] == "Project1"
def test_list_checkpoints_sorted_by_timestamp(self, tmp_path, monkeypatch):
"""Test that checkpoints are sorted by timestamp (newest first)"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
cp1 = checkpoint_dir / "checkpoint_Project_20240115_080000.ppz"
cp2 = checkpoint_dir / "checkpoint_Project_20240115_120000.ppz"
cp3 = checkpoint_dir / "checkpoint_Project_20240115_100000.ppz"
cp1.touch()
cp2.touch()
cp3.touch()
for cp, hour in [(cp1, "08"), (cp2, "12"), (cp3, "10")]:
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
checkpoints = manager.list_checkpoints()
# Should be sorted newest first: 12:00, 10:00, 08:00
assert "12:00:00" in checkpoints[0][1]["timestamp"]
assert "10:00:00" in checkpoints[1][1]["timestamp"]
assert "08:00:00" in checkpoints[2][1]["timestamp"]
class TestLoadCheckpoint:
"""Tests for load_checkpoint method"""
def test_load_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint loading"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_project = Mock()
mock_load.return_value = mock_project
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is True
assert result == mock_project
def test_load_checkpoint_failure(self, tmp_path, monkeypatch):
"""Test checkpoint loading failure"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
with patch("pyPhotoAlbum.autosave_manager.load_from_zip") as mock_load:
mock_load.side_effect = Exception("Corrupt file")
checkpoint_path = checkpoint_dir / "checkpoint_Test.ppz"
success, result = manager.load_checkpoint(checkpoint_path)
assert success is False
assert "Failed to load checkpoint" in result
class TestDeleteCheckpoint:
"""Tests for delete_checkpoint method"""
def test_delete_checkpoint_success(self, tmp_path, monkeypatch):
"""Test successful checkpoint deletion"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoint and metadata files
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
metadata = cp.with_suffix(".json")
metadata.touch()
manager = AutosaveManager()
result = manager.delete_checkpoint(cp)
assert result is True
assert not cp.exists()
assert not metadata.exists()
def test_delete_checkpoint_nonexistent(self, tmp_path, monkeypatch):
"""Test deleting nonexistent checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
cp = checkpoint_dir / "nonexistent.ppz"
result = manager.delete_checkpoint(cp)
assert result is True # Should succeed even if file doesn't exist
class TestDeleteAllCheckpoints:
"""Tests for delete_all_checkpoints method"""
def test_delete_all_checkpoints(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create multiple checkpoints
for i in range(3):
cp = checkpoint_dir / f"checkpoint_Project_{i}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{i}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints()
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 0
def test_delete_all_checkpoints_filtered(self, tmp_path, monkeypatch):
"""Test deleting all checkpoints for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name in ["ProjectA", "ProjectB", "ProjectA"]:
cp = checkpoint_dir / f"checkpoint_{name}_{datetime.now().strftime('%Y%m%d_%H%M%S%f')}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": datetime.now().isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.delete_all_checkpoints("ProjectA")
# Only ProjectB should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "ProjectB" in remaining[0].name
class TestCleanupOldCheckpoints:
"""Tests for cleanup_old_checkpoints method"""
def test_cleanup_old_checkpoints_by_age(self, tmp_path, monkeypatch):
"""Test cleanup of old checkpoints by age"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create old and new checkpoints
old_time = datetime.now() - timedelta(hours=48)
new_time = datetime.now() - timedelta(hours=1)
old_cp = checkpoint_dir / "checkpoint_Project_old.ppz"
new_cp = checkpoint_dir / "checkpoint_Project_new.ppz"
old_cp.touch()
new_cp.touch()
old_metadata = {"project_name": "Project", "timestamp": old_time.isoformat()}
new_metadata = {"project_name": "Project", "timestamp": new_time.isoformat()}
with open(old_cp.with_suffix(".json"), "w") as f:
json.dump(old_metadata, f)
with open(new_cp.with_suffix(".json"), "w") as f:
json.dump(new_metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24)
# Only new checkpoint should remain
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 1
assert "new" in remaining[0].name
def test_cleanup_old_checkpoints_by_count(self, tmp_path, monkeypatch):
"""Test cleanup of checkpoints by count"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create many recent checkpoints
for i in range(5):
timestamp = datetime.now() - timedelta(hours=i)
cp = checkpoint_dir / f"checkpoint_Project_{i:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": timestamp.isoformat()}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
manager.cleanup_old_checkpoints(max_age_hours=24 * 7, max_count=3)
# Should only keep 3 most recent
remaining = list(checkpoint_dir.glob("checkpoint_*.ppz"))
assert len(remaining) == 3
class TestHasCheckpoints:
"""Tests for has_checkpoints method"""
def test_has_checkpoints_true(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns True when checkpoints exist"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
cp = checkpoint_dir / "checkpoint_Test.ppz"
cp.touch()
manager = AutosaveManager()
assert manager.has_checkpoints() is True
def test_has_checkpoints_false(self, tmp_path, monkeypatch):
"""Test has_checkpoints returns False when no checkpoints"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
assert manager.has_checkpoints() is False
class TestGetLatestCheckpoint:
"""Tests for get_latest_checkpoint method"""
def test_get_latest_checkpoint(self, tmp_path, monkeypatch):
"""Test getting the latest checkpoint"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints with different timestamps
for hour in [8, 10, 12]:
cp = checkpoint_dir / f"checkpoint_Project_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": "Project", "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is not None
assert "12:00:00" in result[1]["timestamp"]
def test_get_latest_checkpoint_none(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint when none exist"""
checkpoint_dir = tmp_path / "checkpoints"
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
manager = AutosaveManager()
result = manager.get_latest_checkpoint()
assert result is None
def test_get_latest_checkpoint_filtered(self, tmp_path, monkeypatch):
"""Test getting latest checkpoint for specific project"""
checkpoint_dir = tmp_path / "checkpoints"
checkpoint_dir.mkdir(parents=True)
monkeypatch.setattr(AutosaveManager, "CHECKPOINT_DIR", checkpoint_dir)
# Create checkpoints for different projects
for name, hour in [("ProjectA", 10), ("ProjectB", 12), ("ProjectA", 8)]:
cp = checkpoint_dir / f"checkpoint_{name}_{hour:02d}.ppz"
cp.touch()
metadata = {"project_name": name, "timestamp": f"2024-01-15T{hour:02d}:00:00"}
with open(cp.with_suffix(".json"), "w") as f:
json.dump(metadata, f)
manager = AutosaveManager()
result = manager.get_latest_checkpoint("ProjectA")
assert result is not None
assert result[1]["project_name"] == "ProjectA"
assert "10:00:00" in result[1]["timestamp"] # Latest for ProjectA
+6 -6
View File
@@ -249,7 +249,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_critical = Mock()
monkeypatch.setattr(QMessageBox, 'critical', mock_critical)
monkeypatch.setattr(QMessageBox, "critical", mock_critical)
window.show_error("Error Title", "Error message")
@@ -260,7 +260,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
window.show_warning("Warning Title", "Warning message")
@@ -271,7 +271,7 @@ class TestDialogMethods:
qtbot.addWidget(window)
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
window.show_info("Info Title", "Info message")
@@ -309,7 +309,7 @@ class TestRequirePage:
window._gl_widget = Mock()
mock_warning = Mock()
monkeypatch.setattr(QMessageBox, 'warning', mock_warning)
monkeypatch.setattr(QMessageBox, "warning", mock_warning)
result = window.require_page(show_warning=True)
@@ -366,7 +366,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=1, show_warning=True)
@@ -386,7 +386,7 @@ class TestRequireSelection:
window._gl_widget = gl_widget
mock_info = Mock()
monkeypatch.setattr(QMessageBox, 'information', mock_info)
monkeypatch.setattr(QMessageBox, "information", mock_info)
result = window.require_selection(min_count=3, show_warning=True)
+81 -115
View File
@@ -16,7 +16,7 @@ from pyPhotoAlbum.commands import (
ChangeZOrderCommand,
StateChangeCommand,
CommandHistory,
_normalize_asset_path
_normalize_asset_path,
)
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.page_layout import PageLayout
@@ -107,9 +107,9 @@ class TestAddElementCommand:
data = cmd.serialize()
assert data['type'] == 'add_element'
assert 'element' in data
assert data['executed'] is True
assert data["type"] == "add_element"
assert "element" in data
assert data["executed"] is True
def test_add_element_with_asset_manager(self):
"""Test add element with asset manager reference"""
@@ -164,8 +164,8 @@ class TestDeleteElementCommand:
cmd = DeleteElementCommand(layout, element)
data = cmd.serialize()
assert data['type'] == 'delete_element'
assert 'element' in data
assert data["type"] == "delete_element"
assert "element" in data
class TestMoveElementCommand:
@@ -198,9 +198,9 @@ class TestMoveElementCommand:
cmd = MoveElementCommand(element, old_position=(100, 100), new_position=(200, 200))
data = cmd.serialize()
assert data['type'] == 'move_element'
assert data['old_position'] == (100, 100)
assert data['new_position'] == (200, 200)
assert data["type"] == "move_element"
assert data["old_position"] == (100, 100)
assert data["new_position"] == (200, 200)
class TestResizeElementCommand:
@@ -211,11 +211,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@@ -226,11 +222,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(100, 100),
new_size=(300, 225)
element, old_position=(100, 100), old_size=(200, 150), new_position=(100, 100), new_size=(300, 225)
)
cmd.execute()
@@ -243,11 +235,7 @@ class TestResizeElementCommand:
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150)
cmd = ResizeElementCommand(
element,
old_position=(100, 100),
old_size=(200, 150),
new_position=(90, 90),
new_size=(220, 165)
element, old_position=(100, 100), old_size=(200, 150), new_position=(90, 90), new_size=(220, 165)
)
cmd.execute()
@@ -298,9 +286,9 @@ class TestRotateElementCommand:
cmd = RotateElementCommand(element, old_rotation=0, new_rotation=45)
data = cmd.serialize()
assert data['type'] == 'rotate_element'
assert data['old_rotation'] == 0
assert data['new_rotation'] == 45
assert data["type"] == "rotate_element"
assert data["old_rotation"] == 0
assert data["new_rotation"] == 45
class TestAdjustImageCropCommand:
@@ -310,32 +298,25 @@ class TestAdjustImageCropCommand:
"""Test adjusting image crop"""
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info={'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
x=100,
y=100,
width=200,
height=150,
crop_info={"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0},
)
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
cmd = AdjustImageCropCommand(
element,
old_crop_info=element.crop_info.copy(),
new_crop_info=new_crop
)
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=element.crop_info.copy(), new_crop_info=new_crop)
cmd.execute()
assert element.crop_info == new_crop
def test_adjust_crop_undo(self):
"""Test undoing crop adjustment"""
old_crop = {'x': 0.0, 'y': 0.0, 'width': 1.0, 'height': 1.0}
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=old_crop.copy()
)
old_crop = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0}
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=old_crop.copy())
new_crop = {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8}
new_crop = {"x": 0.1, "y": 0.1, "width": 0.8, "height": 0.8}
cmd = AdjustImageCropCommand(element, old_crop_info=old_crop, new_crop_info=new_crop)
cmd.execute()
@@ -399,10 +380,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
changes = [
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@@ -421,10 +399,7 @@ class TestResizeElementsCommand:
element2.size = (300, 300)
# Command expects list of (element, old_position, old_size) tuples
changes = [
(element1, (100, 100), (100, 100)),
(element2, (200, 200), (150, 150))
]
changes = [(element1, (100, 100), (100, 100)), (element2, (200, 200), (150, 150))]
cmd = ResizeElementsCommand(changes)
cmd.execute()
@@ -476,81 +451,68 @@ class TestStateChangeCommand:
def test_state_change_undo(self):
"""Test undoing state change"""
element = TextBoxData(
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
element.text_content = state['text_content']
element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'}
new_state = {'text_content': 'New Text'}
old_state = {"text_content": "Old Text"}
new_state = {"text_content": "New Text"}
# Apply new state first
element.text_content = 'New Text'
element.text_content = "New Text"
cmd = StateChangeCommand(
description="Change text",
restore_func=restore_state,
before_state=old_state,
after_state=new_state
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo should restore old state
cmd.undo()
assert element.text_content == 'Old Text'
assert element.text_content == "Old Text"
def test_state_change_redo(self):
"""Test redoing state change"""
element = TextBoxData(
text_content="Old Text",
x=100, y=100,
width=200, height=100
)
element = TextBoxData(text_content="Old Text", x=100, y=100, width=200, height=100)
# Define restore function
def restore_state(state):
element.text_content = state['text_content']
element.text_content = state["text_content"]
old_state = {'text_content': 'Old Text'}
new_state = {'text_content': 'New Text'}
old_state = {"text_content": "Old Text"}
new_state = {"text_content": "New Text"}
# Apply new state first
element.text_content = 'New Text'
element.text_content = "New Text"
cmd = StateChangeCommand(
description="Change text",
restore_func=restore_state,
before_state=old_state,
after_state=new_state
description="Change text", restore_func=restore_state, before_state=old_state, after_state=new_state
)
# Undo then redo
cmd.undo()
assert element.text_content == 'Old Text'
assert element.text_content == "Old Text"
cmd.redo()
assert element.text_content == 'New Text'
assert element.text_content == "New Text"
def test_state_change_serialization(self):
"""Test serializing state change command"""
def restore_func(state):
pass
cmd = StateChangeCommand(
description="Test operation",
restore_func=restore_func,
before_state={'test': 'before'},
after_state={'test': 'after'}
before_state={"test": "before"},
after_state={"test": "after"},
)
data = cmd.serialize()
assert data['type'] == 'state_change'
assert data['description'] == 'Test operation'
assert data["type"] == "state_change"
assert data["description"] == "Test operation"
class TestCommandHistory:
@@ -656,7 +618,7 @@ class TestCommandHistory:
layout = PageLayout(width=210, height=297)
for i in range(5):
element = ImageData(image_path=f"/test{i}.jpg", x=i*10, y=i*10, width=100, height=100)
element = ImageData(image_path=f"/test{i}.jpg", x=i * 10, y=i * 10, width=100, height=100)
history.execute(AddElementCommand(layout, element))
# Should only have 3 commands in history (max_history)
@@ -678,8 +640,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert data['undo_stack'][0]['type'] == 'add_element'
assert len(data["undo_stack"]) == 1
assert data["undo_stack"][0]["type"] == "add_element"
# Create mock project for deserialization
mock_project = Mock()
@@ -734,7 +696,7 @@ class TestCommandHistory:
# Manually build serialized history data
data = {
'undo_stack': [
"undo_stack": [
cmd1.serialize(),
cmd2.serialize(),
cmd3.serialize(),
@@ -745,8 +707,8 @@ class TestCommandHistory:
cmd8.serialize(),
cmd9.serialize(),
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Create mock project
@@ -758,15 +720,15 @@ class TestCommandHistory:
new_history.deserialize(data, mock_project)
assert len(new_history.undo_stack) == 9
assert new_history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert new_history.undo_stack[1].__class__.__name__ == 'DeleteElementCommand'
assert new_history.undo_stack[2].__class__.__name__ == 'MoveElementCommand'
assert new_history.undo_stack[3].__class__.__name__ == 'ResizeElementCommand'
assert new_history.undo_stack[4].__class__.__name__ == 'RotateElementCommand'
assert new_history.undo_stack[5].__class__.__name__ == 'AdjustImageCropCommand'
assert new_history.undo_stack[6].__class__.__name__ == 'AlignElementsCommand'
assert new_history.undo_stack[7].__class__.__name__ == 'ResizeElementsCommand'
assert new_history.undo_stack[8].__class__.__name__ == 'ChangeZOrderCommand'
assert new_history.undo_stack[0].__class__.__name__ == "AddElementCommand"
assert new_history.undo_stack[1].__class__.__name__ == "DeleteElementCommand"
assert new_history.undo_stack[2].__class__.__name__ == "MoveElementCommand"
assert new_history.undo_stack[3].__class__.__name__ == "ResizeElementCommand"
assert new_history.undo_stack[4].__class__.__name__ == "RotateElementCommand"
assert new_history.undo_stack[5].__class__.__name__ == "AdjustImageCropCommand"
assert new_history.undo_stack[6].__class__.__name__ == "AlignElementsCommand"
assert new_history.undo_stack[7].__class__.__name__ == "ResizeElementsCommand"
assert new_history.undo_stack[8].__class__.__name__ == "ChangeZOrderCommand"
def test_history_deserialize_unknown_command_type(self):
"""Test deserializing unknown command type returns None and continues"""
@@ -774,12 +736,12 @@ class TestCommandHistory:
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'unknown_command', 'data': 'test'},
{'type': 'add_element', 'element': ImageData().serialize(), 'executed': True}
"undo_stack": [
{"type": "unknown_command", "data": "test"},
{"type": "add_element", "element": ImageData().serialize(), "executed": True},
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Should not raise exception, just skip unknown command
@@ -787,7 +749,7 @@ class TestCommandHistory:
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'AddElementCommand'
assert history.undo_stack[0].__class__.__name__ == "AddElementCommand"
def test_history_deserialize_malformed_command(self):
"""Test deserializing malformed command handles exception gracefully"""
@@ -795,13 +757,17 @@ class TestCommandHistory:
mock_project = Mock()
data = {
'undo_stack': [
{'type': 'add_element'}, # Missing required 'element' field
{'type': 'move_element', 'element': ImageData().serialize(),
'old_position': (0, 0), 'new_position': (10, 10)}
"undo_stack": [
{"type": "add_element"}, # Missing required 'element' field
{
"type": "move_element",
"element": ImageData().serialize(),
"old_position": (0, 0),
"new_position": (10, 10),
},
],
'redo_stack': [],
'max_history': 100
"redo_stack": [],
"max_history": 100,
}
# Should not raise exception, just skip malformed command
@@ -809,7 +775,7 @@ class TestCommandHistory:
# Should only have the valid command
assert len(history.undo_stack) == 1
assert history.undo_stack[0].__class__.__name__ == 'MoveElementCommand'
assert history.undo_stack[0].__class__.__name__ == "MoveElementCommand"
def test_history_serialize_deserialize_with_redo_stack(self):
"""Test serializing and deserializing with items in redo stack"""
@@ -826,8 +792,8 @@ class TestCommandHistory:
# Serialize
data = history.serialize()
assert len(data['undo_stack']) == 1
assert len(data['redo_stack']) == 1
assert len(data["undo_stack"]) == 1
assert len(data["redo_stack"]) == 1
# Deserialize
mock_project = Mock()
+10 -18
View File
@@ -56,7 +56,7 @@ class TestGetSelectedElementsList:
class TestDistributeHorizontally:
"""Test distribute_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -70,7 +70,7 @@ class TestDistributeHorizontally:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (150, 0)),
(element3, (500, 0))
(element3, (500, 0)),
]
window.distribute_horizontally()
@@ -98,7 +98,7 @@ class TestDistributeHorizontally:
class TestDistributeVertically:
"""Test distribute_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribute_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -112,7 +112,7 @@ class TestDistributeVertically:
mock_manager.distribute_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 150)),
(element3, (0, 500))
(element3, (0, 500)),
]
window.distribute_vertically()
@@ -125,7 +125,7 @@ class TestDistributeVertically:
class TestSpaceHorizontally:
"""Test space_horizontally method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_horizontally_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -136,11 +136,7 @@ class TestSpaceHorizontally:
window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
]
mock_manager.space_horizontally.return_value = [(element1, (0, 0)), (element2, (100, 0)), (element3, (200, 0))]
window.space_horizontally()
@@ -152,7 +148,7 @@ class TestSpaceHorizontally:
class TestSpaceVertically:
"""Test space_vertically method"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_space_vertically_success(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -163,11 +159,7 @@ class TestSpaceVertically:
window.gl_widget.selected_elements = {element1, element2, element3}
mock_manager.space_vertically.return_value = [
(element1, (0, 0)),
(element2, (0, 100)),
(element3, (0, 200))
]
mock_manager.space_vertically.return_value = [(element1, (0, 0)), (element2, (0, 100)), (element3, (0, 200))]
window.space_vertically()
@@ -178,7 +170,7 @@ class TestSpaceVertically:
class TestDistributionCommandPattern:
"""Test distribution operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.distribution_ops.AlignmentManager")
def test_distribution_creates_command(self, mock_manager, qtbot):
window = TestDistributionWindow()
qtbot.addWidget(window)
@@ -192,7 +184,7 @@ class TestDistributionCommandPattern:
mock_manager.distribute_horizontally.return_value = [
(element1, (0, 0)),
(element2, (100, 0)),
(element3, (200, 0))
(element3, (200, 0)),
]
assert not window.project.history.can_undo()
+1 -1
View File
@@ -31,7 +31,7 @@ class TestEditWindow(EditOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
+17 -20
View File
@@ -15,6 +15,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create test widget combining necessary mixins
class TestManipulationWidget(ElementManipulationMixin, ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining manipulation and selection mixins"""
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -36,11 +37,7 @@ class TestElementManipulationInitialization:
assert widget.rotation_mode is False
assert widget.rotation_start_angle is None
assert widget.rotation_snap_angle == 15
assert widget.snap_state == {
'is_snapped': False,
'last_position': None,
'last_size': None
}
assert widget.snap_state == {"is_snapped": False, "last_position": None, "last_size": None}
def test_rotation_mode_is_mutable(self, qtbot):
"""Test that rotation mode can be toggled"""
@@ -72,7 +69,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -89,7 +86,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'nw'
widget.resize_handle = "nw"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -106,7 +103,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'ne'
widget.resize_handle = "ne"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -123,7 +120,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'sw'
widget.resize_handle = "sw"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -140,7 +137,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=50, height=50)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@@ -157,7 +154,7 @@ class TestResizeElementNoSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
# Don't set resize_start_pos or resize_start_size
original_pos = elem.position
@@ -185,7 +182,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -208,7 +205,7 @@ class TestResizeElementWithSnap:
params = call_args[0][0]
assert params.dx == 50
assert params.dy == 30
assert params.resize_handle == 'se'
assert params.resize_handle == "se"
# Verify element was updated
assert elem.size == (250, 180)
@@ -220,7 +217,7 @@ class TestResizeElementWithSnap:
elem = ImageData(image_path="test.jpg", x=100, y=100, width=200, height=150)
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (200, 150)
@@ -241,7 +238,7 @@ class TestResizeElementWithSnap:
elem._parent_page = page
widget.selected_element = elem
widget.resize_handle = 'se'
widget.resize_handle = "se"
widget.resize_start_pos = (100, 100)
widget.resize_start_size = (50, 50)
@@ -344,20 +341,20 @@ class TestManipulationStateManagement:
widget = TestManipulationWidget()
qtbot.addWidget(widget)
assert 'is_snapped' in widget.snap_state
assert 'last_position' in widget.snap_state
assert 'last_size' in widget.snap_state
assert "is_snapped" in widget.snap_state
assert "last_position" in widget.snap_state
assert "last_size" in widget.snap_state
def test_resize_state_can_be_set(self, qtbot):
"""Test resize state variables can be set"""
widget = TestManipulationWidget()
qtbot.addWidget(widget)
widget.resize_handle = 'nw'
widget.resize_handle = "nw"
widget.resize_start_pos = (10, 20)
widget.resize_start_size = (100, 200)
assert widget.resize_handle == 'nw'
assert widget.resize_handle == "nw"
assert widget.resize_start_pos == (10, 20)
assert widget.resize_start_size == (100, 200)
+1
View File
@@ -258,6 +258,7 @@ class TestElementMaximizer:
def test_maximize_empty_elements(self):
"""Test maximize with empty element list."""
from pyPhotoAlbum.alignment import AlignmentManager
result = AlignmentManager.maximize_pattern([], (200.0, 200.0))
assert result == []
+13 -13
View File
@@ -43,11 +43,11 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
def require_page(self):
"""Track require_page calls"""
self._require_page_called = True
return self._current_page is not None if hasattr(self, '_current_page') else False
return self._current_page is not None if hasattr(self, "_current_page") else False
def get_current_page(self):
"""Return mock current page"""
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -71,8 +71,8 @@ class TestElementWindow(ElementOperationsMixin, AssetPathMixin, QMainWindow):
class TestAddImage:
"""Test add_image method"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image"""
window = TestElementWindow()
@@ -106,7 +106,7 @@ class TestAddImage:
assert window._update_view_called
assert "added image" in window._status_message.lower()
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
def test_add_image_cancelled(self, mock_file_dialog, qtbot):
"""Test cancelling image selection"""
window = TestElementWindow()
@@ -139,8 +139,8 @@ class TestAddImage:
assert window._require_page_called
assert not window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down"""
window = TestElementWindow()
@@ -164,8 +164,8 @@ class TestAddImage:
# Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow()
@@ -294,8 +294,8 @@ class TestAddPlaceholder:
class TestElementOperationsIntegration:
"""Test integration between element operations"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types"""
window = TestElementWindow()
@@ -326,8 +326,8 @@ class TestElementOperationsIntegration:
# Should have added all three elements
assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
@patch("pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName")
@patch("pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions")
def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone"""
window = TestElementWindow()
+27 -20
View File
@@ -23,19 +23,19 @@ def mock_page_renderer():
# Mock coordinate conversion methods
def page_to_screen(x, y):
return (renderer.screen_x + x * renderer.zoom,
renderer.screen_y + y * renderer.zoom)
return (renderer.screen_x + x * renderer.zoom, renderer.screen_y + y * renderer.zoom)
def screen_to_page(x, y):
return ((x - renderer.screen_x) / renderer.zoom,
(y - renderer.screen_y) / renderer.zoom)
return ((x - renderer.screen_x) / renderer.zoom, (y - renderer.screen_y) / renderer.zoom)
def is_point_in_page(x, y):
# Simple bounds check (assume 210mm x 297mm page at 96 DPI)
page_width_px = 210 * 96 / 25.4
page_height_px = 297 * 96 / 25.4
return (renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom and
renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom)
return (
renderer.screen_x <= x <= renderer.screen_x + page_width_px * renderer.zoom
and renderer.screen_y <= y <= renderer.screen_y + page_height_px * renderer.zoom
)
renderer.page_to_screen = page_to_screen
renderer.screen_to_page = screen_to_page
@@ -47,6 +47,7 @@ def mock_page_renderer():
# Create a minimal test widget class
class TestSelectionWidget(ElementSelectionMixin, QOpenGLWidget):
"""Test widget combining ElementSelectionMixin with QOpenGLWidget"""
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -60,7 +61,7 @@ class TestElementSelectionInitialization:
widget = TestSelectionWidget()
qtbot.addWidget(widget)
assert hasattr(widget, 'selected_elements')
assert hasattr(widget, "selected_elements")
assert isinstance(widget.selected_elements, set)
assert len(widget.selected_elements) == 0
@@ -172,8 +173,8 @@ class TestGetElementAt:
assert result is not None
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
assert hasattr(result, "_page_renderer")
assert hasattr(result, "_parent_page")
def test_get_element_at_finds_topmost_element(self, qtbot, mock_page_renderer):
"""Test _get_element_at returns topmost element when overlapping"""
@@ -246,8 +247,8 @@ class TestGetElementAt:
# Screen coords: (50 + 200, 50 + 175) = (250, 225)
result = widget._get_element_at(250, 225)
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
assert hasattr(result, "_page_renderer")
assert hasattr(result, "_parent_page")
def test_get_element_at_rotated_element_outside(self, qtbot, mock_page_renderer):
"""Test _get_element_at correctly rejects clicks outside rotated element"""
@@ -288,8 +289,8 @@ class TestGetElementAt:
# Should be able to select the element even though it's off the page
assert result is not None
assert result == elem
assert hasattr(result, '_page_renderer')
assert hasattr(result, '_parent_page')
assert hasattr(result, "_page_renderer")
assert hasattr(result, "_parent_page")
class TestGetResizeHandleAt:
@@ -330,6 +331,7 @@ class TestGetResizeHandleAt:
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -351,6 +353,7 @@ class TestGetResizeHandleAt:
# Mock window with project
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -359,7 +362,7 @@ class TestGetResizeHandleAt:
# Click on NW handle (screen: 50 + 100 = 150, 50 + 100 = 150)
result = widget._get_resize_handle_at(150, 150)
assert result == 'nw'
assert result == "nw"
def test_get_resize_handle_detects_all_corners(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at detects all four corners"""
@@ -373,6 +376,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -380,16 +384,16 @@ class TestGetResizeHandleAt:
widget.window = Mock(return_value=mock_window)
# NW corner (screen: 50 + 100 = 150, 50 + 100 = 150)
assert widget._get_resize_handle_at(150, 150) == 'nw'
assert widget._get_resize_handle_at(150, 150) == "nw"
# NE corner (screen: 50 + 300 = 350, 50 + 100 = 150)
assert widget._get_resize_handle_at(350, 150) == 'ne'
assert widget._get_resize_handle_at(350, 150) == "ne"
# SW corner (screen: 50 + 100 = 150, 50 + 250 = 300)
assert widget._get_resize_handle_at(150, 300) == 'sw'
assert widget._get_resize_handle_at(150, 300) == "sw"
# SE corner (screen: 50 + 300 = 350, 50 + 250 = 300)
assert widget._get_resize_handle_at(350, 300) == 'se'
assert widget._get_resize_handle_at(350, 300) == "se"
def test_get_resize_handle_returns_none_for_center(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at returns None for element center"""
@@ -403,6 +407,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -426,6 +431,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -436,7 +442,7 @@ class TestGetResizeHandleAt:
# For rotated element, the handle positions are transformed
result = widget._get_resize_handle_at(150, 150)
# Should detect a handle (exact handle depends on rotation transform)
assert result is None or result in ['nw', 'ne', 'sw', 'se']
assert result is None or result in ["nw", "ne", "sw", "se"]
def test_get_resize_handle_rotated_90_degrees(self, qtbot, mock_page_renderer):
"""Test _get_resize_handle_at handles 90-degree rotated elements"""
@@ -451,6 +457,7 @@ class TestGetResizeHandleAt:
# Mock window
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
@@ -460,7 +467,7 @@ class TestGetResizeHandleAt:
# Test clicking at various positions - rotation code should handle them
# Just verify the method runs without crashing
result = widget._get_resize_handle_at(200, 200)
assert result is None or result in ['nw', 'ne', 'sw', 'se']
assert result is None or result in ["nw", "ne", "sw", "se"]
class TestMultiSelect:
+59 -88
View File
@@ -15,21 +15,18 @@ def test_embed_template_in_project():
"""Test embedding a template in a project"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a simple template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
# Embed the template
template_manager.embed_template(template)
# Verify it's embedded
assert "Test Template" in project.embedded_templates
assert project.embedded_templates["Test Template"]["name"] == "Test Template"
@@ -40,22 +37,19 @@ def test_load_embedded_template():
"""Test loading an embedded template"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create and embed a template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
# Load the embedded template
loaded_template = template_manager.load_template("Test Template")
assert loaded_template.name == "Test Template"
assert loaded_template.description == "A test template"
assert len(loaded_template.elements) == 1
@@ -65,18 +59,18 @@ def test_list_embedded_templates():
"""Test listing embedded templates alongside filesystem templates"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Embed some templates
for i in range(3):
template = Template(name=f"Embedded_{i}")
template_manager.embed_template(template)
# List all templates
templates = template_manager.list_templates()
# Check embedded templates are listed with prefix
embedded_templates = [t for t in templates if t.startswith("[Embedded]")]
assert len(embedded_templates) == 3
@@ -89,14 +83,14 @@ def test_embedded_template_priority():
"""Test that embedded templates take priority over filesystem templates"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Embed a template with a common name
embedded_template = Template(name="Common", description="Embedded version")
template_manager.embed_template(embedded_template)
# Load by name without prefix (should get embedded version)
loaded = template_manager.load_template("Common")
assert loaded.description == "Embedded version"
@@ -106,22 +100,19 @@ def test_serialize_project_with_embedded_templates():
"""Test serializing a project with embedded templates"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create and embed a template
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
# Serialize the project
serialized = project.serialize()
# Verify embedded templates are in serialization
assert "embedded_templates" in serialized
assert "Test Template" in serialized["embedded_templates"]
@@ -133,26 +124,23 @@ def test_deserialize_project_with_embedded_templates():
# Create a project with embedded template
project = Project(name="Test Project")
template_manager = TemplateManager(project=project)
template = Template(name="Test Template", description="A test template")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
template_manager.embed_template(template)
# Serialize the project
serialized = project.serialize()
# Create a new project and deserialize
new_project = Project(name="New Project")
new_project.deserialize(serialized)
# Verify embedded templates were restored
assert "Test Template" in new_project.embedded_templates
assert new_project.embedded_templates["Test Template"]["name"] == "Test Template"
# Verify we can load the template from the new project
new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Test Template")
@@ -166,21 +154,18 @@ def test_auto_embed_on_apply():
project = Project(name="Test Project")
page = Page()
project.add_page(page)
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a template (not embedded yet)
template = Template(name="Auto Embed Test", description="Should auto-embed")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
# Apply template with auto_embed=True (default)
template_manager.apply_template_to_page(template, page)
# Verify template was auto-embedded
assert "Auto Embed Test" in project.embedded_templates
@@ -189,21 +174,18 @@ def test_auto_embed_on_create_page():
"""Test that templates are automatically embedded when creating pages"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a template (not embedded yet)
template = Template(name="Auto Embed Page Test", description="Should auto-embed")
placeholder = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=100, height=100
)
placeholder = PlaceholderData(placeholder_type="image", x=10, y=10, width=100, height=100)
template.add_element(placeholder)
# Create page from template with auto_embed=True (default)
page = template_manager.create_page_from_template(template, page_number=1)
# Verify template was auto-embedded
assert "Auto Embed Page Test" in project.embedded_templates
@@ -212,19 +194,19 @@ def test_delete_embedded_template():
"""Test deleting an embedded template"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Embed a template
template = Template(name="To Delete")
template_manager.embed_template(template)
assert "To Delete" in project.embedded_templates
# Delete the embedded template
template_manager.delete_template("[Embedded] To Delete")
assert "To Delete" not in project.embedded_templates
@@ -232,22 +214,19 @@ def test_embedded_template_with_text():
"""Test embedding template with text elements"""
# Create a project
project = Project(name="Test Project")
# Create a template manager with the project
template_manager = TemplateManager(project=project)
# Create a template with text
template = Template(name="Text Template")
textbox = TextBoxData(
text_content="Sample Text",
x=10, y=10, width=200, height=50
)
textbox = TextBoxData(text_content="Sample Text", x=10, y=10, width=200, height=50)
template.add_element(textbox)
# Embed and reload
template_manager.embed_template(template)
loaded = template_manager.load_template("Text Template")
assert len(loaded.elements) == 1
assert isinstance(loaded.elements[0], TextBoxData)
assert loaded.elements[0].text_content == "Sample Text"
@@ -258,46 +237,38 @@ def test_roundtrip_serialization():
# Create a project with pages and embedded template
project = Project(name="Roundtrip Test")
template_manager = TemplateManager(project=project)
# Create a template
template = Template(name="Roundtrip Template", page_size_mm=(200, 300))
placeholder1 = PlaceholderData(
placeholder_type="image",
x=10, y=10, width=80, height=80
)
placeholder2 = PlaceholderData(
placeholder_type="image",
x=110, y=10, width=80, height=80
)
placeholder1 = PlaceholderData(placeholder_type="image", x=10, y=10, width=80, height=80)
placeholder2 = PlaceholderData(placeholder_type="image", x=110, y=10, width=80, height=80)
template.add_element(placeholder1)
template.add_element(placeholder2)
# Create a page from this template
page = template_manager.create_page_from_template(template, page_number=1)
project.add_page(page)
# Serialize
serialized = project.serialize()
# Create new project and deserialize
new_project = Project(name="New Roundtrip")
new_project.deserialize(serialized)
# Verify embedded template
assert "Roundtrip Template" in new_project.embedded_templates
# Verify we can use the template
new_template_manager = TemplateManager(project=new_project)
loaded_template = new_template_manager.load_template("Roundtrip Template")
assert loaded_template.name == "Roundtrip Template"
assert loaded_template.page_size_mm == (200, 300)
assert len(loaded_template.elements) == 2
# Create another page from the loaded template
new_page = new_template_manager.create_page_from_template(
loaded_template,
page_number=2,
auto_embed=False # Don't embed again
loaded_template, page_number=2, auto_embed=False # Don't embed again
)
assert len(new_page.layout.elements) == 2
+17 -25
View File
@@ -21,13 +21,13 @@ class TestGLWidgetInitialization:
qtbot.addWidget(widget)
# Verify mixin state is initialized
assert hasattr(widget, 'zoom_level')
assert hasattr(widget, 'pan_offset')
assert hasattr(widget, 'selected_elements')
assert hasattr(widget, 'drag_start_pos')
assert hasattr(widget, 'is_dragging')
assert hasattr(widget, 'is_panning')
assert hasattr(widget, 'rotation_mode')
assert hasattr(widget, "zoom_level")
assert hasattr(widget, "pan_offset")
assert hasattr(widget, "selected_elements")
assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, "is_dragging")
assert hasattr(widget, "is_panning")
assert hasattr(widget, "rotation_mode")
def test_gl_widget_accepts_drops(self, qtbot):
"""Test GLWidget is configured to accept drops"""
@@ -105,7 +105,7 @@ class TestGLWidgetMixinIntegration:
QPointF(75, 75),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
Qt.KeyboardModifier.NoModifier,
)
widget.mousePressEvent(event)
@@ -125,7 +125,7 @@ class TestGLWidgetMixinIntegration:
# Begin operation (should be tracked for undo)
widget._begin_move(element)
assert widget._interaction_state.element is not None
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (100, 100)
# End operation
@@ -152,11 +152,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Escape
from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress,
Qt.Key.Key_Escape,
Qt.KeyboardModifier.NoModifier
)
event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Escape, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@@ -186,11 +183,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Tab
from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress,
Qt.Key.Key_Tab,
Qt.KeyboardModifier.NoModifier
)
event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@@ -220,11 +214,8 @@ class TestGLWidgetKeyEvents:
# Create key press event for Delete
from PyQt6.QtGui import QKeyEvent
event = QKeyEvent(
QKeyEvent.Type.KeyPress,
Qt.Key.Key_Delete,
Qt.KeyboardModifier.NoModifier
)
event = QKeyEvent(QKeyEvent.Type.KeyPress, Qt.Key.Key_Delete, Qt.KeyboardModifier.NoModifier)
widget.keyPressEvent(event)
@@ -257,7 +248,7 @@ class TestGLWidgetWithProject:
# Verify we can access project through widget
main_window = widget.window()
assert hasattr(main_window, 'project')
assert hasattr(main_window, "project")
assert main_window.project.name == "Test Project"
assert len(main_window.project.pages) == 1
assert len(main_window.project.pages[0].layout.elements) == 1
@@ -321,6 +312,7 @@ class TestGLWidgetOpenGL:
# Should have NoPartialUpdate set
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
assert widget.updateBehavior() == QOpenGLWidget.UpdateBehavior.NoPartialUpdate
+1
View File
@@ -14,6 +14,7 @@ from pyPhotoAlbum.models import ImageData, PlaceholderData
# Create test widget combining necessary mixins
class TestImagePanWidget(ImagePanMixin, ElementSelectionMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining image pan, selection, and viewport mixins"""
def __init__(self):
super().__init__()
self.drag_start_pos = None
+19 -34
View File
@@ -8,7 +8,7 @@ from pyPhotoAlbum.mixins.interaction_command_builders import (
MoveCommandBuilder,
ResizeCommandBuilder,
RotateCommandBuilder,
ImagePanCommandBuilder
ImagePanCommandBuilder,
)
from pyPhotoAlbum.mixins.interaction_validators import InteractionChangeDetector
@@ -22,7 +22,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
assert builder.can_build(element, start_state)
@@ -32,7 +32,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
assert not builder.can_build(element, start_state)
@@ -52,7 +52,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = builder.build(element, start_state)
@@ -65,7 +65,7 @@ class TestMoveCommandBuilder:
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = builder.build(element, start_state)
@@ -82,10 +82,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@@ -96,10 +93,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (100.0, 100.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@@ -110,10 +104,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert builder.can_build(element, start_state)
@@ -124,10 +115,7 @@ class TestResizeCommandBuilder:
element.position = (0.0, 0.0)
element.size = (100.0, 100.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
assert not builder.can_build(element, start_state)
@@ -138,10 +126,7 @@ class TestResizeCommandBuilder:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
command = builder.build(element, start_state)
@@ -158,7 +143,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
assert builder.can_build(element, start_state)
@@ -168,7 +153,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 0.05
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
assert not builder.can_build(element, start_state)
@@ -178,7 +163,7 @@ class TestRotateCommandBuilder:
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
command = builder.build(element, start_state)
@@ -197,7 +182,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert builder.can_build(element, start_state)
@@ -207,7 +192,7 @@ class TestImagePanCommandBuilder:
element = Mock()
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state)
@@ -219,7 +204,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
element.crop_info = (0.0001, 0.0001, 1.0, 1.0)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
assert not builder.can_build(element, start_state)
@@ -231,7 +216,7 @@ class TestImagePanCommandBuilder:
element = Mock(spec=ImageData)
element.crop_info = (0.1, 0.1, 0.9, 0.9)
start_state = {'crop_info': (0.0, 0.0, 1.0, 1.0)}
start_state = {"crop_info": (0.0, 0.0, 1.0, 1.0)}
command = builder.build(element, start_state)
@@ -250,7 +235,7 @@ class TestCommandBuilderIntegration:
element = Mock()
element.position = (5.0, 5.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
# With high threshold, this should not build
assert not builder.can_build(element, start_state)
@@ -261,7 +246,7 @@ class TestCommandBuilderIntegration:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
builder.build(element, start_state)
+38 -82
View File
@@ -4,10 +4,7 @@ Unit tests for interaction command factory.
import pytest
from unittest.mock import Mock
from pyPhotoAlbum.mixins.interaction_command_factory import (
InteractionCommandFactory,
InteractionState
)
from pyPhotoAlbum.mixins.interaction_command_factory import InteractionCommandFactory, InteractionState
from pyPhotoAlbum.mixins.interaction_command_builders import CommandBuilder
@@ -18,71 +15,49 @@ class TestInteractionState:
"""Test that InteractionState initializes correctly."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
)
assert state.element == element
assert state.interaction_type == 'move'
assert state.interaction_type == "move"
assert state.position == (0.0, 0.0)
assert state.size == (100.0, 100.0)
assert state.rotation == 0.0
def test_to_dict(self):
"""Test that to_dict returns correct dictionary."""
state = InteractionState(
position=(0.0, 0.0),
size=(100.0, 100.0)
)
state = InteractionState(position=(0.0, 0.0), size=(100.0, 100.0))
result = state.to_dict()
assert result == {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
assert result == {"position": (0.0, 0.0), "size": (100.0, 100.0)}
def test_to_dict_excludes_none(self):
"""Test that to_dict excludes None values."""
state = InteractionState(
position=(0.0, 0.0),
size=None
)
state = InteractionState(position=(0.0, 0.0), size=None)
result = state.to_dict()
assert 'position' in result
assert 'size' not in result
assert "position" in result
assert "size" not in result
def test_is_valid_with_required_fields(self):
"""Test that is_valid returns True when required fields are present."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move'
)
state = InteractionState(element=element, interaction_type="move")
assert state.is_valid()
def test_is_valid_without_element(self):
"""Test that is_valid returns False without element."""
state = InteractionState(
element=None,
interaction_type='move'
)
state = InteractionState(element=None, interaction_type="move")
assert not state.is_valid()
def test_is_valid_without_interaction_type(self):
"""Test that is_valid returns False without interaction_type."""
element = Mock()
state = InteractionState(
element=element,
interaction_type=None
)
state = InteractionState(element=element, interaction_type=None)
assert not state.is_valid()
@@ -90,11 +65,7 @@ class TestInteractionState:
"""Test that clear resets all fields."""
element = Mock()
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0),
size=(100.0, 100.0),
rotation=0.0
element=element, interaction_type="move", position=(0.0, 0.0), size=(100.0, 100.0), rotation=0.0
)
state.clear()
@@ -113,19 +84,19 @@ class TestInteractionCommandFactory:
"""Test that factory initializes with default builders."""
factory = InteractionCommandFactory()
assert factory.has_builder('move')
assert factory.has_builder('resize')
assert factory.has_builder('rotate')
assert factory.has_builder('image_pan')
assert factory.has_builder("move")
assert factory.has_builder("resize")
assert factory.has_builder("rotate")
assert factory.has_builder("image_pan")
def test_register_builder(self):
"""Test registering a custom builder."""
factory = InteractionCommandFactory()
custom_builder = Mock(spec=CommandBuilder)
factory.register_builder('custom', custom_builder)
factory.register_builder("custom", custom_builder)
assert factory.has_builder('custom')
assert factory.has_builder("custom")
def test_get_supported_types(self):
"""Test getting list of supported types."""
@@ -133,10 +104,10 @@ class TestInteractionCommandFactory:
types = factory.get_supported_types()
assert 'move' in types
assert 'resize' in types
assert 'rotate' in types
assert 'image_pan' in types
assert "move" in types
assert "resize" in types
assert "rotate" in types
assert "image_pan" in types
def test_create_command_move(self):
"""Test creating a move command."""
@@ -144,9 +115,9 @@ class TestInteractionCommandFactory:
element = Mock()
element.position = (10.0, 10.0)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = factory.create_command('move', element, start_state)
command = factory.create_command("move", element, start_state)
assert command is not None
@@ -157,12 +128,9 @@ class TestInteractionCommandFactory:
element.position = (10.0, 10.0)
element.size = (200.0, 200.0)
start_state = {
'position': (0.0, 0.0),
'size': (100.0, 100.0)
}
start_state = {"position": (0.0, 0.0), "size": (100.0, 100.0)}
command = factory.create_command('resize', element, start_state)
command = factory.create_command("resize", element, start_state)
assert command is not None
@@ -172,9 +140,9 @@ class TestInteractionCommandFactory:
element = Mock()
element.rotation = 45.0
start_state = {'rotation': 0.0}
start_state = {"rotation": 0.0}
command = factory.create_command('rotate', element, start_state)
command = factory.create_command("rotate", element, start_state)
assert command is not None
@@ -183,7 +151,7 @@ class TestInteractionCommandFactory:
factory = InteractionCommandFactory()
element = Mock()
command = factory.create_command('unknown', element, {})
command = factory.create_command("unknown", element, {})
assert command is None
captured = capsys.readouterr()
@@ -195,9 +163,9 @@ class TestInteractionCommandFactory:
element = Mock()
element.position = (0.05, 0.05)
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = factory.create_command('move', element, start_state)
command = factory.create_command("move", element, start_state)
assert command is None
@@ -211,12 +179,12 @@ class TestInteractionCommandFactory:
custom_builder.can_build.return_value = True
custom_builder.build.return_value = mock_command
factory.register_builder('custom', custom_builder)
factory.register_builder("custom", custom_builder)
element = Mock()
start_state = {'position': (0.0, 0.0)}
start_state = {"position": (0.0, 0.0)}
command = factory.create_command('custom', element, start_state)
command = factory.create_command("custom", element, start_state)
assert command == mock_command
custom_builder.can_build.assert_called_once()
@@ -232,17 +200,9 @@ class TestInteractionStateIntegration:
element = Mock()
element.position = (10.0, 10.0)
state = InteractionState(
element=element,
interaction_type='move',
position=(0.0, 0.0)
)
state = InteractionState(element=element, interaction_type="move", position=(0.0, 0.0))
command = factory.create_command(
state.interaction_type,
state.element,
state.to_dict()
)
command = factory.create_command(state.interaction_type, state.element, state.to_dict())
assert command is not None
@@ -254,7 +214,7 @@ class TestInteractionStateIntegration:
# Begin interaction
state = InteractionState()
state.element = element
state.interaction_type = 'move'
state.interaction_type = "move"
state.position = element.position
assert state.is_valid()
@@ -264,11 +224,7 @@ class TestInteractionStateIntegration:
# Create command
factory = InteractionCommandFactory()
command = factory.create_command(
state.interaction_type,
state.element,
state.to_dict()
)
command = factory.create_command(state.interaction_type, state.element, state.to_dict())
assert command is not None
+22 -30
View File
@@ -29,8 +29,8 @@ class TestUndoableInteractionInitialization:
qtbot.addWidget(widget)
# Should have initialized tracking state object
assert hasattr(widget, '_interaction_state')
assert hasattr(widget, '_command_factory')
assert hasattr(widget, "_interaction_state")
assert hasattr(widget, "_command_factory")
# State should be clear initially
assert widget._interaction_state.element is None
@@ -53,7 +53,7 @@ class TestBeginMove:
widget._begin_move(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation is None
@@ -87,7 +87,7 @@ class TestBeginResize:
widget._begin_resize(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'resize'
assert widget._interaction_state.interaction_type == "resize"
assert widget._interaction_state.position == (100, 100)
assert widget._interaction_state.size == (200, 150)
assert widget._interaction_state.rotation is None
@@ -107,7 +107,7 @@ class TestBeginRotate:
widget._begin_rotate(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.position is None
assert widget._interaction_state.size is None
assert widget._interaction_state.rotation == 45.0
@@ -121,17 +121,12 @@ class TestBeginImagePan:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.1, 0.2, 0.8, 0.7)
)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.1, 0.2, 0.8, 0.7))
widget._begin_image_pan(element)
assert widget._interaction_state.element is element
assert widget._interaction_state.interaction_type == 'image_pan'
assert widget._interaction_state.interaction_type == "image_pan"
assert widget._interaction_state.crop_info == (0.1, 0.2, 0.8, 0.7)
def test_begin_image_pan_ignores_non_image(self, qtbot):
@@ -151,7 +146,7 @@ class TestBeginImagePan:
class TestEndInteraction:
"""Test _end_interaction method"""
@patch('pyPhotoAlbum.commands.MoveElementCommand')
@patch("pyPhotoAlbum.commands.MoveElementCommand")
def test_end_interaction_creates_move_command(self, mock_cmd_class, qtbot):
"""Test that ending move interaction creates MoveElementCommand"""
widget = TestUndoableWidget()
@@ -177,7 +172,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, (100, 100), (150, 160))
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
@patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_end_interaction_creates_resize_command(self, mock_cmd_class, qtbot):
"""Test that ending resize interaction creates ResizeElementCommand"""
widget = TestUndoableWidget()
@@ -204,12 +199,12 @@ class TestEndInteraction:
element,
(100, 100), # old position
(200, 150), # old size
(90, 90), # new position
(250, 200) # new size
(90, 90), # new position
(250, 200), # new size
)
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.RotateElementCommand')
@patch("pyPhotoAlbum.commands.RotateElementCommand")
def test_end_interaction_creates_rotate_command(self, mock_cmd_class, qtbot):
"""Test that ending rotate interaction creates RotateElementCommand"""
widget = TestUndoableWidget()
@@ -235,7 +230,7 @@ class TestEndInteraction:
mock_cmd_class.assert_called_once_with(element, 0, 90)
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.AdjustImageCropCommand')
@patch("pyPhotoAlbum.commands.AdjustImageCropCommand")
def test_end_interaction_creates_crop_command(self, mock_cmd_class, qtbot):
"""Test that ending image pan interaction creates AdjustImageCropCommand"""
widget = TestUndoableWidget()
@@ -248,9 +243,11 @@ class TestEndInteraction:
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.0, 0.0, 1.0, 1.0) # Tuple format used in code
x=100,
y=100,
width=200,
height=150,
crop_info=(0.0, 0.0, 1.0, 1.0), # Tuple format used in code
)
widget._begin_image_pan(element)
@@ -391,12 +388,7 @@ class TestClearInteractionState:
widget = TestUndoableWidget()
qtbot.addWidget(widget)
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=200, height=150,
crop_info=(0.0, 0.0, 1.0, 1.0)
)
element = ImageData(image_path="/test.jpg", x=100, y=100, width=200, height=150, crop_info=(0.0, 0.0, 1.0, 1.0))
widget._begin_image_pan(element)
# After begin_image_pan, crop_info should be stored
@@ -452,10 +444,10 @@ class TestInteractionEdgeCases:
widget._begin_rotate(element)
# Should have rotate state (last call wins)
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.rotation == 0
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
@patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_size_change(self, mock_cmd_class, qtbot):
"""Test resize command when only size changes (position same)"""
widget = TestUndoableWidget()
@@ -479,7 +471,7 @@ class TestInteractionEdgeCases:
assert mock_cmd_class.called
assert mock_window.project.history.execute.called
@patch('pyPhotoAlbum.commands.ResizeElementCommand')
@patch("pyPhotoAlbum.commands.ResizeElementCommand")
def test_resize_with_only_position_change(self, mock_cmd_class, qtbot):
"""Test resize command when only position changes (size same)"""
widget = TestUndoableWidget()
+7 -7
View File
@@ -31,8 +31,8 @@ class TestUndoableInteractionMixinRefactored:
"""Test that mixin initializes correctly."""
widget = MockWidget()
assert hasattr(widget, '_command_factory')
assert hasattr(widget, '_interaction_state')
assert hasattr(widget, "_command_factory")
assert hasattr(widget, "_interaction_state")
def test_begin_move(self):
"""Test beginning a move interaction."""
@@ -43,7 +43,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_move(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'move'
assert widget._interaction_state.interaction_type == "move"
assert widget._interaction_state.position == (0.0, 0.0)
def test_begin_resize(self):
@@ -56,7 +56,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_resize(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'resize'
assert widget._interaction_state.interaction_type == "resize"
assert widget._interaction_state.position == (0.0, 0.0)
assert widget._interaction_state.size == (100.0, 100.0)
@@ -69,7 +69,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_rotate(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'rotate'
assert widget._interaction_state.interaction_type == "rotate"
assert widget._interaction_state.rotation == 0.0
def test_begin_image_pan(self):
@@ -83,7 +83,7 @@ class TestUndoableInteractionMixinRefactored:
widget._begin_image_pan(element)
assert widget._interaction_state.element == element
assert widget._interaction_state.interaction_type == 'image_pan'
assert widget._interaction_state.interaction_type == "image_pan"
assert widget._interaction_state.crop_info == (0.0, 0.0, 1.0, 1.0)
def test_begin_image_pan_non_image_element(self):
@@ -211,7 +211,7 @@ class TestUndoableInteractionMixinRefactored:
"""Test that ending interaction without project is safe."""
widget = MockWidget()
# Remove the project attribute entirely
delattr(widget._mock_window, 'project')
delattr(widget._mock_window, "project")
element = Mock(spec=BaseLayoutElement)
element.position = (0.0, 0.0)
+18 -21
View File
@@ -3,10 +3,7 @@ Unit tests for interaction validators and change detection.
"""
import pytest
from pyPhotoAlbum.mixins.interaction_validators import (
ChangeValidator,
InteractionChangeDetector
)
from pyPhotoAlbum.mixins.interaction_validators import ChangeValidator, InteractionChangeDetector
class TestChangeValidator:
@@ -104,10 +101,10 @@ class TestInteractionChangeDetector:
change = detector.detect_position_change(old_pos, new_pos)
assert change is not None
assert change['old_position'] == old_pos
assert change['new_position'] == new_pos
assert change['delta_x'] == 5.0
assert change['delta_y'] == 3.0
assert change["old_position"] == old_pos
assert change["new_position"] == new_pos
assert change["delta_x"] == 5.0
assert change["delta_y"] == 3.0
def test_detect_position_change_insignificant(self):
"""Test that insignificant position changes return None."""
@@ -128,10 +125,10 @@ class TestInteractionChangeDetector:
change = detector.detect_size_change(old_size, new_size)
assert change is not None
assert change['old_size'] == old_size
assert change['new_size'] == new_size
assert change['delta_width'] == 50.0
assert change['delta_height'] == 20.0
assert change["old_size"] == old_size
assert change["new_size"] == new_size
assert change["delta_width"] == 50.0
assert change["delta_height"] == 20.0
def test_detect_rotation_change_significant(self):
"""Test detecting significant rotation changes."""
@@ -142,9 +139,9 @@ class TestInteractionChangeDetector:
change = detector.detect_rotation_change(old_rotation, new_rotation)
assert change is not None
assert change['old_rotation'] == old_rotation
assert change['new_rotation'] == new_rotation
assert change['delta_angle'] == 45.0
assert change["old_rotation"] == old_rotation
assert change["new_rotation"] == new_rotation
assert change["delta_angle"] == 45.0
def test_detect_crop_change_significant(self):
"""Test detecting significant crop changes."""
@@ -155,13 +152,13 @@ class TestInteractionChangeDetector:
change = detector.detect_crop_change(old_crop, new_crop)
assert change is not None
assert change['old_crop'] == old_crop
assert change['new_crop'] == new_crop
assert change["old_crop"] == old_crop
assert change["new_crop"] == new_crop
# Use approximate comparison for floating point
assert abs(change['delta'][0] - 0.1) < 0.001
assert abs(change['delta'][1] - 0.1) < 0.001
assert abs(change['delta'][2] - (-0.1)) < 0.001
assert abs(change['delta'][3] - (-0.1)) < 0.001
assert abs(change["delta"][0] - 0.1) < 0.001
assert abs(change["delta"][1] - 0.1) < 0.001
assert abs(change["delta"][2] - (-0.1)) < 0.001
assert abs(change["delta"][3] - (-0.1)) < 0.001
def test_custom_threshold(self):
"""Test using custom threshold values."""
+33 -85
View File
@@ -26,10 +26,7 @@ def create_base_project():
# Add a page with text
page = Page(page_number=1)
text = TextBoxData(
text_content="Original Text",
x=10, y=10, width=100, height=50
)
text = TextBoxData(text_content="Original Text", x=10, y=10, width=100, height=50)
page.layout.add_element(text)
project.add_page(page)
@@ -145,7 +142,7 @@ def test_different_project_concatenation():
print("\n3. Concatenating projects...")
merged_data = concatenate_projects(data_a, data_b)
assert len(merged_data['pages']) == 2, "Should have 2 pages"
assert len(merged_data["pages"]) == 2, "Should have 2 pages"
print(f" ✓ Concatenated project has {len(merged_data['pages'])} pages")
print(f" ✓ Combined name: {merged_data['name']}")
@@ -252,6 +249,7 @@ def run_all_tests():
except Exception as e:
print(f"\n❌ Test '{name}' FAILED with exception: {e}")
import traceback
traceback.print_exc()
results.append((name, False))
@@ -293,20 +291,20 @@ def test_merge_helper_add_missing_pages():
data_b = project_b.serialize()
# Make them same project
data_b['project_id'] = data_a['project_id']
data_b["project_id"] = data_a["project_id"]
merge_manager = MergeManager()
merge_manager.detect_conflicts(data_a, data_b)
# Test _add_missing_pages
merged_data = data_a.copy()
merged_data['pages'] = list(data_a['pages'])
initial_page_count = len(merged_data['pages'])
merged_data["pages"] = list(data_a["pages"])
initial_page_count = len(merged_data["pages"])
merge_manager._add_missing_pages(merged_data, data_b)
# Should have added only page_b2 since page_b1 has same UUID as page_a1
assert len(merged_data['pages']) == initial_page_count + 1
assert len(merged_data["pages"]) == initial_page_count + 1
print(f" ✓ Added missing page: {len(merged_data['pages'])} total pages")
print(f"\n{'=' * 60}")
@@ -332,7 +330,7 @@ def test_merge_helper_is_element_in_conflict():
element_uuid="elem-456",
our_version={},
their_version={},
description="Test conflict"
description="Test conflict",
)
merge_manager.conflicts.append(conflict)
@@ -365,50 +363,24 @@ def test_merge_helper_merge_by_timestamp():
older = (now - timedelta(hours=1)).isoformat()
newer = (now + timedelta(hours=1)).isoformat()
our_page = {
'layout': {
'elements': [
{
'uuid': 'elem-1',
'text_content': 'Older version',
'last_modified': older
}
]
}
}
our_page = {"layout": {"elements": [{"uuid": "elem-1", "text_content": "Older version", "last_modified": older}]}}
our_elem = our_page['layout']['elements'][0]
their_elem = {
'uuid': 'elem-1',
'text_content': 'Newer version',
'last_modified': newer
}
our_elem = our_page["layout"]["elements"][0]
their_elem = {"uuid": "elem-1", "text_content": "Newer version", "last_modified": newer}
# Test: their version is newer, should replace
merge_manager._merge_by_timestamp(our_page, 'elem-1', their_elem, our_elem)
merge_manager._merge_by_timestamp(our_page, "elem-1", their_elem, our_elem)
assert our_page['layout']['elements'][0]['text_content'] == 'Newer version'
assert our_page["layout"]["elements"][0]["text_content"] == "Newer version"
print(f" ✓ Correctly replaced with newer version")
# Test: our version is newer, should not replace
our_page['layout']['elements'][0] = {
'uuid': 'elem-2',
'text_content': 'Our newer version',
'last_modified': newer
}
their_elem_older = {
'uuid': 'elem-2',
'text_content': 'Their older version',
'last_modified': older
}
our_page["layout"]["elements"][0] = {"uuid": "elem-2", "text_content": "Our newer version", "last_modified": newer}
their_elem_older = {"uuid": "elem-2", "text_content": "Their older version", "last_modified": older}
merge_manager._merge_by_timestamp(
our_page, 'elem-2',
their_elem_older,
our_page['layout']['elements'][0]
)
merge_manager._merge_by_timestamp(our_page, "elem-2", their_elem_older, our_page["layout"]["elements"][0])
assert our_page['layout']['elements'][0]['text_content'] == 'Our newer version'
assert our_page["layout"]["elements"][0]["text_content"] == "Our newer version"
print(f" ✓ Correctly kept our newer version")
print(f"\n{'=' * 60}")
@@ -430,73 +402,49 @@ def test_merge_helper_merge_element():
# Setup: page with one element
our_page = {
'uuid': 'page-1',
'layout': {
'elements': [
{
'uuid': 'elem-existing',
'text_content': 'Existing',
'last_modified': now
}
]
}
"uuid": "page-1",
"layout": {"elements": [{"uuid": "elem-existing", "text_content": "Existing", "last_modified": now}]},
}
our_elements = {
'elem-existing': our_page['layout']['elements'][0]
}
our_elements = {"elem-existing": our_page["layout"]["elements"][0]}
# Test 1: Adding new element
their_new_elem = {
'uuid': 'elem-new',
'text_content': 'New element',
'last_modified': now
}
their_new_elem = {"uuid": "elem-new", "text_content": "New element", "last_modified": now}
merge_manager._merge_element(
our_page=our_page,
page_uuid='page-1',
their_elem=their_new_elem,
our_elements=our_elements
our_page=our_page, page_uuid="page-1", their_elem=their_new_elem, our_elements=our_elements
)
assert len(our_page['layout']['elements']) == 2
assert our_page['layout']['elements'][1]['uuid'] == 'elem-new'
assert len(our_page["layout"]["elements"]) == 2
assert our_page["layout"]["elements"][1]["uuid"] == "elem-new"
print(f" ✓ Correctly added new element")
# Test 2: Element in conflict should be skipped
from pyPhotoAlbum.merge_manager import ConflictInfo, ConflictType
conflict_elem = {
'uuid': 'elem-conflict',
'text_content': 'Conflict element',
'last_modified': now
}
conflict_elem = {"uuid": "elem-conflict", "text_content": "Conflict element", "last_modified": now}
conflict = ConflictInfo(
conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH,
page_uuid='page-1',
element_uuid='elem-conflict',
page_uuid="page-1",
element_uuid="elem-conflict",
our_version={},
their_version={},
description="Test"
description="Test",
)
merge_manager.conflicts.append(conflict)
our_elements['elem-conflict'] = {'uuid': 'elem-conflict', 'text_content': 'Ours'}
our_page['layout']['elements'].append(our_elements['elem-conflict'])
our_elements["elem-conflict"] = {"uuid": "elem-conflict", "text_content": "Ours"}
our_page["layout"]["elements"].append(our_elements["elem-conflict"])
initial_count = len(our_page['layout']['elements'])
initial_count = len(our_page["layout"]["elements"])
merge_manager._merge_element(
our_page=our_page,
page_uuid='page-1',
their_elem=conflict_elem,
our_elements=our_elements
our_page=our_page, page_uuid="page-1", their_elem=conflict_elem, our_elements=our_elements
)
# Should not change anything since it's in conflict
assert len(our_page['layout']['elements']) == initial_count
assert len(our_page["layout"]["elements"]) == initial_count
print(f" ✓ Correctly skipped conflicting element")
print(f"\n{'=' * 60}")
+26 -38
View File
@@ -49,29 +49,16 @@ def create_v2_project_json():
"rotation": 0,
"z_index": 0,
"text_content": "Hello v2.0",
"font_settings": {
"family": "Arial",
"size": 12,
"color": [0, 0, 0]
},
"alignment": "left"
"font_settings": {"family": "Arial", "size": 12, "color": [0, 0, 0]},
"alignment": "left",
}
],
"snapping_system": {
"snap_threshold_mm": 5.0,
"grid_size_mm": 10.0
}
}
"snapping_system": {"snap_threshold_mm": 5.0, "grid_size_mm": 10.0},
},
}
],
"history": {
"undo_stack": [],
"redo_stack": [],
"max_history": 100
},
"asset_manager": {
"reference_counts": {}
}
"history": {"undo_stack": [], "redo_stack": [], "max_history": 100},
"asset_manager": {"reference_counts": {}},
}
@@ -88,9 +75,9 @@ def test_migration():
print(f"\n1. Creating v2.0 project file: {v2_file}")
v2_data = create_v2_project_json()
with zipfile.ZipFile(v2_file, 'w', zipfile.ZIP_DEFLATED) as zipf:
with zipfile.ZipFile(v2_file, "w", zipfile.ZIP_DEFLATED) as zipf:
project_json = json.dumps(v2_data, indent=2)
zipf.writestr('project.json', project_json)
zipf.writestr("project.json", project_json)
print(f" ✓ Created v2.0 project with {len(v2_data['pages'])} page(s)")
print(f" ✓ Version: {v2_data['data_version']}")
@@ -107,9 +94,9 @@ def test_migration():
print(f"\n3. Verifying migration to v3.0...")
# Check project-level fields
assert hasattr(project, 'project_id'), "Missing project_id"
assert hasattr(project, 'created'), "Missing created timestamp"
assert hasattr(project, 'last_modified'), "Missing last_modified timestamp"
assert hasattr(project, "project_id"), "Missing project_id"
assert hasattr(project, "created"), "Missing created timestamp"
assert hasattr(project, "last_modified"), "Missing last_modified timestamp"
print(f" ✓ Project has project_id: {project.project_id}")
print(f" ✓ Project has created: {project.created}")
print(f" ✓ Project has last_modified: {project.last_modified}")
@@ -117,20 +104,20 @@ def test_migration():
# Check page-level fields
assert len(project.pages) > 0, "No pages in project"
page = project.pages[0]
assert hasattr(page, 'uuid'), "Page missing uuid"
assert hasattr(page, 'created'), "Page missing created"
assert hasattr(page, 'last_modified'), "Page missing last_modified"
assert hasattr(page, 'deleted'), "Page missing deleted flag"
assert hasattr(page, "uuid"), "Page missing uuid"
assert hasattr(page, "created"), "Page missing created"
assert hasattr(page, "last_modified"), "Page missing last_modified"
assert hasattr(page, "deleted"), "Page missing deleted flag"
print(f" ✓ Page 1 has uuid: {page.uuid}")
print(f" ✓ Page 1 has timestamps and deletion tracking")
# Check element-level fields
assert len(page.layout.elements) > 0, "No elements in page"
element = page.layout.elements[0]
assert hasattr(element, 'uuid'), "Element missing uuid"
assert hasattr(element, 'created'), "Element missing created"
assert hasattr(element, 'last_modified'), "Element missing last_modified"
assert hasattr(element, 'deleted'), "Element missing deleted flag"
assert hasattr(element, "uuid"), "Element missing uuid"
assert hasattr(element, "created"), "Element missing created"
assert hasattr(element, "last_modified"), "Element missing last_modified"
assert hasattr(element, "deleted"), "Element missing deleted flag"
print(f" ✓ Element has uuid: {element.uuid}")
print(f" ✓ Element has timestamps and deletion tracking")
@@ -142,14 +129,14 @@ def test_migration():
print(f" ✓ Saved to: {v3_file}")
# Verify v3.0 file structure
with zipfile.ZipFile(v3_file, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
with zipfile.ZipFile(v3_file, "r") as zipf:
project_json = zipf.read("project.json").decode("utf-8")
v3_data = json.loads(project_json)
assert v3_data.get('data_version') == "3.0", "Wrong version"
assert 'project_id' in v3_data, "Missing project_id in saved file"
assert 'created' in v3_data, "Missing created in saved file"
assert 'uuid' in v3_data['pages'][0], "Missing page uuid in saved file"
assert v3_data.get("data_version") == "3.0", "Wrong version"
assert "project_id" in v3_data, "Missing project_id in saved file"
assert "created" in v3_data, "Missing created in saved file"
assert "uuid" in v3_data["pages"][0], "Missing page uuid in saved file"
print(f" ✓ Saved file version: {v3_data.get('data_version')}")
print(f" ✓ All v3.0 fields present in saved file")
@@ -164,6 +151,7 @@ def test_migration():
print(f"❌ Migration test FAILED: {e}")
print(f"{'=' * 60}\n")
import traceback
traceback.print_exc()
return False
+12 -28
View File
@@ -30,15 +30,7 @@ class TestImageData:
def test_initialization_with_parameters(self, temp_image_file):
"""Test ImageData initialization with custom parameters"""
img = ImageData(
image_path=temp_image_file,
x=10.0,
y=20.0,
width=200.0,
height=150.0,
rotation=45.0,
z_index=5
)
img = ImageData(image_path=temp_image_file, x=10.0, y=20.0, width=200.0, height=150.0, rotation=45.0, z_index=5)
assert img.image_path == temp_image_file
assert img.position == (10.0, 20.0)
assert img.size == (200.0, 150.0)
@@ -53,15 +45,7 @@ class TestImageData:
def test_serialization(self, temp_image_file):
"""Test ImageData serialization to dictionary"""
img = ImageData(
image_path=temp_image_file,
x=15.0,
y=25.0,
width=180.0,
height=120.0,
rotation=30.0,
z_index=3
)
img = ImageData(image_path=temp_image_file, x=15.0, y=25.0, width=180.0, height=120.0, rotation=30.0, z_index=3)
data = img.serialize()
assert data["type"] == "image"
@@ -81,7 +65,7 @@ class TestImageData:
"rotation": 90.0,
"z_index": 7,
"image_path": "new_image.jpg",
"crop_info": (0.2, 0.3, 0.7, 0.8)
"crop_info": (0.2, 0.3, 0.7, 0.8),
}
img.deserialize(data)
@@ -118,7 +102,7 @@ class TestImageData:
height=200.0,
rotation=0, # Visual rotation should be 0 for images
z_index=2,
crop_info=(0.1, 0.1, 0.9, 0.9)
crop_info=(0.1, 0.1, 0.9, 0.9),
)
original.pil_rotation_90 = 1 # Set PIL rotation to 90 degrees
@@ -170,7 +154,7 @@ class TestPlaceholderData:
width=150.0,
height=100.0,
rotation=10.0,
z_index=4
z_index=4,
)
assert placeholder.placeholder_type == "text"
assert placeholder.default_content == "Sample"
@@ -189,7 +173,7 @@ class TestPlaceholderData:
width=200.0,
height=150.0,
rotation=20.0,
z_index=2
z_index=2,
)
data = placeholder.serialize()
@@ -210,7 +194,7 @@ class TestPlaceholderData:
"rotation": 45.0,
"z_index": 6,
"placeholder_type": "text",
"default_content": "Default Text"
"default_content": "Default Text",
}
placeholder.deserialize(data)
@@ -243,7 +227,7 @@ class TestPlaceholderData:
width=300.0,
height=250.0,
rotation=60.0,
z_index=8
z_index=8,
)
data = original.serialize()
restored = PlaceholderData()
@@ -283,7 +267,7 @@ class TestTextBoxData:
width=180.0,
height=60.0,
rotation=5.0,
z_index=3
z_index=3,
)
assert textbox.text_content == "Hello World"
assert textbox.font_settings == font_settings
@@ -305,7 +289,7 @@ class TestTextBoxData:
width=220.0,
height=80.0,
rotation=15.0,
z_index=5
z_index=5,
)
data = textbox.serialize()
@@ -329,7 +313,7 @@ class TestTextBoxData:
"z_index": 7,
"text_content": "Deserialized Text",
"font_settings": font_settings,
"alignment": "justify"
"alignment": "justify",
}
textbox.deserialize(data)
@@ -366,7 +350,7 @@ class TestTextBoxData:
width=320.0,
height=120.0,
rotation=25.0,
z_index=9
z_index=9,
)
data = original.serialize()
restored = TextBoxData()
+22 -15
View File
@@ -28,7 +28,7 @@ class TestMouseInteractionWidget(
ElementSelectionMixin,
ViewportMixin,
UndoableInteractionMixin,
QOpenGLWidget
QOpenGLWidget,
):
"""Test widget combining mouse interaction with other required mixins"""
@@ -49,9 +49,9 @@ class TestMouseInteractionInitialization:
qtbot.addWidget(widget)
# Should have initialized state
assert hasattr(widget, 'drag_start_pos')
assert hasattr(widget, 'is_dragging')
assert hasattr(widget, 'is_panning')
assert hasattr(widget, "drag_start_pos")
assert hasattr(widget, "is_dragging")
assert hasattr(widget, "is_panning")
assert widget.drag_start_pos is None
assert widget.is_dragging is False
assert widget.is_panning is False
@@ -123,8 +123,11 @@ class TestMousePressEvent:
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
x=50, y=50, width=100, height=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height)
x=50,
y=50,
width=100,
height=100,
crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height)
)
event = Mock()
@@ -205,7 +208,7 @@ class TestMouseMoveEvent:
event.buttons = Mock(return_value=Qt.MouseButton.NoButton)
# Mock resize handle detection
widget._get_resize_handle_at = Mock(return_value='bottom-right')
widget._get_resize_handle_at = Mock(return_value="bottom-right")
widget._get_element_at = Mock(return_value=element)
widget.mouseMoveEvent(event)
@@ -295,9 +298,11 @@ class TestMouseMoveEvent:
# Create image element with crop info
element = ImageData(
image_path="/test.jpg",
x=100, y=100,
width=100, height=100,
crop_info=(0.0, 0.0, 1.0, 1.0) # crop_info is a tuple (x, y, width, height)
x=100,
y=100,
width=100,
height=100,
crop_info=(0.0, 0.0, 1.0, 1.0), # crop_info is a tuple (x, y, width, height)
)
widget.selected_elements.add(element)
@@ -377,9 +382,11 @@ class TestMouseDoubleClickEvent:
# Create text element with correct constructor
text_element = TextBoxData(
text_content="Test",
x=100, y=100,
width=100, height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)}
x=100,
y=100,
width=100,
height=50,
font_settings={"family": "Arial", "size": 12, "color": (0, 0, 0)},
)
# Mock _edit_text_element method
@@ -394,7 +401,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
Qt.KeyboardModifier.NoModifier,
)
widget.mouseDoubleClickEvent(event)
@@ -419,7 +426,7 @@ class TestMouseDoubleClickEvent:
QPointF(125, 125),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier
Qt.KeyboardModifier.NoModifier,
)
widget.mouseDoubleClickEvent(event)
+16 -13
View File
@@ -16,6 +16,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class that doesn't require full GLWidget initialization
class MultiSelectTestWidget(ElementSelectionMixin, RenderingMixin, QOpenGLWidget):
"""Widget combining necessary mixins for multiselect testing"""
def __init__(self):
super().__init__()
self._page_renderers = []
@@ -69,7 +70,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 1: Single selection")
widget.selected_elements = {element1}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
# Simulate paintGL call (only the relevant part)
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@@ -82,7 +83,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 2: Multiple selection (2 elements)")
widget.selected_elements = {element1, element2}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@@ -95,7 +96,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 3: Multiple selection (3 elements)")
widget.selected_elements = {element1, element2, element3}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@@ -108,7 +109,7 @@ def test_multiselect_visual_feedback(qtbot):
print("\nTest 4: No selection")
widget.selected_elements = set()
with patch.object(widget, '_draw_selection_handles') as mock_draw:
with patch.object(widget, "_draw_selection_handles") as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
@@ -120,15 +121,17 @@ def test_multiselect_visual_feedback(qtbot):
widget.selected_elements = {element2}
# Mock OpenGL functions
with patch('pyPhotoAlbum.gl_widget.glColor3f'), \
patch('pyPhotoAlbum.gl_widget.glLineWidth'), \
patch('pyPhotoAlbum.gl_widget.glBegin'), \
patch('pyPhotoAlbum.gl_widget.glEnd'), \
patch('pyPhotoAlbum.gl_widget.glVertex2f'), \
patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \
patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \
patch('pyPhotoAlbum.gl_widget.glTranslatef'), \
patch('pyPhotoAlbum.gl_widget.glRotatef'):
with (
patch("pyPhotoAlbum.gl_widget.glColor3f"),
patch("pyPhotoAlbum.gl_widget.glLineWidth"),
patch("pyPhotoAlbum.gl_widget.glBegin"),
patch("pyPhotoAlbum.gl_widget.glEnd"),
patch("pyPhotoAlbum.gl_widget.glVertex2f"),
patch("pyPhotoAlbum.gl_widget.glPushMatrix"),
patch("pyPhotoAlbum.gl_widget.glPopMatrix"),
patch("pyPhotoAlbum.gl_widget.glTranslatef"),
patch("pyPhotoAlbum.gl_widget.glRotatef"),
):
# Call the actual method
widget._draw_selection_handles(element2)
+16 -29
View File
@@ -174,7 +174,7 @@ class TestPageLayoutDeserialization:
"elements": [],
"grid_layout": None,
"snapping_system": {},
"show_snap_lines": False
"show_snap_lines": False,
}
layout.deserialize(data)
@@ -198,7 +198,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 0,
"crop_info": (0, 0, 1, 1)
"crop_info": (0, 0, 1, 1),
},
{
"type": "placeholder",
@@ -207,7 +207,7 @@ class TestPageLayoutDeserialization:
"position": (30, 40),
"size": (80, 80),
"rotation": 0,
"z_index": 1
"z_index": 1,
},
{
"type": "textbox",
@@ -217,9 +217,9 @@ class TestPageLayoutDeserialization:
"position": (50, 60),
"size": (120, 40),
"rotation": 0,
"z_index": 2
}
]
"z_index": 2,
},
],
}
layout.deserialize(data)
@@ -242,7 +242,7 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 5, # Higher z_index
"crop_info": (0, 0, 1, 1)
"crop_info": (0, 0, 1, 1),
},
{
"type": "placeholder",
@@ -251,9 +251,9 @@ class TestPageLayoutDeserialization:
"position": (30, 40),
"size": (80, 80),
"rotation": 0,
"z_index": 1 # Lower z_index - should be first
}
]
"z_index": 1, # Lower z_index - should be first
},
],
}
layout.deserialize(data)
@@ -269,12 +269,7 @@ class TestPageLayoutDeserialization:
data = {
"size": (210, 297),
"elements": [],
"grid_layout": {
"rows": 2,
"columns": 3,
"spacing": 12.5,
"merged_cells": [(0, 0), (1, 1)]
}
"grid_layout": {"rows": 2, "columns": 3, "spacing": 12.5, "merged_cells": [(0, 0), (1, 1)]},
}
layout.deserialize(data)
@@ -291,10 +286,7 @@ class TestPageLayoutDeserialization:
data = {
"size": (210, 297),
"elements": [
{
"type": "unknown_type",
"position": (10, 20)
},
{"type": "unknown_type", "position": (10, 20)},
{
"type": "image",
"image_path": "test.jpg",
@@ -302,9 +294,9 @@ class TestPageLayoutDeserialization:
"size": (100, 100),
"rotation": 0,
"z_index": 0,
"crop_info": (0, 0, 1, 1)
}
]
"crop_info": (0, 0, 1, 1),
},
],
}
layout.deserialize(data)
@@ -430,12 +422,7 @@ class TestGridLayoutClass:
def test_grid_deserialization(self):
"""Test GridLayout deserialization"""
grid = GridLayout()
data = {
"rows": 4,
"columns": 5,
"spacing": 8.5,
"merged_cells": [(1, 2), (3, 3)]
}
data = {"rows": 4, "columns": 5, "spacing": 8.5, "merged_cells": [(1, 2), (3, 3)]}
grid.deserialize(data)
+12 -18
View File
@@ -15,6 +15,7 @@ from pyPhotoAlbum.models import GhostPageData
# Create test widget combining necessary mixins
class TestPageNavWidget(PageNavigationMixin, ViewportMixin, QOpenGLWidget):
"""Test widget combining page navigation and viewport mixins"""
pass
@@ -149,9 +150,7 @@ class TestGetPagePositions:
mock_window.project.pages = [page]
# Mock calculate_page_layout_with_ghosts
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
('page', page, 0)
])
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[("page", page, 0)])
widget.window = Mock(return_value=mock_window)
@@ -159,7 +158,7 @@ class TestGetPagePositions:
# Should have one page entry
assert len(result) >= 1
assert result[0][0] == 'page'
assert result[0][0] == "page"
assert result[0][1] is page
def test_get_page_positions_includes_ghosts(self, qtbot):
@@ -177,10 +176,9 @@ class TestGetPagePositions:
mock_window.project.pages = [page]
# Mock with ghost page
mock_window.project.calculate_page_layout_with_ghosts = Mock(return_value=[
('page', page, 0),
('ghost', None, 1)
])
mock_window.project.calculate_page_layout_with_ghosts = Mock(
return_value=[("page", page, 0), ("ghost", None, 1)]
)
widget.window = Mock(return_value=mock_window)
@@ -189,8 +187,8 @@ class TestGetPagePositions:
# Should have page + ghost
assert len(result) >= 2
page_types = [r[0] for r in result]
assert 'page' in page_types
assert 'ghost' in page_types
assert "page" in page_types
assert "ghost" in page_types
class TestCheckGhostPageClick:
@@ -217,7 +215,7 @@ class TestCheckGhostPageClick:
result = widget._check_ghost_page_click(100, 100)
assert result is False
@patch('pyPhotoAlbum.page_renderer.PageRenderer')
@patch("pyPhotoAlbum.page_renderer.PageRenderer")
def test_check_ghost_page_click_on_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking on ghost page creates new page"""
widget = TestPageNavWidget()
@@ -238,9 +236,7 @@ class TestCheckGhostPageClick:
# Mock _get_page_positions to return a ghost
ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[
('ghost', ghost, 100)
])
widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
# Mock PageRenderer to say click is in page
mock_renderer_instance = Mock()
@@ -257,7 +253,7 @@ class TestCheckGhostPageClick:
assert len(mock_window.project.pages) == 1
assert widget.update.called
@patch('pyPhotoAlbum.page_renderer.PageRenderer')
@patch("pyPhotoAlbum.page_renderer.PageRenderer")
def test_check_ghost_page_click_outside_ghost(self, mock_page_renderer_class, qtbot):
"""Test clicking outside ghost page returns False"""
widget = TestPageNavWidget()
@@ -273,9 +269,7 @@ class TestCheckGhostPageClick:
mock_window.project.pages = []
ghost = GhostPageData(page_size=(210, 297))
widget._get_page_positions = Mock(return_value=[
('ghost', ghost, 100)
])
widget._get_page_positions = Mock(return_value=[("ghost", ghost, 100)])
# Mock renderer to say click is NOT in page
mock_renderer_instance = Mock()
+6 -29
View File
@@ -98,11 +98,7 @@ class TestGetMostVisiblePageIndex:
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
result = window._get_most_visible_page_index()
# Page 2 (index 1) should be closest to viewport center
@@ -122,10 +118,7 @@ class TestGetMostVisiblePageIndex:
renderer_orphan = Mock()
renderer_orphan.screen_y = 50 # Closer to center
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer_orphan, orphan_page) # Not in project.pages
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer_orphan, orphan_page)] # Not in project.pages
window.gl_widget.current_page_index = 0
result = window._get_most_visible_page_index()
@@ -222,11 +215,7 @@ class TestToggleDoubleSpread:
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.toggle_double_spread()
@@ -340,11 +329,7 @@ class TestAddPage:
renderer3 = Mock()
renderer3.screen_y = 800
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.add_page()
@@ -387,11 +372,7 @@ class TestAddPage:
renderer3 = Mock()
renderer3.screen_y = 1500
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.add_page()
@@ -458,11 +439,7 @@ class TestRemovePage:
renderer3 = Mock()
renderer3.screen_y = -300 # Page 3 is most visible
window.gl_widget._page_renderers = [
(renderer1, page1),
(renderer2, page2),
(renderer3, page3)
]
window.gl_widget._page_renderers = [(renderer1, page1), (renderer2, page2), (renderer3, page3)]
window.remove_page()
+96 -191
View File
@@ -8,164 +8,124 @@ from pyPhotoAlbum.page_renderer import PageRenderer
class TestPageRendererCoordinates:
"""Test coordinate transformation methods"""
def test_page_to_screen_no_zoom_no_pan(self):
"""Test page_to_screen conversion with zoom=1.0 and no pan"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Element at page origin should map to screen_x, screen_y
screen_x, screen_y = renderer.page_to_screen(0, 0)
assert screen_x == 100.0
assert screen_y == 200.0
# Element at (50, 75) should be offset by that amount
screen_x, screen_y = renderer.page_to_screen(50, 75)
assert screen_x == 150.0
assert screen_y == 275.0
def test_page_to_screen_with_zoom(self):
"""Test page_to_screen conversion with zoom applied"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=2.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0
)
# With zoom=2.0, distances should be doubled
screen_x, screen_y = renderer.page_to_screen(50, 75)
assert screen_x == 200.0 # 100 + 50*2
assert screen_y == 350.0 # 200 + 75*2
def test_page_to_screen_with_fractional_zoom(self):
"""Test page_to_screen conversion with fractional zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=0.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=0.5
)
# With zoom=0.5, distances should be halved
screen_x, screen_y = renderer.page_to_screen(100, 150)
assert screen_x == 150.0 # 100 + 100*0.5
assert screen_y == 275.0 # 200 + 150*0.5
def test_screen_to_page_no_zoom_no_pan(self):
"""Test screen_to_page conversion with zoom=1.0 and no pan"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Screen position at screen_x, screen_y should map to page origin
page_x, page_y = renderer.screen_to_page(100.0, 200.0)
assert page_x == 0.0
assert page_y == 0.0
# Screen position offset should map to same offset in page coords
page_x, page_y = renderer.screen_to_page(150.0, 275.0)
assert page_x == 50.0
assert page_y == 75.0
def test_screen_to_page_with_zoom(self):
"""Test screen_to_page conversion with zoom applied"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=2.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=2.0
)
# With zoom=2.0, screen distances should be divided by 2 to get page coords
page_x, page_y = renderer.screen_to_page(200.0, 350.0)
assert page_x == 50.0 # (200-100)/2
assert page_y == 75.0 # (350-200)/2
def test_roundtrip_conversion_no_zoom(self):
"""Test that page->screen->page conversion is accurate with no zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Start with page coordinates
orig_page_x, orig_page_y = 123.45, 678.90
# Convert to screen and back
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
# Should get back the original values
assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001
def test_roundtrip_conversion_with_zoom(self):
"""Test that page->screen->page conversion is accurate with zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
# Start with page coordinates
orig_page_x, orig_page_y = 123.45, 678.90
# Convert to screen and back
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
# Should get back the original values (with floating point tolerance)
assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001
def test_roundtrip_conversion_extreme_zoom(self):
"""Test coordinate conversion with extreme zoom levels"""
for zoom in [0.1, 0.5, 1.0, 2.0, 5.0]:
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=50.0,
screen_y=100.0,
dpi=96,
zoom=zoom
page_width_mm=210.0, page_height_mm=297.0, screen_x=50.0, screen_y=100.0, dpi=96, zoom=zoom
)
orig_page_x, orig_page_y = 250.0, 400.0
screen_x, screen_y = renderer.page_to_screen(orig_page_x, orig_page_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
assert abs(page_x - orig_page_x) < 0.001
assert abs(page_y - orig_page_y) < 0.001
class TestPageRendererBounds:
"""Test page bounds and point detection"""
def test_is_point_in_page_inside(self):
"""Test is_point_in_page for points inside the page"""
renderer = PageRenderer(
@@ -174,108 +134,85 @@ class TestPageRendererBounds:
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# Calculate page dimensions in pixels
page_width_px = 210.0 * 96 / 25.4 # ~794 pixels
page_height_px = 297.0 * 96 / 25.4 # ~1123 pixels
# Point in center should be inside
center_x = 100.0 + page_width_px / 2
center_y = 200.0 + page_height_px / 2
assert renderer.is_point_in_page(center_x, center_y)
# Point at origin should be inside
assert renderer.is_point_in_page(100.0, 200.0)
# Point at bottom-right corner should be inside
assert renderer.is_point_in_page(
100.0 + page_width_px,
200.0 + page_height_px
)
assert renderer.is_point_in_page(100.0 + page_width_px, 200.0 + page_height_px)
def test_is_point_in_page_outside(self):
"""Test is_point_in_page for points outside the page"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Point before page start
assert not renderer.is_point_in_page(50.0, 150.0)
# Point way beyond page
assert not renderer.is_point_in_page(2000.0, 2000.0)
# Point to the left of page
assert not renderer.is_point_in_page(50.0, 500.0)
# Point above page
assert not renderer.is_point_in_page(500.0, 150.0)
def test_is_point_in_page_with_zoom(self):
"""Test is_point_in_page with different zoom levels"""
for zoom in [0.5, 1.0, 2.0]:
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=zoom
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=zoom
)
# Center of page should always be inside regardless of zoom
page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4
center_x = 100.0 + (page_width_px * zoom) / 2
center_y = 200.0 + (page_height_px * zoom) / 2
assert renderer.is_point_in_page(center_x, center_y)
def test_get_page_bounds_screen(self):
"""Test get_page_bounds_screen returns correct screen coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
x, y, w, h = renderer.get_page_bounds_screen()
assert x == 100.0
assert y == 200.0
# Width and height should be scaled by zoom
page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4
assert abs(w - page_width_px * 1.5) < 0.1
assert abs(h - page_height_px * 1.5) < 0.1
def test_get_page_bounds_page(self):
"""Test get_page_bounds_page returns correct page-local coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.5
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.5
)
x, y, w, h = renderer.get_page_bounds_page()
# Origin should be at 0,0 in page-local coordinates
assert x == 0.0
assert y == 0.0
# Width and height should NOT be affected by zoom (page-local coords)
page_width_px = 210.0 * 96 / 25.4
page_height_px = 297.0 * 96 / 25.4
@@ -285,22 +222,17 @@ class TestPageRendererBounds:
class TestPageRendererSubPages:
"""Test sub-page detection for facing pages"""
def test_get_sub_page_at_single_page(self):
"""Test that get_sub_page_at returns None for single pages"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# For non-facing pages, should return None
result = renderer.get_sub_page_at(500.0, is_facing_page=False)
assert result is None
def test_get_sub_page_at_facing_page_left(self):
"""Test get_sub_page_at for left side of facing page"""
renderer = PageRenderer(
@@ -309,17 +241,17 @@ class TestPageRendererSubPages:
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# Calculate center line
page_width_px = 420.0 * 96 / 25.4
center_x = 100.0 + page_width_px / 2
# Point before center should be 'left'
result = renderer.get_sub_page_at(center_x - 10, is_facing_page=True)
assert result == 'left'
assert result == "left"
def test_get_sub_page_at_facing_page_right(self):
"""Test get_sub_page_at for right side of facing page"""
renderer = PageRenderer(
@@ -328,21 +260,21 @@ class TestPageRendererSubPages:
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# Calculate center line
page_width_px = 420.0 * 96 / 25.4
center_x = 100.0 + page_width_px / 2
# Point after center should be 'right'
result = renderer.get_sub_page_at(center_x + 10, is_facing_page=True)
assert result == 'right'
assert result == "right"
class TestPageRendererDimensions:
"""Test page dimension calculations"""
def test_page_dimensions_calculated_correctly(self):
"""Test that page dimensions are calculated correctly from mm to pixels"""
renderer = PageRenderer(
@@ -351,113 +283,86 @@ class TestPageRendererDimensions:
screen_x=0.0,
screen_y=0.0,
dpi=96,
zoom=1.0
zoom=1.0,
)
# A4 at 96 DPI
expected_width = 210.0 * 96 / 25.4 # ~794 pixels
expected_height = 297.0 * 96 / 25.4 # ~1123 pixels
assert abs(renderer.page_width_px - expected_width) < 0.1
assert abs(renderer.page_height_px - expected_height) < 0.1
def test_screen_dimensions_with_zoom(self):
"""Test that screen dimensions account for zoom"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=0.0,
screen_y=0.0,
dpi=96,
zoom=2.0
)
renderer = PageRenderer(page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=96, zoom=2.0)
# Screen dimensions should be doubled due to zoom
expected_width = (210.0 * 96 / 25.4) * 2.0
expected_height = (297.0 * 96 / 25.4) * 2.0
assert abs(renderer.screen_width - expected_width) < 0.1
assert abs(renderer.screen_height - expected_height) < 0.1
def test_different_dpi_values(self):
"""Test page dimensions with different DPI values"""
dpi_values = [72, 96, 150, 300]
for dpi in dpi_values:
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=0.0,
screen_y=0.0,
dpi=dpi,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=0.0, screen_y=0.0, dpi=dpi, zoom=1.0
)
expected_width = 210.0 * dpi / 25.4
expected_height = 297.0 * dpi / 25.4
assert abs(renderer.page_width_px - expected_width) < 0.1
assert abs(renderer.page_height_px - expected_height) < 0.1
class TestPageRendererEdgeCases:
"""Test edge cases and boundary conditions"""
def test_zero_coordinates(self):
"""Test handling of zero coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
screen_x, screen_y = renderer.page_to_screen(0, 0)
assert screen_x == 100.0
assert screen_y == 200.0
page_x, page_y = renderer.screen_to_page(100.0, 200.0)
assert page_x == 0.0
assert page_y == 0.0
def test_negative_page_coordinates(self):
"""Test handling of negative page coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
# Negative page coordinates should still convert correctly
screen_x, screen_y = renderer.page_to_screen(-50, -75)
assert screen_x == 50.0
assert screen_y == 125.0
# And back again
page_x, page_y = renderer.screen_to_page(50.0, 125.0)
assert page_x == -50.0
assert page_y == -75.0
def test_very_large_coordinates(self):
"""Test handling of very large coordinates"""
renderer = PageRenderer(
page_width_mm=210.0,
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
zoom=1.0
page_width_mm=210.0, page_height_mm=297.0, screen_x=100.0, screen_y=200.0, dpi=96, zoom=1.0
)
large_x, large_y = 10000.0, 20000.0
screen_x, screen_y = renderer.page_to_screen(large_x, large_y)
page_x, page_y = renderer.screen_to_page(screen_x, screen_y)
assert abs(page_x - large_x) < 0.001
assert abs(page_y - large_y) < 0.001
+52 -50
View File
@@ -190,16 +190,16 @@ class TestPageSetupDialog:
values = dialog.get_values()
# Check all values returned
assert values['selected_index'] == 0
assert values['selected_page'] == page
assert values['is_cover'] is True
assert values['paper_thickness_mm'] == 0.15
assert values['cover_bleed_mm'] == 5.0
assert values['width_mm'] == 200
assert values['height_mm'] == 280
assert values['working_dpi'] == 150
assert values['export_dpi'] == 600
assert values['set_as_default'] is True
assert values["selected_index"] == 0
assert values["selected_page"] == page
assert values["is_cover"] is True
assert values["paper_thickness_mm"] == 0.15
assert values["cover_bleed_mm"] == 5.0
assert values["width_mm"] == 200
assert values["height_mm"] == 280
assert values["working_dpi"] == 150
assert values["export_dpi"] == 600
assert values["set_as_default"] is True
def test_dialog_page_change_updates_values(self, qtbot):
"""Test changing selected page updates displayed values"""
@@ -240,14 +240,14 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'test': 'value'})
mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
result = window.create_dialog(mock_dialog_class)
assert result == {'test': 'value'}
assert result == {"test": "value"}
mock_dialog.exec.assert_called_once()
def test_dialog_mixin_create_dialog_rejected(self, qtbot):
@@ -283,7 +283,7 @@ class TestDialogMixin:
# Create mock dialog with get_values as a proper method
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'test': 'value'})
mock_dialog.get_values = Mock(return_value={"test": "value"})
# Mock dialog class
mock_dialog_class = Mock(return_value=mock_dialog)
@@ -294,7 +294,7 @@ class TestDialogMixin:
result = window.show_dialog(mock_dialog_class, on_accept=callback)
assert result is True
callback.assert_called_once_with({'test': 'value'})
callback.assert_called_once_with({"test": "value"})
class TestDialogActionDecorator:
@@ -362,7 +362,7 @@ class TestDialogMixinEdgeCases:
mock_dialog = MagicMock(spec=QDialog)
mock_dialog.exec = Mock(return_value=QDialog.DialogCode.Accepted)
mock_dialog.get_values = Mock(return_value={'data': 'test'})
mock_dialog.get_values = Mock(return_value={"data": "test"})
mock_dialog_class = Mock(return_value=mock_dialog)
@@ -370,7 +370,7 @@ class TestDialogMixinEdgeCases:
# Verify setWindowTitle was called
mock_dialog.setWindowTitle.assert_called_once_with("Custom Title")
assert result == {'data': 'test'}
assert result == {"data": "test"}
def test_show_dialog_rejected(self, qtbot):
"""Test show_dialog when user rejects dialog"""
@@ -543,16 +543,16 @@ class TestPageSetupIntegration:
# Create mock values that would come from dialog
values = {
'selected_index': 0,
'selected_page': window.project.pages[0],
'is_cover': False,
'paper_thickness_mm': 0.15,
'cover_bleed_mm': 5.0,
'width_mm': 200,
'height_mm': 280,
'working_dpi': 150,
'export_dpi': 600,
'set_as_default': True
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": False,
"paper_thickness_mm": 0.15,
"cover_bleed_mm": 5.0,
"width_mm": 200,
"height_mm": 280,
"working_dpi": 150,
"export_dpi": 600,
"set_as_default": True,
}
# Access the unwrapped function to test business logic directly
@@ -564,7 +564,7 @@ class TestPageSetupIntegration:
original_func = window.page_setup
# Decorators return wrappers, but we can call them with values directly
# by accessing the innermost wrapped function
while hasattr(original_func, '__wrapped__'):
while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# If no __wrapped__, the decorator system is different
@@ -575,7 +575,7 @@ class TestPageSetupIntegration:
# Get the undecorated method from the class
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
# Find the innermost function
while hasattr(undecorated_page_setup, '__wrapped__'):
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Call the business logic directly
@@ -635,22 +635,23 @@ class TestPageSetupIntegration:
# Test designating first page as cover
values = {
'selected_index': 0,
'selected_page': window.project.pages[0],
'is_cover': True, # Designate as cover
'paper_thickness_mm': 0.1,
'cover_bleed_mm': 3.0,
'width_mm': 210,
'height_mm': 297,
'working_dpi': 96,
'export_dpi': 300,
'set_as_default': False
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": True, # Designate as cover
"paper_thickness_mm": 0.1,
"cover_bleed_mm": 3.0,
"width_mm": 210,
"height_mm": 297,
"working_dpi": 96,
"export_dpi": 300,
"set_as_default": False,
}
# Get the undecorated method
from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, '__wrapped__'):
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
# Mock update_cover_dimensions
@@ -706,21 +707,22 @@ class TestPageSetupIntegration:
# Test changing double spread page size
values = {
'selected_index': 0,
'selected_page': window.project.pages[0],
'is_cover': False,
'paper_thickness_mm': 0.1,
'cover_bleed_mm': 3.0,
'width_mm': 200, # New base width
'height_mm': 280, # New height
'working_dpi': 96,
'export_dpi': 300,
'set_as_default': False
"selected_index": 0,
"selected_page": window.project.pages[0],
"is_cover": False,
"paper_thickness_mm": 0.1,
"cover_bleed_mm": 3.0,
"width_mm": 200, # New base width
"height_mm": 280, # New height
"working_dpi": 96,
"export_dpi": 300,
"set_as_default": False,
}
from pyPhotoAlbum.mixins.operations import page_ops
undecorated_page_setup = page_ops.PageOperationsMixin.page_setup
while hasattr(undecorated_page_setup, '__wrapped__'):
while hasattr(undecorated_page_setup, "__wrapped__"):
undecorated_page_setup = undecorated_page_setup.__wrapped__
undecorated_page_setup(window, values)
+28 -28
View File
@@ -26,7 +26,7 @@ class TestPageSetupDialogWithMocks:
# We can verify the class signature and that it would accept these params
# This is a structural test rather than a full initialization test
assert hasattr(PageSetupDialog, '__init__')
assert hasattr(PageSetupDialog, "__init__")
# The actual widget creation tests are in test_page_setup_dialog.py
# using qtbot which handles Qt properly
@@ -44,7 +44,7 @@ class TestPageSetupDialogWithMocks:
project.pages = [page1, page2]
# Mock the dialog instance
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
# Manually set required attributes
@@ -88,7 +88,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog._cover_group = Mock()
@@ -112,11 +112,11 @@ class TestPageSetupDialogWithMocks:
# Create 3 content pages (not covers)
for i in range(3):
page = Page(layout=PageLayout(width=210, height=297), page_number=i+1)
page = Page(layout=PageLayout(width=210, height=297), page_number=i + 1)
page.is_cover = False
project.pages.append(page)
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog.cover_checkbox = Mock()
@@ -158,7 +158,7 @@ class TestPageSetupDialogWithMocks:
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
@@ -194,16 +194,16 @@ class TestPageSetupDialogWithMocks:
values = dialog.get_values()
# Verify all values were extracted
assert values['selected_index'] == 0
assert values['selected_page'] == page
assert values['is_cover'] is True
assert values['paper_thickness_mm'] == 0.15
assert values['cover_bleed_mm'] == 5.0
assert values['width_mm'] == 200.0
assert values['height_mm'] == 280.0
assert values['working_dpi'] == 150
assert values['export_dpi'] == 600
assert values['set_as_default'] is True
assert values["selected_index"] == 0
assert values["selected_page"] == page
assert values["is_cover"] is True
assert values["paper_thickness_mm"] == 0.15
assert values["cover_bleed_mm"] == 5.0
assert values["width_mm"] == 200.0
assert values["height_mm"] == 280.0
assert values["working_dpi"] == 150
assert values["export_dpi"] == 600
assert values["set_as_default"] is True
def test_cover_page_width_display(self):
"""Test cover page shows full width, not base width"""
@@ -217,7 +217,7 @@ class TestPageSetupDialogWithMocks:
page.is_cover = True
project.pages = [page]
with patch.object(PageSetupDialog, '__init__', lambda self, *args, **kwargs: None):
with patch.object(PageSetupDialog, "__init__", lambda self, *args, **kwargs: None):
dialog = PageSetupDialog(None, None, 0)
dialog.project = project
dialog._cover_group = Mock()
@@ -259,7 +259,7 @@ class TestDialogMixinMocked:
# Mock dialog class
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'key': 'value'}
mock_dialog_instance.get_values.return_value = {"key": "value"}
mock_dialog_class = Mock(return_value=mock_dialog_instance)
@@ -279,7 +279,7 @@ class TestDialogMixinMocked:
mock_dialog_instance.get_values.assert_called_once()
# Verify result
assert result == {'key': 'value'}
assert result == {"key": "value"}
def test_show_dialog_with_callback_flow(self):
"""Test show_dialog method with callback"""
@@ -293,7 +293,7 @@ class TestDialogMixinMocked:
# Mock dialog
mock_dialog_instance = Mock()
mock_dialog_instance.exec.return_value = 1 # Accepted
mock_dialog_instance.get_values.return_value = {'data': 'test'}
mock_dialog_instance.get_values.return_value = {"data": "test"}
mock_dialog_class = Mock(return_value=mock_dialog_instance)
@@ -304,7 +304,7 @@ class TestDialogMixinMocked:
result = window.show_dialog(mock_dialog_class, on_accept=callback, param="value")
# Verify callback was called with dialog values
callback.assert_called_once_with({'data': 'test'})
callback.assert_called_once_with({"data": "test"})
# Verify result
assert result is True
@@ -346,7 +346,7 @@ class TestDialogActionDecoratorMocked:
# Mock dialog instance
mock_dialog = Mock()
mock_dialog.exec.return_value = QDialog.DialogCode.Accepted # Accepted
mock_dialog.get_values.return_value = {'test': 'data'}
mock_dialog.get_values.return_value = {"test": "data"}
# Mock dialog class
mock_dialog_cls = Mock(return_value=mock_dialog)
@@ -354,7 +354,7 @@ class TestDialogActionDecoratorMocked:
# Create decorated function
@dialog_action(dialog_class=mock_dialog_cls, requires_pages=True)
def test_function(self, values):
return values['test']
return values["test"]
# Mock instance with required attributes
instance = Mock()
@@ -375,7 +375,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog.get_values.assert_called_once()
# Verify original function received values
assert result == 'data'
assert result == "data"
def test_decorator_returns_early_when_no_pages(self):
"""Test decorator returns early when pages required but not present"""
@@ -407,7 +407,7 @@ class TestDialogActionDecoratorMocked:
mock_dialog = Mock()
mock_dialog.exec.return_value = 1
mock_dialog.get_values.return_value = {'key': 'val'}
mock_dialog.get_values.return_value = {"key": "val"}
mock_dialog_cls = Mock(return_value=mock_dialog)
@@ -427,8 +427,8 @@ class TestDialogActionDecoratorMocked:
mock_dialog_cls.assert_called_once()
# Verify result
assert result == {'key': 'val'}
assert result == {"key": "val"}
if __name__ == '__main__':
pytest.main([__file__, '-v'])
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+212 -238
View File
@@ -15,27 +15,27 @@ def test_pdf_exporter_basic():
# Create a simple project
project = Project("Test Project")
project.page_size_mm = (210, 297) # A4
# Add a single page
page = Page(page_number=1, is_double_spread=False)
project.add_page(page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created"
assert os.path.getsize(tmp_path) > 0, "PDF file is empty"
print(f"✓ Basic PDF export successful: {tmp_path}")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@@ -45,26 +45,26 @@ def test_pdf_exporter_double_spread():
"""Test PDF export with double-page spread"""
project = Project("Test Spread Project")
project.page_size_mm = (210, 297) # A4
# Add a double-page spread
spread_page = Page(page_number=1, is_double_spread=True)
project.add_page(spread_page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created"
print(f"✓ Double-spread PDF export successful: {tmp_path}")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@@ -83,14 +83,17 @@ def test_pdf_exporter_with_text():
text_content="Hello, World!",
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
alignment="center",
x=50, y=50, width=100, height=30
x=50,
y=50,
width=100,
height=30,
)
page.layout.add_element(text_box)
project.add_page(page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@@ -143,7 +146,7 @@ def test_pdf_text_position_and_size():
x=text_box_x_px,
y=text_box_y_px,
width=text_box_width_px,
height=text_box_height_px
height=text_box_height_px,
)
page.layout.add_element(text_box)
project.add_page(page)
@@ -168,7 +171,7 @@ def test_pdf_text_position_and_size():
expected_font_size_pt = font_size_px * 25.4 / dpi * MM_TO_POINTS
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@@ -186,16 +189,20 @@ def test_pdf_text_position_and_size():
# Get the first character's position and font size
first_char = chars[0]
text_x = first_char['x0']
text_y_baseline = first_char['y0'] # This is the baseline y position
actual_font_size = first_char['size']
text_x = first_char["x0"]
text_y_baseline = first_char["y0"] # This is the baseline y position
actual_font_size = first_char["size"]
print(f"\nText Position Analysis:")
print(f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
f"w={text_box_width_px}, h={text_box_height_px}")
print(f" Text box (in PDF points): x={text_box_x_pt:.1f}, "
f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, "
f"height={text_box_height_pt:.1f}")
print(
f" Text box (in pixels at {dpi} DPI): x={text_box_x_px}, y={text_box_y_px}, "
f"w={text_box_width_px}, h={text_box_height_px}"
)
print(
f" Text box (in PDF points): x={text_box_x_pt:.1f}, "
f"y_bottom={text_box_y_pt_bottom:.1f}, y_top={text_box_y_pt_top:.1f}, "
f"height={text_box_height_pt:.1f}"
)
print(f" Font size (pixels): {font_size_px}")
print(f" Expected font size (points): {expected_font_size_pt:.1f}")
print(f" Actual font size (points): {actual_font_size:.1f}")
@@ -213,8 +220,7 @@ def test_pdf_text_position_and_size():
# Verify text X position is near the left edge of the text box
x_diff = abs(text_x - text_box_x_pt)
assert x_diff < 5.0, (
f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, "
f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
f"Text X position mismatch: expected ~{text_box_x_pt:.1f}, " f"got {text_x:.1f} (diff: {x_diff:.1f}pt)"
)
# Verify text Y baseline is INSIDE the text box (not above it)
@@ -280,7 +286,7 @@ def test_pdf_text_wrapping():
x=text_box_x_px,
y=text_box_y_px,
width=text_box_width_px,
height=text_box_height_px
height=text_box_height_px,
)
page.layout.add_element(text_box)
project.add_page(page)
@@ -296,7 +302,7 @@ def test_pdf_text_wrapping():
text_box_right_pt = text_box_x_pt + text_box_width_pt
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
@@ -313,7 +319,7 @@ def test_pdf_text_wrapping():
assert len(chars) > 0, "No text found in PDF"
# Get all unique Y positions (lines)
y_positions = sorted(set(round(c['top'], 1) for c in chars))
y_positions = sorted(set(round(c["top"], 1) for c in chars))
print(f"\nText Wrapping Analysis:")
print(f" Text box width: {text_box_width_pt:.1f}pt")
@@ -322,21 +328,19 @@ def test_pdf_text_wrapping():
print(f" Line Y positions: {y_positions[:5]}...") # Show first 5
# Verify text wrapped to multiple lines
assert len(y_positions) > 1, (
f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
)
assert len(y_positions) > 1, f"Text should wrap to multiple lines but only found {len(y_positions)} line(s)"
# Verify all characters are within box width (with small tolerance)
tolerance = 5.0 # Small tolerance for rounding
for char in chars:
char_x = char['x0']
char_right = char['x1']
assert char_x >= text_box_x_pt - tolerance, (
f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}"
)
assert char_right <= text_box_right_pt + tolerance, (
f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}"
)
char_x = char["x0"]
char_right = char["x1"]
assert (
char_x >= text_box_x_pt - tolerance
), f"Character '{char['text']}' at x={char_x:.1f} is left of box start {text_box_x_pt:.1f}"
assert (
char_right <= text_box_right_pt + tolerance
), f"Character '{char['text']}' ends at x={char_right:.1f} which exceeds box right {text_box_right_pt:.1f}"
print(f" All characters within box width: ✓")
print(f"\n✓ Text wrapping test passed!")
@@ -354,28 +358,28 @@ def test_pdf_exporter_facing_pages_alignment():
# Add single page (page 1)
page1 = Page(page_number=1, is_double_spread=False)
project.add_page(page1)
# Add double spread (should start on page 2, which requires blank insert)
# Since page 1 is odd, a blank page should be inserted, making the spread pages 2-3
spread = Page(page_number=2, is_double_spread=True)
project.add_page(spread)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(tmp_path), "PDF file was not created"
print(f"✓ Facing pages alignment successful: {tmp_path}")
print(f" Expected: Page 1 (single), blank page, Pages 2-3 (spread)")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@@ -385,34 +389,31 @@ def test_pdf_exporter_missing_image():
"""Test PDF export with missing image (should warn but not fail)"""
project = Project("Test Missing Image")
project.page_size_mm = (210, 297)
# Create page with image that doesn't exist
page = Page(page_number=1, is_double_spread=False)
# Add image with non-existent path
image = ImageData(
image_path="/nonexistent/path/to/image.jpg",
x=50, y=50, width=100, height=100
)
image = ImageData(image_path="/nonexistent/path/to/image.jpg", x=50, y=50, width=100, height=100)
page.layout.add_element(image)
project.add_page(page)
# Export to temporary file
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp_path = tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(tmp_path)
assert success, "Export should succeed even with missing images"
assert len(warnings) > 0, "Should have warnings for missing image"
assert "not found" in warnings[0].lower(), "Warning should mention missing image"
print(f"✓ Missing image handling successful: {tmp_path}")
print(f" Warnings: {warnings}")
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
@@ -422,65 +423,59 @@ def test_pdf_exporter_spanning_image():
"""Test PDF export with image spanning across center line of double spread"""
import tempfile
from PIL import Image as PILImage
project = Project("Test Spanning Image")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96 # Standard DPI
# Create a test image (solid color for easy verification)
test_img = PILImage.new('RGB', (400, 200), color='red')
test_img = PILImage.new("RGB", (400, 200), color="red")
# Save test image to temporary file
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
try:
# Create a double-page spread
spread_page = Page(page_number=1, is_double_spread=True)
# Calculate center position in pixels (for a 210mm page width at 96 DPI)
# Spread width is 2 * 210mm = 420mm
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
center_px = spread_width_px / 2 # ~794 pixels
# Add an image that spans across the center
# Position it so it overlaps the center line
image_width_px = 400
image_x_px = center_px - 200 # Start 200px before center, end 200px after
spanning_image = ImageData(
image_path=img_path,
x=image_x_px,
y=100,
width=image_width_px,
height=200
)
spanning_image = ImageData(image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=200)
spread_page.layout.add_element(spanning_image)
project.add_page(spread_page)
# Export to temporary PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
print(f"✓ Spanning image export successful: {pdf_path}")
print(f" Image spans from {image_x_px:.1f}px to {image_x_px + image_width_px:.1f}px")
print(f" Center line at {center_px:.1f}px")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
finally:
if os.path.exists(img_path):
os.remove(img_path)
@@ -490,71 +485,61 @@ def test_pdf_exporter_multiple_spanning_elements():
"""Test PDF export with multiple images spanning the center line"""
import tempfile
from PIL import Image as PILImage
project = Project("Test Multiple Spanning")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96
# Create test images
test_img1 = PILImage.new('RGB', (300, 150), color='blue')
test_img2 = PILImage.new('RGB', (250, 200), color='green')
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp1:
test_img1 = PILImage.new("RGB", (300, 150), color="blue")
test_img2 = PILImage.new("RGB", (250, 200), color="green")
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp1:
img_path1 = img_tmp1.name
test_img1.save(img_path1)
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp2:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp2:
img_path2 = img_tmp2.name
test_img2.save(img_path2)
try:
spread_page = Page(page_number=1, is_double_spread=True)
# Calculate positions
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
# First spanning image
image1 = ImageData(
image_path=img_path1,
x=center_px - 150, # Centered on split line
y=50,
width=300,
height=150
image_path=img_path1, x=center_px - 150, y=50, width=300, height=150 # Centered on split line
)
# Second spanning image (different position)
image2 = ImageData(
image_path=img_path2,
x=center_px - 100,
y=250,
width=250,
height=200
)
image2 = ImageData(image_path=img_path2, x=center_px - 100, y=250, width=250, height=200)
spread_page.layout.add_element(image1)
spread_page.layout.add_element(image2)
project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
print(f"✓ Multiple spanning images export successful: {pdf_path}")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
finally:
if os.path.exists(img_path1):
os.remove(img_path1)
@@ -566,52 +551,46 @@ def test_pdf_exporter_edge_case_barely_spanning():
"""Test image that barely crosses the threshold"""
import tempfile
from PIL import Image as PILImage
project = Project("Test Edge Case")
project.page_size_mm = (210, 297)
project.working_dpi = 96
test_img = PILImage.new('RGB', (100, 100), color='yellow')
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
test_img = PILImage.new("RGB", (100, 100), color="yellow")
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
try:
spread_page = Page(page_number=1, is_double_spread=True)
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
# Image that just barely crosses the center line
image = ImageData(
image_path=img_path,
x=center_px - 5, # Just 5px overlap
y=100,
width=100,
height=100
)
image = ImageData(image_path=img_path, x=center_px - 5, y=100, width=100, height=100) # Just 5px overlap
spread_page.layout.add_element(image)
project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}"
print(f"✓ Edge case (barely spanning) export successful: {pdf_path}")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
finally:
if os.path.exists(img_path):
os.remove(img_path)
@@ -622,12 +601,12 @@ def test_pdf_exporter_text_spanning():
project = Project("Test Spanning Text")
project.page_size_mm = (210, 297)
project.working_dpi = 96
spread_page = Page(page_number=1, is_double_spread=True)
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
# Text box spanning the center
text_box = TextBoxData(
text_content="Spanning Text",
@@ -636,25 +615,25 @@ def test_pdf_exporter_text_spanning():
x=center_px - 100,
y=100,
width=200,
height=50
height=50,
)
spread_page.layout.add_element(text_box)
project.add_page(spread_page)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}"
print(f"✓ Spanning text box export successful: {pdf_path}")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
@@ -664,76 +643,72 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
"""Test that spanning images maintain correct aspect ratio and can be recombined"""
import tempfile
from PIL import Image as PILImage, ImageDraw
project = Project("Test Aspect Ratio")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96
# Create a distinctive test image: red left half, blue right half, with a vertical line in center
test_width, test_height = 800, 400
test_img = PILImage.new('RGB', (test_width, test_height))
test_img = PILImage.new("RGB", (test_width, test_height))
draw = ImageDraw.Draw(test_img)
# Fill left half red
draw.rectangle([0, 0, test_width // 2, test_height], fill=(255, 0, 0))
# Fill right half blue
draw.rectangle([test_width // 2, 0, test_width, test_height], fill=(0, 0, 255))
# Draw a black vertical line in the middle
draw.line([test_width // 2, 0, test_width // 2, test_height], fill=(0, 0, 0), width=5)
# Draw horizontal reference lines for visual verification
for y in range(0, test_height, 50):
draw.line([0, y, test_width, y], fill=(255, 255, 255), width=2)
# Save test image to temporary file
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
try:
# Create a double-page spread
spread_page = Page(page_number=1, is_double_spread=True)
# Calculate positions
spread_width_px = 420 * 96 / 25.4 # ~1587 pixels
center_px = spread_width_px / 2 # ~794 pixels
# Create an image element that spans the center with a specific aspect ratio
# Make it 600px wide and 300px tall (2:1 aspect ratio)
image_width_px = 600
image_height_px = 300
image_x_px = center_px - 300 # Centered on the split line
spanning_image = ImageData(
image_path=img_path,
x=image_x_px,
y=100,
width=image_width_px,
height=image_height_px
image_path=img_path, x=image_x_px, y=100, width=image_width_px, height=image_height_px
)
spread_page.layout.add_element(spanning_image)
project.add_page(spread_page)
# Export to temporary PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
# Verify the PDF was created and has expected properties
# We can't easily extract and verify pixel-perfect image reconstruction without
# additional dependencies, but we can verify the export succeeded
file_size = os.path.getsize(pdf_path)
assert file_size > 1000, "PDF file seems too small"
print(f"✓ Spanning image aspect ratio test successful: {pdf_path}")
print(f" Original image: {test_width}x{test_height} (aspect {test_width/test_height:.2f}:1)")
print(f" Element size: {image_width_px}x{image_height_px} (aspect {image_width_px/image_height_px:.2f}:1)")
@@ -741,14 +716,14 @@ def test_pdf_exporter_spanning_image_aspect_ratio():
print(f" Left portion: {center_px - image_x_px:.1f}px wide")
print(f" Right portion: {image_width_px - (center_px - image_x_px):.1f}px wide")
print(f" PDF size: {file_size} bytes")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
finally:
if os.path.exists(img_path):
os.remove(img_path)
@@ -758,74 +733,76 @@ def test_pdf_exporter_varying_aspect_ratios():
"""Test spanning images with various aspect ratios"""
import tempfile
from PIL import Image as PILImage, ImageDraw
project = Project("Test Varying Aspects")
project.page_size_mm = (210, 297)
project.working_dpi = 96
# Test different aspect ratios
test_configs = [
("Square", 400, 400), # 1:1
("Landscape", 800, 400), # 2:1
("Portrait", 400, 800), # 1:2
("Wide", 1200, 400), # 3:1
("Square", 400, 400), # 1:1
("Landscape", 800, 400), # 2:1
("Portrait", 400, 800), # 1:2
("Wide", 1200, 400), # 3:1
]
spread_width_px = 420 * 96 / 25.4
center_px = spread_width_px / 2
for idx, (name, img_w, img_h) in enumerate(test_configs):
# Create test image
test_img = PILImage.new('RGB', (img_w, img_h))
test_img = PILImage.new("RGB", (img_w, img_h))
draw = ImageDraw.Draw(test_img)
# Different colors for each test
colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]
draw.rectangle([0, 0, img_w // 2, img_h], fill=colors[idx])
draw.rectangle([img_w // 2, 0, img_w, img_h], fill=(255-colors[idx][0], 255-colors[idx][1], 255-colors[idx][2]))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
draw.rectangle(
[img_w // 2, 0, img_w, img_h], fill=(255 - colors[idx][0], 255 - colors[idx][1], 255 - colors[idx][2])
)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
try:
spread_page = Page(page_number=idx + 1, is_double_spread=True)
# Position spanning element
element_width_px = 500
element_height_px = int(500 * img_h / img_w) # Maintain aspect ratio
spanning_image = ImageData(
image_path=img_path,
x=center_px - 250,
y=100 + idx * 200,
width=element_width_px,
height=element_height_px
height=element_height_px,
)
spread_page.layout.add_element(spanning_image)
project.add_page(spread_page)
finally:
if os.path.exists(img_path):
os.remove(img_path)
# Export all pages
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
exporter = PDFExporter(project)
success, warnings = exporter.export(pdf_path)
assert success, f"Export failed: {warnings}"
assert os.path.exists(pdf_path), "PDF file was not created"
print(f"✓ Varying aspect ratios test successful: {pdf_path}")
print(f" Tested {len(test_configs)} different aspect ratios")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(pdf_path):
os.remove(pdf_path)
@@ -842,7 +819,7 @@ def test_pdf_exporter_rotated_image():
# Create a distinctive test image that shows rotation clearly
# Make it wider than tall (400x200) so we can verify rotation
test_img = PILImage.new('RGB', (400, 200), color='white')
test_img = PILImage.new("RGB", (400, 200), color="white")
draw = ImageDraw.Draw(test_img)
# Draw a pattern that shows orientation
@@ -855,7 +832,7 @@ def test_pdf_exporter_rotated_image():
# Yellow vertical stripe on right
draw.rectangle([350, 0, 400, 200], fill=(255, 255, 0))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
test_img.save(img_path)
@@ -865,11 +842,7 @@ def test_pdf_exporter_rotated_image():
# Add image with 90-degree PIL rotation
image = ImageData(
image_path=img_path,
x=50,
y=50,
width=200, # These dimensions are for the rotated version
height=400
image_path=img_path, x=50, y=50, width=200, height=400 # These dimensions are for the rotated version
)
image.pil_rotation_90 = 1 # 90 degree rotation
image.image_dimensions = (400, 200) # Original dimensions before rotation
@@ -878,7 +851,7 @@ def test_pdf_exporter_rotated_image():
project.add_page(page)
# Export to PDF
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp:
pdf_path = pdf_tmp.name
try:
@@ -908,84 +881,85 @@ def test_pdf_exporter_image_downsampling():
"""Test that export DPI controls image downsampling and reduces file size"""
import tempfile
from PIL import Image as PILImage
project = Project("Test Downsampling")
project.page_size_mm = (210, 297) # A4
project.working_dpi = 96
# Create a large test image (4000x3000 - typical high-res camera)
large_img = PILImage.new('RGB', (4000, 3000))
large_img = PILImage.new("RGB", (4000, 3000))
# Add some pattern so it doesn't compress too much
import random
pixels = large_img.load()
for i in range(0, 4000, 10):
for j in range(0, 3000, 10):
pixels[i, j] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as img_tmp:
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as img_tmp:
img_path = img_tmp.name
large_img.save(img_path)
try:
# Create a page with the large image
page = Page(page_number=1, is_double_spread=False)
# Add image at reasonable size (100mm x 75mm)
image = ImageData(
image_path=img_path,
x=50,
y=50,
width=int(100 * 96 / 25.4), # ~378 px
height=int(75 * 96 / 25.4) # ~283 px
height=int(75 * 96 / 25.4), # ~283 px
)
page.layout.add_element(image)
project.add_page(page)
# Export with high DPI (300 - print quality)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp1:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp1:
pdf_path_300dpi = pdf_tmp1.name
# Export with low DPI (150 - screen quality)
with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as pdf_tmp2:
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as pdf_tmp2:
pdf_path_150dpi = pdf_tmp2.name
try:
# Export at 300 DPI
exporter_300 = PDFExporter(project, export_dpi=300)
success1, warnings1 = exporter_300.export(pdf_path_300dpi)
assert success1, f"300 DPI export failed: {warnings1}"
# Export at 150 DPI
exporter_150 = PDFExporter(project, export_dpi=150)
success2, warnings2 = exporter_150.export(pdf_path_150dpi)
assert success2, f"150 DPI export failed: {warnings2}"
# Check file sizes
size_300dpi = os.path.getsize(pdf_path_300dpi)
size_150dpi = os.path.getsize(pdf_path_150dpi)
print(f"✓ Image downsampling test successful:")
print(f" Original image: 4000x3000 pixels")
print(f" Element size: 100mm x 75mm")
print(f" PDF at 300 DPI: {size_300dpi:,} bytes")
print(f" PDF at 150 DPI: {size_150dpi:,} bytes")
print(f" Size reduction: {(1 - size_150dpi/size_300dpi)*100:.1f}%")
# 150 DPI should be smaller than 300 DPI
assert size_150dpi < size_300dpi, \
f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
assert (
size_150dpi < size_300dpi
), f"150 DPI file ({size_150dpi}) should be smaller than 300 DPI file ({size_300dpi})"
# 150 DPI should be significantly smaller (at least 50% reduction)
reduction_ratio = size_150dpi / size_300dpi
assert reduction_ratio < 0.7, \
f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
assert reduction_ratio < 0.7, f"150 DPI should be at least 30% smaller, got {(1-reduction_ratio)*100:.1f}%"
finally:
if os.path.exists(pdf_path_300dpi):
os.remove(pdf_path_300dpi)
if os.path.exists(pdf_path_150dpi):
os.remove(pdf_path_150dpi)
finally:
if os.path.exists(img_path):
os.remove(img_path)
+45 -51
View File
@@ -15,7 +15,7 @@ class TestPage:
"""Test Page initialization with default values"""
layout = PageLayout()
page = Page(layout=layout, page_number=1)
assert page.layout is layout
assert page.page_number == 1
@@ -23,7 +23,7 @@ class TestPage:
"""Test Page initialization with custom parameters"""
layout = PageLayout()
page = Page(layout=layout, page_number=5)
assert page.layout is layout
assert page.page_number == 5
@@ -32,7 +32,7 @@ class TestPage:
layout = PageLayout()
page = Page(layout=layout, page_number=1)
page.page_number = 10
assert page.page_number == 10
@@ -42,7 +42,7 @@ class TestProject:
def test_initialization_default(self):
"""Test Project initialization with default values"""
project = Project()
assert project.name == "Untitled Project"
assert len(project.pages) == 0
assert project.working_dpi == 300
@@ -51,7 +51,7 @@ class TestProject:
def test_initialization_with_name(self):
"""Test Project initialization with custom name"""
project = Project(name="My Album")
assert project.name == "My Album"
def test_add_page(self):
@@ -59,24 +59,24 @@ class TestProject:
project = Project()
layout = PageLayout()
page = Page(layout=layout, page_number=1)
project.add_page(page)
assert len(project.pages) == 1
assert project.pages[0] is page
def test_add_multiple_pages(self):
"""Test adding multiple pages to the project"""
project = Project()
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
page3 = Page(layout=PageLayout(), page_number=3)
project.add_page(page1)
project.add_page(page2)
project.add_page(page3)
assert len(project.pages) == 3
assert project.pages[0] is page1
assert project.pages[1] is page2
@@ -85,15 +85,15 @@ class TestProject:
def test_remove_page(self):
"""Test removing a page from the project"""
project = Project()
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1)
project.add_page(page2)
project.remove_page(page1)
assert len(project.pages) == 1
assert project.pages[0] is page2
@@ -103,7 +103,7 @@ class TestProject:
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1)
# Try to remove a page that was never added
with pytest.raises(ValueError):
project.remove_page(page2)
@@ -112,51 +112,51 @@ class TestProject:
"""Test modifying working DPI"""
project = Project()
project.working_dpi = 300
assert project.working_dpi == 300
def test_page_size_modification(self):
"""Test modifying page size"""
project = Project()
project.page_size_mm = (300, 400)
assert project.page_size_mm == (300, 400)
def test_project_name_modification(self):
"""Test modifying project name"""
project = Project(name="Initial Name")
project.name = "New Name"
assert project.name == "New Name"
def test_asset_manager_exists(self):
"""Test that project has an asset manager"""
project = Project()
assert hasattr(project, 'asset_manager')
assert hasattr(project, "asset_manager")
assert project.asset_manager is not None
def test_history_exists(self):
"""Test that project has a history manager"""
project = Project()
assert hasattr(project, 'history')
assert hasattr(project, "history")
assert project.history is not None
def test_pages_list_is_mutable(self):
"""Test that pages list can be directly modified"""
project = Project()
page = Page(layout=PageLayout(), page_number=1)
project.pages.append(page)
assert len(project.pages) == 1
assert project.pages[0] is page
def test_empty_project_has_no_pages(self):
"""Test that a new project has no pages"""
project = Project()
assert len(project.pages) == 0
assert project.pages == []
@@ -167,23 +167,17 @@ class TestProjectWithPages:
def test_project_with_populated_pages(self, sample_image_data):
"""Test project with pages containing elements"""
project = Project(name="Photo Album")
# Create pages with elements
for i in range(3):
layout = PageLayout()
img = ImageData(
image_path=f"image_{i}.jpg",
x=10 + i*10,
y=20 + i*10,
width=100,
height=100
)
img = ImageData(image_path=f"image_{i}.jpg", x=10 + i * 10, y=20 + i * 10, width=100, height=100)
layout.add_element(img)
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
project.add_page(page)
assert len(project.pages) == 3
# Check each page has elements
for i, page in enumerate(project.pages):
assert len(page.layout.elements) == 1
@@ -192,18 +186,18 @@ class TestProjectWithPages:
def test_reorder_pages(self):
"""Test reordering pages in project"""
project = Project()
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
page3 = Page(layout=PageLayout(), page_number=3)
project.add_page(page1)
project.add_page(page2)
project.add_page(page3)
# Swap page 1 and page 3
project.pages[0], project.pages[2] = project.pages[2], project.pages[0]
assert project.pages[0] is page3
assert project.pages[1] is page2
assert project.pages[2] is page1
@@ -211,43 +205,43 @@ class TestProjectWithPages:
def test_clear_all_pages(self):
"""Test clearing all pages from project"""
project = Project()
for i in range(5):
page = Page(layout=PageLayout(), page_number=i+1)
page = Page(layout=PageLayout(), page_number=i + 1)
project.add_page(page)
# Clear all pages
project.pages.clear()
assert len(project.pages) == 0
def test_get_page_by_index(self):
"""Test accessing pages by index"""
project = Project()
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
project.add_page(page1)
project.add_page(page2)
assert project.pages[0] is page1
assert project.pages[1] is page2
def test_insert_page_at_position(self):
"""Test inserting a page at a specific position"""
project = Project()
page1 = Page(layout=PageLayout(), page_number=1)
page2 = Page(layout=PageLayout(), page_number=2)
page_new = Page(layout=PageLayout(), page_number=99)
project.add_page(page1)
project.add_page(page2)
# Insert new page in the middle
project.pages.insert(1, page_new)
assert len(project.pages) == 3
assert project.pages[0] is page1
assert project.pages[1] is page_new
+97 -105
View File
@@ -39,9 +39,9 @@ def sample_project(temp_dir):
def sample_image(temp_dir):
"""Create a sample image file for testing"""
from PIL import Image
# Create a simple test image
img = Image.new('RGB', (100, 100), color='red')
img = Image.new("RGB", (100, 100), color="red")
image_path = os.path.join(temp_dir, "test_image.jpg")
img.save(image_path)
return image_path
@@ -49,42 +49,41 @@ def sample_image(temp_dir):
class TestBasicSerialization:
"""Tests for basic save/load functionality"""
def test_save_empty_project(self, sample_project, temp_dir):
"""Test saving an empty project to ZIP"""
zip_path = os.path.join(temp_dir, "empty_project.ppz")
success, error = save_to_zip(sample_project, zip_path)
assert success is True
assert error is None
assert os.path.exists(zip_path)
assert zip_path.endswith('.ppz')
assert zip_path.endswith(".ppz")
def test_save_adds_ppz_extension(self, sample_project, temp_dir):
"""Test that .ppz extension is added automatically"""
zip_path = os.path.join(temp_dir, "project")
success, error = save_to_zip(sample_project, zip_path)
assert success is True
expected_path = zip_path + '.ppz'
expected_path = zip_path + ".ppz"
assert os.path.exists(expected_path)
def test_load_empty_project(self, sample_project, temp_dir):
"""Test loading an empty project from ZIP"""
zip_path = os.path.join(temp_dir, "empty_project.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert loaded_project.name == "Test Project"
assert loaded_project.page_size_mm == (210, 297)
assert loaded_project.working_dpi == 300
assert len(loaded_project.pages) == 0
def test_load_nonexistent_file(self, temp_dir):
"""Test loading from a non-existent file"""
zip_path = os.path.join(temp_dir, "nonexistent.ppz")
@@ -95,34 +94,34 @@ class TestBasicSerialization:
except Exception as error:
assert error is not None
assert "not found" in str(error).lower()
def test_save_project_with_pages(self, sample_project, temp_dir):
"""Test saving a project with multiple pages"""
# Add pages
for i in range(3):
layout = PageLayout()
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
success, error = save_to_zip(sample_project, zip_path)
assert success is True
assert os.path.exists(zip_path)
def test_load_project_with_pages(self, sample_project, temp_dir):
"""Test loading a project with multiple pages"""
# Add pages
for i in range(3):
layout = PageLayout()
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
# Save and load
zip_path = os.path.join(temp_dir, "project_with_pages.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert len(loaded_project.pages) == 3
assert loaded_project.pages[0].page_number == 1
@@ -131,139 +130,139 @@ class TestBasicSerialization:
class TestZipStructure:
"""Tests for ZIP file structure and content"""
def test_zip_contains_project_json(self, sample_project, temp_dir):
"""Test that ZIP contains project.json"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf:
assert 'project.json' in zipf.namelist()
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "project.json" in zipf.namelist()
def test_project_json_is_valid(self, sample_project, temp_dir):
"""Test that project.json contains valid JSON"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json)
assert 'name' in data
assert 'serialization_version' in data
assert data['name'] == "Test Project"
assert "name" in data
assert "serialization_version" in data
assert data["name"] == "Test Project"
def test_version_in_serialized_data(self, sample_project, temp_dir):
"""Test that version information is included"""
zip_path = os.path.join(temp_dir, "test.ppz")
save_to_zip(sample_project, zip_path)
with zipfile.ZipFile(zip_path, 'r') as zipf:
project_json = zipf.read('project.json').decode('utf-8')
with zipfile.ZipFile(zip_path, "r") as zipf:
project_json = zipf.read("project.json").decode("utf-8")
data = json.loads(project_json)
assert 'serialization_version' in data
assert data['serialization_version'] == "3.0"
assert "serialization_version" in data
assert data["serialization_version"] == "3.0"
class TestAssetManagement:
"""Tests for asset bundling and management"""
def test_save_project_with_image(self, sample_project, sample_image, temp_dir):
"""Test saving a project with an image"""
# Import image to project
imported_path = sample_project.asset_manager.import_asset(sample_image)
# Create page with image
layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
# Save
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
success, error = save_to_zip(sample_project, zip_path)
assert success is True
assert os.path.exists(zip_path)
def test_assets_folder_in_zip(self, sample_project, sample_image, temp_dir):
"""Test that assets folder is included in ZIP"""
# Import image
imported_path = sample_project.asset_manager.import_asset(sample_image)
# Create page with image
layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
# Save
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path)
# Check ZIP contents
with zipfile.ZipFile(zip_path, 'r') as zipf:
with zipfile.ZipFile(zip_path, "r") as zipf:
names = zipf.namelist()
# Should contain assets folder
asset_files = [n for n in names if n.startswith('assets/')]
asset_files = [n for n in names if n.startswith("assets/")]
assert len(asset_files) > 0
def test_load_project_with_image(self, sample_project, sample_image, temp_dir):
"""Test loading a project with images"""
# Import image
imported_path = sample_project.asset_manager.import_asset(sample_image)
# Create page with image
layout = PageLayout()
img_data = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
# Save and load
zip_path = os.path.join(temp_dir, "project_with_image.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert len(loaded_project.pages) == 1
assert len(loaded_project.pages[0].layout.elements) == 1
# Verify image element
img_element = loaded_project.pages[0].layout.elements[0]
assert isinstance(img_element, ImageData)
assert img_element.image_path != ""
def test_asset_reference_counts_preserved(self, sample_project, sample_image, temp_dir):
"""Test that asset reference counts are preserved"""
# Import image
imported_path = sample_project.asset_manager.import_asset(sample_image)
# Use image twice
layout1 = PageLayout()
img1 = ImageData(image_path=imported_path, x=10, y=10, width=100, height=100)
layout1.add_element(img1)
page1 = Page(layout=layout1, page_number=1)
sample_project.add_page(page1)
layout2 = PageLayout()
img2 = ImageData(image_path=imported_path, x=20, y=20, width=100, height=100)
layout2.add_element(img2)
page2 = Page(layout=layout2, page_number=2)
sample_project.add_page(page2)
# Get relative path for reference count check
rel_path = os.path.relpath(imported_path, sample_project.folder_path)
original_ref_count = sample_project.asset_manager.get_reference_count(rel_path)
# Save and load
zip_path = os.path.join(temp_dir, "project_refs.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
# Reference counts should be preserved
# Note: The actual reference counting behavior depends on deserialize implementation
@@ -271,7 +270,7 @@ class TestAssetManagement:
class TestPortability:
"""Tests for project portability across different locations"""
def test_load_to_different_directory(self, sample_project, sample_image, temp_dir):
"""Test loading project to a different directory"""
# Import image and create page
@@ -281,23 +280,23 @@ class TestPortability:
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
# Save
zip_path = os.path.join(temp_dir, "portable_project.ppz")
save_to_zip(sample_project, zip_path)
# Load to a different location
new_location = os.path.join(temp_dir, "different_location")
loaded_project = load_from_zip(zip_path, extract_to=new_location)
assert loaded_project is not None
assert loaded_project.folder_path == new_location
assert os.path.exists(new_location)
# Verify assets were extracted
assets_folder = os.path.join(new_location, "assets")
assert os.path.exists(assets_folder)
def test_relative_paths_work_after_move(self, sample_project, sample_image, temp_dir):
"""Test that relative paths still work after loading to different location"""
# Import image
@@ -307,19 +306,19 @@ class TestPortability:
layout.add_element(img_data)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
# Save
zip_path = os.path.join(temp_dir, "portable_project.ppz")
save_to_zip(sample_project, zip_path)
# Load to different location
new_location = os.path.join(temp_dir, "new_location")
loaded_project = load_from_zip(zip_path, extract_to=new_location)
# Verify image path is accessible from new location
img_element = loaded_project.pages[0].layout.elements[0]
image_path = img_element.image_path
# Image path should exist
# Note: May be absolute or relative depending on implementation
if not os.path.isabs(image_path):
@@ -331,77 +330,73 @@ class TestPortability:
class TestProjectInfo:
"""Tests for get_project_info utility function"""
def test_get_project_info(self, sample_project, temp_dir):
"""Test getting project info without loading"""
# Add some pages
for i in range(5):
layout = PageLayout()
page = Page(layout=layout, page_number=i+1)
page = Page(layout=layout, page_number=i + 1)
sample_project.add_page(page)
# Save
zip_path = os.path.join(temp_dir, "info_test.ppz")
save_to_zip(sample_project, zip_path)
# Get info
info = get_project_info(zip_path)
assert info is not None
assert info['name'] == "Test Project"
assert info['page_count'] == 5
assert info['version'] == "3.0"
assert info['working_dpi'] == 300
assert info["name"] == "Test Project"
assert info["page_count"] == 5
assert info["version"] == "3.0"
assert info["working_dpi"] == 300
def test_get_info_invalid_zip(self, temp_dir):
"""Test getting info from invalid ZIP"""
zip_path = os.path.join(temp_dir, "invalid.ppz")
info = get_project_info(zip_path)
assert info is None
class TestEdgeCases:
"""Tests for edge cases and error handling"""
def test_save_to_invalid_path(self, sample_project):
"""Test saving to an invalid path"""
invalid_path = "/nonexistent/directory/project.ppz"
success, error = save_to_zip(sample_project, invalid_path)
assert success is False
assert error is not None
def test_load_corrupted_zip(self, temp_dir):
"""Test loading a corrupted ZIP file"""
# Create a fake corrupted file
corrupted_path = os.path.join(temp_dir, "corrupted.ppz")
with open(corrupted_path, 'w') as f:
with open(corrupted_path, "w") as f:
f.write("This is not a ZIP file")
try:
loaded_project = load_from_zip(corrupted_path)
assert False, "Should have raised an exception"
except Exception as error:
assert error is not None
def test_load_zip_without_project_json(self, temp_dir):
"""Test loading a ZIP without project.json"""
zip_path = os.path.join(temp_dir, "no_json.ppz")
# Create ZIP without project.json
with zipfile.ZipFile(zip_path, 'w') as zipf:
zipf.writestr('dummy.txt', 'dummy content')
with zipfile.ZipFile(zip_path, "w") as zipf:
zipf.writestr("dummy.txt", "dummy content")
try:
loaded_project = load_from_zip(zip_path)
@@ -409,27 +404,24 @@ class TestEdgeCases:
except Exception as error:
assert error is not None
assert "project.json not found" in str(error)
def test_project_with_text_elements(self, sample_project, temp_dir):
"""Test saving/loading project with text elements"""
# Create page with text
layout = PageLayout()
text = TextBoxData(
text_content="Hello World",
x=10, y=10, width=200, height=50
)
text = TextBoxData(text_content="Hello World", x=10, y=10, width=200, height=50)
layout.add_element(text)
page = Page(layout=layout, page_number=1)
sample_project.add_page(page)
# Save and load
zip_path = os.path.join(temp_dir, "with_text.ppz")
save_to_zip(sample_project, zip_path)
loaded_project = load_from_zip(zip_path)
assert loaded_project is not None
assert len(loaded_project.pages) == 1
text_element = loaded_project.pages[0].layout.elements[0]
assert isinstance(text_element, TextBoxData)
assert text_element.text_content == "Hello World"
+432
View File
@@ -0,0 +1,432 @@
"""
Comprehensive tests for project_serializer module
"""
import pytest
import os
import json
import zipfile
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.project_serializer import (
save_to_zip,
load_from_zip,
get_project_info,
_normalize_asset_paths,
_import_external_images,
SERIALIZATION_VERSION,
)
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData
class TestSaveToZip:
"""Tests for save_to_zip function"""
def test_save_to_zip_basic(self, tmp_path):
"""Test basic project saving to zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
success, error = save_to_zip(project, zip_path)
assert success is True
assert error is None
assert os.path.exists(zip_path)
def test_save_to_zip_adds_extension(self, tmp_path):
"""Test that .ppz extension is added if missing"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project") # No extension
success, error = save_to_zip(project, zip_path)
assert success is True
assert os.path.exists(zip_path + ".ppz")
def test_save_to_zip_includes_project_json(self, tmp_path):
"""Test that saved zip contains project.json"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "project.json" in zipf.namelist()
project_data = json.loads(zipf.read("project.json"))
assert project_data["name"] == "TestProject"
assert "data_version" in project_data
def test_save_to_zip_includes_assets(self, tmp_path):
"""Test that saved zip includes asset files"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Create a dummy asset file
asset_file = assets_folder / "image.jpg"
asset_file.write_bytes(b"fake image data")
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "assets/image.jpg" in zipf.namelist()
def test_save_to_zip_handles_error(self, tmp_path):
"""Test error handling during save"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
# Try to save to an invalid path
zip_path = "/nonexistent/directory/test.ppz"
success, error = save_to_zip(project, zip_path)
assert success is False
assert error is not None
assert "Error saving" in error
class TestLoadFromZip:
"""Tests for load_from_zip function"""
def test_load_from_zip_basic(self, tmp_path):
"""Test basic project loading from zip"""
# First create a valid project zip
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="LoadTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Now load it
extract_to = str(tmp_path / "extracted")
loaded_project = load_from_zip(zip_path, extract_to)
assert loaded_project.name == "LoadTest"
assert loaded_project.folder_path == extract_to
def test_load_from_zip_creates_temp_dir(self, tmp_path):
"""Test that loading creates a temp directory when none specified"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TempTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Load without specifying extraction directory
loaded_project = load_from_zip(zip_path)
assert loaded_project.name == "TempTest"
assert loaded_project.folder_path is not None
assert os.path.exists(loaded_project.folder_path)
# Should have a _temp_dir attribute
assert hasattr(loaded_project, "_temp_dir")
def test_load_from_zip_file_not_found(self, tmp_path):
"""Test loading from nonexistent file"""
with pytest.raises(FileNotFoundError):
load_from_zip(str(tmp_path / "nonexistent.ppz"))
def test_load_from_zip_invalid_zip(self, tmp_path):
"""Test loading from invalid zip file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip file")
with pytest.raises(Exception):
load_from_zip(str(invalid_file))
def test_load_from_zip_missing_project_json(self, tmp_path):
"""Test loading from zip without project.json"""
zip_path = tmp_path / "no_project.ppz"
# Create zip without project.json
with zipfile.ZipFile(str(zip_path), "w") as zipf:
zipf.writestr("other_file.txt", "some content")
with pytest.raises(ValueError) as exc_info:
load_from_zip(str(zip_path))
assert "project.json not found" in str(exc_info.value)
class TestGetProjectInfo:
"""Tests for get_project_info function"""
def test_get_project_info_basic(self, tmp_path):
"""Test getting project info from zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="InfoTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
info = get_project_info(zip_path)
assert info is not None
assert info["name"] == "InfoTest"
assert "version" in info
assert "page_count" in info
assert "page_size_mm" in info
assert "working_dpi" in info
def test_get_project_info_invalid_file(self, tmp_path):
"""Test getting info from invalid file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip")
info = get_project_info(str(invalid_file))
assert info is None
def test_get_project_info_nonexistent_file(self, tmp_path):
"""Test getting info from nonexistent file"""
info = get_project_info(str(tmp_path / "nonexistent.ppz"))
assert info is None
class TestNormalizeAssetPaths:
"""Tests for _normalize_asset_paths function"""
def test_normalize_relative_path_unchanged(self, tmp_path):
"""Test that simple relative paths are unchanged"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
# Add a page with an image that has a simple relative path
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be unchanged
assert img.image_path == "assets/image.jpg"
def test_normalize_absolute_path(self, tmp_path):
"""Test that absolute paths are normalized"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Use a path that contains /assets/ pattern
abs_path = str(project_folder / "assets" / "image.jpg")
img = ImageData(image_path=abs_path)
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be normalized to relative
assert img.image_path == "assets/image.jpg"
def test_normalize_legacy_path(self, tmp_path):
"""Test normalizing legacy project path format"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Legacy path format
img = ImageData(image_path="./projects/old_project/assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Should extract just the assets/filename part
assert img.image_path == "assets/image.jpg"
class TestImportExternalImages:
"""Tests for _import_external_images function"""
def test_import_external_images_no_external(self, tmp_path):
"""Test with no external images"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/existing.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
# Should not raise and not change path
_import_external_images(project)
assert img.image_path == "assets/existing.jpg"
class TestRoundTrip:
"""Test save and load roundtrip"""
def test_roundtrip_basic(self, tmp_path):
"""Test saving and loading a project"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="RoundTrip", folder_path=str(project_folder))
original.working_dpi = 150
zip_path = str(tmp_path / "roundtrip.ppz")
success, _ = save_to_zip(original, zip_path)
assert success
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert loaded.name == original.name
assert loaded.working_dpi == original.working_dpi
def test_roundtrip_with_pages(self, tmp_path):
"""Test roundtrip with pages"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="WithPages", folder_path=str(project_folder))
# Project starts with 1 page, add more using create_page
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
page2 = Page(PageLayout(width=210, height=297))
page3 = Page(PageLayout(width=210, height=297))
original.add_page(page2)
original.add_page(page3)
zip_path = str(tmp_path / "pages.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
# Pages are preserved (Project might not start with a default page)
assert len(loaded.pages) >= 2
def test_roundtrip_with_elements(self, tmp_path, temp_image_file):
"""Test roundtrip with elements on page"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Copy temp image to assets
shutil.copy(temp_image_file, assets_folder / "test.jpg")
original = Project(name="WithElements", folder_path=str(project_folder))
# Add element to first page (project starts with at least 1 page)
img = ImageData(image_path="assets/test.jpg", x=50, y=50, width=100, height=100)
# Check if there's a default page, add one if needed
if not original.pages:
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
original.add_page(Page(PageLayout(width=210, height=297)))
original.pages[0].layout.add_element(img)
zip_path = str(tmp_path / "elements.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert len(loaded.pages) >= 1
assert len(loaded.pages[0].layout.elements) >= 1
loaded_elem = loaded.pages[0].layout.elements[0]
assert loaded_elem.position == (50.0, 50.0)
assert loaded_elem.size == (100.0, 100.0)
class TestVersionCompatibility:
"""Tests for version handling"""
def test_version_included_in_save(self, tmp_path):
"""Test that version is included when saving"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Version", folder_path=str(project_folder))
zip_path = str(tmp_path / "version.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
data = json.loads(zipf.read("project.json"))
# Should have both legacy and new version fields
assert "serialization_version" in data
assert "data_version" in data
+634
View File
@@ -0,0 +1,634 @@
"""
Tests for ribbon_builder module
"""
import pytest
from io import StringIO
from unittest.mock import Mock, patch
from pyPhotoAlbum.ribbon_builder import (
build_ribbon_config,
get_keyboard_shortcuts,
validate_ribbon_config,
print_ribbon_summary,
)
class TestBuildRibbonConfig:
"""Tests for build_ribbon_config function"""
def test_empty_class(self):
"""Test with a class that has no ribbon actions"""
class EmptyClass:
pass
config = build_ribbon_config(EmptyClass)
assert config == {}
def test_single_action(self):
"""Test with a class that has one ribbon action"""
class SingleAction:
def my_action(self):
pass
my_action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "My Action",
"action": "my_action",
"tooltip": "Does something",
}
config = build_ribbon_config(SingleAction)
assert "Home" in config
assert len(config["Home"]["groups"]) == 1
assert config["Home"]["groups"][0]["name"] == "File"
assert len(config["Home"]["groups"][0]["actions"]) == 1
assert config["Home"]["groups"][0]["actions"][0]["label"] == "My Action"
def test_multiple_actions_same_group(self):
"""Test with multiple actions in the same group"""
class MultiAction:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Action 1",
"action": "action1",
"tooltip": "First action",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Action 2",
"action": "action2",
"tooltip": "Second action",
}
config = build_ribbon_config(MultiAction)
assert "Home" in config
assert len(config["Home"]["groups"]) == 1
assert config["Home"]["groups"][0]["name"] == "Edit"
assert len(config["Home"]["groups"][0]["actions"]) == 2
def test_multiple_groups(self):
"""Test with actions in different groups"""
class MultiGroup:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "File Action",
"action": "action1",
"tooltip": "File stuff",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Edit Action",
"action": "action2",
"tooltip": "Edit stuff",
}
config = build_ribbon_config(MultiGroup)
assert "Home" in config
assert len(config["Home"]["groups"]) == 2
group_names = [g["name"] for g in config["Home"]["groups"]]
assert "File" in group_names
assert "Edit" in group_names
def test_multiple_tabs(self):
"""Test with actions in different tabs"""
class MultiTab:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Home Action",
"action": "action1",
"tooltip": "Home stuff",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "View",
"group": "Zoom",
"label": "View Action",
"action": "action2",
"tooltip": "View stuff",
}
config = build_ribbon_config(MultiTab)
assert "Home" in config
assert "View" in config
def test_tab_ordering(self):
"""Test that tabs are ordered correctly"""
class OrderedTabs:
def action1(self):
pass
action1._ribbon_action = {
"tab": "Export",
"group": "Export",
"label": "Export",
"action": "action1",
"tooltip": "Export",
}
def action2(self):
pass
action2._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Home",
"action": "action2",
"tooltip": "Home",
}
def action3(self):
pass
action3._ribbon_action = {
"tab": "View",
"group": "Zoom",
"label": "View",
"action": "action3",
"tooltip": "View",
}
config = build_ribbon_config(OrderedTabs)
tab_names = list(config.keys())
# Home should come before View, View before Export
assert tab_names.index("Home") < tab_names.index("View")
assert tab_names.index("View") < tab_names.index("Export")
def test_action_with_optional_fields(self):
"""Test action with optional icon and shortcut"""
class WithOptional:
def action(self):
pass
action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save project",
"icon": "save.png",
"shortcut": "Ctrl+S",
}
config = build_ribbon_config(WithOptional)
action = config["Home"]["groups"][0]["actions"][0]
assert action["icon"] == "save.png"
assert action["shortcut"] == "Ctrl+S"
def test_action_without_optional_fields(self):
"""Test action without optional icon and shortcut"""
class WithoutOptional:
def action(self):
pass
action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Action",
"action": "action",
"tooltip": "Does stuff",
}
config = build_ribbon_config(WithoutOptional)
action = config["Home"]["groups"][0]["actions"][0]
assert action.get("icon") is None
assert action.get("shortcut") is None
def test_custom_tab_not_in_order(self):
"""Test custom tab not in predefined order"""
class CustomTab:
def action(self):
pass
action._ribbon_action = {
"tab": "CustomTab",
"group": "CustomGroup",
"label": "Custom",
"action": "action",
"tooltip": "Custom action",
}
config = build_ribbon_config(CustomTab)
assert "CustomTab" in config
def test_inherited_actions(self):
"""Test that actions from parent classes are included"""
class BaseClass:
def base_action(self):
pass
base_action._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Base Action",
"action": "base_action",
"tooltip": "From base",
}
class DerivedClass(BaseClass):
def derived_action(self):
pass
derived_action._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Derived Action",
"action": "derived_action",
"tooltip": "From derived",
}
config = build_ribbon_config(DerivedClass)
# Should have both actions
all_actions = []
for group in config["Home"]["groups"]:
all_actions.extend(group["actions"])
action_names = [a["action"] for a in all_actions]
assert "base_action" in action_names
assert "derived_action" in action_names
class TestGetKeyboardShortcuts:
"""Tests for get_keyboard_shortcuts function"""
def test_empty_class(self):
"""Test with a class that has no shortcuts"""
class NoShortcuts:
pass
shortcuts = get_keyboard_shortcuts(NoShortcuts)
assert shortcuts == {}
def test_single_shortcut(self):
"""Test with a single shortcut"""
class SingleShortcut:
def save(self):
pass
save._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
shortcuts = get_keyboard_shortcuts(SingleShortcut)
assert "Ctrl+S" in shortcuts
assert shortcuts["Ctrl+S"] == "save"
def test_multiple_shortcuts(self):
"""Test with multiple shortcuts"""
class MultiShortcut:
def save(self):
pass
save._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
def undo(self):
pass
undo._ribbon_action = {
"tab": "Home",
"group": "Edit",
"label": "Undo",
"action": "undo",
"tooltip": "Undo",
"shortcut": "Ctrl+Z",
}
shortcuts = get_keyboard_shortcuts(MultiShortcut)
assert len(shortcuts) == 2
assert shortcuts["Ctrl+S"] == "save"
assert shortcuts["Ctrl+Z"] == "undo"
def test_action_without_shortcut_ignored(self):
"""Test that actions without shortcuts are not included"""
class MixedShortcuts:
def with_shortcut(self):
pass
with_shortcut._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "With",
"action": "with_shortcut",
"tooltip": "Has shortcut",
"shortcut": "Ctrl+W",
}
def without_shortcut(self):
pass
without_shortcut._ribbon_action = {
"tab": "Home",
"group": "File",
"label": "Without",
"action": "without_shortcut",
"tooltip": "No shortcut",
}
shortcuts = get_keyboard_shortcuts(MixedShortcuts)
assert len(shortcuts) == 1
assert "Ctrl+W" in shortcuts
class TestValidateRibbonConfig:
"""Tests for validate_ribbon_config function"""
def test_valid_config(self):
"""Test with a valid configuration"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save project",
}
],
}
]
}
}
errors = validate_ribbon_config(config)
assert errors == []
def test_empty_config(self):
"""Test with empty config"""
errors = validate_ribbon_config({})
assert errors == []
def test_config_not_dict(self):
"""Test with non-dict config"""
errors = validate_ribbon_config("not a dict")
assert len(errors) == 1
assert "must be a dictionary" in errors[0]
def test_tab_data_not_dict(self):
"""Test with tab data that is not a dict"""
config = {"Home": "not a dict"}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "Tab 'Home' data must be a dictionary" in errors[0]
def test_missing_groups_key(self):
"""Test with missing 'groups' key"""
config = {"Home": {"other_key": []}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "missing 'groups' key" in errors[0]
def test_groups_not_list(self):
"""Test with groups that is not a list"""
config = {"Home": {"groups": "not a list"}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "groups must be a list" in errors[0]
def test_group_not_dict(self):
"""Test with group that is not a dict"""
config = {"Home": {"groups": ["not a dict"]}}
errors = validate_ribbon_config(config)
assert len(errors) == 1
assert "group 0 must be a dictionary" in errors[0]
def test_group_missing_name(self):
"""Test with group missing name"""
config = {"Home": {"groups": [{"actions": []}]}}
errors = validate_ribbon_config(config)
assert any("missing 'name'" in e for e in errors)
def test_group_missing_actions(self):
"""Test with group missing actions"""
config = {"Home": {"groups": [{"name": "File"}]}}
errors = validate_ribbon_config(config)
assert any("missing 'actions'" in e for e in errors)
def test_actions_not_list(self):
"""Test with actions that is not a list"""
config = {"Home": {"groups": [{"name": "File", "actions": "not a list"}]}}
errors = validate_ribbon_config(config)
assert any("actions must be a list" in e for e in errors)
def test_action_not_dict(self):
"""Test with action that is not a dict"""
config = {"Home": {"groups": [{"name": "File", "actions": ["not a dict"]}]}}
errors = validate_ribbon_config(config)
assert any("action 0 must be a dictionary" in e for e in errors)
def test_action_missing_required_keys(self):
"""Test with action missing required keys"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save"
# missing 'action' and 'tooltip'
}
],
}
]
}
}
errors = validate_ribbon_config(config)
assert any("missing 'action'" in e for e in errors)
assert any("missing 'tooltip'" in e for e in errors)
def test_multiple_errors(self):
"""Test that multiple errors are collected"""
config = {
"Tab1": {"groups": [{"name": "Group1", "actions": [{"label": "A"}]}]}, # missing action and tooltip
"Tab2": {"groups": "not a list"},
}
errors = validate_ribbon_config(config)
assert len(errors) >= 3 # At least: missing action, missing tooltip, groups not list
class TestPrintRibbonSummary:
"""Tests for print_ribbon_summary function"""
def test_print_empty_config(self):
"""Test printing empty config"""
config = {}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 0" in output
assert "Total Groups: 0" in output
assert "Total Actions: 0" in output
def test_print_single_tab(self):
"""Test printing single tab config"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save",
}
],
}
]
}
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 1" in output
assert "Total Groups: 1" in output
assert "Total Actions: 1" in output
assert "Home" in output
assert "File" in output
assert "Save" in output
def test_print_with_shortcuts(self):
"""Test printing actions with shortcuts"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{
"label": "Save",
"action": "save",
"tooltip": "Save",
"shortcut": "Ctrl+S",
}
],
}
]
}
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "(Ctrl+S)" in output
def test_print_multiple_tabs_and_groups(self):
"""Test printing config with multiple tabs and groups"""
config = {
"Home": {
"groups": [
{
"name": "File",
"actions": [
{"label": "New", "action": "new", "tooltip": "New"},
{"label": "Open", "action": "open", "tooltip": "Open"},
],
},
{
"name": "Edit",
"actions": [
{"label": "Undo", "action": "undo", "tooltip": "Undo"},
],
},
]
},
"View": {
"groups": [
{
"name": "Zoom",
"actions": [
{"label": "Zoom In", "action": "zoom_in", "tooltip": "Zoom In"},
],
}
]
},
}
with patch("sys.stdout", new_callable=StringIO) as mock_stdout:
print_ribbon_summary(config)
output = mock_stdout.getvalue()
assert "Total Tabs: 2" in output
assert "Total Groups: 3" in output
assert "Total Actions: 4" in output
+402
View File
@@ -0,0 +1,402 @@
"""
Tests for ribbon_widget module
"""
import pytest
from unittest.mock import Mock, MagicMock, patch
class TestRibbonWidgetInit:
"""Tests for RibbonWidget initialization"""
def test_init_with_custom_config(self, qtbot):
"""Test initialization with custom ribbon config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {
"File": {
"groups": [
{"name": "Project", "actions": [{"label": "New", "action": "new_project", "tooltip": "Create new"}]}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.main_window == mock_main_window
assert widget.ribbon_config == config
assert widget.buttons_per_row == 4 # default
def test_init_with_custom_buttons_per_row(self, qtbot):
"""Test initialization with custom buttons_per_row"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config, buttons_per_row=6)
qtbot.addWidget(widget)
assert widget.buttons_per_row == 6
def test_init_creates_tab_widget(self, qtbot):
"""Test that initialization creates a tab widget"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Tab1": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget is not None
assert widget.tab_widget.count() == 1
class TestBuildRibbon:
"""Tests for _build_ribbon method"""
def test_build_ribbon_creates_tabs(self, qtbot):
"""Test that _build_ribbon creates tabs from config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"File": {"groups": []}, "Edit": {"groups": []}, "View": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget.count() == 3
# Tab names should be present
tab_names = [widget.tab_widget.tabText(i) for i in range(widget.tab_widget.count())]
assert "File" in tab_names
assert "Edit" in tab_names
assert "View" in tab_names
def test_build_ribbon_empty_config(self, qtbot):
"""Test _build_ribbon with empty config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
assert widget.tab_widget.count() == 0
class TestCreateTab:
"""Tests for _create_tab method"""
def test_create_tab_with_groups(self, qtbot):
"""Test tab creation with groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Group1", "actions": []}, {"name": "Group2", "actions": []}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Get the tab widget content
tab_content = widget.tab_widget.widget(0)
assert tab_content is not None
def test_create_tab_empty_groups(self, qtbot):
"""Test tab creation with no groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
assert tab_content is not None
class TestCreateGroup:
"""Tests for _create_group method"""
def test_create_group_with_actions(self, qtbot):
"""Test group creation with action buttons"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{
"name": "Actions",
"actions": [
{"label": "Action1", "action": "do_action1"},
{"label": "Action2", "action": "do_action2"},
],
}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
# Find buttons in the tab
buttons = tab_content.findChildren(QPushButton)
assert len(buttons) == 2
button_labels = [btn.text() for btn in buttons]
assert "Action1" in button_labels
assert "Action2" in button_labels
def test_create_group_respects_buttons_per_row(self, qtbot):
"""Test that group respects buttons_per_row from config"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{
"name": "Grid",
"buttons_per_row": 2,
"actions": [
{"label": "A", "action": "a"},
{"label": "B", "action": "b"},
{"label": "C", "action": "c"},
{"label": "D", "action": "d"},
],
}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
buttons = tab_content.findChildren(QPushButton)
assert len(buttons) == 4
class TestCreateActionButton:
"""Tests for _create_action_button method"""
def test_button_has_correct_label(self, qtbot):
"""Test that button has correct label"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "My Button", "action": "my_action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert len(buttons) == 1
assert buttons[0].text() == "My Button"
def test_button_has_tooltip(self, qtbot):
"""Test that button has correct tooltip"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"Test": {
"groups": [
{"name": "Test", "actions": [{"label": "Button", "action": "action", "tooltip": "My tooltip"}]}
]
}
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].toolTip() == "My tooltip"
def test_button_without_tooltip(self, qtbot):
"""Test button without tooltip configured"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Button", "action": "action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].toolTip() == ""
def test_button_minimum_size(self, qtbot):
"""Test that button has minimum size set"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Button", "action": "action"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert buttons[0].minimumWidth() == 60
assert buttons[0].minimumHeight() == 40
class TestExecuteAction:
"""Tests for _execute_action method"""
def test_execute_action_calls_main_window_method(self, qtbot):
"""Test that _execute_action calls the method on main_window"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
mock_main_window.my_action = Mock()
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
widget._execute_action("my_action")
mock_main_window.my_action.assert_called_once()
def test_execute_action_missing_method_prints_warning(self, qtbot, capsys):
"""Test that _execute_action prints warning for missing method"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock(spec=[]) # No methods
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
widget._execute_action("nonexistent_action")
captured = capsys.readouterr()
assert "Warning" in captured.out
assert "nonexistent_action" in captured.out
def test_execute_action_non_callable_not_called(self, qtbot):
"""Test that non-callable attributes are not called"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
mock_main_window = Mock()
mock_main_window.not_a_method = "just a string"
config = {"Test": {"groups": []}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Should not raise
widget._execute_action("not_a_method")
def test_button_click_executes_action(self, qtbot):
"""Test that clicking a button executes the action"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
mock_main_window.do_something = Mock()
config = {"Test": {"groups": [{"name": "Test", "actions": [{"label": "Do It", "action": "do_something"}]}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Find the button and click it
buttons = widget.tab_widget.widget(0).findChildren(QPushButton)
assert len(buttons) == 1
qtbot.mouseClick(buttons[0], Qt.MouseButton.LeftButton)
mock_main_window.do_something.assert_called_once()
class TestGroupLabel:
"""Tests for group label creation"""
def test_group_has_label(self, qtbot):
"""Test that group has a label"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QLabel
mock_main_window = Mock()
config = {"Test": {"groups": [{"name": "My Group", "actions": []}]}}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
tab_content = widget.tab_widget.widget(0)
labels = tab_content.findChildren(QLabel)
# Should have at least one label with the group name
label_texts = [lbl.text() for lbl in labels]
assert "My Group" in label_texts
class TestRibbonLayoutIntegration:
"""Integration tests for ribbon layout"""
def test_full_ribbon_structure(self, qtbot):
"""Test complete ribbon structure with multiple tabs and groups"""
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from PyQt6.QtWidgets import QPushButton
mock_main_window = Mock()
config = {
"File": {
"groups": [
{
"name": "Project",
"actions": [
{"label": "New", "action": "new_project"},
{"label": "Open", "action": "open_project"},
{"label": "Save", "action": "save_project"},
],
},
{"name": "Export", "actions": [{"label": "Export PDF", "action": "export_pdf"}]},
]
},
"Edit": {
"groups": [
{
"name": "Clipboard",
"actions": [{"label": "Copy", "action": "copy"}, {"label": "Paste", "action": "paste"}],
}
]
},
}
widget = RibbonWidget(mock_main_window, ribbon_config=config)
qtbot.addWidget(widget)
# Check tabs
assert widget.tab_widget.count() == 2
# Check File tab has 4 buttons
file_tab = widget.tab_widget.widget(0)
file_buttons = file_tab.findChildren(QPushButton)
assert len(file_buttons) == 4
# Check Edit tab has 2 buttons
edit_tab = widget.tab_widget.widget(1)
edit_buttons = edit_tab.findChildren(QPushButton)
assert len(edit_buttons) == 2
# Import Qt for click simulation
from PyQt6.QtCore import Qt
+8 -7
View File
@@ -19,7 +19,7 @@ class TestRotationSerialization:
def sample_image(self):
"""Create a sample test image"""
# Create a 400x200 test image (wider than tall)
img = Image.new('RGBA', (400, 200), color=(255, 0, 0, 255))
img = Image.new("RGBA", (400, 200), color=(255, 0, 0, 255))
return img
def test_serialize_rotation_metadata(self):
@@ -47,7 +47,7 @@ class TestRotationSerialization:
"image_path": "test.jpg",
"crop_info": (0, 0, 1, 1),
"pil_rotation_90": 1,
"image_dimensions": (400, 200)
"image_dimensions": (400, 200),
}
img_data = ImageData()
@@ -71,8 +71,10 @@ class TestRotationSerialization:
img_data._on_async_image_loaded(sample_image)
# Verify dimensions are updated to rotated dimensions
assert img_data.image_dimensions == (200, 400), \
f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}"
assert img_data.image_dimensions == (
200,
400,
), f"Expected rotated dimensions (200, 400), got {img_data.image_dimensions}"
assert img_data._img_width == 200
assert img_data._img_height == 400
@@ -143,7 +145,7 @@ class TestRotationSerialization:
"image_path": "test.jpg",
"crop_info": (0, 0, 1, 1),
"pil_rotation_90": 0, # Not set in old format
"image_dimensions": (400, 200)
"image_dimensions": (400, 200),
}
img_data = ImageData()
@@ -182,7 +184,6 @@ class TestRotationSerialization:
img2._on_async_image_loaded(sample_image)
# Verify dimensions are STILL correct after reload
assert img2.image_dimensions == (200, 400), \
"Dimensions should remain correct after reload"
assert img2.image_dimensions == (200, 400), "Dimensions should remain correct after reload"
assert img2._img_width == 200
assert img2._img_height == 400
+14 -26
View File
@@ -30,7 +30,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
return len(self.gl_widget.selected_elements) >= min_count
def get_current_page(self):
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -47,7 +47,7 @@ class TestSizeWindow(SizeOperationsMixin, QMainWindow):
class TestMakeSameSize:
"""Test make_same_size method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_size_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -57,10 +57,7 @@ class TestMakeSameSize:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_size.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_size()
@@ -84,7 +81,7 @@ class TestMakeSameSize:
class TestMakeSameWidth:
"""Test make_same_width method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_width_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -94,10 +91,7 @@ class TestMakeSameWidth:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_width.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_width.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_width()
@@ -109,7 +103,7 @@ class TestMakeSameWidth:
class TestMakeSameHeight:
"""Test make_same_height method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_make_same_height_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -119,10 +113,7 @@ class TestMakeSameHeight:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_height.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_height.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
window.make_same_height()
@@ -134,7 +125,7 @@ class TestMakeSameHeight:
class TestFitToWidth:
"""Test fit_to_width method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_width_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -174,7 +165,7 @@ class TestFitToWidth:
class TestFitToHeight:
"""Test fit_to_height method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_height_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -199,7 +190,7 @@ class TestFitToHeight:
class TestFitToPage:
"""Test fit_to_page method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_to_page_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -238,7 +229,7 @@ class TestFitToPage:
class TestSizeCommandPattern:
"""Test size operations with command pattern"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_size_operation_creates_command(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -248,10 +239,7 @@ class TestSizeCommandPattern:
window.gl_widget.selected_elements = {element1, element2}
mock_manager.make_same_size.return_value = [
(element1, (0, 0), (100, 100)),
(element2, (150, 0), (200, 150))
]
mock_manager.make_same_size.return_value = [(element1, (0, 0), (100, 100)), (element2, (150, 0), (200, 150))]
assert not window.project.history.can_undo()
@@ -259,7 +247,7 @@ class TestSizeCommandPattern:
assert window.project.history.can_undo()
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_fit_operation_creates_command(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
@@ -284,7 +272,7 @@ class TestSizeCommandPattern:
class TestExpandImage:
"""Test expand_image method"""
@patch('pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager')
@patch("pyPhotoAlbum.mixins.operations.size_ops.AlignmentManager")
def test_expand_image_success(self, mock_manager, qtbot):
window = TestSizeWindow()
qtbot.addWidget(window)
+100 -98
View File
@@ -11,25 +11,25 @@ class TestGuide:
def test_guide_initialization(self):
"""Test Guide initialization"""
guide = Guide(position=50.0, orientation='vertical')
guide = Guide(position=50.0, orientation="vertical")
assert guide.position == 50.0
assert guide.orientation == 'vertical'
assert guide.orientation == "vertical"
def test_guide_serialization(self):
"""Test Guide serialization to dictionary"""
guide = Guide(position=75.5, orientation='horizontal')
guide = Guide(position=75.5, orientation="horizontal")
data = guide.serialize()
assert data['position'] == 75.5
assert data['orientation'] == 'horizontal'
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'}
data = {"position": 100.0, "orientation": "vertical"}
guide = Guide.deserialize(data)
assert guide.position == 100.0
assert guide.orientation == 'vertical'
assert guide.orientation == "vertical"
def test_guide_deserialization_with_defaults(self):
"""Test Guide deserialization with missing fields uses defaults"""
@@ -37,7 +37,7 @@ class TestGuide:
guide = Guide.deserialize(data)
assert guide.position == 0
assert guide.orientation == 'vertical'
assert guide.orientation == "vertical"
class TestSnappingSystem:
@@ -46,7 +46,7 @@ class TestSnappingSystem:
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
@@ -62,19 +62,19 @@ class TestSnappingSystem:
def test_add_guide(self):
"""Test adding a guide"""
system = SnappingSystem()
guide = system.add_guide(position=50.0, orientation='vertical')
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.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')
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
@@ -84,8 +84,8 @@ class TestSnappingSystem:
def test_remove_guide(self):
"""Test removing a guide"""
system = SnappingSystem()
guide = system.add_guide(position=50.0, orientation='vertical')
guide = system.add_guide(position=50.0, orientation="vertical")
system.remove_guide(guide)
assert len(system.guides) == 0
assert guide not in system.guides
@@ -93,9 +93,9 @@ class TestSnappingSystem:
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')
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
@@ -104,9 +104,9 @@ class TestSnappingSystem:
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.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
@@ -151,13 +151,13 @@ class TestSnappingSystem:
# 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
@@ -173,8 +173,8 @@ class TestSnappingSystem:
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')
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)
@@ -182,7 +182,7 @@ class TestSnappingSystem:
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
@@ -203,6 +203,7 @@ class TestSnappingSystem:
def test_snap_resize_bottom_right_handle(self):
"""Test snap_resize with bottom-right handle"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_grid = True
system.grid_size_mm = 10.0
@@ -211,17 +212,11 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = 10.0
dy = 10.0
resize_handle = 'se'
resize_handle = "se"
page_size = (210.0, 297.0)
params = SnapResizeParams(
position=position,
size=size,
dx=dx,
dy=dy,
resize_handle=resize_handle,
page_size=page_size,
dpi=300
position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
@@ -234,6 +229,7 @@ class TestSnappingSystem:
def test_snap_resize_top_left_handle(self):
"""Test snap_resize with top-left handle"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@@ -241,11 +237,12 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = -10.0
dy = -10.0
resize_handle = 'nw'
resize_handle = "nw"
page_size = (210.0, 297.0)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
params = SnapResizeParams(
position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
# Both position and size should change for top-left handle
@@ -255,6 +252,7 @@ class TestSnappingSystem:
def test_snap_resize_top_handle(self):
"""Test snap_resize with top handle only"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@@ -262,11 +260,12 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = 0.0
dy = -10.0
resize_handle = 'n'
resize_handle = "n"
page_size = (210.0, 297.0)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
params = SnapResizeParams(
position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
# X position should stay same, Y should change
@@ -279,6 +278,7 @@ class TestSnappingSystem:
def test_snap_resize_right_handle(self):
"""Test snap_resize with right handle only"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = True
@@ -286,11 +286,12 @@ class TestSnappingSystem:
size = (200.0, 200.0)
dx = 10.0
dy = 0.0
resize_handle = 'e'
resize_handle = "e"
page_size = (210.0, 297.0)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
params = SnapResizeParams(
position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
# Position should stay same
@@ -302,6 +303,7 @@ class TestSnappingSystem:
def test_snap_resize_minimum_size(self):
"""Test snap_resize enforces minimum size"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
@@ -309,11 +311,12 @@ class TestSnappingSystem:
size = (50.0, 50.0)
dx = -100.0 # Try to make it very small
dy = -100.0
resize_handle = 'se'
resize_handle = "se"
page_size = (210.0, 297.0)
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=resize_handle, page_size=page_size, dpi=300)
params = SnapResizeParams(
position=position, size=size, dx=dx, dy=dy, resize_handle=resize_handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
# Should enforce minimum size of 10 pixels
@@ -323,6 +326,7 @@ class TestSnappingSystem:
def test_snap_resize_all_handles(self):
"""Test snap_resize works with all handle types"""
from pyPhotoAlbum.snapping import SnapResizeParams
system = SnappingSystem(snap_threshold_mm=5.0)
system.snap_to_edges = False
@@ -332,11 +336,12 @@ class TestSnappingSystem:
dy = 10.0
page_size = (210.0, 297.0)
handles = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"]
for handle in handles:
params = SnapResizeParams(position=position, size=size, dx=dx, dy=dy,
resize_handle=handle, page_size=page_size, dpi=300)
params = SnapResizeParams(
position=position, size=size, dx=dx, dy=dy, resize_handle=handle, page_size=page_size, dpi=300
)
new_pos, new_size = system.snap_resize(params)
# Should return valid position and size
assert isinstance(new_pos, tuple)
@@ -356,9 +361,9 @@ class TestSnappingSystem:
page_size = (210.0, 297.0)
lines = system.get_snap_lines(page_size, dpi=300)
assert lines['grid'] == []
assert lines['edges'] == []
assert lines['guides'] == []
assert lines["grid"] == []
assert lines["edges"] == []
assert lines["guides"] == []
def test_get_snap_lines_with_grid(self):
"""Test get_snap_lines with grid enabled"""
@@ -370,11 +375,11 @@ class TestSnappingSystem:
lines = system.get_snap_lines(page_size, dpi=300)
# Should have grid lines
assert len(lines['grid']) > 0
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']
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
@@ -387,34 +392,34 @@ class TestSnappingSystem:
lines = system.get_snap_lines(page_size, dpi=300)
# Should have exactly 4 edge lines (left, right, top, bottom)
assert len(lines['edges']) == 4
assert len(lines["edges"]) == 4
# Check for vertical edges
vertical_edges = [line for line in lines['edges'] if line[0] == 'vertical']
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']
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')
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
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']
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
@@ -425,35 +430,32 @@ class TestSnappingSystem:
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')
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
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'}
]
"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
@@ -463,15 +465,15 @@ class TestSnappingSystem:
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[0].orientation == "vertical"
assert system.guides[1].position == 100.0
assert system.guides[1].orientation == 'horizontal'
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
@@ -488,10 +490,10 @@ class TestSnappingSystem:
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')
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()
@@ -503,7 +505,7 @@ class TestSnappingSystem:
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
+630
View File
@@ -0,0 +1,630 @@
"""
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 TestSnapAxis:
"""Tests for _snap_axis method"""
def test_snap_axis_to_start(self):
"""Test snapping axis to start edge"""
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_axis(
position=10, size=50, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical"
)
assert result == 0
def test_snap_axis_to_end(self):
"""Test snapping axis so element end aligns with page end"""
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
element_size = 50
threshold_px = 50
result = snap._snap_axis(
position=page_size_px - element_size - 10,
size=element_size,
page_size_mm=page_size_mm,
dpi=dpi,
snap_threshold_px=threshold_px,
orientation="vertical",
)
expected = page_size_px - element_size
assert abs(result - expected) < 1
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 == []
+117 -197
View File
@@ -25,11 +25,7 @@ class TestTemplate:
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)
)
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)
@@ -38,7 +34,7 @@ class TestTemplate:
"""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
@@ -48,10 +44,10 @@ class TestTemplate:
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
@@ -61,9 +57,9 @@ class TestTemplate:
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)
@@ -77,23 +73,13 @@ class TestTemplate:
"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"
}
]
{"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)
@@ -107,12 +93,12 @@ class TestTemplate:
"name": "Test",
"elements": [
{"type": "image", "position": (10, 20), "size": (100, 50)},
{"type": "placeholder", "position": (30, 40), "size": (120, 60)}
]
{"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)
@@ -122,15 +108,15 @@ class TestTemplate:
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:
with open(file_path, "r") as f:
data = json.load(f)
assert data["name"] == "Save Test"
assert data["description"] == "Test save"
@@ -142,23 +128,16 @@ class TestTemplate:
"name": "Load Test",
"description": "Test load",
"page_size_mm": [210, 297],
"elements": [
{
"type": "placeholder",
"position": (10, 20),
"size": (100, 50),
"placeholder_type": "image"
}
]
"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:
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
@@ -177,7 +156,7 @@ class TestTemplateManager:
"""Test getting templates directory"""
manager = TemplateManager()
templates_dir = manager._get_templates_directory()
assert templates_dir.name == "templates"
assert ".pyphotoalbum" in str(templates_dir)
@@ -185,7 +164,7 @@ class TestTemplateManager:
"""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)
@@ -196,11 +175,11 @@ class TestTemplateManager:
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)
monkeypatch.setattr(manager, "templates_dir", user_dir)
monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir)
templates = manager.list_templates()
assert templates == []
@@ -210,21 +189,21 @@ class TestTemplateManager:
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)
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
@@ -233,13 +212,13 @@ class TestTemplateManager:
"""Test saving a template"""
user_dir = tmp_path / "user_templates"
user_dir.mkdir()
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
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()
@@ -248,16 +227,16 @@ class TestTemplateManager:
"""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:
with open(template_file, "w") as f:
json.dump(data, f)
manager = TemplateManager()
monkeypatch.setattr(manager, 'templates_dir', user_dir)
monkeypatch.setattr(manager, "templates_dir", user_dir)
template = manager.load_template("User Template")
assert template.name == "User Template"
@@ -265,16 +244,16 @@ class TestTemplateManager:
"""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:
with open(template_file, "w") as f:
json.dump(data, f)
manager = TemplateManager()
monkeypatch.setattr(manager, '_get_builtin_templates_directory', lambda: builtin_dir)
monkeypatch.setattr(manager, "_get_builtin_templates_directory", lambda: builtin_dir)
template = manager.load_template("[Built-in] Grid 2x2")
assert template.name == "Grid 2x2"
@@ -282,10 +261,10 @@ class TestTemplateManager:
"""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)
monkeypatch.setattr(manager, "templates_dir", user_dir)
with pytest.raises(FileNotFoundError):
manager.load_template("NonExistent")
@@ -293,21 +272,21 @@ class TestTemplateManager:
"""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)
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")
@@ -318,25 +297,21 @@ class TestTemplateManager:
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"
)
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)
@@ -352,10 +327,7 @@ class TestTemplateManager:
# Scale to 400x400 (2x scale) - results in pixels at 300 DPI
scaled = manager.scale_template_elements(
elements,
from_size=(200, 200),
to_size=(400, 400),
scale_mode="proportional"
elements, from_size=(200, 200), to_size=(400, 400), scale_mode="proportional"
)
assert len(scaled) == 1
@@ -379,10 +351,7 @@ class TestTemplateManager:
# Scale to 400x200 (2x width, 1x height) - results in pixels at 300 DPI
scaled = manager.scale_template_elements(
elements,
from_size=(200, 200),
to_size=(400, 200),
scale_mode="stretch"
elements, from_size=(200, 200), to_size=(400, 200), scale_mode="stretch"
)
assert len(scaled) == 1
@@ -403,10 +372,7 @@ class TestTemplateManager:
# Center in larger space without scaling - results in pixels at 300 DPI
scaled = manager.scale_template_elements(
elements,
from_size=(200, 200),
to_size=(400, 400),
scale_mode="center"
elements, from_size=(200, 200), to_size=(400, 400), scale_mode="center"
)
assert len(scaled) == 1
@@ -422,19 +388,16 @@ class TestTemplateManager:
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"
[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"
@@ -442,19 +405,19 @@ class TestTemplateManager:
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)
@@ -462,21 +425,21 @@ class TestTemplateManager:
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
@@ -487,14 +450,14 @@ class TestTemplateManager:
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
@@ -510,11 +473,7 @@ class TestTemplateManager:
# Create page at 400x400 with 0% margin for exact 2x scaling
page = manager.create_page_from_template(
template,
page_number=1,
target_size_mm=(400, 400),
scale_mode="proportional",
margin_percent=0.0
template, page_number=1, target_size_mm=(400, 400), scale_mode="proportional", margin_percent=0.0
)
assert page.layout.size == (400, 400)
@@ -529,24 +488,14 @@ class TestTemplateManager:
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
)
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"
[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
@@ -567,12 +516,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=210)
page = Page(layout=layout, page_number=1)
manager.apply_template_to_page(
template, page,
mode="replace",
scale_mode="stretch",
margin_percent=2.5
)
manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=2.5)
# With 2.5% margin on 210mm page: margin = 5.25mm, content area = 199.5mm
# Template is 200mm, so scale = 199.5 / 200 = 0.9975
@@ -615,12 +559,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
manager.apply_template_to_page(
template, page,
mode="replace",
scale_mode="stretch",
margin_percent=2.5
)
manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=2.5)
# With 2.5% margin: x_margin = 5.25mm, y_margin = 7.425mm
# Content area: 199.5 x 282.15mm
@@ -661,12 +600,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=210)
page = Page(layout=layout, page_number=1)
manager.apply_template_to_page(
template, page,
mode="replace",
scale_mode="stretch",
margin_percent=0.0
)
manager.apply_template_to_page(template, page, mode="replace", scale_mode="stretch", margin_percent=0.0)
# With 0% margin: scale = 210/200 = 1.05, offset = 0
# Results are converted to pixels at 300 DPI
@@ -681,12 +615,7 @@ class TestTemplateManager:
layout2 = PageLayout(width=210, height=210)
page2 = Page(layout=layout2, page_number=1)
manager.apply_template_to_page(
template, page2,
mode="replace",
scale_mode="stretch",
margin_percent=5.0
)
manager.apply_template_to_page(template, page2, mode="replace", scale_mode="stretch", margin_percent=5.0)
# With 5% margin: margin = 10.5mm, content = 189mm, scale = 189/200 = 0.945
# Results are converted to pixels at 300 DPI
@@ -710,12 +639,7 @@ class TestTemplateManager:
layout = PageLayout(width=210, height=297)
page = Page(layout=layout, page_number=1)
manager.apply_template_to_page(
template, page,
mode="replace",
scale_mode="proportional",
margin_percent=2.5
)
manager.apply_template_to_page(template, page, mode="replace", scale_mode="proportional", margin_percent=2.5)
# With proportional mode on 210x297 page:
# Content area: 199.5 x 282.15mm
@@ -750,22 +674,22 @@ class TestTemplateManager:
# Add various elements with specific sizes (in pixels)
# Using pixel positions that correspond to reasonable mm values
img1 = ImageData(image_path="test1.jpg", x=10*mm_to_px, y=20*mm_to_px, width=100*mm_to_px, height=75*mm_to_px)
img2 = ImageData(image_path="test2.jpg", x=120*mm_to_px, y=30*mm_to_px, width=80*mm_to_px, height=60*mm_to_px)
img1 = ImageData(
image_path="test1.jpg", x=10 * mm_to_px, y=20 * mm_to_px, width=100 * mm_to_px, height=75 * mm_to_px
)
img2 = ImageData(
image_path="test2.jpg", x=120 * mm_to_px, y=30 * mm_to_px, width=80 * mm_to_px, height=60 * mm_to_px
)
text1 = TextBoxData(
text_content="Test Text",
x=30*mm_to_px,
y=150*mm_to_px,
width=150*mm_to_px,
height=40*mm_to_px,
font_settings={"family": "Arial", "size": 12}
x=30 * mm_to_px,
y=150 * mm_to_px,
width=150 * mm_to_px,
height=40 * mm_to_px,
font_settings={"family": "Arial", "size": 12},
)
placeholder1 = PlaceholderData(
placeholder_type="image",
x=50*mm_to_px,
y=220*mm_to_px,
width=110*mm_to_px,
height=60*mm_to_px
placeholder_type="image", x=50 * mm_to_px, y=220 * mm_to_px, width=110 * mm_to_px, height=60 * mm_to_px
)
layout.add_element(img1)
@@ -778,19 +702,19 @@ class TestTemplateManager:
# Store original element data
original_elements_data = []
for elem in original_page.layout.elements:
original_elements_data.append({
'type': type(elem).__name__,
'position': elem.position,
'size': elem.size,
'rotation': elem.rotation,
'z_index': elem.z_index
})
original_elements_data.append(
{
"type": type(elem).__name__,
"position": elem.position,
"size": elem.size,
"rotation": elem.rotation,
"z_index": elem.z_index,
}
)
# Create a template from the page
template = manager.create_template_from_page(
original_page,
name="Roundtrip Test Template",
description="Testing size preservation"
original_page, name="Roundtrip Test Template", description="Testing size preservation"
)
# Create a new page with the same size
@@ -800,11 +724,7 @@ class TestTemplateManager:
# Apply the template to the new page with no margins and proportional scaling
# This should result in identical sizes since page sizes match
manager.apply_template_to_page(
template,
new_page,
mode="replace",
scale_mode="proportional",
margin_percent=0.0
template, new_page, mode="replace", scale_mode="proportional", margin_percent=0.0
)
# Verify we have the same number of elements
+5 -5
View File
@@ -33,7 +33,7 @@ class TestViewWindow(ViewOperationsMixin, QMainWindow):
self._status_message = None
def get_current_page(self):
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None
@@ -289,8 +289,8 @@ class TestGuideOperations:
layout = PageLayout()
# Add some guides
layout.snapping_system.add_guide(100, 'vertical')
layout.snapping_system.add_guide(150, 'horizontal')
layout.snapping_system.add_guide(100, "vertical")
layout.snapping_system.add_guide(150, "horizontal")
page = Mock()
page.layout = layout
window._current_page = page
@@ -357,7 +357,7 @@ class TestGridSettingsDialog:
mock_dialog = Mock(spec=QDialog)
mock_dialog.exec.return_value = QDialog.DialogCode.Rejected
with patch('PyQt6.QtWidgets.QDialog', return_value=mock_dialog):
with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog):
window.set_grid_size()
# Dialog should have been created and exec called
@@ -426,7 +426,7 @@ class TestLayoutTabDelegation:
mock_dialog = Mock(spec=QDialog)
mock_dialog.exec.return_value = QDialog.DialogCode.Rejected
with patch('PyQt6.QtWidgets.QDialog', return_value=mock_dialog):
with patch("PyQt6.QtWidgets.QDialog", return_value=mock_dialog):
window.layout_set_grid_size()
# Verify method was called (dialog creation attempted)
+21 -59
View File
@@ -14,6 +14,7 @@ from pyPhotoAlbum.page_layout import PageLayout
# Create a minimal test widget class
class TestViewportWidget(ViewportMixin, QOpenGLWidget):
"""Test widget combining ViewportMixin with QOpenGLWidget"""
pass
@@ -90,10 +91,7 @@ class TestViewportCalculations:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -120,10 +118,7 @@ class TestViewportCalculations:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -144,10 +139,7 @@ class TestViewportCalculations:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -167,10 +159,7 @@ class TestViewportCalculations:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 300 # High DPI
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -226,10 +215,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -262,10 +248,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -298,10 +281,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# 6x4 inch photo: 152.4mm x 101.6mm
page = Page(
layout=PageLayout(width=152.4, height=101.6),
page_number=1
)
page = Page(layout=PageLayout(width=152.4, height=101.6), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -324,10 +304,7 @@ class TestViewportCentering:
mock_window.project.working_dpi = 96
# A4 page: 210mm x 297mm
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -337,7 +314,7 @@ class TestViewportCentering:
# Large window should have large positive offsets
assert offset[0] > 1000 # Lots of horizontal space
assert offset[1] > 400 # Lots of vertical space
assert offset[1] > 400 # Lots of vertical space
def test_calculate_center_pan_offset_different_zoom_levels(self, qtbot):
"""Test that different zoom levels produce different offsets"""
@@ -349,10 +326,7 @@ class TestViewportCentering:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -378,10 +352,7 @@ class TestViewportCentering:
mock_window = Mock()
mock_window.project = Project(name="Test")
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
# Test at 96 DPI
@@ -412,10 +383,7 @@ class TestViewportResizing:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -424,14 +392,14 @@ class TestViewportResizing:
widget.zoom_level = 0.5
# Get initial centered offset for 1000x800
with patch.object(widget, 'width', return_value=1000):
with patch.object(widget, 'height', return_value=800):
with patch.object(widget, "width", return_value=1000):
with patch.object(widget, "height", return_value=800):
initial_offset = list(widget._calculate_center_pan_offset(0.5))
# Trigger a resize to larger window (1200x900)
# Mock the widget's dimensions during resizeGL
with patch.object(widget, 'width', return_value=1200):
with patch.object(widget, 'height', return_value=900):
with patch.object(widget, "width", return_value=1200):
with patch.object(widget, "height", return_value=900):
widget.resizeGL(1200, 900)
new_offset = widget.pan_offset
@@ -466,10 +434,7 @@ class TestViewportResizing:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -493,10 +458,7 @@ class TestViewportResizing:
mock_window.project = Project(name="Test")
mock_window.project.working_dpi = 96
page = Page(
layout=PageLayout(width=210, height=297),
page_number=1
)
page = Page(layout=PageLayout(width=210, height=297), page_number=1)
mock_window.project.pages = [page]
widget.window = Mock(return_value=mock_window)
@@ -527,7 +489,7 @@ class TestViewportOpenGL:
qtbot.addWidget(widget)
# Just verify the method exists and is callable
assert hasattr(widget, 'initializeGL')
assert hasattr(widget, "initializeGL")
assert callable(widget.initializeGL)
def test_resizeGL_is_callable(self, qtbot):
@@ -535,5 +497,5 @@ class TestViewportOpenGL:
widget = TestViewportWidget()
qtbot.addWidget(widget)
assert hasattr(widget, 'resizeGL')
assert hasattr(widget, "resizeGL")
assert callable(widget.resizeGL)
+129 -108
View File
@@ -10,35 +10,35 @@ from pyPhotoAlbum.commands import ChangeZOrderCommand, CommandHistory
class TestZOrderBasics:
"""Tests for basic z-order functionality"""
def test_list_order_is_render_order(self):
"""Test that list order determines render order"""
layout = PageLayout(width=210, height=297)
# Add elements in order
elem1 = ImageData(x=10, y=10, width=50, height=50)
elem2 = TextBoxData(x=20, y=20, width=50, height=50)
elem3 = PlaceholderData(x=30, y=30, width=50, height=50)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Verify order
assert layout.elements[0] is elem1
assert layout.elements[1] is elem2
assert layout.elements[2] is elem3
def test_element_at_end_renders_on_top(self):
"""Test that element at end of list renders on top"""
layout = PageLayout(width=210, height=297)
elem1 = ImageData(x=10, y=10)
elem2 = ImageData(x=20, y=20)
layout.add_element(elem1)
layout.add_element(elem2)
# elem2 should be last (on top)
assert layout.elements[-1] is elem2
assert layout.elements.index(elem2) > layout.elements.index(elem1)
@@ -46,133 +46,133 @@ class TestZOrderBasics:
class TestChangeZOrderCommand:
"""Tests for ChangeZOrderCommand"""
def test_move_element_forward(self):
"""Test moving an element forward one position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem1 forward (swap with elem2)
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1)
cmd.execute()
assert layout.elements.index(elem1) == 1
assert layout.elements.index(elem2) == 0
assert layout.elements.index(elem3) == 2
def test_move_element_backward(self):
"""Test moving an element backward one position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem2 backward (swap with elem1)
cmd = ChangeZOrderCommand(layout, elem2, old_index=1, new_index=0)
cmd.execute()
assert layout.elements.index(elem2) == 0
assert layout.elements.index(elem1) == 1
assert layout.elements.index(elem3) == 2
def test_move_to_front(self):
"""Test moving an element to the front (end of list)"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem1 to front
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
cmd.execute()
assert layout.elements[-1] is elem1
assert layout.elements.index(elem1) == 2
def test_move_to_back(self):
"""Test moving an element to the back (start of list)"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem3 to back
cmd = ChangeZOrderCommand(layout, elem3, old_index=2, new_index=0)
cmd.execute()
assert layout.elements[0] is elem3
assert layout.elements.index(elem3) == 0
def test_undo_redo(self):
"""Test undo/redo functionality"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
original_order = list(layout.elements)
# Move elem1 forward
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=1)
cmd.execute()
assert layout.elements.index(elem1) == 1
# Undo
cmd.undo()
assert layout.elements == original_order
# Redo
cmd.redo()
assert layout.elements.index(elem1) == 1
def test_command_with_history(self):
"""Test ChangeZOrderCommand with CommandHistory"""
layout = PageLayout()
history = CommandHistory()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Execute command through history
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
history.execute(cmd)
assert layout.elements.index(elem1) == 2
assert history.can_undo()
# Undo through history
history.undo()
assert layout.elements.index(elem1) == 0
assert history.can_redo()
# Redo through history
history.redo()
assert layout.elements.index(elem1) == 2
@@ -180,72 +180,93 @@ class TestChangeZOrderCommand:
class TestZOrderSerialization:
"""Tests for z-order serialization and deserialization"""
def test_serialize_preserves_order(self):
"""Test that serialization preserves element order"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10, z_index=0)
elem2 = TextBoxData(x=20, y=20, z_index=1)
elem3 = PlaceholderData(x=30, y=30, z_index=2)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Serialize
data = layout.serialize()
# Elements should be in order
assert len(data['elements']) == 3
assert data['elements'][0]['type'] == 'image'
assert data['elements'][1]['type'] == 'textbox'
assert data['elements'][2]['type'] == 'placeholder'
assert len(data["elements"]) == 3
assert data["elements"][0]["type"] == "image"
assert data["elements"][1]["type"] == "textbox"
assert data["elements"][2]["type"] == "placeholder"
def test_deserialize_sorts_by_zindex(self):
"""Test that deserialization sorts by z_index for backward compatibility"""
layout = PageLayout()
# Create data with z_index values out of order
data = {
'size': (210, 297),
'base_width': 210,
'is_facing_page': False,
'background_color': (1.0, 1.0, 1.0),
'elements': [
{'type': 'image', 'position': (10, 10), 'size': (50, 50),
'rotation': 0, 'z_index': 2, 'image_path': '', 'crop_info': (0, 0, 1, 1)},
{'type': 'textbox', 'position': (20, 20), 'size': (50, 50),
'rotation': 0, 'z_index': 0, 'text_content': '',
'font_settings': {}, 'alignment': 'left'},
{'type': 'placeholder', 'position': (30, 30), 'size': (50, 50),
'rotation': 0, 'z_index': 1, 'placeholder_type': 'image', 'default_content': ''},
]
"size": (210, 297),
"base_width": 210,
"is_facing_page": False,
"background_color": (1.0, 1.0, 1.0),
"elements": [
{
"type": "image",
"position": (10, 10),
"size": (50, 50),
"rotation": 0,
"z_index": 2,
"image_path": "",
"crop_info": (0, 0, 1, 1),
},
{
"type": "textbox",
"position": (20, 20),
"size": (50, 50),
"rotation": 0,
"z_index": 0,
"text_content": "",
"font_settings": {},
"alignment": "left",
},
{
"type": "placeholder",
"position": (30, 30),
"size": (50, 50),
"rotation": 0,
"z_index": 1,
"placeholder_type": "image",
"default_content": "",
},
],
}
layout.deserialize(data)
# Elements should be sorted by z_index
assert len(layout.elements) == 3
assert isinstance(layout.elements[0], TextBoxData) # z_index=0
assert isinstance(layout.elements[1], PlaceholderData) # z_index=1
assert isinstance(layout.elements[2], ImageData) # z_index=2
def test_roundtrip_maintains_order(self):
"""Test that serialize/deserialize maintains element order"""
layout1 = PageLayout()
elem1 = ImageData(x=10, y=10, z_index=0)
elem2 = TextBoxData(x=20, y=20, z_index=1)
elem3 = PlaceholderData(x=30, y=30, z_index=2)
layout1.add_element(elem1)
layout1.add_element(elem2)
layout1.add_element(elem3)
# Serialize and deserialize
data = layout1.serialize()
layout2 = PageLayout()
layout2.deserialize(data)
# Order should be maintained
assert len(layout2.elements) == 3
assert isinstance(layout2.elements[0], ImageData)
@@ -255,126 +276,126 @@ class TestZOrderSerialization:
class TestZOrderEdgeCases:
"""Tests for z-order edge cases"""
def test_single_element(self):
"""Test operations with single element"""
layout = PageLayout()
elem = ImageData(x=10, y=10)
layout.add_element(elem)
# Try to move forward (should stay at index 0)
cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=0)
cmd.execute()
assert layout.elements.index(elem) == 0
def test_empty_list(self):
"""Test operations with empty list"""
layout = PageLayout()
assert len(layout.elements) == 0
def test_move_to_same_position(self):
"""Test moving element to its current position"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
layout.add_element(elem1)
layout.add_element(elem2)
# Move to same position
cmd = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=0)
cmd.execute()
assert layout.elements.index(elem1) == 0
assert layout.elements.index(elem2) == 1
def test_swap_adjacent_elements(self):
"""Test swapping two adjacent elements"""
layout = PageLayout()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
layout.add_element(elem1)
layout.add_element(elem2)
# Swap by moving elem1 forward
elements = layout.elements
index1 = elements.index(elem1)
index2 = elements.index(elem2)
elements[index1], elements[index2] = elements[index2], elements[index1]
assert layout.elements[0] is elem2
assert layout.elements[1] is elem1
def test_multiple_zorder_changes(self):
"""Test multiple z-order changes in sequence"""
layout = PageLayout()
history = CommandHistory()
elem1 = ImageData(x=10, y=10)
elem2 = TextBoxData(x=20, y=20)
elem3 = PlaceholderData(x=30, y=30)
layout.add_element(elem1)
layout.add_element(elem2)
layout.add_element(elem3)
# Move elem1 to front
cmd1 = ChangeZOrderCommand(layout, elem1, old_index=0, new_index=2)
history.execute(cmd1)
assert layout.elements.index(elem1) == 2
# Move elem2 to front
cmd2 = ChangeZOrderCommand(layout, elem2, old_index=0, new_index=2)
history.execute(cmd2)
assert layout.elements.index(elem2) == 2
# Undo both
history.undo()
assert layout.elements.index(elem2) == 0
history.undo()
assert layout.elements.index(elem1) == 0
class TestZOrderCommandSerialization:
"""Tests for ChangeZOrderCommand serialization"""
def test_serialize_command(self):
"""Test serializing a ChangeZOrderCommand"""
layout = PageLayout()
elem = ImageData(x=10, y=10)
layout.add_element(elem)
cmd = ChangeZOrderCommand(layout, elem, old_index=0, new_index=1)
data = cmd.serialize()
assert data['type'] == 'change_zorder'
assert data['old_index'] == 0
assert data['new_index'] == 1
assert 'element' in data
assert data["type"] == "change_zorder"
assert data["old_index"] == 0
assert data["new_index"] == 1
assert "element" in data
def test_deserialize_command(self):
"""Test deserializing a ChangeZOrderCommand"""
data = {
'type': 'change_zorder',
'element': {
'type': 'image',
'position': (10, 10),
'size': (50, 50),
'rotation': 0,
'z_index': 0,
'image_path': '',
'crop_info': (0, 0, 1, 1)
"type": "change_zorder",
"element": {
"type": "image",
"position": (10, 10),
"size": (50, 50),
"rotation": 0,
"z_index": 0,
"image_path": "",
"crop_info": (0, 0, 1, 1),
},
'old_index': 0,
'new_index': 1
"old_index": 0,
"new_index": 1,
}
cmd = ChangeZOrderCommand.deserialize(data, None)
assert isinstance(cmd, ChangeZOrderCommand)
assert cmd.old_index == 0
assert cmd.new_index == 1
+1 -1
View File
@@ -34,7 +34,7 @@ class TestZOrderWindow(ZOrderOperationsMixin, QMainWindow):
def get_current_page(self):
"""Return mock current page"""
if hasattr(self, '_current_page'):
if hasattr(self, "_current_page"):
return self._current_page
return None