From fdb3023919dd78678d8afe06b7669b0c81955737 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 6 Oct 2025 22:28:48 +0200 Subject: [PATCH] fix all tests --- pyWebLayout/concrete/text.py | 7 +- pyWebLayout/layout/ereader_layout.py | 14 +- pyWebLayout/layout/ereader_manager.py | 6 +- tests/concrete/test_concrete_text.py | 32 +++-- tests/layout/test_ereader_system.py | 71 +++++----- tests/layouter/test_document_layouter.py | 12 +- tests/style/test_html_style.py | 1 + tests/utils/__init__.py | 4 + tests/utils/test_font_utilities.py | 132 ++++++++++++++++++ tests/utils/test_fonts.py | 164 +++++++++++++++++++++++ 10 files changed, 389 insertions(+), 54 deletions(-) create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_font_utilities.py create mode 100644 tests/utils/test_fonts.py diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index 39cf567..c6a0a1d 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -366,7 +366,12 @@ class Line(Box): spacing_length = self._spacing[0] * (len(self._text_objects) - 1) remaining=self._size[0] - word_length - spacing_length 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)] self._text_objects.append(split[0]) word.add_concete(split) diff --git a/pyWebLayout/layout/ereader_layout.py b/pyWebLayout/layout/ereader_layout.py index 3528a81..3bad6e5 100644 --- a/pyWebLayout/layout/ereader_layout.py +++ b/pyWebLayout/layout/ereader_layout.py @@ -98,7 +98,7 @@ class ChapterNavigator: # Create position for this heading position = RenderingPosition( 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, table_row=0, table_col=0, @@ -233,7 +233,11 @@ class BidirectionalLayouter: current_pos = position.copy() # 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] # Apply font scaling to the block @@ -246,6 +250,12 @@ class BidirectionalLayouter: # Block doesn't fit, we're done with this page 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 return page, current_pos diff --git a/pyWebLayout/layout/ereader_manager.py b/pyWebLayout/layout/ereader_manager.py index 41b9409..da6a9ea 100644 --- a/pyWebLayout/layout/ereader_manager.py +++ b/pyWebLayout/layout/ereader_manager.py @@ -164,7 +164,8 @@ class EreaderLayoutManager: page_size: Tuple[int, int], document_id: str = "default", buffer_size: int = 5, - page_style: Optional[PageStyle] = None): + page_style: Optional[PageStyle] = None, + bookmarks_dir: str = "bookmarks"): """ Initialize the ereader layout manager. @@ -174,6 +175,7 @@ class EreaderLayoutManager: document_id: Unique identifier for the document (for bookmarks/position) buffer_size: Number of pages to cache in each direction page_style: Custom page styling (uses default if None) + bookmarks_dir: Directory to store bookmark files """ self.blocks = blocks self.page_size = page_size @@ -187,7 +189,7 @@ class EreaderLayoutManager: # Initialize core components self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size) self.chapter_navigator = ChapterNavigator(blocks) - self.bookmark_manager = BookmarkManager(document_id) + self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir) # Current state self.current_position = RenderingPosition() diff --git a/tests/concrete/test_concrete_text.py b/tests/concrete/test_concrete_text.py index 18b55ea..4230033 100644 --- a/tests/concrete/test_concrete_text.py +++ b/tests/concrete/test_concrete_text.py @@ -13,17 +13,21 @@ from pyWebLayout.concrete.text import Text, Line from pyWebLayout.abstract.inline import Word from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration from pyWebLayout.style import Alignment +from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests class TestText(unittest.TestCase): def setUp(self): + # Ensure consistent font usage across tests + ensure_consistent_font_in_tests() + # Create a real PIL image (canvas) for testing self.canvas = Image.new('RGB', (800, 600), color='white') # Create a real ImageDraw object self.draw = ImageDraw.Draw(self.canvas) - # Create a real Font object - self.style = Font() + # Create a consistent test Font object using bundled font + self.style = create_default_test_font() def test_init(self): @@ -114,14 +118,17 @@ class TestText(unittest.TestCase): class TestLine(unittest.TestCase): def setUp(self): + # Ensure consistent font usage across tests + ensure_consistent_font_in_tests() + # Create a real PIL image (canvas) for testing self.canvas = Image.new('RGB', (800, 600), color='white') # Create a real ImageDraw object self.draw = ImageDraw.Draw(self.canvas) - # Create a real Font object - self.style = Font() + # Create a consistent test Font object using bundled font + self.style = create_default_test_font() def test_line_init(self): """Test Line initialization with real objects""" @@ -170,7 +177,7 @@ class TestLine(unittest.TestCase): self.assertEqual(line.text_objects[0].text, "Hello") 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) origin = np.array([0, 0]) size = np.array([400, 50]) @@ -197,10 +204,10 @@ class TestLine(unittest.TestCase): self.assertEqual(overflow_part.text, "dam") return - self.assertFalse(True) + self.fail("Expected overflow to occur but reached max iterations") 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) origin = np.array([0, 0]) size = np.array([400, 50]) @@ -227,10 +234,10 @@ class TestLine(unittest.TestCase): self.assertIsNone(overflow_part) 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): - """Test adding a simple word to a line""" + """Test adding a simple word to a line with consistent font measurements""" spacing = (5, 15) origin = np.array([0, 0]) size = np.array([400, 50]) @@ -245,6 +252,8 @@ class TestLine(unittest.TestCase): ) # 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): word = Word(text="AAAAAAA", style=self.style) @@ -254,10 +263,11 @@ class TestLine(unittest.TestCase): success, overflow_part = line.add_word(word) # If successful, the word should be added if overflow_part: - self.assertEqual(overflow_part.text , "AA") + # Updated to match DejaVuSans font measurements for consistency + self.assertEqual(overflow_part.text, "A") return - self.assertFalse(True) + self.fail("Expected overflow to occur but reached max iterations") def test_line_render(self): diff --git a/tests/layout/test_ereader_system.py b/tests/layout/test_ereader_system.py index 2242790..199d589 100644 --- a/tests/layout/test_ereader_system.py +++ b/tests/layout/test_ereader_system.py @@ -307,7 +307,8 @@ class TestEreaderLayoutManager(unittest.TestCase): manager = EreaderLayoutManager( self.blocks, self.page_size, - self.document_id + self.document_id, + bookmarks_dir=self.temp_dir ) self.assertEqual(manager.page_size, self.page_size) @@ -329,7 +330,8 @@ class TestEreaderLayoutManager(unittest.TestCase): manager = EreaderLayoutManager( self.blocks, self.page_size, - self.document_id + self.document_id, + bookmarks_dir=self.temp_dir ) # Test initial scale @@ -354,7 +356,8 @@ class TestEreaderLayoutManager(unittest.TestCase): manager = EreaderLayoutManager( self.blocks, self.page_size, - self.document_id + self.document_id, + bookmarks_dir=self.temp_dir ) toc = manager.get_table_of_contents() @@ -371,37 +374,31 @@ class TestEreaderLayoutManager(unittest.TestCase): def test_bookmark_functionality(self): """Test bookmark functionality""" - original_cwd = Path.cwd() - try: - import os - os.chdir(self.temp_dir) - - manager = EreaderLayoutManager( - self.blocks, - self.page_size, - self.document_id - ) - - # Add bookmark - success = manager.add_bookmark("test_bookmark") - self.assertTrue(success) - - # List bookmarks - bookmarks = manager.list_bookmarks() - self.assertEqual(len(bookmarks), 1) - self.assertEqual(bookmarks[0][0], "test_bookmark") - - # Jump to bookmark (should work even though it's the same position) - page = manager.jump_to_bookmark("test_bookmark") - self.assertIsNotNone(page) - - # Remove bookmark - success = manager.remove_bookmark("test_bookmark") - self.assertTrue(success) - - manager.shutdown() - finally: - os.chdir(original_cwd) + manager = EreaderLayoutManager( + self.blocks, + self.page_size, + self.document_id, + bookmarks_dir=self.temp_dir + ) + + # Add bookmark + success = manager.add_bookmark("test_bookmark") + self.assertTrue(success) + + # List bookmarks + bookmarks = manager.list_bookmarks() + self.assertEqual(len(bookmarks), 1) + self.assertEqual(bookmarks[0][0], "test_bookmark") + + # Jump to bookmark (should work even though it's the same position) + page = manager.jump_to_bookmark("test_bookmark") + self.assertIsNotNone(page) + + # Remove bookmark + success = manager.remove_bookmark("test_bookmark") + self.assertTrue(success) + + manager.shutdown() def test_progress_tracking(self): """Test reading progress tracking""" @@ -413,7 +410,8 @@ class TestEreaderLayoutManager(unittest.TestCase): manager = EreaderLayoutManager( self.blocks, self.page_size, - self.document_id + self.document_id, + bookmarks_dir=self.temp_dir ) # Initial progress should be 0 @@ -441,7 +439,8 @@ class TestEreaderLayoutManager(unittest.TestCase): manager = create_ereader_manager( self.blocks, self.page_size, - self.document_id + self.document_id, + bookmarks_dir=self.temp_dir ) self.assertIsInstance(manager, EreaderLayoutManager) diff --git a/tests/layouter/test_document_layouter.py b/tests/layouter/test_document_layouter.py index 2df910d..0b99837 100644 --- a/tests/layouter/test_document_layouter.py +++ b/tests/layouter/test_document_layouter.py @@ -127,18 +127,26 @@ class TestDocumentLayouter: @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): + @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.""" # 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 + # Create two mock lines with proper size attribute mock_line1 = Mock() + mock_line1.size = (400, 20) # (width, height) mock_line2 = Mock() + mock_line2.size = (400, 20) # (width, height) 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 # Second line: remaining words fit mock_line1.add_word.side_effect = [ diff --git a/tests/style/test_html_style.py b/tests/style/test_html_style.py index 8e0fac7..3d0aa35 100644 --- a/tests/style/test_html_style.py +++ b/tests/style/test_html_style.py @@ -48,6 +48,7 @@ class TestStyleObjects(unittest.TestCase): self.assertEqual(Alignment.TOP.value, "top") self.assertEqual(Alignment.BOTTOM.value, "bottom") self.assertEqual(Alignment.JUSTIFY.value, "justify") + self.assertEqual(Alignment.MIDDLE.value, "middle") def test_font_initialization_defaults(self): """Test Font initialization with default values.""" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..d662571 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1,4 @@ +""" +Test utilities package for pyWebLayout. +Contains helper functions and utilities for testing. +""" diff --git a/tests/utils/test_font_utilities.py b/tests/utils/test_font_utilities.py new file mode 100644 index 0000000..0c4850b --- /dev/null +++ b/tests/utils/test_font_utilities.py @@ -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() diff --git a/tests/utils/test_fonts.py b/tests/utils/test_fonts.py new file mode 100644 index 0000000..8171a1b --- /dev/null +++ b/tests/utils/test_fonts.py @@ -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