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
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:
+26
-87
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user