first commit
Lint / lint (push) Failing after 2m46s
Tests / test (3.11) (push) Has been cancelled
Tests / test (3.9) (push) Has been cancelled
Tests / test (3.10) (push) Has been cancelled

This commit is contained in:
2025-10-21 22:02:49 +02:00
commit 46585228fd
50 changed files with 12567 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""
Unit tests for pyPhotoAlbum
"""
+105
View File
@@ -0,0 +1,105 @@
"""
Pytest configuration and fixtures for pyPhotoAlbum tests
"""
import pytest
import tempfile
import os
from pathlib import Path
from PIL import Image
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
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:
# Create a simple test image
img = Image.new('RGB', (100, 100), color='red')
img.save(f.name)
yield f.name
# Cleanup
try:
os.unlink(f.name)
except:
pass
@pytest.fixture
def temp_dir():
"""Create a temporary directory for tests"""
with tempfile.TemporaryDirectory() as tmpdir:
yield tmpdir
@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
)
@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
)
@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
)
@pytest.fixture
def sample_page_layout():
"""Create a sample PageLayout instance"""
layout = PageLayout()
return layout
@pytest.fixture
def sample_grid_layout():
"""Create a sample GridLayout instance"""
return GridLayout(rows=2, columns=2, spacing=10.0)
@pytest.fixture
def sample_page(sample_page_layout):
"""Create a sample Page instance"""
return Page(layout=sample_page_layout, page_number=1)
@pytest.fixture
def sample_project():
"""Create a sample Project instance"""
return Project(name="Test Project")
@pytest.fixture
def populated_page_layout(sample_image_data, sample_placeholder_data, sample_textbox_data):
"""Create a page layout populated with various elements"""
layout = PageLayout()
layout.add_element(sample_image_data)
layout.add_element(sample_placeholder_data)
layout.add_element(sample_textbox_data)
return layout
+424
View File
@@ -0,0 +1,424 @@
"""
Unit tests for pyPhotoAlbum models
"""
import pytest
from pyPhotoAlbum.models import ImageData, PlaceholderData, TextBoxData, BaseLayoutElement
class TestBaseLayoutElement:
"""Tests for BaseLayoutElement abstract class"""
def test_cannot_instantiate_abstract_class(self):
"""Test that BaseLayoutElement cannot be instantiated directly"""
with pytest.raises(TypeError):
BaseLayoutElement()
class TestImageData:
"""Tests for ImageData class"""
def test_initialization_default(self):
"""Test ImageData initialization with default values"""
img = ImageData()
assert img.image_path == ""
assert img.position == (0, 0)
assert img.size == (100, 100)
assert img.rotation == 0
assert img.z_index == 0
assert img.crop_info == (0, 0, 1, 1)
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
)
assert img.image_path == temp_image_file
assert img.position == (10.0, 20.0)
assert img.size == (200.0, 150.0)
assert img.rotation == 45.0
assert img.z_index == 5
def test_initialization_with_crop_info(self):
"""Test ImageData initialization with custom crop info"""
crop = (0.1, 0.2, 0.8, 0.9)
img = ImageData(image_path="test.jpg", crop_info=crop)
assert img.crop_info == crop
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
)
data = img.serialize()
assert data["type"] == "image"
assert data["image_path"] == temp_image_file
assert data["position"] == (15.0, 25.0)
assert data["size"] == (180.0, 120.0)
assert data["rotation"] == 30.0
assert data["z_index"] == 3
assert data["crop_info"] == (0, 0, 1, 1)
def test_deserialization(self):
"""Test ImageData deserialization from dictionary"""
img = ImageData()
data = {
"position": (30.0, 40.0),
"size": (220.0, 180.0),
"rotation": 90.0,
"z_index": 7,
"image_path": "new_image.jpg",
"crop_info": (0.2, 0.3, 0.7, 0.8)
}
img.deserialize(data)
assert img.position == (30.0, 40.0)
assert img.size == (220.0, 180.0)
assert img.rotation == 90.0
assert img.z_index == 7
assert img.image_path == "new_image.jpg"
assert img.crop_info == (0.2, 0.3, 0.7, 0.8)
def test_deserialization_with_defaults(self):
"""Test ImageData deserialization with missing fields uses defaults"""
img = ImageData()
data = {"image_path": "test.jpg"}
img.deserialize(data)
assert img.position == (0, 0)
assert img.size == (100, 100)
assert img.rotation == 0
assert img.z_index == 0
assert img.crop_info == (0, 0, 1, 1)
def test_serialize_deserialize_roundtrip(self, temp_image_file):
"""Test that serialize and deserialize are inverse operations"""
original = ImageData(
image_path=temp_image_file,
x=50.0,
y=60.0,
width=300.0,
height=200.0,
rotation=15.0,
z_index=2,
crop_info=(0.1, 0.1, 0.9, 0.9)
)
data = original.serialize()
restored = ImageData()
restored.deserialize(data)
assert restored.image_path == original.image_path
assert restored.position == original.position
assert restored.size == original.size
assert restored.rotation == original.rotation
assert restored.z_index == original.z_index
assert restored.crop_info == original.crop_info
def test_position_modification(self):
"""Test modifying position after initialization"""
img = ImageData()
img.position = (100.0, 200.0)
assert img.position == (100.0, 200.0)
def test_size_modification(self):
"""Test modifying size after initialization"""
img = ImageData()
img.size = (400.0, 300.0)
assert img.size == (400.0, 300.0)
class TestPlaceholderData:
"""Tests for PlaceholderData class"""
def test_initialization_default(self):
"""Test PlaceholderData initialization with default values"""
placeholder = PlaceholderData()
assert placeholder.placeholder_type == "image"
assert placeholder.default_content == ""
assert placeholder.position == (0, 0)
assert placeholder.size == (100, 100)
assert placeholder.rotation == 0
assert placeholder.z_index == 0
def test_initialization_with_parameters(self):
"""Test PlaceholderData initialization with custom parameters"""
placeholder = PlaceholderData(
placeholder_type="text",
default_content="Sample",
x=20.0,
y=30.0,
width=150.0,
height=100.0,
rotation=10.0,
z_index=4
)
assert placeholder.placeholder_type == "text"
assert placeholder.default_content == "Sample"
assert placeholder.position == (20.0, 30.0)
assert placeholder.size == (150.0, 100.0)
assert placeholder.rotation == 10.0
assert placeholder.z_index == 4
def test_serialization(self):
"""Test PlaceholderData serialization to dictionary"""
placeholder = PlaceholderData(
placeholder_type="image",
default_content="placeholder.jpg",
x=40.0,
y=50.0,
width=200.0,
height=150.0,
rotation=20.0,
z_index=2
)
data = placeholder.serialize()
assert data["type"] == "placeholder"
assert data["placeholder_type"] == "image"
assert data["default_content"] == "placeholder.jpg"
assert data["position"] == (40.0, 50.0)
assert data["size"] == (200.0, 150.0)
assert data["rotation"] == 20.0
assert data["z_index"] == 2
def test_deserialization(self):
"""Test PlaceholderData deserialization from dictionary"""
placeholder = PlaceholderData()
data = {
"position": (60.0, 70.0),
"size": (250.0, 180.0),
"rotation": 45.0,
"z_index": 6,
"placeholder_type": "text",
"default_content": "Default Text"
}
placeholder.deserialize(data)
assert placeholder.position == (60.0, 70.0)
assert placeholder.size == (250.0, 180.0)
assert placeholder.rotation == 45.0
assert placeholder.z_index == 6
assert placeholder.placeholder_type == "text"
assert placeholder.default_content == "Default Text"
def test_deserialization_with_defaults(self):
"""Test PlaceholderData deserialization with missing fields uses defaults"""
placeholder = PlaceholderData()
data = {"placeholder_type": "image"}
placeholder.deserialize(data)
assert placeholder.position == (0, 0)
assert placeholder.size == (100, 100)
assert placeholder.rotation == 0
assert placeholder.z_index == 0
assert placeholder.default_content == ""
def test_serialize_deserialize_roundtrip(self):
"""Test that serialize and deserialize are inverse operations"""
original = PlaceholderData(
placeholder_type="image",
default_content="test.jpg",
x=80.0,
y=90.0,
width=300.0,
height=250.0,
rotation=60.0,
z_index=8
)
data = original.serialize()
restored = PlaceholderData()
restored.deserialize(data)
assert restored.placeholder_type == original.placeholder_type
assert restored.default_content == original.default_content
assert restored.position == original.position
assert restored.size == original.size
assert restored.rotation == original.rotation
assert restored.z_index == original.z_index
class TestTextBoxData:
"""Tests for TextBoxData class"""
def test_initialization_default(self):
"""Test TextBoxData initialization with default values"""
textbox = TextBoxData()
assert textbox.text_content == ""
assert textbox.font_settings == {"family": "Arial", "size": 12, "color": (0, 0, 0)}
assert textbox.alignment == "left"
assert textbox.position == (0, 0)
assert textbox.size == (100, 100)
assert textbox.rotation == 0
assert textbox.z_index == 0
def test_initialization_with_parameters(self):
"""Test TextBoxData initialization with custom parameters"""
font_settings = {"family": "Times", "size": 14, "color": (255, 0, 0)}
textbox = TextBoxData(
text_content="Hello World",
font_settings=font_settings,
alignment="center",
x=25.0,
y=35.0,
width=180.0,
height=60.0,
rotation=5.0,
z_index=3
)
assert textbox.text_content == "Hello World"
assert textbox.font_settings == font_settings
assert textbox.alignment == "center"
assert textbox.position == (25.0, 35.0)
assert textbox.size == (180.0, 60.0)
assert textbox.rotation == 5.0
assert textbox.z_index == 3
def test_serialization(self):
"""Test TextBoxData serialization to dictionary"""
font_settings = {"family": "Helvetica", "size": 16, "color": (0, 0, 255)}
textbox = TextBoxData(
text_content="Test Text",
font_settings=font_settings,
alignment="right",
x=45.0,
y=55.0,
width=220.0,
height=80.0,
rotation=15.0,
z_index=5
)
data = textbox.serialize()
assert data["type"] == "textbox"
assert data["text_content"] == "Test Text"
assert data["font_settings"] == font_settings
assert data["alignment"] == "right"
assert data["position"] == (45.0, 55.0)
assert data["size"] == (220.0, 80.0)
assert data["rotation"] == 15.0
assert data["z_index"] == 5
def test_deserialization(self):
"""Test TextBoxData deserialization from dictionary"""
textbox = TextBoxData()
font_settings = {"family": "Courier", "size": 18, "color": (128, 128, 128)}
data = {
"position": (65.0, 75.0),
"size": (260.0, 100.0),
"rotation": 30.0,
"z_index": 7,
"text_content": "Deserialized Text",
"font_settings": font_settings,
"alignment": "justify"
}
textbox.deserialize(data)
assert textbox.position == (65.0, 75.0)
assert textbox.size == (260.0, 100.0)
assert textbox.rotation == 30.0
assert textbox.z_index == 7
assert textbox.text_content == "Deserialized Text"
assert textbox.font_settings == font_settings
assert textbox.alignment == "justify"
def test_deserialization_with_defaults(self):
"""Test TextBoxData deserialization with missing fields uses defaults"""
textbox = TextBoxData()
data = {"text_content": "Minimal"}
textbox.deserialize(data)
assert textbox.position == (0, 0)
assert textbox.size == (100, 100)
assert textbox.rotation == 0
assert textbox.z_index == 0
assert textbox.font_settings == {"family": "Arial", "size": 12, "color": (0, 0, 0)}
assert textbox.alignment == "left"
def test_serialize_deserialize_roundtrip(self):
"""Test that serialize and deserialize are inverse operations"""
font_settings = {"family": "Georgia", "size": 20, "color": (255, 255, 0)}
original = TextBoxData(
text_content="Round Trip Test",
font_settings=font_settings,
alignment="center",
x=85.0,
y=95.0,
width=320.0,
height=120.0,
rotation=25.0,
z_index=9
)
data = original.serialize()
restored = TextBoxData()
restored.deserialize(data)
assert restored.text_content == original.text_content
assert restored.font_settings == original.font_settings
assert restored.alignment == original.alignment
assert restored.position == original.position
assert restored.size == original.size
assert restored.rotation == original.rotation
assert restored.z_index == original.z_index
def test_text_content_modification(self):
"""Test modifying text content after initialization"""
textbox = TextBoxData()
textbox.text_content = "Modified Text"
assert textbox.text_content == "Modified Text"
def test_font_settings_modification(self):
"""Test modifying font settings after initialization"""
textbox = TextBoxData()
new_font = {"family": "Verdana", "size": 24, "color": (100, 200, 50)}
textbox.font_settings = new_font
assert textbox.font_settings == new_font
def test_alignment_modification(self):
"""Test modifying alignment after initialization"""
textbox = TextBoxData()
textbox.alignment = "right"
assert textbox.alignment == "right"
class TestElementComparison:
"""Tests comparing different element types"""
def test_different_element_types_serialize_differently(self):
"""Test that different element types have different serialization"""
img = ImageData(x=10, y=10)
placeholder = PlaceholderData(x=10, y=10)
textbox = TextBoxData(x=10, y=10)
img_data = img.serialize()
placeholder_data = placeholder.serialize()
textbox_data = textbox.serialize()
assert img_data["type"] == "image"
assert placeholder_data["type"] == "placeholder"
assert textbox_data["type"] == "textbox"
def test_z_index_comparison(self):
"""Test that z_index can be used for layering"""
img1 = ImageData(z_index=1)
img2 = ImageData(z_index=5)
img3 = ImageData(z_index=3)
elements = [img1, img2, img3]
sorted_elements = sorted(elements, key=lambda e: e.z_index)
assert sorted_elements[0].z_index == 1
assert sorted_elements[1].z_index == 3
assert sorted_elements[2].z_index == 5
+463
View File
@@ -0,0 +1,463 @@
"""
Unit tests for PageRenderer coordinate transformations
"""
import pytest
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
)
# 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
)
# 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
)
# 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
)
# 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
)
# 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
)
# 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
)
# 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
)
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(
page_width_mm=210.0, # A4 width
page_height_mm=297.0, # A4 height
screen_x=100.0,
screen_y=200.0,
dpi=96,
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
)
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
)
# 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
)
# 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
)
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
)
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
assert abs(w - page_width_px) < 0.1
assert abs(h - page_height_px) < 0.1
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
)
# 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(
page_width_mm=420.0, # Double width for facing page
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
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'
def test_get_sub_page_at_facing_page_right(self):
"""Test get_sub_page_at for right side of facing page"""
renderer = PageRenderer(
page_width_mm=420.0, # Double width for facing page
page_height_mm=297.0,
screen_x=100.0,
screen_y=200.0,
dpi=96,
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'
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(
page_width_mm=210.0, # A4 width
page_height_mm=297.0, # A4 height
screen_x=0.0,
screen_y=0.0,
dpi=96,
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
)
# 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
)
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
)
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
)
# 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
)
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
+708
View File
@@ -0,0 +1,708 @@
"""
Tests for PDF export functionality
"""
import os
import tempfile
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData, TextBoxData
from pyPhotoAlbum.pdf_exporter import PDFExporter
def test_pdf_exporter_basic():
"""Test basic PDF export with single page"""
# 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:
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)
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:
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)
def test_pdf_exporter_with_text():
"""Test PDF export with text boxes"""
project = Project("Test Text Project")
project.page_size_mm = (210, 297)
# Create page with text box
page = Page(page_number=1, is_double_spread=False)
# Add a text box
text_box = TextBoxData(
text_content="Hello, World!",
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
alignment="center",
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:
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"✓ Text box PDF export successful: {tmp_path}")
if warnings:
print(f" Warnings: {warnings}")
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
def test_pdf_exporter_facing_pages_alignment():
"""Test that double spreads align to facing pages"""
project = Project("Test Facing Pages")
project.page_size_mm = (210, 297)
# 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:
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)
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
)
page.layout.add_element(image)
project.add_page(page)
# Export to temporary file
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)
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')
# Save test image to temporary file
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
)
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:
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)
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:
img_path1 = img_tmp1.name
test_img1.save(img_path1)
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
)
# Second spanning image (different position)
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:
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)
if os.path.exists(img_path2):
os.remove(img_path2)
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:
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
)
spread_page.layout.add_element(image)
project.add_page(spread_page)
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)
def test_pdf_exporter_text_spanning():
"""Test text box spanning the center line"""
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",
font_settings={"family": "Helvetica", "size": 24, "color": (0, 0, 0)},
alignment="center",
x=center_px - 100,
y=100,
width=200,
height=50
)
spread_page.layout.add_element(text_box)
project.add_page(spread_page)
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)
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))
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:
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
)
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:
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)")
print(f" Split at: {center_px:.1f}px")
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)
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
]
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))
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:
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
)
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:
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)
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))
# 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:
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
)
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:
pdf_path_300dpi = pdf_tmp1.name
# Export with low DPI (150 - screen quality)
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})"
# 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}%"
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)
if __name__ == "__main__":
print("Running PDF export tests...\n")
try:
test_pdf_exporter_basic()
test_pdf_exporter_double_spread()
test_pdf_exporter_with_text()
test_pdf_exporter_facing_pages_alignment()
test_pdf_exporter_missing_image()
test_pdf_exporter_spanning_image()
test_pdf_exporter_multiple_spanning_elements()
test_pdf_exporter_edge_case_barely_spanning()
test_pdf_exporter_text_spanning()
test_pdf_exporter_spanning_image_aspect_ratio()
test_pdf_exporter_varying_aspect_ratios()
test_pdf_exporter_image_downsampling()
print("\n✓ All tests passed!")
except AssertionError as e:
print(f"\n✗ Test failed: {e}")
raise
except Exception as e:
print(f"\n✗ Unexpected error: {e}")
raise
+254
View File
@@ -0,0 +1,254 @@
"""
Unit tests for pyPhotoAlbum project module
"""
import pytest
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData
class TestPage:
"""Tests for Page class"""
def test_initialization_default(self):
"""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
def test_initialization_with_parameters(self):
"""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
def test_page_number_modification(self):
"""Test modifying page number after initialization"""
layout = PageLayout()
page = Page(layout=layout, page_number=1)
page.page_number = 10
assert page.page_number == 10
class TestProject:
"""Tests for Project class"""
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
assert project.page_size_mm == (140, 140) # Default 14cm x 14cm square
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):
"""Test adding a page to the project"""
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
assert project.pages[2] is page3
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
def test_remove_page_not_in_list(self):
"""Test removing a page that's not in the project"""
project = Project()
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)
def test_working_dpi_modification(self):
"""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 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 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 == []
class TestProjectWithPages:
"""Integration tests for Project with Page operations"""
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
)
layout.add_element(img)
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
assert page.page_number == i + 1
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
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)
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
assert project.pages[2] is page2
+424
View File
@@ -0,0 +1,424 @@
"""
Unit tests for project serialization (save/load to ZIP)
"""
import pytest
import os
import json
import zipfile
import tempfile
import shutil
from pathlib import Path
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.models import ImageData, TextBoxData
from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip, get_project_info
@pytest.fixture
def temp_dir():
"""Create a temporary directory for testing"""
temp_path = tempfile.mkdtemp()
yield temp_path
# Cleanup
if os.path.exists(temp_path):
shutil.rmtree(temp_path)
@pytest.fixture
def sample_project(temp_dir):
"""Create a sample project for testing"""
project = Project(name="Test Project", folder_path=os.path.join(temp_dir, "test_project"))
project.page_size_mm = (210, 297)
project.working_dpi = 300
project.export_dpi = 300
return project
@pytest.fixture
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')
image_path = os.path.join(temp_dir, "test_image.jpg")
img.save(image_path)
return image_path
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')
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'
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, error = load_from_zip(zip_path)
assert loaded_project is not None
assert error is 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")
loaded_project, error = load_from_zip(zip_path)
assert loaded_project is None
assert error is not None
assert "not found" in 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)
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)
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, error = 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
assert loaded_project.pages[2].page_number == 3
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()
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')
data = json.loads(project_json)
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')
data = json.loads(project_json)
assert 'serialization_version' in data
assert data['serialization_version'] == "1.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:
names = zipf.namelist()
# Should contain assets folder
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, error = 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, error = 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
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
imported_path = sample_project.asset_manager.import_asset(sample_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, "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, error = 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
imported_path = sample_project.asset_manager.import_asset(sample_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, "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, error = 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):
full_path = os.path.join(loaded_project.folder_path, image_path)
assert os.path.exists(full_path)
else:
assert os.path.exists(image_path)
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)
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'] == "1.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:
f.write("This is not a ZIP file")
loaded_project, error = load_from_zip(corrupted_path)
assert loaded_project is None
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')
loaded_project, error = load_from_zip(zip_path)
assert loaded_project is None
assert error is not None
assert "project.json not found" in 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
)
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, error = 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"