""" 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