All checks were successful
Python CI / test (push) Successful in 1m24s
Lint / lint (push) Successful in 1m13s
Tests / test (3.11) (push) Successful in 1m37s
Tests / test (3.12) (push) Successful in 1m41s
Tests / test (3.13) (push) Successful in 1m34s
Tests / test (3.14) (push) Successful in 1m12s
1374 lines
49 KiB
Python
Executable File
1374 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.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 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 line stipple for dashed border
|
|
from pyPhotoAlbum.models import GL_LINE_STIPPLE
|
|
|
|
mock_glEnable.assert_called_with(GL_LINE_STIPPLE)
|
|
mock_glLineStipple.assert_called_once_with(2, 0xAAAA)
|
|
mock_glDisable.assert_called_with(GL_LINE_STIPPLE)
|
|
|
|
# Should draw light gray dashed border
|
|
mock_glColor3f.assert_called_with(0.7, 0.7, 0.7)
|
|
|
|
# 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
|