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