From 37505d3dcc2f964ffc69ae937b7ce1042da36a25 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 4 Nov 2025 22:30:04 +0100 Subject: [PATCH] Fix tests for CI? --- pyWebLayout/layout/document_layouter.py | 10 ++- pyWebLayout/style/concrete_style.py | 23 +++-- pyWebLayout/style/fonts.py | 23 ++++- tests/layouter/test_document_layouter.py | 87 ++++++++++++++----- .../test_document_layouter_integration.py | 36 ++++++++ 5 files changed, 148 insertions(+), 31 deletions(-) diff --git a/pyWebLayout/layout/document_layouter.py b/pyWebLayout/layout/document_layouter.py index 1b386a5..a84692d 100644 --- a/pyWebLayout/layout/document_layouter.py +++ b/pyWebLayout/layout/document_layouter.py @@ -53,7 +53,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr text_align = Alignment.LEFT # Default alignment else: # paragraph.style is an AbstractStyle, resolve it - rendering_context = RenderingContext(base_font_size=paragraph.style.font_size) + # Ensure font_size is an int (it could be a FontSize enum) + from pyWebLayout.style.abstract_style import FontSize + if isinstance(paragraph.style.font_size, FontSize): + # Use a default base font size, the resolver will handle the semantic size + base_font_size = 16 + else: + base_font_size = int(paragraph.style.font_size) + + rendering_context = RenderingContext(base_font_size=base_font_size) style_resolver = StyleResolver(rendering_context) style_registry = ConcreteStyleRegistry(style_resolver) concrete_style = style_registry.get_concrete_style(paragraph.style) diff --git a/pyWebLayout/style/concrete_style.py b/pyWebLayout/style/concrete_style.py index f63ebd9..b308013 100644 --- a/pyWebLayout/style/concrete_style.py +++ b/pyWebLayout/style/concrete_style.py @@ -159,6 +159,8 @@ class StyleResolver: # Resolve each property font_path = self._resolve_font_path(abstract_style.font_family) font_size = self._resolve_font_size(abstract_style.font_size) + # Ensure font_size is always an int before using in arithmetic + font_size = int(font_size) color = self._resolve_color(abstract_style.color) background_color = self._resolve_background_color(abstract_style.background_color) line_height = self._resolve_line_height(abstract_style.line_height) @@ -166,7 +168,7 @@ class StyleResolver: word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size) word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size) word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size) - min_hyphenation_width = max(font_size * 4, 32) # At least 32 pixels + min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels # Apply default logic for word spacing constraints if word_spacing_min == 0.0 and word_spacing_max == 0.0: @@ -223,13 +225,21 @@ class StyleResolver: def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int: """Resolve font size to actual pixel/point size.""" - if isinstance(font_size, int): - # Already a concrete size, apply scaling - base_size = font_size - else: + # Ensure we handle FontSize enums properly + if isinstance(font_size, FontSize): # Semantic size, convert to multiplier multiplier = self._semantic_font_sizes.get(font_size, 1.0) base_size = int(self.context.base_font_size * multiplier) + elif isinstance(font_size, int): + # Already a concrete size, apply scaling + base_size = font_size + else: + # Fallback for any other type - try to convert to int + try: + base_size = int(font_size) + except (ValueError, TypeError): + # If conversion fails, use default + base_size = self.context.base_font_size # Apply global font scaling final_size = int(base_size * self.context.font_scale_factor) @@ -238,7 +248,8 @@ class StyleResolver: if self.context.large_text: final_size = int(final_size * 1.2) - return max(final_size, 8) # Minimum 8pt font + # Ensure we always return an int, minimum 8pt font + return max(int(final_size), 8) def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]: """Resolve color to RGB tuple.""" diff --git a/pyWebLayout/style/fonts.py b/pyWebLayout/style/fonts.py index dd3c2d1..0374923 100644 --- a/pyWebLayout/style/fonts.py +++ b/pyWebLayout/style/fonts.py @@ -3,6 +3,10 @@ from PIL import ImageFont from enum import Enum from typing import Tuple, Union, Optional import os +import logging + +# Set up logging for font loading +logger = logging.getLogger(__name__) class FontWeight(Enum): @@ -71,29 +75,46 @@ class Font: # Navigate to the assets/fonts directory assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts') bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf') - return bundled_font_path if os.path.exists(bundled_font_path) else None + + logger.debug(f"Font loading: current_dir = {current_dir}") + logger.debug(f"Font loading: assets_dir = {assets_dir}") + logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}") + logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}") + + if os.path.exists(bundled_font_path): + logger.info(f"Found bundled font at: {bundled_font_path}") + return bundled_font_path + else: + logger.warning(f"Bundled font not found at: {bundled_font_path}") + return None def _load_font(self): """Load the font using PIL's ImageFont with consistent bundled font""" try: if self._font_path: # Use specified font path + logger.info(f"Loading font from specified path: {self._font_path}") self._font = ImageFont.truetype( self._font_path, self._font_size ) + logger.info(f"Successfully loaded font from: {self._font_path}") else: # Use bundled font for consistency across environments bundled_font_path = self._get_bundled_font_path() if bundled_font_path: + logger.info(f"Loading bundled font from: {bundled_font_path}") self._font = ImageFont.truetype(bundled_font_path, self._font_size) + logger.info(f"Successfully loaded bundled font at size {self._font_size}") else: # Only fall back to PIL's default font if bundled font is not available + logger.warning(f"Bundled font not available, falling back to PIL default font") self._font = ImageFont.load_default() except Exception as e: # Ultimate fallback to default font + logger.error(f"Failed to load font: {e}, falling back to PIL default font") self._font = ImageFont.load_default() @property diff --git a/tests/layouter/test_document_layouter.py b/tests/layouter/test_document_layouter.py index 0b99837..deacefd 100644 --- a/tests/layouter/test_document_layouter.py +++ b/tests/layouter/test_document_layouter.py @@ -28,6 +28,11 @@ class TestDocumentLayouter: self.mock_page.can_fit_line = Mock(return_value=True) self.mock_page.add_child = Mock() + # Create mock page style with all required numeric properties + self.mock_page.style = Mock() + self.mock_page.style.max_font_size = 72 # Reasonable maximum font size + self.mock_page.style.line_spacing_multiplier = 1.2 # Standard line spacing + # Create mock style resolver self.mock_style_resolver = Mock() self.mock_page.style_resolver = self.mock_style_resolver @@ -51,12 +56,20 @@ class TestDocumentLayouter: self.mock_concrete_style.word_spacing_max = 8.0 self.mock_concrete_style.text_align = "left" - # Create mock font that returns proper metrics + # Create mock font that returns proper numeric metrics (not Mock objects) mock_font = Mock() - mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) + # CRITICAL: getmetrics() must return actual numeric values, not Mock objects + # This prevents "TypeError: '>' not supported between instances of 'Mock' and 'Mock'" + mock_font.getmetrics.return_value = (12, 4) # (ascent, descent) as actual integers mock_font.font = mock_font # For accessing .font property - self.mock_concrete_style.create_font = Mock() + # Create mock font object that can be used by create_font + mock_font_instance = Mock() + mock_font_instance.font = mock_font + mock_font_instance.font_size = 16 + mock_font_instance.colour = (0, 0, 0) + mock_font_instance.background = (255, 255, 255, 0) + self.mock_concrete_style.create_font = Mock(return_value=mock_font_instance) # Update mock words to have proper style with font for word in self.mock_words: @@ -66,11 +79,15 @@ class TestDocumentLayouter: word.style.colour = (0, 0, 0) word.style.background = None + @patch('pyWebLayout.layout.document_layouter.StyleResolver') @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): + def test_paragraph_layouter_basic_flow(self, mock_line_class, mock_style_registry_class, mock_style_resolver_class): """Test basic paragraph layouter functionality.""" - # Setup mocks + # Setup mocks for StyleResolver and ConcreteStyleRegistry + mock_style_resolver = Mock() + mock_style_resolver_class.return_value = mock_style_resolver + 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 @@ -88,8 +105,9 @@ class TestDocumentLayouter: 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) + # Verify StyleResolver and ConcreteStyleRegistry were created correctly + mock_style_resolver_class.assert_called_once() + mock_style_registry_class.assert_called_once_with(mock_style_resolver) mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style) # Verify Line was created with correct spacing constraints @@ -98,16 +116,27 @@ class TestDocumentLayouter: call_args = mock_line_class.call_args assert call_args[1]['spacing'] == expected_spacing + @patch('pyWebLayout.layout.document_layouter.StyleResolver') @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): + def test_paragraph_layouter_word_spacing_constraints_extraction(self, mock_line_class, mock_style_registry_class, mock_style_resolver_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() + + # Create a mock font that concrete_style.create_font returns + mock_font = Mock() + mock_font.font = Mock() + mock_font.font.getmetrics.return_value = (12, 4) + mock_font.font_size = 16 + concrete_style.create_font = Mock(return_value=mock_font) + + # Setup StyleResolver and ConcreteStyleRegistry mocks + mock_style_resolver = Mock() + mock_style_resolver_class.return_value = mock_style_resolver mock_style_registry = Mock() mock_style_registry_class.return_value = mock_style_registry @@ -252,39 +281,51 @@ class TestDocumentLayouter: ) assert result == (True, None, None) - @patch('pyWebLayout.layout.document_layouter.paragraph_layouter') - def test_document_layouter_layout_document_success(self, mock_paragraph_layouter): + def test_document_layouter_layout_document_success(self): """Test DocumentLayouter.layout_document with successful layout.""" - mock_paragraph_layouter.return_value = (True, None, None) + from pyWebLayout.abstract import Paragraph - paragraphs = [self.mock_paragraph, Mock(), Mock()] + # Create Mock paragraphs that pass isinstance checks + paragraphs = [ + Mock(spec=Paragraph), + Mock(spec=Paragraph), + Mock(spec=Paragraph) + ] with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) + # Mock the layout_paragraph method to return success + layouter.layout_paragraph = Mock(return_value=(True, None, None)) + result = layouter.layout_document(paragraphs) assert result is True - assert mock_paragraph_layouter.call_count == 3 + assert layouter.layout_paragraph.call_count == 3 - @patch('pyWebLayout.layout.document_layouter.paragraph_layouter') - def test_document_layouter_layout_document_failure(self, mock_paragraph_layouter): + def test_document_layouter_layout_document_failure(self): """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 - ] + from pyWebLayout.abstract import Paragraph - paragraphs = [self.mock_paragraph, Mock()] + # Create Mock paragraphs that pass isinstance checks + paragraphs = [ + Mock(spec=Paragraph), + Mock(spec=Paragraph) + ] with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'): layouter = DocumentLayouter(self.mock_page) + # Mock the layout_paragraph method: first succeeds, second fails + layouter.layout_paragraph = Mock(side_effect=[ + (True, None, None), # First paragraph succeeds + (False, 3, None), # Second paragraph fails + ]) + result = layouter.layout_document(paragraphs) assert result is False - assert mock_paragraph_layouter.call_count == 2 + assert layouter.layout_paragraph.call_count == 2 def test_real_style_integration(self): """Test integration with real style system.""" diff --git a/tests/layouter/test_document_layouter_integration.py b/tests/layouter/test_document_layouter_integration.py index 631afe6..b41c1f2 100644 --- a/tests/layouter/test_document_layouter_integration.py +++ b/tests/layouter/test_document_layouter_integration.py @@ -11,6 +11,8 @@ from unittest.mock import Mock, patch from PIL import Image, ImageDraw import numpy as np from typing import List, Optional +import os +import logging from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter from pyWebLayout.style.abstract_style import AbstractStyle @@ -21,13 +23,42 @@ from pyWebLayout.concrete.page import Page from pyWebLayout.concrete.text import Line, Text from pyWebLayout.abstract.inline import Word +# Enable logging to see font loading messages +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def verify_bundled_font_available(): + """Verify that the bundled font is available for integration tests.""" + # Get the bundled font path + current_dir = os.path.dirname(os.path.abspath(__file__)) + # Navigate up to pyWebLayout root, then to assets/fonts + project_root = os.path.dirname(os.path.dirname(current_dir)) + bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf') + + logger.info(f"Integration tests checking for bundled font at: {bundled_font_path}") + + if not os.path.exists(bundled_font_path): + pytest.fail( + f"INTEGRATION TEST FAILURE: Bundled font not found at {bundled_font_path}\n" + f"Integration tests require the bundled font to ensure consistent behavior.\n" + f"This likely means the font was not included in the package build." + ) + + logger.info(f"Bundled font found at: {bundled_font_path}") + return bundled_font_path + class MockWord(Word): """A simple mock word that extends the real Word class.""" def __init__(self, text, style=None): if style is None: + # Integration tests MUST use the bundled font for consistency style = Font(font_size=16) + # Verify the font loaded properly + if style.font.path is None: + logger.warning("Font loaded without explicit path - may be using PIL default") # Initialize the base Word with required parameters super().__init__(text, style) self._concrete_texts = [] @@ -73,6 +104,11 @@ class MockParagraph: class TestDocumentLayouterIntegration: """Integration tests using real components.""" + @classmethod + def setup_class(cls): + """Verify bundled font is available before running any tests.""" + verify_bundled_font_available() + def test_single_page_layout_with_real_components(self): """Test layout on a single page using real Line and Text objects.""" # Create a real page that can fit content