pyPhotoAlbum/tests/test_models.py
Duncan Tourolle b18a780a33
All checks were successful
Python CI / test (push) Successful in 1m28s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m41s
Tests / test (3.12) (push) Successful in 1m42s
Tests / test (3.13) (push) Successful in 1m35s
Tests / test (3.14) (push) Successful in 1m15s
increase test coverage
2025-11-28 19:54:41 +01:00

1379 lines
49 KiB
Python
Executable File

"""
Unit tests for pyPhotoAlbum models
"""
import pytest
import os
import tempfile
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from PIL import Image
from pyPhotoAlbum.models import (
ImageData,
PlaceholderData,
TextBoxData,
GhostPageData,
BaseLayoutElement,
set_asset_resolution_context,
get_asset_search_paths,
)
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()
def test_mark_modified(self):
"""Test that mark_modified updates the last_modified timestamp"""
img = ImageData()
original_modified = img.last_modified
# Wait a tiny bit to ensure timestamp changes
import time
time.sleep(0.01)
img.mark_modified()
assert img.last_modified != original_modified
# Verify it's a valid ISO format timestamp
datetime.fromisoformat(img.last_modified)
def test_mark_deleted(self):
"""Test that mark_deleted properly marks an element as deleted"""
img = ImageData()
assert img.deleted is False
assert img.deleted_at is None
img.mark_deleted()
assert img.deleted is True
assert img.deleted_at is not None
# Verify deleted_at is a valid ISO format timestamp
datetime.fromisoformat(img.deleted_at)
def test_serialize_base_fields(self):
"""Test that base fields are included in serialization"""
img = ImageData()
data = img.serialize()
assert "uuid" in data
assert "created" in data
assert "last_modified" in data
assert "deleted" in data
assert "deleted_at" in data
assert data["deleted"] is False
def test_deserialize_base_fields_with_uuid(self):
"""Test deserializing base fields when uuid is present"""
img = ImageData()
test_uuid = "test-uuid-12345"
test_created = "2024-01-01T00:00:00+00:00"
test_modified = "2024-01-02T00:00:00+00:00"
data = {
"uuid": test_uuid,
"created": test_created,
"last_modified": test_modified,
"deleted": False,
"deleted_at": None,
}
img.deserialize(data)
assert img.uuid == test_uuid
assert img.created == test_created
assert img.last_modified == test_modified
assert img.deleted is False
assert img.deleted_at is None
def test_deserialize_base_fields_generates_uuid_if_missing(self):
"""Test that deserialize generates UUID if not present (backwards compatibility)"""
img = ImageData()
data = {} # No UUID
img.deserialize(data)
# Should generate a UUID
assert img.uuid is not None
assert len(img.uuid) > 0
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)
# After rotation refactoring, old visual rotation is converted to pil_rotation_90
assert img.rotation == 0 # Visual rotation reset to 0
assert img.pil_rotation_90 == 1 # 90 degrees converted to pil_rotation_90
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"""
# Note: After rotation refactoring, ImageData uses pil_rotation_90 for 90-degree rotations
# Setting rotation directly is not the typical workflow anymore, but we test it works
original = ImageData(
image_path=temp_image_file,
x=50.0,
y=60.0,
width=300.0,
height=200.0,
rotation=0, # Visual rotation should be 0 for images
z_index=2,
crop_info=(0.1, 0.1, 0.9, 0.9),
)
original.pil_rotation_90 = 1 # Set PIL rotation to 90 degrees
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 == 0 # Should remain 0
assert restored.pil_rotation_90 == 1 # PIL rotation preserved
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)
def test_resolve_image_path_absolute_exists(self, temp_image_file):
"""Test resolve_image_path with absolute path that exists"""
img = ImageData(image_path=temp_image_file)
resolved = img.resolve_image_path()
assert resolved == temp_image_file
assert os.path.isabs(resolved)
def test_resolve_image_path_absolute_not_exists(self):
"""Test resolve_image_path with absolute path that doesn't exist"""
img = ImageData(image_path="/nonexistent/absolute/path.jpg")
resolved = img.resolve_image_path()
assert resolved is None
def test_resolve_image_path_empty(self):
"""Test resolve_image_path with empty path"""
img = ImageData(image_path="")
resolved = img.resolve_image_path()
assert resolved is None
def test_resolve_image_path_relative_with_project_folder(self, temp_dir):
"""Test resolve_image_path with relative path and project folder set"""
# Create a test image in the temp directory
test_image_path = os.path.join(temp_dir, "test_image.jpg")
img = Image.new("RGB", (50, 50), color="blue")
img.save(test_image_path)
# Set the asset resolution context
set_asset_resolution_context(temp_dir)
# Create ImageData with relative path
img_data = ImageData(image_path="test_image.jpg")
resolved = img_data.resolve_image_path()
assert resolved is not None
assert os.path.exists(resolved)
assert resolved == test_image_path
# Reset context
set_asset_resolution_context(None)
def test_resolve_image_path_relative_no_project_folder(self):
"""Test resolve_image_path with relative path but no project folder set"""
# Reset context
set_asset_resolution_context(None)
img = ImageData(image_path="relative/path.jpg")
resolved = img.resolve_image_path()
assert resolved is None
def test_rotation_conversion_90_degrees(self):
"""Test conversion of old visual rotation (90°) to pil_rotation_90"""
img = ImageData()
data = {
"rotation": 90.0,
"pil_rotation_90": 0, # Old data without PIL rotation
}
img.deserialize(data)
assert img.rotation == 0 # Visual rotation should be reset
assert img.pil_rotation_90 == 1 # Should be converted to 1 (90°)
def test_rotation_conversion_180_degrees(self):
"""Test conversion of old visual rotation (180°) to pil_rotation_90"""
img = ImageData()
data = {
"rotation": 180.0,
"pil_rotation_90": 0,
}
img.deserialize(data)
assert img.rotation == 0
assert img.pil_rotation_90 == 2
def test_rotation_conversion_270_degrees(self):
"""Test conversion of old visual rotation (270°) to pil_rotation_90"""
img = ImageData()
data = {
"rotation": 270.0,
"pil_rotation_90": 0,
}
img.deserialize(data)
assert img.rotation == 0
assert img.pil_rotation_90 == 3
def test_no_rotation_conversion_when_pil_rotation_set(self):
"""Test that rotation conversion doesn't happen if pil_rotation_90 is already set"""
img = ImageData()
data = {
"rotation": 90.0,
"pil_rotation_90": 2, # Already set to 180°
}
img.deserialize(data)
# Should keep existing pil_rotation_90, not convert
assert img.pil_rotation_90 == 2
assert img.rotation == 90.0 # Keeps original rotation
def test_image_dimensions_serialization(self):
"""Test that image_dimensions are serialized when available"""
img = ImageData()
img.image_dimensions = (800, 600)
data = img.serialize()
assert "image_dimensions" in data
assert data["image_dimensions"] == (800, 600)
def test_image_dimensions_deserialization(self):
"""Test that image_dimensions are deserialized correctly"""
img = ImageData()
data = {
"image_dimensions": [1920, 1080] # List format from JSON
}
img.deserialize(data)
assert img.image_dimensions == (1920, 1080) # Converted to tuple
def test_pil_rotation_serialization(self):
"""Test that pil_rotation_90 is serialized"""
img = ImageData()
img.pil_rotation_90 = 2
data = img.serialize()
assert data["pil_rotation_90"] == 2
def test_async_loading_initial_state(self):
"""Test initial state of async loading flags"""
img = ImageData()
assert img._async_loading is False
assert img._async_load_requested is False
def test_on_async_image_loaded_callback(self):
"""Test _on_async_image_loaded callback sets pending image"""
img = ImageData(image_path="test.jpg")
# Create a mock PIL image
pil_image = Image.new("RGBA", (200, 100), color="red")
# Call the callback
img._on_async_image_loaded(pil_image)
# Verify the pending image is set
assert hasattr(img, "_pending_pil_image")
assert img._pending_pil_image is not None
assert img._img_width == 200
assert img._img_height == 100
assert img._async_loading is False
assert img.image_dimensions == (200, 100)
def test_on_async_image_loaded_with_pil_rotation(self):
"""Test _on_async_image_loaded applies PIL rotation"""
img = ImageData(image_path="test.jpg")
img.pil_rotation_90 = 1 # 90 degrees
# Create a mock PIL image (100x200)
pil_image = Image.new("RGBA", (100, 200), color="blue")
# Call the callback
img._on_async_image_loaded(pil_image)
# After 90° rotation, dimensions should be swapped (200x100)
assert img._img_width == 200
assert img._img_height == 100
assert img.image_dimensions == (200, 100)
def test_on_async_image_load_failed_callback(self):
"""Test _on_async_image_load_failed callback"""
img = ImageData(image_path="test.jpg")
img._async_loading = True
img._async_load_requested = True
# Call the failure callback
img._on_async_image_load_failed("File not found")
# Verify flags are reset
assert img._async_loading is False
assert img._async_load_requested is False
def test_on_async_image_loaded_exception_handling(self):
"""Test _on_async_image_loaded handles exceptions gracefully"""
img = ImageData(image_path="test.jpg")
# Create a mock object that will raise an exception when accessed
class BadImage:
@property
def size(self):
raise RuntimeError("Simulated error")
@property
def width(self):
raise RuntimeError("Simulated error")
bad_image = BadImage()
# Call should not raise, but should handle the error
img._on_async_image_loaded(bad_image)
# Verify that async loading is reset and pending image is None
assert img._async_loading is False
assert not hasattr(img, "_pending_pil_image") or img._pending_pil_image is None
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_without_texture(self, mock_glEnd, mock_glBegin, mock_glVertex2f, mock_glColor3f):
"""Test ImageData.render() without texture (placeholder mode)"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img.render()
# Should draw a light blue placeholder rectangle
mock_glColor3f.assert_any_call(0.7, 0.85, 1.0)
# Should draw a black border
mock_glColor3f.assert_any_call(0.0, 0.0, 0.0)
# Verify GL_QUADS and GL_LINE_LOOP were used
assert mock_glBegin.call_count >= 2
assert mock_glEnd.call_count >= 2
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexCoord2f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_texture(
self,
mock_glEnd,
mock_glBegin,
mock_glColor4f,
mock_glVertex2f,
mock_glTexCoord2f,
mock_glBindTexture,
mock_glEnable,
mock_glDisable,
):
"""Test ImageData.render() with texture"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img._texture_id = 123
img._img_width = 200
img._img_height = 100
img.render()
# Should enable and bind texture
from pyPhotoAlbum.models import GL_TEXTURE_2D
mock_glEnable.assert_called()
mock_glBindTexture.assert_called_with(GL_TEXTURE_2D, 123)
mock_glColor4f.assert_called_with(1.0, 1.0, 1.0, 1.0)
# Should set texture coordinates and vertices
assert mock_glTexCoord2f.call_count >= 4
assert mock_glVertex2f.call_count >= 4
# Should disable texture after rendering
mock_glDisable.assert_called()
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_from_pending_image_success(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
):
"""Test _create_texture_from_pending_image successfully creates texture"""
img = ImageData(image_path="test.jpg")
# Mock GL context is available
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 456
# Create a pending PIL image
pil_image = Image.new("RGBA", (100, 50), color="green")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
assert img._texture_id == 456
assert img._pending_pil_image is None # Should be cleared
mock_glGenTextures.assert_called_once_with(1)
mock_glTexImage2D.assert_called_once()
@patch("pyPhotoAlbum.models.glGetString")
def test_create_texture_from_pending_image_no_gl_context(self, mock_glGetString):
"""Test _create_texture_from_pending_image defers when no GL context"""
img = ImageData(image_path="test.jpg")
# Mock no GL context available
mock_glGetString.return_value = None
# Create a pending PIL image
pil_image = Image.new("RGBA", (100, 50), color="yellow")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is False
assert img._pending_pil_image is not None # Should keep pending image
assert not hasattr(img, "_texture_id") or img._texture_id is None
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glGetString")
def test_create_texture_from_pending_image_gl_error(self, mock_glGetString, mock_glGenTextures):
"""Test _create_texture_from_pending_image handles GL errors"""
img = ImageData(image_path="test.jpg")
# Mock GL context available but genTextures fails with GL error
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.side_effect = Exception("GLError 1282: Invalid operation")
pil_image = Image.new("RGBA", (100, 50), color="purple")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
# Should return False and keep trying on next render
assert result is False
assert img._pending_pil_image is not None
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glGetString")
def test_create_texture_from_pending_image_other_error(self, mock_glGetString, mock_glGenTextures):
"""Test _create_texture_from_pending_image handles non-GL errors"""
img = ImageData(image_path="test.jpg")
# Mock GL context available but other error occurs
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.side_effect = Exception("Some other error")
pil_image = Image.new("RGBA", (100, 50), color="cyan")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
# Should return False and clear pending image (give up)
assert result is False
assert img._pending_pil_image is None
assert img._texture_id is None
@patch("pyPhotoAlbum.models.glDeleteTextures")
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_deletes_old_texture(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString, mock_glDeleteTextures
):
"""Test _create_texture_from_pending_image deletes old texture"""
img = ImageData(image_path="test.jpg")
img._texture_id = 789 # Old texture
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 999
pil_image = Image.new("RGBA", (100, 50), color="red")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
mock_glDeleteTextures.assert_called_once_with([789])
assert img._texture_id == 999
def test_create_texture_from_pending_image_no_pending(self):
"""Test _create_texture_from_pending_image returns False when no pending image"""
img = ImageData(image_path="test.jpg")
# No pending image set
result = img._create_texture_from_pending_image()
assert result is False
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_converts_non_rgba(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
):
"""Test _create_texture_from_pending_image converts non-RGBA images"""
img = ImageData(image_path="test.jpg")
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 456
# Create RGB image (not RGBA)
pil_image = Image.new("RGB", (100, 50), color="blue")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
# Image should have been converted to RGBA
assert img._texture_id == 456
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
def test_create_texture_clears_warning_flag(
self, mock_glTexImage2D, mock_glTexParameteri, mock_glBindTexture, mock_glGenTextures, mock_glGetString
):
"""Test _create_texture_from_pending_image clears GL context warning flag on success"""
img = ImageData(image_path="test.jpg")
img._gl_context_warned = True # Set warning flag
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 456
pil_image = Image.new("RGBA", (100, 50), color="green")
img._pending_pil_image = pil_image
result = img._create_texture_from_pending_image()
assert result is True
# Warning flag should be cleared
assert not hasattr(img, "_gl_context_warned")
@patch("pyPhotoAlbum.models.calculate_center_crop_coords")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexCoord2f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_texture_using_image_dimensions(
self,
mock_glEnd,
mock_glBegin,
mock_glColor3f,
mock_glColor4f,
mock_glVertex2f,
mock_glTexCoord2f,
mock_glBindTexture,
mock_glEnable,
mock_glDisable,
mock_calculate_coords,
):
"""Test render() with texture but no _img_width/_img_height, using image_dimensions"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img._texture_id = 123
img.image_dimensions = (800, 600) # Has dimensions but not _img_width/_img_height
mock_calculate_coords.return_value = (0, 0, 1, 1)
img.render()
# Should use image_dimensions for crop calculation
mock_calculate_coords.assert_called_once()
args = mock_calculate_coords.call_args[0]
assert args[0] == 800 # img_width from image_dimensions
assert args[1] == 600 # img_height from image_dimensions
@patch("pyPhotoAlbum.models.calculate_center_crop_coords")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexCoord2f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_texture_no_dimensions(
self,
mock_glEnd,
mock_glBegin,
mock_glColor3f,
mock_glColor4f,
mock_glVertex2f,
mock_glTexCoord2f,
mock_glBindTexture,
mock_glEnable,
mock_glDisable,
mock_calculate_coords,
):
"""Test render() with texture but no dimensions at all"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
img._texture_id = 123
# No _img_width/_img_height and no image_dimensions
mock_calculate_coords.return_value = (0, 0, 1, 1)
img.render()
# Should use element size as fallback
mock_calculate_coords.assert_called_once()
args = mock_calculate_coords.call_args[0]
assert args[0] == 100 # Uses width as img_width
assert args[1] == 50 # Uses height as img_height
@patch("pyPhotoAlbum.models.glGetString")
@patch("pyPhotoAlbum.models.glGenTextures")
@patch("pyPhotoAlbum.models.glBindTexture")
@patch("pyPhotoAlbum.models.glTexParameteri")
@patch("pyPhotoAlbum.models.glTexImage2D")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glDisable")
def test_render_calls_create_texture_from_pending(
self,
mock_glDisable,
mock_glEnable,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glTexImage2D,
mock_glTexParameteri,
mock_glBindTexture,
mock_glGenTextures,
mock_glGetString,
):
"""Test render() calls _create_texture_from_pending_image when pending image exists"""
img = ImageData(image_path="test.jpg", x=10, y=20, width=100, height=50)
# Set up pending image
pil_image = Image.new("RGBA", (100, 50), color="orange")
img._pending_pil_image = pil_image
mock_glGetString.return_value = b"4.5.0"
mock_glGenTextures.return_value = 999
img.render()
# Should have created texture from pending image
assert img._texture_id == 999
assert img._pending_pil_image is None # Should be cleared
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
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glLineStipple")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_without_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glLineStipple,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test PlaceholderData.render() without rotation"""
placeholder = PlaceholderData(x=10, y=20, width=100, height=50, rotation=0)
placeholder.render()
# Should draw light gray rectangle
mock_glColor3f.assert_any_call(0.9, 0.9, 0.9)
# Should draw gray dashed border
mock_glColor3f.assert_any_call(0.5, 0.5, 0.5)
# Should enable and disable line stipple
from pyPhotoAlbum.models import GL_LINE_STIPPLE
mock_glEnable.assert_called_with(GL_LINE_STIPPLE)
mock_glLineStipple.assert_called_once_with(1, 0x00FF)
mock_glDisable.assert_called_with(GL_LINE_STIPPLE)
# Should NOT push/pop matrix when rotation is 0
mock_glPushMatrix.assert_not_called()
mock_glPopMatrix.assert_not_called()
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glLineStipple")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glLineStipple,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test PlaceholderData.render() with rotation"""
placeholder = PlaceholderData(x=10, y=20, width=100, height=50, rotation=45)
placeholder.render()
# Should push/pop matrix for rotation
mock_glPushMatrix.assert_called_once()
mock_glPopMatrix.assert_called_once()
# Should translate to center and rotate
assert mock_glTranslatef.call_count == 2
mock_glRotatef.assert_called_once_with(45, 0, 0, 1)
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"
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBlendFunc")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_without_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glColor4f,
mock_glBlendFunc,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test TextBoxData.render() without rotation"""
textbox = TextBoxData(text_content="Test", x=10, y=20, width=100, height=50, rotation=0)
textbox.render()
# Should enable and disable blending
from pyPhotoAlbum.models import GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA
mock_glEnable.assert_called_with(GL_BLEND)
mock_glBlendFunc.assert_called_once_with(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
mock_glDisable.assert_called_with(GL_BLEND)
# Should draw semi-transparent yellow background
mock_glColor4f.assert_called_with(1.0, 1.0, 0.7, 0.3)
# Should draw black border
mock_glColor3f.assert_called_with(0.0, 0.0, 0.0)
# Should NOT push/pop matrix when rotation is 0
mock_glPushMatrix.assert_not_called()
mock_glPopMatrix.assert_not_called()
@patch("pyPhotoAlbum.models.glPopMatrix")
@patch("pyPhotoAlbum.models.glPushMatrix")
@patch("pyPhotoAlbum.models.glRotatef")
@patch("pyPhotoAlbum.models.glTranslatef")
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBlendFunc")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render_with_rotation(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glColor4f,
mock_glBlendFunc,
mock_glEnable,
mock_glDisable,
mock_glTranslatef,
mock_glRotatef,
mock_glPushMatrix,
mock_glPopMatrix,
):
"""Test TextBoxData.render() with rotation"""
textbox = TextBoxData(text_content="Test", x=10, y=20, width=100, height=50, rotation=30)
textbox.render()
# Should push/pop matrix for rotation
mock_glPushMatrix.assert_called_once()
mock_glPopMatrix.assert_called_once()
# Should translate to center and rotate
assert mock_glTranslatef.call_count == 2
mock_glRotatef.assert_called_once_with(30, 0, 0, 1)
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
class TestAssetResolution:
"""Tests for asset resolution functions"""
def test_set_and_get_asset_resolution_context(self, temp_dir):
"""Test setting and getting asset resolution context"""
additional_paths = ["/path1", "/path2"]
set_asset_resolution_context(temp_dir, additional_paths)
project_folder, search_paths = get_asset_search_paths()
assert project_folder == temp_dir
assert search_paths == additional_paths
def test_set_asset_resolution_context_no_additional_paths(self, temp_dir):
"""Test setting context without additional search paths"""
set_asset_resolution_context(temp_dir)
project_folder, search_paths = get_asset_search_paths()
assert project_folder == temp_dir
assert search_paths == []
def test_get_asset_search_paths_default(self):
"""Test getting asset search paths when not set"""
# This depends on global state, so we just verify it returns a tuple
result = get_asset_search_paths()
assert isinstance(result, tuple)
assert len(result) == 2
class TestGhostPageData:
"""Tests for GhostPageData class"""
def test_initialization_default(self):
"""Test GhostPageData initialization with default values"""
ghost = GhostPageData()
assert ghost.page_size == (210, 297) # A4 size in mm
assert ghost.is_ghost is True
assert ghost.position == (0, 0)
assert ghost.size == (100, 100)
def test_initialization_with_custom_page_size(self):
"""Test GhostPageData initialization with custom page size"""
custom_size = (200, 250)
ghost = GhostPageData(page_size=custom_size, x=10, y=20)
assert ghost.page_size == custom_size
assert ghost.position == (10, 20)
assert ghost.is_ghost is True
def test_serialization(self):
"""Test GhostPageData serialization"""
ghost = GhostPageData(page_size=(200, 280), x=5, y=10)
data = ghost.serialize()
assert data["type"] == "ghostpage"
assert data["page_size"] == (200, 280)
assert data["position"] == (5, 10)
# Check base fields
assert "uuid" in data
assert "created" in data
assert "last_modified" in data
def test_deserialization(self):
"""Test GhostPageData deserialization"""
ghost = GhostPageData()
data = {
"position": (15, 25),
"size": (150, 200),
"page_size": (220, 300),
}
ghost.deserialize(data)
assert ghost.position == (15, 25)
assert ghost.size == (150, 200)
assert ghost.page_size == (220, 300)
def test_deserialization_with_defaults(self):
"""Test GhostPageData deserialization with missing fields"""
ghost = GhostPageData()
data = {}
ghost.deserialize(data)
assert ghost.position == (0, 0)
assert ghost.size == (100, 100)
assert ghost.page_size == (210, 297)
def test_serialize_deserialize_roundtrip(self):
"""Test that serialize and deserialize are inverse operations"""
original = GhostPageData(page_size=(200, 250), x=20, y=30)
data = original.serialize()
restored = GhostPageData()
restored.deserialize(data)
assert restored.page_size == original.page_size
assert restored.position == original.position
assert restored.is_ghost is True
def test_get_page_rect(self):
"""Test get_page_rect returns correct bounding box"""
ghost = GhostPageData(page_size=(210, 297))
rect = ghost.get_page_rect()
assert len(rect) == 4
x, y, w, h = rect
assert x == 0
assert y == 0
# Width and height should be calculated from page_size at 300 DPI
# 210mm * 300 DPI / 25.4 mm/inch ≈ 2480px
assert w > 0
assert h > 0
def test_page_size_modification(self):
"""Test modifying page_size after initialization"""
ghost = GhostPageData()
ghost.page_size = (250, 350)
assert ghost.page_size == (250, 350)
@patch("pyPhotoAlbum.models.glDisable")
@patch("pyPhotoAlbum.models.glEnable")
@patch("pyPhotoAlbum.models.glBlendFunc")
@patch("pyPhotoAlbum.models.glLineStipple")
@patch("pyPhotoAlbum.models.glColor4f")
@patch("pyPhotoAlbum.models.glColor3f")
@patch("pyPhotoAlbum.models.glVertex2f")
@patch("pyPhotoAlbum.models.glBegin")
@patch("pyPhotoAlbum.models.glEnd")
def test_render(
self,
mock_glEnd,
mock_glBegin,
mock_glVertex2f,
mock_glColor3f,
mock_glColor4f,
mock_glLineStipple,
mock_glBlendFunc,
mock_glEnable,
mock_glDisable,
):
"""Test GhostPageData.render()"""
ghost = GhostPageData(page_size=(210, 297))
ghost.render()
# Should enable and disable blending
from pyPhotoAlbum.models import GL_BLEND, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_LINE_STIPPLE
mock_glEnable.assert_any_call(GL_BLEND)
mock_glBlendFunc.assert_called_once_with(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Should draw semi-transparent grey background
mock_glColor4f.assert_called_with(0.8, 0.8, 0.8, 0.5)
# Should draw grey dashed border
mock_glColor3f.assert_called_with(0.5, 0.5, 0.5)
mock_glEnable.assert_any_call(GL_LINE_STIPPLE)
mock_glLineStipple.assert_called_once_with(2, 0x0F0F)
mock_glDisable.assert_any_call(GL_LINE_STIPPLE)
# Should have drawn quads and line loop
assert mock_glBegin.call_count >= 2
assert mock_glEnd.call_count >= 2
assert mock_glVertex2f.call_count >= 8 # 4 vertices for quad + 4 for border