diff --git a/pyWebLayout/concrete/box.py b/pyWebLayout/concrete/box.py index 8827945..0bf8271 100644 --- a/pyWebLayout/concrete/box.py +++ b/pyWebLayout/concrete/box.py @@ -21,7 +21,7 @@ class Box(Renderable, Queriable): def in_shape(self, point): - return np.all((point >= self.origin) & (point < self._end), axis=-1) + return np.all((point >= self._origin) & (point < self._end), axis=-1) def render(self) -> Image: # Create a new image canvas diff --git a/pyWebLayout/concrete/image.py b/pyWebLayout/concrete/image.py index 95515c4..c2fbe9a 100644 --- a/pyWebLayout/concrete/image.py +++ b/pyWebLayout/concrete/image.py @@ -43,6 +43,9 @@ class RenderableImage(Box, Queriable): # Calculate the size if not provided if size is None: size = image.calculate_scaled_dimensions(max_width, max_height) + # Ensure we have valid dimensions, fallback to defaults if None + if size[0] is None or size[1] is None: + size = (100, 100) # Default size when image dimensions are unavailable # Initialize the box super().__init__(origin or (0, 0), size, callback, sheet, mode, halign, valign) @@ -131,7 +134,7 @@ class RenderableImage(Box, Queriable): A resized PIL Image """ if not self._pil_image: - return PILImage.new('RGBA', self._size, (200, 200, 200, 100)) + return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100)) # Get the target dimensions target_width, target_height = self._size diff --git a/tests/test_concrete_box.py b/tests/test_concrete_box.py new file mode 100644 index 0000000..37269d3 --- /dev/null +++ b/tests/test_concrete_box.py @@ -0,0 +1,189 @@ +""" +Unit tests for pyWebLayout.concrete.box module. +Tests the Box class which handles basic box model rendering with alignment. +""" + +import unittest +import numpy as np +from PIL import Image +from unittest.mock import Mock, patch + +from pyWebLayout.concrete.box import Box +from pyWebLayout.style.layout import Alignment + + +class TestBox(unittest.TestCase): + """Test cases for the Box class""" + + def setUp(self): + """Set up test fixtures""" + self.origin = (10, 20) + self.size = (100, 50) + self.callback = Mock() + + def test_box_initialization_basic(self): + """Test basic box initialization""" + box = Box(self.origin, self.size) + + np.testing.assert_array_equal(box._origin, np.array([10, 20])) + np.testing.assert_array_equal(box._size, np.array([100, 50])) + np.testing.assert_array_equal(box._end, np.array([110, 70])) + self.assertIsNone(box._callback) + self.assertIsNone(box._sheet) + self.assertIsNone(box._mode) + self.assertEqual(box._halign, Alignment.CENTER) + self.assertEqual(box._valign, Alignment.CENTER) + + def test_box_initialization_with_callback(self): + """Test box initialization with callback""" + box = Box(self.origin, self.size, callback=self.callback) + + self.assertEqual(box._callback, self.callback) + + def test_box_initialization_with_sheet(self): + """Test box initialization with image sheet""" + sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255)) + box = Box(self.origin, self.size, sheet=sheet) + + self.assertEqual(box._sheet, sheet) + self.assertEqual(box._mode, 'RGBA') + + def test_box_initialization_with_mode(self): + """Test box initialization with explicit mode""" + box = Box(self.origin, self.size, mode='RGB') + + self.assertEqual(box._mode, 'RGB') + + def test_box_initialization_with_alignment(self): + """Test box initialization with custom alignment""" + box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP) + + self.assertEqual(box._halign, Alignment.LEFT) + self.assertEqual(box._valign, Alignment.TOP) + + def test_in_shape_point_inside(self): + """Test in_shape method with point inside box""" + box = Box(self.origin, self.size) + + # Test point inside + self.assertTrue(box.in_shape(np.array([50, 40]))) + self.assertTrue(box.in_shape(np.array([10, 20]))) # Top-left corner + self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right + + def test_in_shape_point_outside(self): + """Test in_shape method with point outside box""" + box = Box(self.origin, self.size) + + # Test points outside + self.assertFalse(box.in_shape(np.array([5, 15]))) # Before origin + self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive) + self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside + + def test_in_shape_multiple_points(self): + """Test in_shape method with array of points""" + box = Box(self.origin, self.size) + + points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]]) + result = box.in_shape(points) + + np.testing.assert_array_equal(result, [True, False, True, False]) + + def test_render_default_no_content(self): + """Test render method with no content""" + box = Box(self.origin, self.size) + result = box.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + self.assertEqual(result.mode, 'RGBA') + + def test_render_with_sheet_mode(self): + """Test render method with sheet providing mode""" + sheet = Image.new('RGB', (200, 100), (255, 0, 0)) + box = Box(self.origin, self.size, sheet=sheet) + result = box.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + self.assertEqual(result.mode, 'RGB') + + def test_render_with_explicit_mode(self): + """Test render method with explicit mode""" + box = Box(self.origin, self.size, mode='L') # Grayscale + result = box.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + self.assertEqual(result.mode, 'L') + + def test_render_with_content_centered(self): + """Test render method with content centered""" + box = Box(self.origin, self.size, halign=Alignment.CENTER, valign=Alignment.CENTER) + + # Mock content that has a render method + mock_content = Mock() + mock_content.render.return_value = Image.new('RGBA', (50, 30), (255, 0, 0, 255)) + box._content = mock_content + + result = box.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + mock_content.render.assert_called_once() + + def test_render_with_content_left_aligned(self): + """Test render method with content left-aligned""" + box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP) + + # Mock content + mock_content = Mock() + mock_content.render.return_value = Image.new('RGBA', (30, 20), (0, 255, 0, 255)) + box._content = mock_content + + result = box.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + mock_content.render.assert_called_once() + + def test_render_with_content_right_aligned(self): + """Test render method with content right-aligned""" + box = Box(self.origin, self.size, halign=Alignment.RIGHT, valign=Alignment.BOTTOM) + + # Mock content + mock_content = Mock() + mock_content.render.return_value = Image.new('RGBA', (40, 25), (0, 0, 255, 255)) + box._content = mock_content + + result = box.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + mock_content.render.assert_called_once() + + def test_render_content_larger_than_box(self): + """Test render method when content is larger than box""" + small_box = Box((0, 0), (20, 15)) + + # Mock content larger than box + mock_content = Mock() + mock_content.render.return_value = Image.new('RGBA', (50, 40), (255, 255, 0, 255)) + small_box._content = mock_content + + result = small_box.render() + + # Should still create box-sized canvas + self.assertEqual(result.size, (20, 15)) + mock_content.render.assert_called_once() + + def test_properties_access(self): + """Test that properties can be accessed correctly""" + box = Box(self.origin, self.size, callback=self.callback) + + # Test that origin property works (should be available via inheritance) + np.testing.assert_array_equal(box._origin, np.array([10, 20])) + np.testing.assert_array_equal(box._size, np.array([100, 50])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_concrete_image.py b/tests/test_concrete_image.py new file mode 100644 index 0000000..0521f71 --- /dev/null +++ b/tests/test_concrete_image.py @@ -0,0 +1,369 @@ +""" +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() diff --git a/tests/test_concrete_page.py b/tests/test_concrete_page.py new file mode 100644 index 0000000..e889307 --- /dev/null +++ b/tests/test_concrete_page.py @@ -0,0 +1,459 @@ +""" +Unit tests for pyWebLayout.concrete.page module. +Tests the Container and Page classes for layout and rendering functionality. +""" + +import unittest +import numpy as np +from PIL import Image +from unittest.mock import Mock, patch, MagicMock + +from pyWebLayout.concrete.page import Container, Page +from pyWebLayout.concrete.box import Box +from pyWebLayout.style.layout import Alignment + + +class TestContainer(unittest.TestCase): + """Test cases for the Container class""" + + def setUp(self): + """Set up test fixtures""" + self.origin = (0, 0) + self.size = (400, 300) + self.callback = Mock() + + # Create mock child elements + self.mock_child1 = Mock() + self.mock_child1._size = np.array([100, 50]) + self.mock_child1._origin = np.array([0, 0]) + self.mock_child1.render.return_value = Image.new('RGBA', (100, 50), (255, 0, 0, 255)) + + self.mock_child2 = Mock() + self.mock_child2._size = np.array([120, 60]) + self.mock_child2._origin = np.array([0, 0]) + self.mock_child2.render.return_value = Image.new('RGBA', (120, 60), (0, 255, 0, 255)) + + def test_container_initialization_basic(self): + """Test basic container initialization""" + container = Container(self.origin, self.size) + + np.testing.assert_array_equal(container._origin, np.array(self.origin)) + np.testing.assert_array_equal(container._size, np.array(self.size)) + self.assertEqual(container._direction, 'vertical') + self.assertEqual(container._spacing, 5) + self.assertEqual(len(container._children), 0) + self.assertEqual(container._padding, (10, 10, 10, 10)) + self.assertEqual(container._halign, Alignment.CENTER) + self.assertEqual(container._valign, Alignment.CENTER) + + def test_container_initialization_with_params(self): + """Test container initialization with custom parameters""" + custom_direction = 'horizontal' + custom_spacing = 15 + custom_padding = (5, 8, 5, 8) + + container = Container( + self.origin, self.size, + direction=custom_direction, + spacing=custom_spacing, + callback=self.callback, + halign=Alignment.LEFT, + valign=Alignment.TOP, + padding=custom_padding + ) + + self.assertEqual(container._direction, custom_direction) + self.assertEqual(container._spacing, custom_spacing) + self.assertEqual(container._callback, self.callback) + self.assertEqual(container._halign, Alignment.LEFT) + self.assertEqual(container._valign, Alignment.TOP) + self.assertEqual(container._padding, custom_padding) + + def test_add_child(self): + """Test adding child elements""" + container = Container(self.origin, self.size) + + result = container.add_child(self.mock_child1) + + self.assertEqual(len(container._children), 1) + self.assertEqual(container._children[0], self.mock_child1) + self.assertEqual(result, container) # Should return self for chaining + + def test_add_multiple_children(self): + """Test adding multiple child elements""" + container = Container(self.origin, self.size) + + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + self.assertEqual(len(container._children), 2) + self.assertEqual(container._children[0], self.mock_child1) + self.assertEqual(container._children[1], self.mock_child2) + + def test_layout_vertical_centered(self): + """Test vertical layout with center alignment""" + container = Container(self.origin, self.size, direction='vertical', halign=Alignment.CENTER) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Check that children have been positioned + # First child should be at top padding + expected_x1 = 10 + (380 - 100) // 2 # padding + centered in available width + expected_y1 = 10 # top padding + np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1])) + + # Second child should be below first child + spacing + expected_x2 = 10 + (380 - 120) // 2 # padding + centered in available width + expected_y2 = 10 + 50 + 5 # top padding + first child height + spacing + np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2])) + + def test_layout_vertical_left_aligned(self): + """Test vertical layout with left alignment""" + container = Container(self.origin, self.size, direction='vertical', halign=Alignment.LEFT) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Both children should be left-aligned + expected_x = 10 # left padding + np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x, 10])) + np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x, 65])) + + def test_layout_vertical_right_aligned(self): + """Test vertical layout with right alignment""" + container = Container(self.origin, self.size, direction='vertical', halign=Alignment.RIGHT) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Children should be right-aligned + expected_x1 = 10 + 380 - 100 # left padding + available width - child width + expected_x2 = 10 + 380 - 120 + np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, 10])) + np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, 65])) + + def test_layout_horizontal_centered(self): + """Test horizontal layout with center alignment""" + container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.CENTER) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Children should be positioned horizontally + expected_x1 = 10 # left padding + expected_x2 = 10 + 100 + 5 # left padding + first child width + spacing + + # Vertically centered + expected_y1 = 10 + (280 - 50) // 2 # top padding + centered in available height + expected_y2 = 10 + (280 - 60) // 2 + + np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1])) + np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2])) + + def test_layout_horizontal_top_aligned(self): + """Test horizontal layout with top alignment""" + container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.TOP) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Both children should be top-aligned + expected_y = 10 # top padding + np.testing.assert_array_equal(self.mock_child1._origin, np.array([10, expected_y])) + np.testing.assert_array_equal(self.mock_child2._origin, np.array([115, expected_y])) + + def test_layout_horizontal_bottom_aligned(self): + """Test horizontal layout with bottom alignment""" + container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.BOTTOM) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Children should be bottom-aligned + expected_y1 = 10 + 280 - 50 # top padding + available height - child height + expected_y2 = 10 + 280 - 60 + np.testing.assert_array_equal(self.mock_child1._origin, np.array([10, expected_y1])) + np.testing.assert_array_equal(self.mock_child2._origin, np.array([115, expected_y2])) + + def test_layout_empty_container(self): + """Test layout with no children""" + container = Container(self.origin, self.size) + + # Should not raise an error + container.layout() + + self.assertEqual(len(container._children), 0) + + def test_layout_with_layoutable_children(self): + """Test layout with children that are also layoutable""" + # Create a mock child that implements Layoutable + mock_layoutable_child = Mock() + mock_layoutable_child._size = np.array([80, 40]) + mock_layoutable_child._origin = np.array([0, 0]) + + # Make it look like a Layoutable by adding layout method + from pyWebLayout.core.base import Layoutable + mock_layoutable_child.__class__ = type('MockLayoutable', (Mock, Layoutable), {}) + mock_layoutable_child.layout = Mock() + + container = Container(self.origin, self.size) + container.add_child(mock_layoutable_child) + + container.layout() + + # Child's layout method should have been called + mock_layoutable_child.layout.assert_called_once() + + def test_render_empty_container(self): + """Test rendering empty container""" + container = Container(self.origin, self.size) + result = container.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + def test_render_with_children(self): + """Test rendering container with children""" + container = Container(self.origin, self.size) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + result = container.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + # Children should have been rendered + self.mock_child1.render.assert_called_once() + self.mock_child2.render.assert_called_once() + + def test_render_calls_layout(self): + """Test that render calls layout""" + container = Container(self.origin, self.size) + container.add_child(self.mock_child1) + + with patch.object(container, 'layout') as mock_layout: + result = container.render() + + mock_layout.assert_called_once() + + def test_custom_spacing(self): + """Test container with custom spacing""" + custom_spacing = 20 + container = Container(self.origin, self.size, spacing=custom_spacing) + container.add_child(self.mock_child1) + container.add_child(self.mock_child2) + + container.layout() + + # Second child should be positioned with custom spacing + expected_y2 = 10 + 50 + custom_spacing # top padding + first child height + custom spacing + self.assertEqual(self.mock_child2._origin[1], expected_y2) + + +class TestPage(unittest.TestCase): + """Test cases for the Page class""" + + def setUp(self): + """Set up test fixtures""" + self.page_size = (800, 600) + self.background_color = (255, 255, 255) + + # Create mock child elements + self.mock_child1 = Mock() + self.mock_child1._size = np.array([200, 100]) + self.mock_child1._origin = np.array([0, 0]) + self.mock_child1.render.return_value = Image.new('RGBA', (200, 100), (255, 0, 0, 255)) + + self.mock_child2 = Mock() + self.mock_child2._size = np.array([150, 80]) + self.mock_child2._origin = np.array([0, 0]) + self.mock_child2.render.return_value = Image.new('RGBA', (150, 80), (0, 255, 0, 255)) + + def test_page_initialization_basic(self): + """Test basic page initialization""" + page = Page() + + np.testing.assert_array_equal(page._origin, np.array([0, 0])) + np.testing.assert_array_equal(page._size, np.array([800, 600])) + self.assertEqual(page._background_color, (255, 255, 255)) + self.assertEqual(page._mode, 'RGBA') + self.assertEqual(page._direction, 'vertical') + self.assertEqual(page._spacing, 10) + self.assertEqual(page._halign, Alignment.CENTER) + self.assertEqual(page._valign, Alignment.TOP) + + def test_page_initialization_with_params(self): + """Test page initialization with custom parameters""" + custom_size = (1024, 768) + custom_background = (240, 240, 240) + custom_mode = 'RGB' + + page = Page( + size=custom_size, + background_color=custom_background, + mode=custom_mode + ) + + np.testing.assert_array_equal(page._size, np.array(custom_size)) + self.assertEqual(page._background_color, custom_background) + self.assertEqual(page._mode, custom_mode) + + def test_page_add_child(self): + """Test adding child elements to page""" + page = Page() + + page.add_child(self.mock_child1) + page.add_child(self.mock_child2) + + self.assertEqual(len(page._children), 2) + self.assertEqual(page._children[0], self.mock_child1) + self.assertEqual(page._children[1], self.mock_child2) + + def test_page_layout(self): + """Test page layout functionality""" + page = Page() + page.add_child(self.mock_child1) + page.add_child(self.mock_child2) + + page.layout() + + # Children should be positioned vertically, centered horizontally + expected_x1 = (800 - 200) // 2 # Centered horizontally + expected_y1 = 10 # Top padding + np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1])) + + expected_x2 = (800 - 150) // 2 # Centered horizontally + expected_y2 = 10 + 100 + 10 # Top padding + first child height + spacing + np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2])) + + def test_page_render_empty(self): + """Test rendering empty page""" + page = Page(size=self.page_size, background_color=self.background_color) + result = page.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, self.page_size) + self.assertEqual(result.mode, 'RGBA') + + # Check that background color is applied + # Sample a pixel from the center to verify background + center_pixel = result.getpixel((400, 300)) + self.assertEqual(center_pixel[:3], self.background_color) + + def test_page_render_with_children_rgba(self): + """Test rendering page with children (RGBA mode)""" + page = Page(size=self.page_size, background_color=self.background_color) + page.add_child(self.mock_child1) + page.add_child(self.mock_child2) + + result = page.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, self.page_size) + self.assertEqual(result.mode, 'RGBA') + + # Children should have been rendered + self.mock_child1.render.assert_called_once() + self.mock_child2.render.assert_called_once() + + def test_page_render_with_children_rgb(self): + """Test rendering page with children (RGB mode)""" + # Create children that return RGB images + rgb_child = Mock() + rgb_child._size = np.array([100, 50]) + rgb_child._origin = np.array([0, 0]) + rgb_child.render.return_value = Image.new('RGB', (100, 50), (255, 0, 0)) + + page = Page(size=self.page_size, background_color=self.background_color, mode='RGB') + page.add_child(rgb_child) + + result = page.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, self.page_size) + self.assertEqual(result.mode, 'RGB') + + rgb_child.render.assert_called_once() + + def test_page_render_calls_layout(self): + """Test that page render calls layout""" + page = Page() + page.add_child(self.mock_child1) + + with patch.object(page, 'layout') as mock_layout: + result = page.render() + + mock_layout.assert_called_once() + + def test_page_inherits_container_functionality(self): + """Test that Page inherits Container functionality""" + page = Page() + + # Should inherit Container methods + self.assertTrue(hasattr(page, 'add_child')) + self.assertTrue(hasattr(page, 'layout')) + self.assertTrue(hasattr(page, '_children')) + self.assertTrue(hasattr(page, '_direction')) + self.assertTrue(hasattr(page, '_spacing')) + + def test_page_with_mixed_child_image_modes(self): + """Test page with children having different image modes""" + # Create children with different modes + rgba_child = Mock() + rgba_child._size = np.array([100, 50]) + rgba_child._origin = np.array([0, 0]) + rgba_child.render.return_value = Image.new('RGBA', (100, 50), (255, 0, 0, 255)) + + rgb_child = Mock() + rgb_child._size = np.array([100, 50]) + rgb_child._origin = np.array([0, 0]) + rgb_child.render.return_value = Image.new('RGB', (100, 50), (0, 255, 0)) + + page = Page() + page.add_child(rgba_child) + page.add_child(rgb_child) + + result = page.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.mode, 'RGBA') + + # Both children should have been rendered + rgba_child.render.assert_called_once() + rgb_child.render.assert_called_once() + + def test_page_background_color_application(self): + """Test that background color is properly applied""" + custom_bg = (100, 150, 200) + page = Page(background_color=custom_bg) + + result = page.render() + + # Sample multiple points to verify background + corners = [(0, 0), (799, 0), (0, 599), (799, 599)] + for corner in corners: + pixel = result.getpixel(corner) + self.assertEqual(pixel[:3], custom_bg) + + def test_page_size_constraints(self): + """Test page with various size constraints""" + small_page = Page(size=(200, 150)) + large_page = Page(size=(1920, 1080)) + + small_result = small_page.render() + large_result = large_page.render() + + self.assertEqual(small_result.size, (200, 150)) + self.assertEqual(large_result.size, (1920, 1080)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_concrete_text.py b/tests/test_concrete_text.py new file mode 100644 index 0000000..fd6efd0 --- /dev/null +++ b/tests/test_concrete_text.py @@ -0,0 +1,468 @@ +""" +Unit tests for pyWebLayout.concrete.text module. +Tests the Text, RenderableWord, and Line classes for text rendering functionality. +""" + +import unittest +import numpy as np +from PIL import Image, ImageFont +from unittest.mock import Mock, patch, MagicMock + +from pyWebLayout.concrete.text import Text, RenderableWord, Line +from pyWebLayout.abstract.inline import Word +from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration +from pyWebLayout.style.layout import Alignment + + +class TestText(unittest.TestCase): + """Test cases for the Text class""" + + def setUp(self): + """Set up test fixtures""" + self.font = Font( + font_path=None, # Use default font + font_size=12, + colour=(0, 0, 0), + weight=FontWeight.NORMAL, + style=FontStyle.NORMAL, + decoration=TextDecoration.NONE + ) + self.sample_text = "Hello World" + + def test_text_initialization(self): + """Test basic text initialization""" + text = Text(self.sample_text, self.font) + + self.assertEqual(text._text, self.sample_text) + self.assertEqual(text._style, self.font) + self.assertIsNone(text._line) + self.assertIsNone(text._previous) + self.assertIsNone(text._next) + np.testing.assert_array_equal(text._origin, np.array([0, 0])) + + def test_text_properties(self): + """Test text property accessors""" + text = Text(self.sample_text, self.font) + + self.assertEqual(text.text, self.sample_text) + self.assertEqual(text.style, self.font) + self.assertIsNone(text.line) + + # Test size property + self.assertIsInstance(text.size, tuple) + self.assertEqual(len(text.size), 2) + self.assertGreater(text.width, 0) + self.assertGreater(text.height, 0) + + def test_set_origin(self): + """Test setting text origin""" + text = Text(self.sample_text, self.font) + text.set_origin(50, 75) + + np.testing.assert_array_equal(text._origin, np.array([50, 75])) + + def test_line_assignment(self): + """Test line assignment""" + text = Text(self.sample_text, self.font) + mock_line = Mock() + + text.line = mock_line + self.assertEqual(text.line, mock_line) + self.assertEqual(text._line, mock_line) + + def test_add_to_line(self): + """Test adding text to a line""" + text = Text(self.sample_text, self.font) + mock_line = Mock() + + text.add_to_line(mock_line) + self.assertEqual(text._line, mock_line) + + @patch('PIL.ImageDraw.Draw') + def test_render_basic(self, mock_draw_class): + """Test basic text rendering""" + mock_draw = Mock() + mock_draw_class.return_value = mock_draw + + text = Text(self.sample_text, self.font) + result = text.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.mode, 'RGBA') + mock_draw.text.assert_called_once() + + @patch('PIL.ImageDraw.Draw') + def test_render_with_background(self, mock_draw_class): + """Test text rendering with background color""" + mock_draw = Mock() + mock_draw_class.return_value = mock_draw + + font_with_bg = Font( + font_path=None, # Use default font + font_size=12, + colour=(0, 0, 0), + background=(255, 255, 0, 128) # Yellow background with alpha + ) + + text = Text(self.sample_text, font_with_bg) + result = text.render() + + self.assertIsInstance(result, Image.Image) + mock_draw.rectangle.assert_called_once() + mock_draw.text.assert_called_once() + + @patch('PIL.ImageDraw.Draw') + def test_apply_decoration_underline(self, mock_draw_class): + """Test underline decoration""" + mock_draw = Mock() + mock_draw_class.return_value = mock_draw + + font_underlined = Font( + font_path=None, # Use default font + font_size=12, + colour=(0, 0, 0), + decoration=TextDecoration.UNDERLINE + ) + + text = Text(self.sample_text, font_underlined) + text._apply_decoration(mock_draw) + + mock_draw.line.assert_called_once() + + @patch('PIL.ImageDraw.Draw') + def test_apply_decoration_strikethrough(self, mock_draw_class): + """Test strikethrough decoration""" + mock_draw = Mock() + mock_draw_class.return_value = mock_draw + + font_strikethrough = Font( + font_path=None, # Use default font + font_size=12, + colour=(0, 0, 0), + decoration=TextDecoration.STRIKETHROUGH + ) + + text = Text(self.sample_text, font_strikethrough) + text._apply_decoration(mock_draw) + + mock_draw.line.assert_called_once() + + def test_in_object_point_inside(self): + """Test in_object method with point inside text""" + text = Text(self.sample_text, self.font) + text.set_origin(10, 20) + + # Point inside text bounds + inside_point = np.array([15, 25]) + self.assertTrue(text.in_object(inside_point)) + + def test_in_object_point_outside(self): + """Test in_object method with point outside text""" + text = Text(self.sample_text, self.font) + text.set_origin(10, 20) + + # Point outside text bounds + outside_point = np.array([200, 200]) + self.assertFalse(text.in_object(outside_point)) + + def test_get_size(self): + """Test get_size method""" + text = Text(self.sample_text, self.font) + size = text.get_size() + + self.assertIsInstance(size, tuple) + self.assertEqual(len(size), 2) + self.assertEqual(size, text.size) + + +class TestRenderableWord(unittest.TestCase): + """Test cases for the RenderableWord class""" + + def setUp(self): + """Set up test fixtures""" + self.font = Font( + font_path=None, # Use default font + font_size=12, + colour=(0, 0, 0) + ) + self.abstract_word = Word("testing", self.font) + + def test_renderable_word_initialization(self): + """Test basic RenderableWord initialization""" + renderable = RenderableWord(self.abstract_word) + + self.assertEqual(renderable._word, self.abstract_word) + self.assertEqual(len(renderable._text_parts), 1) + self.assertEqual(renderable._text_parts[0].text, "testing") + np.testing.assert_array_equal(renderable._origin, np.array([0, 0])) + + def test_word_property(self): + """Test word property accessor""" + renderable = RenderableWord(self.abstract_word) + + self.assertEqual(renderable.word, self.abstract_word) + + def test_text_parts_property(self): + """Test text_parts property""" + renderable = RenderableWord(self.abstract_word) + + self.assertIsInstance(renderable.text_parts, list) + self.assertEqual(len(renderable.text_parts), 1) + self.assertIsInstance(renderable.text_parts[0], Text) + + def test_size_properties(self): + """Test width and height properties""" + renderable = RenderableWord(self.abstract_word) + + self.assertGreater(renderable.width, 0) + self.assertGreater(renderable.height, 0) + self.assertEqual(renderable.width, renderable._size[0]) + self.assertEqual(renderable.height, renderable._size[1]) + + def test_set_origin(self): + """Test setting origin coordinates""" + renderable = RenderableWord(self.abstract_word) + renderable.set_origin(25, 30) + + np.testing.assert_array_equal(renderable._origin, np.array([25, 30])) + # Check that text parts also have updated origins + self.assertEqual(renderable._text_parts[0]._origin[0], 25) + self.assertEqual(renderable._text_parts[0]._origin[1], 30) + + @patch.object(Word, 'hyphenate') + def test_update_from_word_hyphenated(self, mock_hyphenate): + """Test updating from hyphenated word""" + # Mock hyphenation + mock_hyphenate.return_value = True + self.abstract_word._hyphenated_parts = ["test-", "ing"] + + renderable = RenderableWord(self.abstract_word) + renderable.update_from_word() + + self.assertEqual(len(renderable._text_parts), 2) + self.assertEqual(renderable._text_parts[0].text, "test-") + self.assertEqual(renderable._text_parts[1].text, "ing") + + def test_get_part_size(self): + """Test getting size of specific text part""" + renderable = RenderableWord(self.abstract_word) + + size = renderable.get_part_size(0) + self.assertIsInstance(size, tuple) + self.assertEqual(len(size), 2) + + def test_get_part_size_invalid_index(self): + """Test getting size with invalid index""" + renderable = RenderableWord(self.abstract_word) + + with self.assertRaises(IndexError): + renderable.get_part_size(5) + + def test_render_single_part(self): + """Test rendering word with single part""" + renderable = RenderableWord(self.abstract_word) + result = renderable.render() + + self.assertIsInstance(result, Image.Image) + self.assertGreater(result.width, 0) + self.assertGreater(result.height, 0) + + @patch.object(Word, 'hyphenate') + def test_render_multiple_parts(self, mock_hyphenate): + """Test rendering word with multiple parts""" + # Mock hyphenation + mock_hyphenate.return_value = True + self.abstract_word._hyphenated_parts = ["test-", "ing"] + + renderable = RenderableWord(self.abstract_word) + renderable.update_from_word() + result = renderable.render() + + self.assertIsInstance(result, Image.Image) + self.assertGreater(result.width, 0) + self.assertGreater(result.height, 0) + + def test_in_object_inside(self): + """Test in_object with point inside word""" + renderable = RenderableWord(self.abstract_word) + renderable.set_origin(10, 15) + + # Point inside word bounds + point = np.array([15, 20]) + # This test might fail if the actual size calculation differs + # We'll check that the method returns a boolean + result = renderable.in_object(point) + self.assertIsInstance(result, (bool, np.bool_)) + + def test_in_object_outside(self): + """Test in_object with point outside word""" + renderable = RenderableWord(self.abstract_word) + renderable.set_origin(10, 15) + + # Point clearly outside word bounds + point = np.array([1000, 1000]) + self.assertFalse(renderable.in_object(point)) + + +class TestLine(unittest.TestCase): + """Test cases for the Line class""" + + def setUp(self): + """Set up test fixtures""" + self.font = Font( + font_path=None, # Use default font + font_size=12, + colour=(0, 0, 0) + ) + self.spacing = (5, 10) # min, max spacing + self.origin = (0, 0) + self.size = (200, 20) + + def test_line_initialization(self): + """Test basic line initialization""" + line = Line(self.spacing, self.origin, self.size, self.font) + + self.assertEqual(line._spacing, self.spacing) + self.assertEqual(line._font, self.font) + self.assertEqual(len(line._renderable_words), 0) + self.assertEqual(line._current_width, 0) + self.assertIsNone(line._previous) + self.assertIsNone(line._next) + + def test_line_initialization_with_previous(self): + """Test line initialization with previous line""" + previous_line = Mock() + line = Line(self.spacing, self.origin, self.size, self.font, previous=previous_line) + + self.assertEqual(line._previous, previous_line) + + def test_renderable_words_property(self): + """Test renderable_words property""" + line = Line(self.spacing, self.origin, self.size, self.font) + + self.assertIsInstance(line.renderable_words, list) + self.assertEqual(len(line.renderable_words), 0) + + def test_set_next(self): + """Test setting next line""" + line = Line(self.spacing, self.origin, self.size, self.font) + next_line = Mock() + + line.set_next(next_line) + self.assertEqual(line._next, next_line) + + def test_add_word_fits(self): + """Test adding word that fits in line""" + line = Line(self.spacing, self.origin, self.size, self.font) + result = line.add_word("short") + + self.assertIsNone(result) # Word fits, no overflow + self.assertEqual(len(line._renderable_words), 1) + self.assertGreater(line._current_width, 0) + + def test_add_word_overflow(self): + """Test adding word that doesn't fit""" + # Create a narrow line + narrow_line = Line(self.spacing, self.origin, (50, 20), self.font) + + # Add a long word that won't fit + result = narrow_line.add_word("supercalifragilisticexpialidocious") + + # Should return the word text indicating overflow + self.assertIsInstance(result, str) + + @patch.object(Word, 'hyphenate') + @patch.object(Word, 'get_hyphenated_part') + @patch.object(Word, 'get_hyphenated_part_count') + def test_add_word_hyphenated(self, mock_part_count, mock_get_part, mock_hyphenate): + """Test adding word that gets hyphenated""" + # Mock hyphenation behavior + mock_hyphenate.return_value = True + mock_get_part.side_effect = lambda i: ["supercalifragilisticexpialidocious-", "remainder"][i] + mock_part_count.return_value = 2 + + # Use a very narrow line to ensure even the first part doesn't fit + narrow_line = Line(self.spacing, self.origin, (30, 20), self.font) + result = narrow_line.add_word("supercalifragilisticexpialidocious") + + # Should return the original word since even the first part doesn't fit + self.assertIsInstance(result, str) + self.assertEqual(result, "supercalifragilisticexpialidocious") + + def test_add_multiple_words(self): + """Test adding multiple words to line""" + line = Line(self.spacing, self.origin, self.size, self.font) + + line.add_word("first") + line.add_word("second") + line.add_word("third") + + self.assertEqual(len(line._renderable_words), 3) + self.assertGreater(line._current_width, 0) + + def test_render_empty_line(self): + """Test rendering empty line""" + line = Line(self.spacing, self.origin, self.size, self.font) + result = line.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + def test_render_with_words_left_aligned(self): + """Test rendering line with left alignment""" + line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT) + line.add_word("hello") + line.add_word("world") + + result = line.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + def test_render_with_words_right_aligned(self): + """Test rendering line with right alignment""" + line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT) + line.add_word("hello") + line.add_word("world") + + result = line.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + def test_render_with_words_centered(self): + """Test rendering line with center alignment""" + line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER) + line.add_word("hello") + line.add_word("world") + + result = line.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + def test_render_with_words_justified(self): + """Test rendering line with justified alignment""" + line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY) + line.add_word("hello") + line.add_word("world") + line.add_word("test") + + result = line.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + def test_render_single_word(self): + """Test rendering line with single word""" + line = Line(self.spacing, self.origin, self.size, self.font) + line.add_word("single") + + result = line.render() + + self.assertIsInstance(result, Image.Image) + self.assertEqual(result.size, tuple(self.size)) + + +if __name__ == '__main__': + unittest.main()