""" Test file for document layouter functionality. This test focuses on verifying that the document layouter properly integrates word spacing constraints from the style system. """ import pytest from unittest.mock import Mock, MagicMock, patch from typing import List, Optional from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter from pyWebLayout.style.abstract_style import AbstractStyle from pyWebLayout.style.concrete_style import ConcreteStyle, StyleResolver, RenderingContext class TestDocumentLayouter: """Test cases for document layouter functionality.""" def setup_method(self): """Set up test fixtures before each test method.""" # Create mock objects self.mock_page = Mock() self.mock_page.border_size = 20 self.mock_page._current_y_offset = 50 self.mock_page.available_width = 400 self.mock_page.draw = Mock() self.mock_page.can_fit_line = Mock(return_value=True) self.mock_page.add_child = Mock() # Create mock style resolver self.mock_style_resolver = Mock() self.mock_page.style_resolver = self.mock_style_resolver # Create mock paragraph self.mock_paragraph = Mock() self.mock_paragraph.line_height = 20 self.mock_paragraph.style = AbstractStyle() # Create mock words self.mock_words = [] for i in range(5): word = Mock() word.text = f"word{i}" self.mock_words.append(word) self.mock_paragraph.words = self.mock_words # Create mock concrete style with word spacing constraints self.mock_concrete_style = Mock() self.mock_concrete_style.word_spacing_min = 2.0 self.mock_concrete_style.word_spacing_max = 8.0 self.mock_concrete_style.text_align = "left" self.mock_concrete_style.create_font = Mock() @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class): """Test basic paragraph layouter functionality.""" # Setup mocks mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style mock_line = Mock() mock_line_class.return_value = mock_line mock_line.add_word.return_value = (True, None) # All words fit successfully # Call function result = paragraph_layouter(self.mock_paragraph, self.mock_page) # Verify results success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None assert remaining_pretext is None # Verify style registry was used correctly mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver) mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style) # Verify Line was created with correct spacing constraints expected_spacing = (2, 8) # From mock_concrete_style mock_line_class.assert_called_once() call_args = mock_line_class.call_args assert call_args[1]['spacing'] == expected_spacing @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class): """Test that word spacing constraints are correctly extracted from style.""" # Create concrete style with specific constraints concrete_style = Mock() concrete_style.word_spacing_min = 5.5 concrete_style.word_spacing_max = 15.2 concrete_style.text_align = "justify" concrete_style.create_font = Mock() mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = concrete_style mock_line = Mock() mock_line_class.return_value = mock_line mock_line.add_word.return_value = (True, None) # Call function paragraph_layouter(self.mock_paragraph, self.mock_page) # Verify spacing constraints were extracted correctly (converted to int) expected_spacing = (5, 15) # int() conversion of 5.5 and 15.2 call_args = mock_line_class.call_args assert call_args[1]['spacing'] == expected_spacing @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') def test_paragraph_layouter_line_overflow(self, mock_line_class, mock_style_registry_class): """Test handling of line overflow when words don't fit.""" # Setup mocks mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style # Create two mock lines mock_line1 = Mock() mock_line2 = Mock() mock_line_class.side_effect = [mock_line1, mock_line2] # First line: first 2 words fit, third doesn't # Second line: remaining words fit mock_line1.add_word.side_effect = [ (True, None), # word0 fits (True, None), # word1 fits (False, None), # word2 doesn't fit ] mock_line2.add_word.side_effect = [ (True, None), # word2 fits on new line (True, None), # word3 fits (True, None), # word4 fits ] # Call function result = paragraph_layouter(self.mock_paragraph, self.mock_page) # Verify results success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None assert remaining_pretext is None # Verify two lines were created assert mock_line_class.call_count == 2 assert self.mock_page.add_child.call_count == 2 @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.Line') def test_paragraph_layouter_page_full(self, mock_line_class, mock_style_registry_class): """Test handling when page runs out of space.""" # Setup mocks mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style # Page can fit first line but not second self.mock_page.can_fit_line.side_effect = [True, False] mock_line = Mock() mock_line_class.return_value = mock_line mock_line.add_word.side_effect = [ (True, None), # word0 fits (False, None), # word1 doesn't fit, need new line ] # Call function result = paragraph_layouter(self.mock_paragraph, self.mock_page) # Verify results indicate page is full success, failed_word_index, remaining_pretext = result assert success is False assert failed_word_index == 1 # word1 didn't fit assert remaining_pretext is None def test_paragraph_layouter_empty_paragraph(self): """Test handling of empty paragraph.""" empty_paragraph = Mock() empty_paragraph.words = [] result = paragraph_layouter(empty_paragraph, self.mock_page) success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None assert remaining_pretext is None def test_paragraph_layouter_invalid_start_word(self): """Test handling of invalid start_word index.""" result = paragraph_layouter(self.mock_paragraph, self.mock_page, start_word=10) success, failed_word_index, remaining_pretext = result assert success is True assert failed_word_index is None assert remaining_pretext is None @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') def test_document_layouter_class(self, mock_style_registry_class): """Test DocumentLayouter class functionality.""" # Setup mock mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry # Create layouter layouter = DocumentLayouter(self.mock_page) # Verify initialization assert layouter.page == self.mock_page mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver) @patch('pyWebLayout.layout.document_layouter.paragraph_layouter') def test_document_layouter_layout_paragraph(self, mock_paragraph_layouter): """Test DocumentLayouter.layout_paragraph method.""" mock_paragraph_layouter.return_value = (True, None, None) with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) result = layouter.layout_paragraph(self.mock_paragraph, start_word=2, pretext="test") # Verify the function was called correctly mock_paragraph_layouter.assert_called_once_with( self.mock_paragraph, self.mock_page, 2, "test" ) assert result == (True, None, None) @patch('pyWebLayout.layout.document_layouter.paragraph_layouter') def test_document_layouter_layout_document_success(self, mock_paragraph_layouter): """Test DocumentLayouter.layout_document with successful layout.""" mock_paragraph_layouter.return_value = (True, None, None) paragraphs = [self.mock_paragraph, Mock(), Mock()] with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) result = layouter.layout_document(paragraphs) assert result is True assert mock_paragraph_layouter.call_count == 3 @patch('pyWebLayout.layout.document_layouter.paragraph_layouter') def test_document_layouter_layout_document_failure(self, mock_paragraph_layouter): """Test DocumentLayouter.layout_document with layout failure.""" # First paragraph succeeds, second fails mock_paragraph_layouter.side_effect = [ (True, None, None), # First paragraph succeeds (False, 3, None), # Second paragraph fails ] paragraphs = [self.mock_paragraph, Mock()] with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) result = layouter.layout_document(paragraphs) assert result is False assert mock_paragraph_layouter.call_count == 2 def test_real_style_integration(self): """Test integration with real style system.""" # Create real style objects context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) abstract_style = AbstractStyle( word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=10.0 ) concrete_style = resolver.resolve_style(abstract_style) # Verify constraints are resolved correctly assert concrete_style.word_spacing_min == 2.0 assert concrete_style.word_spacing_max == 10.0 # This demonstrates the integration works end-to-end class TestWordSpacingConstraintsInLayout: """Specific tests for word spacing constraints in layout context.""" def test_different_spacing_scenarios(self): """Test various word spacing constraint scenarios.""" context = RenderingContext(base_font_size=16) resolver = StyleResolver(context) test_cases = [ # (word_spacing, word_spacing_min, word_spacing_max, expected_min, expected_max) (None, None, None, 2.0, 8.0), # Default case (5.0, None, None, 5.0, 10.0), # Only base specified (4.0, 2.0, 8.0, 2.0, 8.0), # All specified (3.0, 1.0, None, 1.0, 3.0), # Min specified, max = max(word_spacing, min*2) = max(3.0, 2.0) = 3.0 (6.0, None, 12.0, 6.0, 12.0), # Max specified, min from base ] for word_spacing, min_spacing, max_spacing, expected_min, expected_max in test_cases: style_kwargs = {} if word_spacing is not None: style_kwargs['word_spacing'] = word_spacing if min_spacing is not None: style_kwargs['word_spacing_min'] = min_spacing if max_spacing is not None: style_kwargs['word_spacing_max'] = max_spacing abstract_style = AbstractStyle(**style_kwargs) concrete_style = resolver.resolve_style(abstract_style) assert concrete_style.word_spacing_min == expected_min, f"Failed for case: {style_kwargs}" assert concrete_style.word_spacing_max == expected_max, f"Failed for case: {style_kwargs}" class TestMultiPageLayout: """Test cases for multi-page document layout scenarios.""" def setup_method(self): """Set up test fixtures for multi-page tests.""" # Create multiple mock pages self.mock_pages = [] for i in range(3): page = Mock() page.page_number = i + 1 page.border_size = 20 page._current_y_offset = 50 page.available_width = 400 page.available_height = 600 page.draw = Mock() page.add_child = Mock() page.style_resolver = Mock() self.mock_pages.append(page) # Create a long paragraph that will span multiple pages self.long_paragraph = Mock() self.long_paragraph.line_height = 25 self.long_paragraph.style = AbstractStyle() # Create many words to ensure page overflow self.long_paragraph.words = [] for i in range(50): # 50 words should definitely overflow a page word = Mock() word.text = f"word_{i:02d}" self.long_paragraph.words.append(word) # Create mock concrete style self.mock_concrete_style = Mock() self.mock_concrete_style.word_spacing_min = 3.0 self.mock_concrete_style.word_spacing_max = 12.0 self.mock_concrete_style.text_align = "justify" self.mock_concrete_style.create_font = Mock() @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') def test_document_layouter_multi_page_scenario(self, mock_style_registry_class): """Test DocumentLayouter handling multiple pages with continuation.""" # Setup style registry mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style # Create a multi-page document layouter class MultiPageDocumentLayouter(DocumentLayouter): def __init__(self, pages): self.pages = pages self.current_page_index = 0 self.page = pages[0] self.style_registry = Mock() def get_next_page(self): """Get the next available page.""" if self.current_page_index + 1 < len(self.pages): self.current_page_index += 1 self.page = self.pages[self.current_page_index] return self.page return None def layout_document_with_pagination(self, paragraphs): """Layout document with automatic pagination.""" for paragraph in paragraphs: start_word = 0 pretext = None while start_word < len(paragraph.words): complete, next_word, remaining_pretext = self.layout_paragraph( paragraph, start_word, pretext ) if complete: # Paragraph finished break if next_word is None: # Error condition return False, f"Failed to layout paragraph at word {start_word}" # Try to get next page next_page = self.get_next_page() if not next_page: return False, f"Ran out of pages at word {next_word}" # Continue with remaining words on next page start_word = next_word pretext = remaining_pretext return True, "All paragraphs laid out successfully" # Create layouter with multiple pages layouter = MultiPageDocumentLayouter(self.mock_pages) # Mock the layout_paragraph method to simulate page filling original_layout_paragraph = layouter.layout_paragraph call_count = [0] def mock_layout_paragraph(paragraph, start_word=0, pretext=None): call_count[0] += 1 # Simulate different scenarios based on call count if call_count[0] == 1: # First page: can fit words 0-19, fails at word 20 return (False, 20, None) elif call_count[0] == 2: # Second page: can fit words 20-39, fails at word 40 return (False, 40, None) elif call_count[0] == 3: # Third page: can fit remaining words 40-49 return (True, None, None) else: return (False, start_word, None) layouter.layout_paragraph = mock_layout_paragraph # Test multi-page layout success, message = layouter.layout_document_with_pagination([self.long_paragraph]) # Verify results assert success is True assert "successfully" in message assert call_count[0] == 3 # Should have made 3 layout attempts assert layouter.current_page_index == 2 # Should end on page 3 (index 2) def test_realistic_multi_page_scenario(self): """Test a realistic scenario with actual content and page constraints.""" # Create realistic paragraph with varied content realistic_paragraph = Mock() realistic_paragraph.line_height = 20 realistic_paragraph.style = AbstractStyle( word_spacing=4.0, word_spacing_min=2.0, word_spacing_max=8.0, text_align="justify" ) # Create words of varying lengths (realistic text) words = [ "The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog.", "This", "sentence", "contains", "words", "of", "varying", "lengths", "to", "simulate", "realistic", "text", "content", "that", "would", "require", "proper", "word", "spacing", "calculations", "and", "potentially", "multiple", "pages", "for", "layout.", "Each", "word", "represents", "a", "challenge", "for", "the", "layouter", "system", "to", "handle", "appropriately", "with", "the", "given", "constraints", "and", "spacing", "requirements." ] realistic_paragraph.words = [] for word_text in words: word = Mock() word.text = word_text realistic_paragraph.words.append(word) # Create page with realistic constraints realistic_page = Mock() realistic_page.border_size = 30 realistic_page._current_y_offset = 100 realistic_page.available_width = 350 # Narrower page realistic_page.available_height = 500 realistic_page.draw = Mock() realistic_page.add_child = Mock() realistic_page.style_resolver = Mock() # Simulate page that can fit approximately 20 lines lines_fitted = [0] max_lines = 20 def realistic_can_fit_line(line_height): lines_fitted[0] += 1 return lines_fitted[0] <= max_lines realistic_page.can_fit_line = realistic_can_fit_line # Test with real style system context = RenderingContext(base_font_size=14) resolver = StyleResolver(context) concrete_style = resolver.resolve_style(realistic_paragraph.style) # Verify realistic constraints were calculated assert concrete_style.word_spacing == 4.0 assert concrete_style.word_spacing_min == 2.0 assert concrete_style.word_spacing_max == 8.0 # This test demonstrates the integration without mocking everything # In a real scenario, this would interface with actual Line and Text objects print(f"✓ Realistic scenario test completed") print(f" - Words to layout: {len(realistic_paragraph.words)}") print(f" - Page width: {realistic_page.available_width}px") print(f" - Word spacing constraints: {concrete_style.word_spacing_min}-{concrete_style.word_spacing_max}px") if __name__ == "__main__": # Run specific tests for debugging test = TestDocumentLayouter() test.setup_method() # Run a simple test with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') as mock_registry: with patch('pyWebLayout.layout.document_layouter.Line') as mock_line: mock_style_registry = Mock() mock_registry.return_value = mock_style_registry mock_style_registry.get_concrete_style.return_value = test.mock_concrete_style mock_line_instance = Mock() mock_line.return_value = mock_line_instance mock_line_instance.add_word.return_value = (True, None) result = paragraph_layouter(test.mock_paragraph, test.mock_page) print(f"Test result: {result}") # Run multi-page tests multi_test = TestMultiPageLayout() multi_test.setup_method() multi_test.test_realistic_multi_page_scenario() print("Document layouter tests completed!")