first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Unit tests for pyPhotoAlbum
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user