Fix tests for CI?
Some checks failed
Python CI / test (push) Failing after 7m45s

This commit is contained in:
Duncan Tourolle 2025-11-04 22:30:04 +01:00
parent 9ba35d2fa8
commit 37505d3dcc
5 changed files with 148 additions and 31 deletions

View File

@ -53,7 +53,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
text_align = Alignment.LEFT # Default alignment text_align = Alignment.LEFT # Default alignment
else: else:
# paragraph.style is an AbstractStyle, resolve it # 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_resolver = StyleResolver(rendering_context)
style_registry = ConcreteStyleRegistry(style_resolver) style_registry = ConcreteStyleRegistry(style_resolver)
concrete_style = style_registry.get_concrete_style(paragraph.style) concrete_style = style_registry.get_concrete_style(paragraph.style)

View File

@ -159,6 +159,8 @@ class StyleResolver:
# Resolve each property # Resolve each property
font_path = self._resolve_font_path(abstract_style.font_family) font_path = self._resolve_font_path(abstract_style.font_family)
font_size = self._resolve_font_size(abstract_style.font_size) 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) color = self._resolve_color(abstract_style.color)
background_color = self._resolve_background_color(abstract_style.background_color) background_color = self._resolve_background_color(abstract_style.background_color)
line_height = self._resolve_line_height(abstract_style.line_height) 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 = 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_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) 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 # Apply default logic for word spacing constraints
if word_spacing_min == 0.0 and word_spacing_max == 0.0: 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: def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
"""Resolve font size to actual pixel/point size.""" """Resolve font size to actual pixel/point size."""
if isinstance(font_size, int): # Ensure we handle FontSize enums properly
# Already a concrete size, apply scaling if isinstance(font_size, FontSize):
base_size = font_size
else:
# Semantic size, convert to multiplier # Semantic size, convert to multiplier
multiplier = self._semantic_font_sizes.get(font_size, 1.0) multiplier = self._semantic_font_sizes.get(font_size, 1.0)
base_size = int(self.context.base_font_size * multiplier) 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 # Apply global font scaling
final_size = int(base_size * self.context.font_scale_factor) final_size = int(base_size * self.context.font_scale_factor)
@ -238,7 +248,8 @@ class StyleResolver:
if self.context.large_text: if self.context.large_text:
final_size = int(final_size * 1.2) 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]: def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
"""Resolve color to RGB tuple.""" """Resolve color to RGB tuple."""

View File

@ -3,6 +3,10 @@ from PIL import ImageFont
from enum import Enum from enum import Enum
from typing import Tuple, Union, Optional from typing import Tuple, Union, Optional
import os import os
import logging
# Set up logging for font loading
logger = logging.getLogger(__name__)
class FontWeight(Enum): class FontWeight(Enum):
@ -71,29 +75,46 @@ class Font:
# Navigate to the assets/fonts directory # Navigate to the assets/fonts directory
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts') assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf') 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): def _load_font(self):
"""Load the font using PIL's ImageFont with consistent bundled font""" """Load the font using PIL's ImageFont with consistent bundled font"""
try: try:
if self._font_path: if self._font_path:
# Use specified font path # Use specified font path
logger.info(f"Loading font from specified path: {self._font_path}")
self._font = ImageFont.truetype( self._font = ImageFont.truetype(
self._font_path, self._font_path,
self._font_size self._font_size
) )
logger.info(f"Successfully loaded font from: {self._font_path}")
else: else:
# Use bundled font for consistency across environments # Use bundled font for consistency across environments
bundled_font_path = self._get_bundled_font_path() bundled_font_path = self._get_bundled_font_path()
if 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) self._font = ImageFont.truetype(bundled_font_path, self._font_size)
logger.info(f"Successfully loaded bundled font at size {self._font_size}")
else: else:
# Only fall back to PIL's default font if bundled font is not available # 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() self._font = ImageFont.load_default()
except Exception as e: except Exception as e:
# Ultimate fallback to default font # Ultimate fallback to default font
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
self._font = ImageFont.load_default() self._font = ImageFont.load_default()
@property @property

View File

@ -28,6 +28,11 @@ class TestDocumentLayouter:
self.mock_page.can_fit_line = Mock(return_value=True) self.mock_page.can_fit_line = Mock(return_value=True)
self.mock_page.add_child = Mock() 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 # Create mock style resolver
self.mock_style_resolver = Mock() self.mock_style_resolver = Mock()
self.mock_page.style_resolver = self.mock_style_resolver 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.word_spacing_max = 8.0
self.mock_concrete_style.text_align = "left" 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 = 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 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 # Update mock words to have proper style with font
for word in self.mock_words: for word in self.mock_words:
@ -66,11 +79,15 @@ class TestDocumentLayouter:
word.style.colour = (0, 0, 0) word.style.colour = (0, 0, 0)
word.style.background = None word.style.background = None
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line') @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.""" """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 = Mock()
mock_style_registry_class.return_value = mock_style_registry mock_style_registry_class.return_value = mock_style_registry
mock_style_registry.get_concrete_style.return_value = self.mock_concrete_style 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 failed_word_index is None
assert remaining_pretext is None assert remaining_pretext is None
# Verify style registry was used correctly # Verify StyleResolver and ConcreteStyleRegistry were created correctly
mock_style_registry_class.assert_called_once_with(self.mock_page.style_resolver) 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) mock_style_registry.get_concrete_style.assert_called_once_with(self.mock_paragraph.style)
# Verify Line was created with correct spacing constraints # Verify Line was created with correct spacing constraints
@ -98,16 +116,27 @@ class TestDocumentLayouter:
call_args = mock_line_class.call_args call_args = mock_line_class.call_args
assert call_args[1]['spacing'] == expected_spacing assert call_args[1]['spacing'] == expected_spacing
@patch('pyWebLayout.layout.document_layouter.StyleResolver')
@patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry') @patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry')
@patch('pyWebLayout.layout.document_layouter.Line') @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.""" """Test that word spacing constraints are correctly extracted from style."""
# Create concrete style with specific constraints # Create concrete style with specific constraints
concrete_style = Mock() concrete_style = Mock()
concrete_style.word_spacing_min = 5.5 concrete_style.word_spacing_min = 5.5
concrete_style.word_spacing_max = 15.2 concrete_style.word_spacing_max = 15.2
concrete_style.text_align = "justify" 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 = Mock()
mock_style_registry_class.return_value = mock_style_registry mock_style_registry_class.return_value = mock_style_registry
@ -252,39 +281,51 @@ class TestDocumentLayouter:
) )
assert result == (True, None, None) assert result == (True, None, None)
@patch('pyWebLayout.layout.document_layouter.paragraph_layouter') def test_document_layouter_layout_document_success(self):
def test_document_layouter_layout_document_success(self, mock_paragraph_layouter):
"""Test DocumentLayouter.layout_document with successful layout.""" """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'): with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page) 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) result = layouter.layout_document(paragraphs)
assert result is True 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):
def test_document_layouter_layout_document_failure(self, mock_paragraph_layouter):
"""Test DocumentLayouter.layout_document with layout failure.""" """Test DocumentLayouter.layout_document with layout failure."""
# First paragraph succeeds, second fails from pyWebLayout.abstract import Paragraph
mock_paragraph_layouter.side_effect = [
(True, None, None), # First paragraph succeeds
(False, 3, None), # Second paragraph fails
]
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'): with patch('pyWebLayout.layout.document_layouter.ConcreteStyleRegistry'):
layouter = DocumentLayouter(self.mock_page) 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) result = layouter.layout_document(paragraphs)
assert result is False assert result is False
assert mock_paragraph_layouter.call_count == 2 assert layouter.layout_paragraph.call_count == 2
def test_real_style_integration(self): def test_real_style_integration(self):
"""Test integration with real style system.""" """Test integration with real style system."""

View File

@ -11,6 +11,8 @@ from unittest.mock import Mock, patch
from PIL import Image, ImageDraw from PIL import Image, ImageDraw
import numpy as np import numpy as np
from typing import List, Optional from typing import List, Optional
import os
import logging
from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter from pyWebLayout.layout.document_layouter import paragraph_layouter, DocumentLayouter
from pyWebLayout.style.abstract_style import AbstractStyle 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.concrete.text import Line, Text
from pyWebLayout.abstract.inline import Word 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): class MockWord(Word):
"""A simple mock word that extends the real Word class.""" """A simple mock word that extends the real Word class."""
def __init__(self, text, style=None): def __init__(self, text, style=None):
if style is None: if style is None:
# Integration tests MUST use the bundled font for consistency
style = Font(font_size=16) 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 # Initialize the base Word with required parameters
super().__init__(text, style) super().__init__(text, style)
self._concrete_texts = [] self._concrete_texts = []
@ -73,6 +104,11 @@ class MockParagraph:
class TestDocumentLayouterIntegration: class TestDocumentLayouterIntegration:
"""Integration tests using real components.""" """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): def test_single_page_layout_with_real_components(self):
"""Test layout on a single page using real Line and Text objects.""" """Test layout on a single page using real Line and Text objects."""
# Create a real page that can fit content # Create a real page that can fit content