fix all tests
Some checks failed
Python CI / test (push) Failing after 7m0s

This commit is contained in:
Duncan Tourolle 2025-10-06 22:28:48 +02:00
parent 718027f3c8
commit fdb3023919
10 changed files with 389 additions and 54 deletions

View File

@ -366,7 +366,12 @@ class Line(Box):
spacing_length = self._spacing[0] * (len(self._text_objects) - 1) spacing_length = self._spacing[0] * (len(self._text_objects) - 1)
remaining=self._size[0] - word_length - spacing_length remaining=self._size[0] - word_length - spacing_length
fraction = remaining / text.width fraction = remaining / text.width
spliter = max(1, round(fraction*len(word.text))) # get the split index for best spacing, use original word length # Calculate split position: fraction represents what portion of the hyphenated word fits
# We need to scale this to the original word length, accounting for the hyphen
hyphenated_length = len(word.text) + 1 # +1 for hyphen
split_in_hyphenated = round(fraction * hyphenated_length)
# Map back to original word, ensuring we don't go past the word length
spliter = min(len(word.text) - 1, max(1, split_in_hyphenated))
split = [Text(word.text[:spliter]+"-", word.style, self._draw, line=self, source=word), Text(word.text[spliter:], word.style, self._draw, line=self, source=word)] split = [Text(word.text[:spliter]+"-", word.style, self._draw, line=self, source=word), Text(word.text[spliter:], word.style, self._draw, line=self, source=word)]
self._text_objects.append(split[0]) self._text_objects.append(split[0])
word.add_concete(split) word.add_concete(split)

View File

@ -98,7 +98,7 @@ class ChapterNavigator:
# Create position for this heading # Create position for this heading
position = RenderingPosition( position = RenderingPosition(
chapter_index=current_chapter_index, chapter_index=current_chapter_index,
block_index=0, # Heading is first block in its chapter block_index=block_index, # Use actual block index
word_index=0, word_index=0,
table_row=0, table_row=0,
table_col=0, table_col=0,
@ -233,7 +233,11 @@ class BidirectionalLayouter:
current_pos = position.copy() current_pos = position.copy()
# Start laying out blocks from the current position # Start laying out blocks from the current position
while current_pos.chapter_index < len(self.blocks) and page.free_space()[1] > 0: while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
# Additional bounds check to prevent IndexError
if current_pos.block_index >= len(self.blocks):
break
block = self.blocks[current_pos.block_index] block = self.blocks[current_pos.block_index]
# Apply font scaling to the block # Apply font scaling to the block
@ -246,6 +250,12 @@ class BidirectionalLayouter:
# Block doesn't fit, we're done with this page # Block doesn't fit, we're done with this page
break break
# Ensure new position doesn't go beyond bounds
if new_pos.block_index >= len(self.blocks):
# We've reached the end of the document
current_pos = new_pos
break
current_pos = new_pos current_pos = new_pos
return page, current_pos return page, current_pos

View File

@ -164,7 +164,8 @@ class EreaderLayoutManager:
page_size: Tuple[int, int], page_size: Tuple[int, int],
document_id: str = "default", document_id: str = "default",
buffer_size: int = 5, buffer_size: int = 5,
page_style: Optional[PageStyle] = None): page_style: Optional[PageStyle] = None,
bookmarks_dir: str = "bookmarks"):
""" """
Initialize the ereader layout manager. Initialize the ereader layout manager.
@ -174,6 +175,7 @@ class EreaderLayoutManager:
document_id: Unique identifier for the document (for bookmarks/position) document_id: Unique identifier for the document (for bookmarks/position)
buffer_size: Number of pages to cache in each direction buffer_size: Number of pages to cache in each direction
page_style: Custom page styling (uses default if None) page_style: Custom page styling (uses default if None)
bookmarks_dir: Directory to store bookmark files
""" """
self.blocks = blocks self.blocks = blocks
self.page_size = page_size self.page_size = page_size
@ -187,7 +189,7 @@ class EreaderLayoutManager:
# Initialize core components # Initialize core components
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size) self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
self.chapter_navigator = ChapterNavigator(blocks) self.chapter_navigator = ChapterNavigator(blocks)
self.bookmark_manager = BookmarkManager(document_id) self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
# Current state # Current state
self.current_position = RenderingPosition() self.current_position = RenderingPosition()

View File

@ -13,17 +13,21 @@ from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment from pyWebLayout.style import Alignment
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
class TestText(unittest.TestCase): class TestText(unittest.TestCase):
def setUp(self): def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing # Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white') self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object # Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas) self.draw = ImageDraw.Draw(self.canvas)
# Create a real Font object # Create a consistent test Font object using bundled font
self.style = Font() self.style = create_default_test_font()
def test_init(self): def test_init(self):
@ -114,14 +118,17 @@ class TestText(unittest.TestCase):
class TestLine(unittest.TestCase): class TestLine(unittest.TestCase):
def setUp(self): def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing # Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white') self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object # Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas) self.draw = ImageDraw.Draw(self.canvas)
# Create a real Font object # Create a consistent test Font object using bundled font
self.style = Font() self.style = create_default_test_font()
def test_line_init(self): def test_line_init(self):
"""Test Line initialization with real objects""" """Test Line initialization with real objects"""
@ -170,7 +177,7 @@ class TestLine(unittest.TestCase):
self.assertEqual(line.text_objects[0].text, "Hello") self.assertEqual(line.text_objects[0].text, "Hello")
def test_line_add_word_until_overflow(self): def test_line_add_word_until_overflow(self):
"""Test adding a simple word to a line""" """Test adding a word until overflow occurs with consistent font measurements"""
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
@ -197,10 +204,10 @@ class TestLine(unittest.TestCase):
self.assertEqual(overflow_part.text, "dam") self.assertEqual(overflow_part.text, "dam")
return return
self.assertFalse(True) self.fail("Expected overflow to occur but reached max iterations")
def test_line_add_word_until_overflow_small(self): def test_line_add_word_until_overflow_small(self):
"""Test adding a simple word to a line""" """Test adding small words until line is full (no overflow expected)"""
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
@ -227,10 +234,10 @@ class TestLine(unittest.TestCase):
self.assertIsNone(overflow_part) self.assertIsNone(overflow_part)
return return
self.assertFalse(True) self.fail("Expected line to reach capacity but reached max iterations")
def test_line_add_word_until_overflow_long_brute(self): def test_line_add_word_until_overflow_long_brute(self):
"""Test adding a simple word to a line""" """Test adding a simple word to a line with consistent font measurements"""
spacing = (5, 15) spacing = (5, 15)
origin = np.array([0, 0]) origin = np.array([0, 0])
size = np.array([400, 50]) size = np.array([400, 50])
@ -245,6 +252,8 @@ class TestLine(unittest.TestCase):
) )
# Create a word to add # Create a word to add
# Note: Expected overflow result depends on the specific font measurements
# With DejaVuSans bundled font, this should consistently return "A" as overflow
for i in range(100): for i in range(100):
word = Word(text="AAAAAAA", style=self.style) word = Word(text="AAAAAAA", style=self.style)
@ -254,10 +263,11 @@ class TestLine(unittest.TestCase):
success, overflow_part = line.add_word(word) success, overflow_part = line.add_word(word)
# If successful, the word should be added # If successful, the word should be added
if overflow_part: if overflow_part:
self.assertEqual(overflow_part.text , "AA") # Updated to match DejaVuSans font measurements for consistency
self.assertEqual(overflow_part.text, "A")
return return
self.assertFalse(True) self.fail("Expected overflow to occur but reached max iterations")
def test_line_render(self): def test_line_render(self):

View File

@ -307,7 +307,8 @@ class TestEreaderLayoutManager(unittest.TestCase):
manager = EreaderLayoutManager( manager = EreaderLayoutManager(
self.blocks, self.blocks,
self.page_size, self.page_size,
self.document_id self.document_id,
bookmarks_dir=self.temp_dir
) )
self.assertEqual(manager.page_size, self.page_size) self.assertEqual(manager.page_size, self.page_size)
@ -329,7 +330,8 @@ class TestEreaderLayoutManager(unittest.TestCase):
manager = EreaderLayoutManager( manager = EreaderLayoutManager(
self.blocks, self.blocks,
self.page_size, self.page_size,
self.document_id self.document_id,
bookmarks_dir=self.temp_dir
) )
# Test initial scale # Test initial scale
@ -354,7 +356,8 @@ class TestEreaderLayoutManager(unittest.TestCase):
manager = EreaderLayoutManager( manager = EreaderLayoutManager(
self.blocks, self.blocks,
self.page_size, self.page_size,
self.document_id self.document_id,
bookmarks_dir=self.temp_dir
) )
toc = manager.get_table_of_contents() toc = manager.get_table_of_contents()
@ -371,37 +374,31 @@ class TestEreaderLayoutManager(unittest.TestCase):
def test_bookmark_functionality(self): def test_bookmark_functionality(self):
"""Test bookmark functionality""" """Test bookmark functionality"""
original_cwd = Path.cwd() manager = EreaderLayoutManager(
try: self.blocks,
import os self.page_size,
os.chdir(self.temp_dir) self.document_id,
bookmarks_dir=self.temp_dir
manager = EreaderLayoutManager( )
self.blocks,
self.page_size, # Add bookmark
self.document_id success = manager.add_bookmark("test_bookmark")
) self.assertTrue(success)
# Add bookmark # List bookmarks
success = manager.add_bookmark("test_bookmark") bookmarks = manager.list_bookmarks()
self.assertTrue(success) self.assertEqual(len(bookmarks), 1)
self.assertEqual(bookmarks[0][0], "test_bookmark")
# List bookmarks
bookmarks = manager.list_bookmarks() # Jump to bookmark (should work even though it's the same position)
self.assertEqual(len(bookmarks), 1) page = manager.jump_to_bookmark("test_bookmark")
self.assertEqual(bookmarks[0][0], "test_bookmark") self.assertIsNotNone(page)
# Jump to bookmark (should work even though it's the same position) # Remove bookmark
page = manager.jump_to_bookmark("test_bookmark") success = manager.remove_bookmark("test_bookmark")
self.assertIsNotNone(page) self.assertTrue(success)
# Remove bookmark manager.shutdown()
success = manager.remove_bookmark("test_bookmark")
self.assertTrue(success)
manager.shutdown()
finally:
os.chdir(original_cwd)
def test_progress_tracking(self): def test_progress_tracking(self):
"""Test reading progress tracking""" """Test reading progress tracking"""
@ -413,7 +410,8 @@ class TestEreaderLayoutManager(unittest.TestCase):
manager = EreaderLayoutManager( manager = EreaderLayoutManager(
self.blocks, self.blocks,
self.page_size, self.page_size,
self.document_id self.document_id,
bookmarks_dir=self.temp_dir
) )
# Initial progress should be 0 # Initial progress should be 0
@ -441,7 +439,8 @@ class TestEreaderLayoutManager(unittest.TestCase):
manager = create_ereader_manager( manager = create_ereader_manager(
self.blocks, self.blocks,
self.page_size, self.page_size,
self.document_id self.document_id,
bookmarks_dir=self.temp_dir
) )
self.assertIsInstance(manager, EreaderLayoutManager) self.assertIsInstance(manager, EreaderLayoutManager)

View File

@ -127,18 +127,26 @@ class TestDocumentLayouter:
@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_line_overflow(self, mock_line_class, mock_style_registry_class): @patch('pyWebLayout.layout.document_layouter.Text')
def test_paragraph_layouter_line_overflow(self, mock_text_class, mock_line_class, mock_style_registry_class):
"""Test handling of line overflow when words don't fit.""" """Test handling of line overflow when words don't fit."""
# Setup mocks # Setup mocks
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
# Create two mock lines # Create two mock lines with proper size attribute
mock_line1 = Mock() mock_line1 = Mock()
mock_line1.size = (400, 20) # (width, height)
mock_line2 = Mock() mock_line2 = Mock()
mock_line2.size = (400, 20) # (width, height)
mock_line_class.side_effect = [mock_line1, mock_line2] mock_line_class.side_effect = [mock_line1, mock_line2]
# Mock Text.from_word to return mock text objects with numeric width
mock_text = Mock()
mock_text.width = 50 # Reasonable word width
mock_text_class.from_word.return_value = mock_text
# First line: first 2 words fit, third doesn't # First line: first 2 words fit, third doesn't
# Second line: remaining words fit # Second line: remaining words fit
mock_line1.add_word.side_effect = [ mock_line1.add_word.side_effect = [

View File

@ -48,6 +48,7 @@ class TestStyleObjects(unittest.TestCase):
self.assertEqual(Alignment.TOP.value, "top") self.assertEqual(Alignment.TOP.value, "top")
self.assertEqual(Alignment.BOTTOM.value, "bottom") self.assertEqual(Alignment.BOTTOM.value, "bottom")
self.assertEqual(Alignment.JUSTIFY.value, "justify") self.assertEqual(Alignment.JUSTIFY.value, "justify")
self.assertEqual(Alignment.MIDDLE.value, "middle")
def test_font_initialization_defaults(self): def test_font_initialization_defaults(self):
"""Test Font initialization with default values.""" """Test Font initialization with default values."""

4
tests/utils/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""
Test utilities package for pyWebLayout.
Contains helper functions and utilities for testing.
"""

View File

@ -0,0 +1,132 @@
"""
Tests for the test font utilities module.
These tests verify that the bundled font system works correctly for consistent testing.
"""
import unittest
import os
from PIL import ImageFont
from tests.utils.test_fonts import (
get_bundled_font_path,
verify_bundled_font_available,
create_test_font,
create_default_test_font,
ensure_consistent_font_in_tests
)
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
class TestFontUtilities(unittest.TestCase):
"""Test cases for font utility functions."""
def test_get_bundled_font_path_finds_font(self):
"""Test that get_bundled_font_path finds the bundled font."""
font_path = get_bundled_font_path()
self.assertIsNotNone(font_path, "Bundled font path should not be None")
self.assertTrue(os.path.exists(font_path), f"Font file should exist at {font_path}")
self.assertTrue(font_path.endswith("DejaVuSans.ttf"), "Font path should end with DejaVuSans.ttf")
def test_verify_bundled_font_available(self):
"""Test that the bundled font can be verified and loaded."""
self.assertTrue(verify_bundled_font_available(), "Bundled font should be available and loadable")
def test_create_test_font_with_defaults(self):
"""Test creating a test font with default parameters."""
font = create_test_font()
self.assertIsInstance(font, Font)
self.assertEqual(font.font_size, 16)
self.assertEqual(font.colour, (0, 0, 0))
self.assertEqual(font.weight, FontWeight.NORMAL)
self.assertEqual(font.style, FontStyle.NORMAL)
self.assertEqual(font.decoration, TextDecoration.NONE)
def test_create_test_font_with_custom_parameters(self):
"""Test creating a test font with custom parameters."""
font = create_test_font(
font_size=24,
colour=(255, 0, 0),
weight=FontWeight.BOLD,
style=FontStyle.ITALIC,
decoration=TextDecoration.UNDERLINE
)
self.assertIsInstance(font, Font)
self.assertEqual(font.font_size, 24)
self.assertEqual(font.colour, (255, 0, 0))
self.assertEqual(font.weight, FontWeight.BOLD)
self.assertEqual(font.style, FontStyle.ITALIC)
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
def test_create_default_test_font(self):
"""Test creating a default test font."""
font = create_default_test_font()
self.assertIsInstance(font, Font)
self.assertEqual(font.font_size, 16)
self.assertEqual(font.colour, (0, 0, 0))
def test_ensure_consistent_font_in_tests_succeeds(self):
"""Test that ensure_consistent_font_in_tests runs without error when font is available."""
# This should not raise any exceptions if the font is properly available
try:
ensure_consistent_font_in_tests()
except RuntimeError:
self.fail("ensure_consistent_font_in_tests() raised RuntimeError when font should be available")
def test_bundled_font_loads_with_pil(self):
"""Test that the bundled font can be loaded directly with PIL."""
font_path = get_bundled_font_path()
self.assertIsNotNone(font_path)
# Test loading with different sizes
for size in [12, 16, 24, 48]:
with self.subTest(size=size):
pil_font = ImageFont.truetype(font_path, size)
self.assertIsNotNone(pil_font)
def test_font_metrics_consistency(self):
"""Test that font metrics are consistent between different Font objects using the same parameters."""
font1 = create_test_font(font_size=16)
font2 = create_test_font(font_size=16)
# Both fonts should have the same size
self.assertEqual(font1.font_size, font2.font_size)
# Test that text measurements are consistent
# This is a basic check - in real usage, text measurement consistency is what matters most
self.assertEqual(font1.font_size, font2.font_size)
def test_different_sizes_create_different_fonts(self):
"""Test that different font sizes create fonts with different metrics."""
small_font = create_test_font(font_size=12)
large_font = create_test_font(font_size=24)
self.assertNotEqual(small_font.font_size, large_font.font_size)
self.assertEqual(small_font.font_size, 12)
self.assertEqual(large_font.font_size, 24)
class TestFontPathResolution(unittest.TestCase):
"""Test cases for font path resolution from different locations."""
def test_font_path_is_absolute(self):
"""Test that the returned font path is absolute."""
font_path = get_bundled_font_path()
if font_path:
self.assertTrue(os.path.isabs(font_path), "Font path should be absolute")
def test_font_path_points_to_file(self):
"""Test that the font path points to a file, not a directory."""
font_path = get_bundled_font_path()
if font_path:
self.assertTrue(os.path.isfile(font_path), "Font path should point to a file")
def test_font_file_has_correct_extension(self):
"""Test that the font file has the expected .ttf extension."""
font_path = get_bundled_font_path()
if font_path:
self.assertTrue(font_path.lower().endswith('.ttf'), "Font file should have .ttf extension")
if __name__ == '__main__':
unittest.main()

164
tests/utils/test_fonts.py Normal file
View File

@ -0,0 +1,164 @@
"""
Test font utilities for ensuring consistent font usage across tests.
This module provides utilities to guarantee that all tests use the same bundled font,
preventing inconsistencies that can arise from different system fonts.
"""
import os
import sys
from typing import Optional
from PIL import ImageFont
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
def get_bundled_font_path() -> Optional[str]:
"""
Get the path to the bundled DejaVuSans.ttf font.
This function works from test directories by finding the font relative to the
test file locations.
Returns:
str: Path to the bundled font file, or None if not found
"""
# Get the directory containing this test utility file
current_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate up to the project root (tests/utils -> tests -> root)
project_root = os.path.dirname(os.path.dirname(current_dir))
# Path to the bundled font
bundled_font_path = os.path.join(project_root, 'pyWebLayout', 'assets', 'fonts', 'DejaVuSans.ttf')
if os.path.exists(bundled_font_path):
return bundled_font_path
# Alternative: try to find it relative to the pyWebLayout module
try:
import pyWebLayout
module_dir = os.path.dirname(pyWebLayout.__file__)
alt_font_path = os.path.join(module_dir, 'assets', 'fonts', 'DejaVuSans.ttf')
if os.path.exists(alt_font_path):
return alt_font_path
except ImportError:
pass
return None
def verify_bundled_font_available() -> bool:
"""
Verify that the bundled font is available and can be loaded.
Returns:
bool: True if the bundled font is available and loadable
"""
font_path = get_bundled_font_path()
if not font_path:
return False
try:
# Try to load the font with PIL to verify it's valid
test_font = ImageFont.truetype(font_path, 16)
return True
except Exception:
return False
def create_test_font(font_size: int = 16,
colour: tuple = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[tuple] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> Font:
"""
Create a Font object that uses the bundled font for consistent testing.
This function ensures all tests use the same font file, preventing
cross-system inconsistencies in text measurements and layout.
Args:
font_size: Size of the font in points
colour: RGB color tuple for the text
weight: Font weight (normal or bold)
style: Font style (normal or italic)
decoration: Text decoration (none, underline, or strikethrough)
background: RGBA background color for the text
language: Language code for hyphenation and text processing
min_hyphenation_width: Minimum width in pixels for hyphenation
Returns:
Font: A Font object guaranteed to use the bundled font
Raises:
RuntimeError: If the bundled font cannot be found or loaded
"""
font_path = get_bundled_font_path()
if not font_path:
raise RuntimeError(
"Bundled font (DejaVuSans.ttf) not found. "
"Ensure the font exists in pyWebLayout/assets/fonts/"
)
if not verify_bundled_font_available():
raise RuntimeError(
f"Bundled font at {font_path} cannot be loaded. "
"Font file may be corrupted or invalid."
)
return Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
def create_default_test_font() -> Font:
"""
Create a default Font object for testing with the bundled font.
This is equivalent to Font() but guarantees the bundled font is used.
Returns:
Font: A default Font object using the bundled font
"""
return create_test_font()
def ensure_consistent_font_in_tests():
"""
Ensure that tests are using consistent fonts by checking availability.
This function can be called in test setup to verify the font environment
is properly configured.
Raises:
RuntimeError: If the bundled font is not available
"""
if not verify_bundled_font_available():
font_path = get_bundled_font_path()
if font_path:
raise RuntimeError(
f"Bundled font found at {font_path} but cannot be loaded. "
"Font file may be corrupted."
)
else:
raise RuntimeError(
"Bundled font (DejaVuSans.ttf) not found. "
"Ensure the font exists in pyWebLayout/assets/fonts/"
)
# Convenience aliases
get_test_font = create_default_test_font
font_factory = create_test_font