pyWebLayout/tests/test_concrete_image.py
Duncan Tourolle 3f9bd0072e
All checks were successful
Python CI / test (push) Successful in 48s
test for concrete
2025-06-07 19:43:59 +02:00

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()