test for concrete
All checks were successful
Python CI / test (push) Successful in 48s

This commit is contained in:
Duncan Tourolle 2025-06-07 19:43:59 +02:00
parent 22d3505256
commit 3f9bd0072e
6 changed files with 1490 additions and 2 deletions

View File

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

View File

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

189
tests/test_concrete_box.py Normal file
View File

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

View File

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

459
tests/test_concrete_page.py Normal file
View File

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

468
tests/test_concrete_text.py Normal file
View File

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