370 lines
14 KiB
Python
370 lines
14 KiB
Python
"""
|
|
Unit tests for pyWebLayout.concrete.image module.
|
|
Tests the RenderableImage class for image loading, scaling, and rendering functionality.
|
|
"""
|
|
|
|
import unittest
|
|
import os
|
|
import tempfile
|
|
import numpy as np
|
|
from PIL import Image as PILImage
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
|
|
from pyWebLayout.concrete.image import RenderableImage
|
|
from pyWebLayout.abstract.block import Image as AbstractImage
|
|
from pyWebLayout.style.layout import Alignment
|
|
|
|
|
|
class TestRenderableImage(unittest.TestCase):
|
|
"""Test cases for the RenderableImage class"""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures"""
|
|
# Create a temporary test image
|
|
self.temp_dir = tempfile.mkdtemp()
|
|
self.test_image_path = os.path.join(self.temp_dir, "test_image.png")
|
|
|
|
# Create a simple test image
|
|
test_img = PILImage.new('RGB', (100, 80), (255, 0, 0)) # Red image
|
|
test_img.save(self.test_image_path)
|
|
|
|
# Create abstract image objects
|
|
self.abstract_image = AbstractImage(self.test_image_path, "Test Image", 100, 80)
|
|
self.abstract_image_no_dims = AbstractImage(self.test_image_path, "Test Image")
|
|
|
|
def tearDown(self):
|
|
"""Clean up test fixtures"""
|
|
# Clean up temporary files
|
|
try:
|
|
os.unlink(self.test_image_path)
|
|
os.rmdir(self.temp_dir)
|
|
except:
|
|
pass
|
|
|
|
def test_renderable_image_initialization_basic(self):
|
|
"""Test basic image initialization"""
|
|
renderable = RenderableImage(self.abstract_image)
|
|
|
|
self.assertEqual(renderable._abstract_image, self.abstract_image)
|
|
self.assertIsNotNone(renderable._pil_image)
|
|
self.assertIsNone(renderable._error_message)
|
|
self.assertEqual(renderable._halign, Alignment.CENTER)
|
|
self.assertEqual(renderable._valign, Alignment.CENTER)
|
|
|
|
def test_renderable_image_initialization_with_constraints(self):
|
|
"""Test image initialization with size constraints"""
|
|
max_width = 50
|
|
max_height = 40
|
|
|
|
renderable = RenderableImage(
|
|
self.abstract_image,
|
|
max_width=max_width,
|
|
max_height=max_height
|
|
)
|
|
|
|
self.assertEqual(renderable._abstract_image, self.abstract_image)
|
|
# Size should be constrained
|
|
self.assertLessEqual(renderable._size[0], max_width)
|
|
self.assertLessEqual(renderable._size[1], max_height)
|
|
|
|
def test_renderable_image_initialization_with_custom_params(self):
|
|
"""Test image initialization with custom parameters"""
|
|
custom_origin = (20, 30)
|
|
custom_size = (120, 90)
|
|
custom_callback = Mock()
|
|
|
|
renderable = RenderableImage(
|
|
self.abstract_image,
|
|
origin=custom_origin,
|
|
size=custom_size,
|
|
callback=custom_callback,
|
|
halign=Alignment.LEFT,
|
|
valign=Alignment.TOP
|
|
)
|
|
|
|
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
|
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
|
self.assertEqual(renderable._callback, custom_callback)
|
|
self.assertEqual(renderable._halign, Alignment.LEFT)
|
|
self.assertEqual(renderable._valign, Alignment.TOP)
|
|
|
|
def test_load_image_local_file(self):
|
|
"""Test loading image from local file"""
|
|
renderable = RenderableImage(self.abstract_image)
|
|
|
|
# Image should be loaded
|
|
self.assertIsNotNone(renderable._pil_image)
|
|
self.assertIsNone(renderable._error_message)
|
|
self.assertEqual(renderable._pil_image.size, (100, 80))
|
|
|
|
def test_load_image_nonexistent_file(self):
|
|
"""Test loading image from nonexistent file"""
|
|
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
|
renderable = RenderableImage(bad_abstract)
|
|
|
|
# Should have error message, no PIL image
|
|
self.assertIsNone(renderable._pil_image)
|
|
self.assertIsNotNone(renderable._error_message)
|
|
|
|
@patch('requests.get')
|
|
def test_load_image_url_success(self, mock_get):
|
|
"""Test loading image from URL (success)"""
|
|
# Create a mock response
|
|
mock_response = Mock()
|
|
mock_response.status_code = 200
|
|
mock_response.content = open(self.test_image_path, 'rb').read()
|
|
mock_get.return_value = mock_response
|
|
|
|
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
|
|
renderable = RenderableImage(url_abstract)
|
|
|
|
# Should successfully load image
|
|
self.assertIsNotNone(renderable._pil_image)
|
|
self.assertIsNone(renderable._error_message)
|
|
|
|
@patch('requests.get')
|
|
def test_load_image_url_failure(self, mock_get):
|
|
"""Test loading image from URL (failure)"""
|
|
# Mock a failed request
|
|
mock_response = Mock()
|
|
mock_response.status_code = 404
|
|
mock_get.return_value = mock_response
|
|
|
|
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
|
|
renderable = RenderableImage(url_abstract)
|
|
|
|
# Should have error message
|
|
self.assertIsNone(renderable._pil_image)
|
|
self.assertIsNotNone(renderable._error_message)
|
|
|
|
def test_load_image_no_requests_library(self):
|
|
"""Test loading URL image when requests library is not available"""
|
|
# Mock the import to raise ImportError for requests
|
|
def mock_import(name, *args, **kwargs):
|
|
if name == 'requests':
|
|
raise ImportError("No module named 'requests'")
|
|
return __import__(name, *args, **kwargs)
|
|
|
|
with patch('builtins.__import__', side_effect=mock_import):
|
|
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
|
|
renderable = RenderableImage(url_abstract)
|
|
|
|
# Should have error message about missing requests
|
|
self.assertIsNone(renderable._pil_image)
|
|
self.assertIsNotNone(renderable._error_message)
|
|
self.assertIn("Requests library not available", renderable._error_message)
|
|
|
|
def test_resize_image_fit_within_bounds(self):
|
|
"""Test image resizing to fit within bounds"""
|
|
renderable = RenderableImage(self.abstract_image)
|
|
|
|
# Original image is 100x80, resize to fit in 50x50
|
|
renderable._size = np.array([50, 50])
|
|
resized = renderable._resize_image()
|
|
|
|
self.assertIsInstance(resized, PILImage.Image)
|
|
# Should maintain aspect ratio and fit within bounds
|
|
self.assertLessEqual(resized.width, 50)
|
|
self.assertLessEqual(resized.height, 50)
|
|
# Check aspect ratio is maintained (approximately)
|
|
original_ratio = 100 / 80
|
|
new_ratio = resized.width / resized.height
|
|
self.assertAlmostEqual(original_ratio, new_ratio, delta=0.1)
|
|
|
|
def test_resize_image_larger_target(self):
|
|
"""Test image resizing when target is larger than original"""
|
|
renderable = RenderableImage(self.abstract_image)
|
|
|
|
# Target size larger than original
|
|
renderable._size = np.array([200, 160])
|
|
resized = renderable._resize_image()
|
|
|
|
self.assertIsInstance(resized, PILImage.Image)
|
|
# Should scale up to fill the space while maintaining aspect ratio
|
|
self.assertGreater(resized.width, 100)
|
|
self.assertGreater(resized.height, 80)
|
|
|
|
def test_resize_image_no_image(self):
|
|
"""Test resize when no image is loaded"""
|
|
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
|
renderable = RenderableImage(bad_abstract)
|
|
|
|
resized = renderable._resize_image()
|
|
|
|
# Should return a placeholder image
|
|
self.assertIsInstance(resized, PILImage.Image)
|
|
self.assertEqual(resized.mode, 'RGBA')
|
|
|
|
@patch('PIL.ImageDraw.Draw')
|
|
def test_draw_error_placeholder(self, mock_draw_class):
|
|
"""Test drawing error placeholder"""
|
|
mock_draw = Mock()
|
|
mock_draw_class.return_value = mock_draw
|
|
|
|
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
|
renderable = RenderableImage(bad_abstract)
|
|
|
|
canvas = PILImage.new('RGBA', (100, 80), (255, 255, 255, 255))
|
|
renderable._draw_error_placeholder(canvas)
|
|
|
|
# Should draw rectangle and lines for the X
|
|
mock_draw.rectangle.assert_called_once()
|
|
self.assertEqual(mock_draw.line.call_count, 2) # Two lines for the X
|
|
|
|
@patch('PIL.ImageDraw.Draw')
|
|
@patch('PIL.ImageFont.load_default')
|
|
def test_draw_error_placeholder_with_text(self, mock_font, mock_draw_class):
|
|
"""Test drawing error placeholder with error message"""
|
|
mock_draw = Mock()
|
|
mock_draw_class.return_value = mock_draw
|
|
mock_font.return_value = Mock()
|
|
|
|
# Mock textbbox to return reasonable bounds
|
|
mock_draw.textbbox.return_value = (0, 0, 50, 12)
|
|
|
|
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
|
renderable = RenderableImage(bad_abstract)
|
|
renderable._error_message = "File not found"
|
|
|
|
canvas = PILImage.new('RGBA', (100, 80), (255, 255, 255, 255))
|
|
renderable._draw_error_placeholder(canvas)
|
|
|
|
# Should draw rectangle, lines, and text
|
|
mock_draw.rectangle.assert_called_once()
|
|
self.assertEqual(mock_draw.line.call_count, 2)
|
|
mock_draw.text.assert_called() # Error text should be drawn
|
|
|
|
def test_render_successful_image(self):
|
|
"""Test rendering successfully loaded image"""
|
|
renderable = RenderableImage(self.abstract_image)
|
|
result = renderable.render()
|
|
|
|
self.assertIsInstance(result, PILImage.Image)
|
|
self.assertEqual(result.size, tuple(renderable._size))
|
|
self.assertEqual(result.mode, 'RGBA')
|
|
|
|
def test_render_failed_image(self):
|
|
"""Test rendering when image failed to load"""
|
|
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
|
renderable = RenderableImage(bad_abstract)
|
|
|
|
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
|
|
result = renderable.render()
|
|
|
|
self.assertIsInstance(result, PILImage.Image)
|
|
mock_draw_error.assert_called_once()
|
|
|
|
def test_render_with_left_alignment(self):
|
|
"""Test rendering with left alignment"""
|
|
renderable = RenderableImage(
|
|
self.abstract_image,
|
|
halign=Alignment.LEFT,
|
|
valign=Alignment.TOP
|
|
)
|
|
|
|
result = renderable.render()
|
|
|
|
self.assertIsInstance(result, PILImage.Image)
|
|
self.assertEqual(result.size, tuple(renderable._size))
|
|
|
|
def test_render_with_right_alignment(self):
|
|
"""Test rendering with right alignment"""
|
|
renderable = RenderableImage(
|
|
self.abstract_image,
|
|
halign=Alignment.RIGHT,
|
|
valign=Alignment.BOTTOM
|
|
)
|
|
|
|
result = renderable.render()
|
|
|
|
self.assertIsInstance(result, PILImage.Image)
|
|
self.assertEqual(result.size, tuple(renderable._size))
|
|
|
|
def test_render_rgba_image_on_rgba_canvas(self):
|
|
"""Test rendering RGBA image on RGBA canvas"""
|
|
# Create RGBA test image
|
|
rgba_image_path = os.path.join(self.temp_dir, "rgba_test.png")
|
|
rgba_img = PILImage.new('RGBA', (50, 40), (0, 255, 0, 128)) # Green with transparency
|
|
rgba_img.save(rgba_image_path)
|
|
|
|
try:
|
|
rgba_abstract = AbstractImage(rgba_image_path, "RGBA Image", 50, 40)
|
|
renderable = RenderableImage(rgba_abstract)
|
|
|
|
result = renderable.render()
|
|
|
|
self.assertIsInstance(result, PILImage.Image)
|
|
self.assertEqual(result.mode, 'RGBA')
|
|
finally:
|
|
try:
|
|
os.unlink(rgba_image_path)
|
|
except:
|
|
pass
|
|
|
|
def test_render_rgb_image_conversion(self):
|
|
"""Test rendering RGB image (should be converted to RGBA)"""
|
|
# Our test image is RGB, so this should test the conversion path
|
|
renderable = RenderableImage(self.abstract_image)
|
|
result = renderable.render()
|
|
|
|
self.assertIsInstance(result, PILImage.Image)
|
|
self.assertEqual(result.mode, 'RGBA')
|
|
|
|
def test_in_object(self):
|
|
"""Test in_object method"""
|
|
renderable = RenderableImage(self.abstract_image, origin=(10, 20))
|
|
|
|
# Point inside image
|
|
self.assertTrue(renderable.in_object((15, 25)))
|
|
|
|
# Point outside image
|
|
self.assertFalse(renderable.in_object((200, 200)))
|
|
|
|
def test_in_object_with_numpy_array(self):
|
|
"""Test in_object with numpy array point"""
|
|
renderable = RenderableImage(self.abstract_image, origin=(10, 20))
|
|
|
|
# Point inside image as numpy array
|
|
point = np.array([15, 25])
|
|
self.assertTrue(renderable.in_object(point))
|
|
|
|
# Point outside image as numpy array
|
|
point = np.array([200, 200])
|
|
self.assertFalse(renderable.in_object(point))
|
|
|
|
def test_image_size_calculation_with_abstract_image_dimensions(self):
|
|
"""Test that size is calculated from abstract image when available"""
|
|
# Abstract image has dimensions 100x80
|
|
renderable = RenderableImage(self.abstract_image)
|
|
|
|
# Size should match the calculated scaled dimensions
|
|
expected_size = self.abstract_image.calculate_scaled_dimensions()
|
|
np.testing.assert_array_equal(renderable._size, np.array(expected_size))
|
|
|
|
def test_image_size_calculation_with_constraints(self):
|
|
"""Test size calculation with max constraints"""
|
|
max_width = 60
|
|
max_height = 50
|
|
|
|
renderable = RenderableImage(
|
|
self.abstract_image,
|
|
max_width=max_width,
|
|
max_height=max_height
|
|
)
|
|
|
|
# Size should respect constraints
|
|
self.assertLessEqual(renderable._size[0], max_width)
|
|
self.assertLessEqual(renderable._size[1], max_height)
|
|
|
|
def test_image_without_initial_dimensions(self):
|
|
"""Test image without initial dimensions in abstract image"""
|
|
renderable = RenderableImage(self.abstract_image_no_dims)
|
|
|
|
# Should still work, using default or calculated size
|
|
self.assertIsInstance(renderable._size, np.ndarray)
|
|
self.assertEqual(len(renderable._size), 2)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|