pyPhotoAlbum/tests/test_project_serializer_full.py
Duncan Tourolle f6ed11b0bc
All checks were successful
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s
black formatting
2025-11-27 23:07:16 +01:00

433 lines
15 KiB
Python

"""
Comprehensive tests for project_serializer module
"""
import pytest
import os
import json
import zipfile
import tempfile
import shutil
from pathlib import Path
from unittest.mock import Mock, patch, MagicMock
from pyPhotoAlbum.project_serializer import (
save_to_zip,
load_from_zip,
get_project_info,
_normalize_asset_paths,
_import_external_images,
SERIALIZATION_VERSION,
)
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.models import ImageData
class TestSaveToZip:
"""Tests for save_to_zip function"""
def test_save_to_zip_basic(self, tmp_path):
"""Test basic project saving to zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
success, error = save_to_zip(project, zip_path)
assert success is True
assert error is None
assert os.path.exists(zip_path)
def test_save_to_zip_adds_extension(self, tmp_path):
"""Test that .ppz extension is added if missing"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project") # No extension
success, error = save_to_zip(project, zip_path)
assert success is True
assert os.path.exists(zip_path + ".ppz")
def test_save_to_zip_includes_project_json(self, tmp_path):
"""Test that saved zip contains project.json"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "project.json" in zipf.namelist()
project_data = json.loads(zipf.read("project.json"))
assert project_data["name"] == "TestProject"
assert "data_version" in project_data
def test_save_to_zip_includes_assets(self, tmp_path):
"""Test that saved zip includes asset files"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Create a dummy asset file
asset_file = assets_folder / "image.jpg"
asset_file.write_bytes(b"fake image data")
project = Project(name="TestProject", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
assert "assets/image.jpg" in zipf.namelist()
def test_save_to_zip_handles_error(self, tmp_path):
"""Test error handling during save"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TestProject", folder_path=str(project_folder))
# Try to save to an invalid path
zip_path = "/nonexistent/directory/test.ppz"
success, error = save_to_zip(project, zip_path)
assert success is False
assert error is not None
assert "Error saving" in error
class TestLoadFromZip:
"""Tests for load_from_zip function"""
def test_load_from_zip_basic(self, tmp_path):
"""Test basic project loading from zip"""
# First create a valid project zip
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="LoadTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Now load it
extract_to = str(tmp_path / "extracted")
loaded_project = load_from_zip(zip_path, extract_to)
assert loaded_project.name == "LoadTest"
assert loaded_project.folder_path == extract_to
def test_load_from_zip_creates_temp_dir(self, tmp_path):
"""Test that loading creates a temp directory when none specified"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="TempTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
# Load without specifying extraction directory
loaded_project = load_from_zip(zip_path)
assert loaded_project.name == "TempTest"
assert loaded_project.folder_path is not None
assert os.path.exists(loaded_project.folder_path)
# Should have a _temp_dir attribute
assert hasattr(loaded_project, "_temp_dir")
def test_load_from_zip_file_not_found(self, tmp_path):
"""Test loading from nonexistent file"""
with pytest.raises(FileNotFoundError):
load_from_zip(str(tmp_path / "nonexistent.ppz"))
def test_load_from_zip_invalid_zip(self, tmp_path):
"""Test loading from invalid zip file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip file")
with pytest.raises(Exception):
load_from_zip(str(invalid_file))
def test_load_from_zip_missing_project_json(self, tmp_path):
"""Test loading from zip without project.json"""
zip_path = tmp_path / "no_project.ppz"
# Create zip without project.json
with zipfile.ZipFile(str(zip_path), "w") as zipf:
zipf.writestr("other_file.txt", "some content")
with pytest.raises(ValueError) as exc_info:
load_from_zip(str(zip_path))
assert "project.json not found" in str(exc_info.value)
class TestGetProjectInfo:
"""Tests for get_project_info function"""
def test_get_project_info_basic(self, tmp_path):
"""Test getting project info from zip"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="InfoTest", folder_path=str(project_folder))
zip_path = str(tmp_path / "test_project.ppz")
save_to_zip(project, zip_path)
info = get_project_info(zip_path)
assert info is not None
assert info["name"] == "InfoTest"
assert "version" in info
assert "page_count" in info
assert "page_size_mm" in info
assert "working_dpi" in info
def test_get_project_info_invalid_file(self, tmp_path):
"""Test getting info from invalid file"""
invalid_file = tmp_path / "invalid.ppz"
invalid_file.write_text("not a zip")
info = get_project_info(str(invalid_file))
assert info is None
def test_get_project_info_nonexistent_file(self, tmp_path):
"""Test getting info from nonexistent file"""
info = get_project_info(str(tmp_path / "nonexistent.ppz"))
assert info is None
class TestNormalizeAssetPaths:
"""Tests for _normalize_asset_paths function"""
def test_normalize_relative_path_unchanged(self, tmp_path):
"""Test that simple relative paths are unchanged"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
# Add a page with an image that has a simple relative path
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be unchanged
assert img.image_path == "assets/image.jpg"
def test_normalize_absolute_path(self, tmp_path):
"""Test that absolute paths are normalized"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Use a path that contains /assets/ pattern
abs_path = str(project_folder / "assets" / "image.jpg")
img = ImageData(image_path=abs_path)
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Path should be normalized to relative
assert img.image_path == "assets/image.jpg"
def test_normalize_legacy_path(self, tmp_path):
"""Test normalizing legacy project path format"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
# Legacy path format
img = ImageData(image_path="./projects/old_project/assets/image.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
_normalize_asset_paths(project, str(project_folder))
# Should extract just the assets/filename part
assert img.image_path == "assets/image.jpg"
class TestImportExternalImages:
"""Tests for _import_external_images function"""
def test_import_external_images_no_external(self, tmp_path):
"""Test with no external images"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Test", folder_path=str(project_folder))
from pyPhotoAlbum.page_layout import PageLayout
page_mock = Mock()
layout = PageLayout(width=210, height=297)
img = ImageData(image_path="assets/existing.jpg")
layout.add_element(img)
page_mock.layout = layout
project.pages = [page_mock]
# Should not raise and not change path
_import_external_images(project)
assert img.image_path == "assets/existing.jpg"
class TestRoundTrip:
"""Test save and load roundtrip"""
def test_roundtrip_basic(self, tmp_path):
"""Test saving and loading a project"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="RoundTrip", folder_path=str(project_folder))
original.working_dpi = 150
zip_path = str(tmp_path / "roundtrip.ppz")
success, _ = save_to_zip(original, zip_path)
assert success
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert loaded.name == original.name
assert loaded.working_dpi == original.working_dpi
def test_roundtrip_with_pages(self, tmp_path):
"""Test roundtrip with pages"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
original = Project(name="WithPages", folder_path=str(project_folder))
# Project starts with 1 page, add more using create_page
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
page2 = Page(PageLayout(width=210, height=297))
page3 = Page(PageLayout(width=210, height=297))
original.add_page(page2)
original.add_page(page3)
zip_path = str(tmp_path / "pages.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
# Pages are preserved (Project might not start with a default page)
assert len(loaded.pages) >= 2
def test_roundtrip_with_elements(self, tmp_path, temp_image_file):
"""Test roundtrip with elements on page"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
# Copy temp image to assets
shutil.copy(temp_image_file, assets_folder / "test.jpg")
original = Project(name="WithElements", folder_path=str(project_folder))
# Add element to first page (project starts with at least 1 page)
img = ImageData(image_path="assets/test.jpg", x=50, y=50, width=100, height=100)
# Check if there's a default page, add one if needed
if not original.pages:
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout
original.add_page(Page(PageLayout(width=210, height=297)))
original.pages[0].layout.add_element(img)
zip_path = str(tmp_path / "elements.ppz")
save_to_zip(original, zip_path)
extract_to = str(tmp_path / "extracted")
loaded = load_from_zip(zip_path, extract_to)
assert len(loaded.pages) >= 1
assert len(loaded.pages[0].layout.elements) >= 1
loaded_elem = loaded.pages[0].layout.elements[0]
assert loaded_elem.position == (50.0, 50.0)
assert loaded_elem.size == (100.0, 100.0)
class TestVersionCompatibility:
"""Tests for version handling"""
def test_version_included_in_save(self, tmp_path):
"""Test that version is included when saving"""
project_folder = tmp_path / "project"
project_folder.mkdir()
assets_folder = project_folder / "assets"
assets_folder.mkdir()
project = Project(name="Version", folder_path=str(project_folder))
zip_path = str(tmp_path / "version.ppz")
save_to_zip(project, zip_path)
with zipfile.ZipFile(zip_path, "r") as zipf:
data = json.loads(zipf.read("project.json"))
# Should have both legacy and new version fields
assert "serialization_version" in data
assert "data_version" in data