""" 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)) class TestPageBorderMarginRendering(unittest.TestCase): """Test cases specifically for border/margin consistency in Page rendering""" def setUp(self): """Set up test fixtures for border/margin tests""" self.page_size = (400, 300) self.padding = (20, 15, 25, 10) # top, right, bottom, left self.background_color = (240, 240, 240) def test_border_consistency_with_text_content(self): """Test that borders/margins are consistent when rendering text content""" from pyWebLayout.concrete.text import Text from pyWebLayout.style.fonts import Font # Create page with specific padding page = Page(size=self.page_size, background_color=self.background_color) page._padding = self.padding # Add text content - let Text objects calculate their own dimensions font = Font(font_size=14) text1 = Text("First line of text", font) text2 = Text("Second line of text", font) page.add_child(text1) page.add_child(text2) # Render the page result = page.render() # Extract border areas and verify consistency border_measurements = self._extract_border_measurements(result, self.padding) self._verify_border_consistency(border_measurements, self.padding) # Verify content area is correctly positioned content_area = self._extract_content_area(result, self.padding) self.assertIsNotNone(content_area) # Ensure content doesn't bleed into border areas self._verify_no_content_in_borders(result, self.padding, self.background_color) def test_border_consistency_with_paragraph_content(self): """Test borders/margins with paragraph content that may wrap""" from pyWebLayout.abstract.block import Paragraph from pyWebLayout.abstract.inline import Word from pyWebLayout.style.fonts import Font # Create a mock paragraph with multiple words paragraph = Paragraph() font = Font(font_size=12) # Add words to create a longer paragraph words_text = ["This", "is", "a", "longer", "paragraph", "that", "should", "wrap", "across", "multiple", "lines", "to", "test", "margin", "consistency"] for word_text in words_text: word = Word(word_text, font) paragraph.add_word(word) # Create page with specific padding page = Page(size=self.page_size, background_color=self.background_color) page._padding = self.padding # Render paragraph on page page.render_blocks([paragraph]) result = page.render() # Extract and verify border measurements border_measurements = self._extract_border_measurements(result, self.padding) self._verify_border_consistency(border_measurements, self.padding) # Verify content positioning self._verify_content_within_bounds(result, self.padding) def test_border_consistency_with_mixed_content(self): """Test borders/margins with mixed content types""" from pyWebLayout.concrete.text import Text from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel from pyWebLayout.abstract.inline import Word from pyWebLayout.style.fonts import Font, FontWeight # Create page with asymmetric padding to test edge cases asymmetric_padding = (30, 20, 15, 25) page = Page(size=(500, 400), background_color=self.background_color) page._padding = asymmetric_padding # Create mixed content heading = Heading(HeadingLevel.H2) heading_font = Font(font_size=18, weight=FontWeight.BOLD) heading.add_word(Word("Test Heading", heading_font)) paragraph = Paragraph() para_font = Font(font_size=12) para_words = ["This", "paragraph", "follows", "the", "heading", "and", "tests", "mixed", "content", "rendering"] for word_text in para_words: paragraph.add_word(Word(word_text, para_font)) # Render mixed content page.render_blocks([heading, paragraph]) result = page.render() # Verify border consistency with asymmetric padding border_measurements = self._extract_border_measurements(result, asymmetric_padding) self._verify_border_consistency(border_measurements, asymmetric_padding) # Verify no content bleeds into margins self._verify_no_content_in_borders(result, asymmetric_padding, self.background_color) def test_border_consistency_with_different_padding_values(self): """Test that different padding values maintain consistent borders""" from pyWebLayout.concrete.text import Text from pyWebLayout.style.fonts import Font padding_configs = [ (10, 10, 10, 10), # uniform (5, 15, 5, 15), # symmetric horizontal/vertical (20, 30, 10, 5), # asymmetric (0, 5, 0, 5), # minimal top/bottom ] font = Font(font_size=14) for padding in padding_configs: with self.subTest(padding=padding): # Create a fresh text object for each test to avoid state issues test_text = Text("Border consistency test", font) page = Page(size=self.page_size, background_color=self.background_color) page._padding = padding page.add_child(test_text) result = page.render() # Verify border measurements match expected padding border_measurements = self._extract_border_measurements(result, padding) self._verify_border_consistency(border_measurements, padding) # Verify content area calculation expected_content_width = self.page_size[0] - padding[1] - padding[3] # width - right - left expected_content_height = self.page_size[1] - padding[0] - padding[2] # height - top - bottom content_area = self._extract_content_area(result, padding) self.assertEqual(content_area['width'], expected_content_width) self.assertEqual(content_area['height'], expected_content_height) def test_border_uniformity_across_renders(self): """Test that border areas remain uniform across multiple renders""" from pyWebLayout.concrete.text import Text from pyWebLayout.style.fonts import Font page = Page(size=self.page_size, background_color=self.background_color) page._padding = self.padding font = Font(font_size=12) text = Text("Consistency test content", font) page.add_child(text) # Render multiple times results = [] for i in range(3): result = page.render() results.append(result) border_measurements = self._extract_border_measurements(result, self.padding) # Store first measurement as baseline if i == 0: baseline_measurements = border_measurements else: # Compare with baseline self._compare_border_measurements(baseline_measurements, border_measurements) def _extract_border_measurements(self, image, padding): """Extract measurements of border/margin areas from rendered image""" width, height = image.size top_pad, right_pad, bottom_pad, left_pad = padding measurements = { 'top_border': { 'area': (0, 0, width, top_pad), 'pixels': self._get_area_pixels(image, (0, 0, width, top_pad)) }, 'right_border': { 'area': (width - right_pad, 0, width, height), 'pixels': self._get_area_pixels(image, (width - right_pad, 0, width, height)) }, 'bottom_border': { 'area': (0, height - bottom_pad, width, height), 'pixels': self._get_area_pixels(image, (0, height - bottom_pad, width, height)) }, 'left_border': { 'area': (0, 0, left_pad, height), 'pixels': self._get_area_pixels(image, (0, 0, left_pad, height)) } } return measurements def _get_area_pixels(self, image, area): """Extract pixel data from a specific area of the image""" if area[2] <= area[0] or area[3] <= area[1]: return [] # Invalid area cropped = image.crop(area) return list(cropped.getdata()) def _verify_border_consistency(self, measurements, expected_padding): """Verify that border measurements match expected padding values""" # Get actual dimensions from the measurements instead of using self.page_size # This allows the test to work with different page sizes top_area = measurements['top_border']['area'] width = top_area[2] # right coordinate of top border gives us the width height = measurements['left_border']['area'][3] # bottom coordinate of left border gives us the height top_pad, right_pad, bottom_pad, left_pad = expected_padding # Check area dimensions self.assertEqual(top_area, (0, 0, width, top_pad)) right_area = measurements['right_border']['area'] self.assertEqual(right_area, (width - right_pad, 0, width, height)) bottom_area = measurements['bottom_border']['area'] self.assertEqual(bottom_area, (0, height - bottom_pad, width, height)) left_area = measurements['left_border']['area'] self.assertEqual(left_area, (0, 0, left_pad, height)) def _extract_content_area(self, image, padding): """Extract the content area (area inside borders/margins)""" width, height = image.size top_pad, right_pad, bottom_pad, left_pad = padding content_area = { 'left': left_pad, 'top': top_pad, 'right': width - right_pad, 'bottom': height - bottom_pad, 'width': width - left_pad - right_pad, 'height': height - top_pad - bottom_pad } return content_area def _verify_no_content_in_borders(self, image, padding, background_color): """Verify that no content bleeds into the border/margin areas""" measurements = self._extract_border_measurements(image, padding) # Check that border areas contain only background color for border_name, border_data in measurements.items(): pixels = border_data['pixels'] if pixels: # Only check if area is not empty # Most pixels should be background color (allowing for some anti-aliasing) bg_count = sum(1 for pixel in pixels if self._is_background_color(pixel, background_color)) total_pixels = len(pixels) # Allow up to 10% deviation for anti-aliasing effects bg_ratio = bg_count / total_pixels if total_pixels > 0 else 1.0 self.assertGreaterEqual(bg_ratio, 0.9, f"Border area '{border_name}' contains too much non-background content. " f"Background ratio: {bg_ratio:.2f}") def _is_background_color(self, pixel, background_color, tolerance=10): """Check if a pixel is close to the background color within tolerance""" if len(pixel) >= 3: r_diff = abs(pixel[0] - background_color[0]) g_diff = abs(pixel[1] - background_color[1]) b_diff = abs(pixel[2] - background_color[2]) return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance return False def _verify_content_within_bounds(self, image, padding): """Verify that content is positioned within the expected bounds""" content_area = self._extract_content_area(image, padding) # Sample the content area to ensure it's not all background if content_area['width'] > 0 and content_area['height'] > 0: content_crop = image.crop(( content_area['left'], content_area['top'], content_area['right'], content_area['bottom'] )) # Content area should have some non-background pixels content_pixels = list(content_crop.getdata()) non_bg_pixels = sum(1 for pixel in content_pixels if not self._is_background_color(pixel, self.background_color)) # Expect at least some content in the content area self.assertGreater(non_bg_pixels, 0, "Content area appears to be empty") def _compare_border_measurements(self, baseline, current): """Compare two sets of border measurements for consistency""" for border_name in baseline.keys(): baseline_area = baseline[border_name]['area'] current_area = current[border_name]['area'] self.assertEqual(baseline_area, current_area, f"Border area '{border_name}' is inconsistent between renders") if __name__ == '__main__': unittest.main()