Large clean up
Python CI / test (push) Failing after 5m5s

This commit is contained in:
2025-06-28 21:10:30 +02:00
parent d0153c6397
commit 56a6ec19e8
60 changed files with 2016 additions and 12198 deletions
-299
View File
@@ -1,299 +0,0 @@
# PyWebLayout Testing Strategy
This document outlines the comprehensive unit testing strategy for the pyWebLayout project.
## Testing Philosophy
The testing strategy follows these principles:
- **Separation of Concerns**: Each component is tested independently
- **Comprehensive Coverage**: All public APIs and critical functionality are tested
- **Integration Testing**: End-to-end workflows are validated
- **Regression Prevention**: Tests prevent breaking changes
- **Documentation**: Tests serve as living documentation of expected behavior
## Test Organization
### Current Test Files (Implemented)
#### ✅ `test_html_style.py`
Tests the `HTMLStyleManager` class for CSS parsing and style management.
**Coverage:**
- Style initialization and defaults
- Style stack operations (push/pop)
- CSS property parsing (font-size, font-weight, colors, etc.)
- Color parsing (named, hex, rgb, rgba)
- Tag-specific default styles
- Inline style parsing
- Font object creation
- Style combination (tag + inline styles)
#### ✅ `test_html_text.py`
Tests the `HTMLTextProcessor` class for text buffering and word creation.
**Coverage:**
- Text buffer management
- HTML entity reference handling
- Character reference processing (decimal/hex)
- Word creation with styling
- Paragraph management
- Text flushing operations
- Buffer state operations
#### ✅ `test_html_content.py`
Integration tests for the `HTMLContentReader` class covering complete HTML parsing.
**Coverage:**
- Simple paragraph parsing
- Heading levels (h1-h6)
- Styled text (bold, italic)
- Lists (ul, ol, dl)
- Tables with headers and cells
- Blockquotes with nested content
- Code blocks with language detection
- HTML entities
- Nested element structures
- Complex document parsing
#### ✅ `test_abstract_blocks.py`
Tests for the core abstract block element classes.
**Coverage:**
- Paragraph word management
- Heading levels and properties
- Quote nesting capabilities
- Code block line management
- List creation and item handling
- Table structure (rows, cells, sections)
- Image properties and scaling
- Simple elements (hr, br)
#### ✅ `test_runner.py`
Test runner script for executing all tests with summary reporting.
---
## Additional Tests Needed
### 🔄 High Priority (Should Implement Next)
#### `test_abstract_inline.py`
Tests for inline elements and text formatting.
**Needed Coverage:**
- Word creation and properties
- Word hyphenation functionality
- FormattedSpan management
- Word chaining (previous/next relationships)
- Font style application
- Language-specific hyphenation
#### `test_abstract_document.py`
Tests for document structure and metadata.
**Needed Coverage:**
- Document creation and initialization
- Metadata management (title, author, language, etc.)
- Block addition and management
- Anchor creation and resolution
- Resource management
- Table of contents generation
- Chapter and book structures
#### `test_abstract_functional.py`
Tests for functional elements (links, buttons, forms).
**Needed Coverage:**
- Link creation and type detection
- Link execution for different types
- Button functionality and state
- Form field management
- Form validation and submission
- Parameter handling
#### `test_style_system.py`
Tests for the style system (fonts, colors, alignment).
**Needed Coverage:**
- Font creation and properties
- Color representation and manipulation
- Font weight, style, decoration enums
- Alignment enums and behavior
- Style inheritance and cascading
### 🔧 Medium Priority
#### `test_html_elements.py`
Unit tests for the HTML element handlers.
**Needed Coverage:**
- BlockElementHandler individual methods
- ListElementHandler state management
- TableElementHandler complex scenarios
- InlineElementHandler link processing
- Handler coordination and delegation
- Error handling in handlers
#### `test_html_metadata.py`
Tests for HTML metadata extraction.
**Needed Coverage:**
- Meta tag parsing
- Open Graph extraction
- JSON-LD structured data
- Title and description extraction
- Language detection
- Character encoding handling
#### `test_html_resources.py`
Tests for HTML resource extraction.
**Needed Coverage:**
- CSS stylesheet extraction
- JavaScript resource identification
- Image source collection
- Media element detection
- External resource resolution
- Base URL handling
#### `test_io_base.py`
Tests for the base reader architecture.
**Needed Coverage:**
- BaseReader interface compliance
- MetadataReader abstract methods
- ContentReader abstract methods
- ResourceReader abstract methods
- CompositeReader coordination
### 🔍 Lower Priority
#### `test_concrete_elements.py`
Tests for concrete rendering implementations.
**Needed Coverage:**
- Box model calculations
- Text rendering specifics
- Image rendering and scaling
- Page layout management
- Functional element rendering
#### `test_typesetting.py`
Tests for the typesetting system.
**Needed Coverage:**
- Flow algorithms
- Pagination logic
- Document pagination
- Line breaking
- Hyphenation integration
#### `test_epub_reader.py`
Tests for EPUB reading functionality.
**Needed Coverage:**
- EPUB file structure parsing
- Manifest processing
- Chapter extraction
- Metadata reading
- Navigation document parsing
#### `test_integration.py`
End-to-end integration tests.
**Needed Coverage:**
- Complete HTML-to-document workflows
- EPUB-to-document workflows
- Style application across parsers
- Resource resolution chains
- Error handling scenarios
## Testing Infrastructure
### Test Dependencies
```python
# Required for testing
unittest # Built-in Python testing framework
unittest.mock # For mocking and test doubles
```
### Test Data
- Create `tests/data/` directory with sample files:
- `sample.html` - Well-formed HTML document
- `complex.html` - Complex nested HTML
- `malformed.html` - Edge cases and error conditions
- `sample.epub` - Sample EPUB file
- `test_images/` - Sample images for testing
### Continuous Integration
- Tests should run on Python 3.6+
- All tests must pass before merging
- Aim for >90% code coverage
- Performance regression testing for parsing speed
## Running Tests
### Run All Tests
```bash
python tests/test_runner.py
```
### Run Specific Test Module
```bash
python tests/test_runner.py html_style
python -m unittest tests.test_html_style
```
### Run Individual Test
```bash
python -m unittest tests.test_html_style.TestHTMLStyleManager.test_color_parsing
```
### Run with Coverage
```bash
pip install coverage
coverage run -m unittest discover tests/
coverage report -m
coverage html # Generate HTML report
```
## Test Quality Guidelines
### Test Naming
- Test files: `test_<module_name>.py`
- Test classes: `Test<ClassName>`
- Test methods: `test_<specific_functionality>`
### Test Structure
1. **Arrange**: Set up test data and mocks
2. **Act**: Execute the functionality being tested
3. **Assert**: Verify the expected behavior
### Mock Usage
- Mock external dependencies (file I/O, network)
- Mock complex objects when testing units in isolation
- Prefer real objects for integration tests
### Edge Cases
- Empty inputs
- Invalid inputs
- Boundary conditions
- Error scenarios
- Performance edge cases
## Success Metrics
- **Coverage**: >90% line coverage across all modules
- **Performance**: No test takes longer than 1 second
- **Reliability**: Tests pass consistently across environments
- **Maintainability**: Tests are easy to understand and modify
- **Documentation**: Tests clearly show expected behavior
## Implementation Priority
1. **Week 1**: Complete high-priority abstract tests
2. **Week 2**: Implement HTML processing component tests
3. **Week 3**: Add integration and end-to-end tests
4. **Week 4**: Performance and edge case testing
This testing strategy ensures comprehensive coverage of the pyWebLayout library while maintaining good separation of concerns and providing clear documentation of expected behavior.
@@ -28,7 +28,18 @@ class TestWord(unittest.TestCase):
self.assertEqual(word.style, self.font)
self.assertIsNone(word.previous)
self.assertIsNone(word.next)
self.assertIsNone(word.hyphenated_parts)
self.assertEqual(len(word.possible_hyphenation()),0)
def test_word_hyphenation(self):
"""Test word creation with minimal parameters."""
word = Word("amsterdam", self.font)
self.assertEqual(word.text, "amsterdam")
self.assertEqual(word.style, self.font)
self.assertIsNone(word.previous)
self.assertIsNone(word.next)
self.assertEqual(len(word.possible_hyphenation()),3)
def test_word_creation_with_previous(self):
"""Test word creation with previous word reference."""
@@ -37,7 +48,7 @@ class TestWord(unittest.TestCase):
self.assertEqual(word2.previous, word1)
self.assertIsNone(word1.previous)
self.assertIsNone(word1.next)
self.assertEqual(word1.next, word2)
self.assertIsNone(word2.next)
def test_word_creation_with_background_override(self):
@@ -59,7 +70,7 @@ class TestWord(unittest.TestCase):
self.assertEqual(word2.background, "blue")
self.assertEqual(word2.previous, word1)
self.assertIsNone(word2.next)
self.assertIsNone(word2.hyphenated_parts)
def test_add_next_word(self):
"""Test linking words with add_next method."""
@@ -87,9 +98,6 @@ class TestWord(unittest.TestCase):
word2 = Word("second", self.font, previous=word1)
word3 = Word("third", self.font, previous=word2)
# Add forward links
word1.add_next(word2)
word2.add_next(word3)
# Test complete chain
self.assertIsNone(word1.previous)
@@ -101,156 +109,6 @@ class TestWord(unittest.TestCase):
self.assertEqual(word3.previous, word2)
self.assertIsNone(word3.next)
@patch('pyWebLayout.abstract.inline.pyphen')
def test_can_hyphenate_true(self, mock_pyphen):
"""Test can_hyphenate method when word can be hyphenated."""
# Mock pyphen behavior
mock_dic = Mock()
mock_dic.inserted.return_value = "hy-phen-ated"
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("hyphenated", self.font)
result = word.can_hyphenate()
self.assertTrue(result)
# Font language is set as "en_EN" by default (with typo in constructor param)
mock_pyphen.Pyphen.assert_called_once_with(lang="en_EN")
mock_dic.inserted.assert_called_once_with("hyphenated", hyphen='-')
@patch('pyWebLayout.abstract.inline.pyphen')
def test_can_hyphenate_false(self, mock_pyphen):
"""Test can_hyphenate method when word cannot be hyphenated."""
# Mock pyphen behavior for non-hyphenatable word
mock_dic = Mock()
mock_dic.inserted.return_value = "cat" # No hyphens added
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("cat", self.font)
result = word.can_hyphenate()
self.assertFalse(result)
mock_dic.inserted.assert_called_once_with("cat", hyphen='-')
@patch('pyWebLayout.abstract.inline.pyphen')
def test_can_hyphenate_with_language_override(self, mock_pyphen):
"""Test can_hyphenate with explicit language parameter."""
mock_dic = Mock()
mock_dic.inserted.return_value = "hy-phen"
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("hyphen", self.font)
result = word.can_hyphenate("de_DE")
self.assertTrue(result)
mock_pyphen.Pyphen.assert_called_once_with(lang="de_DE")
@patch('pyWebLayout.abstract.inline.pyphen')
def test_hyphenate_success(self, mock_pyphen):
"""Test successful word hyphenation."""
# Mock pyphen behavior
mock_dic = Mock()
mock_dic.inserted.return_value = "hy-phen-ation"
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("hyphenation", self.font)
result = word.hyphenate()
self.assertTrue(result)
self.assertEqual(word.hyphenated_parts, ["hy-", "phen-", "ation"])
mock_pyphen.Pyphen.assert_called_once_with(lang="en_EN")
@patch('pyWebLayout.abstract.inline.pyphen')
def test_hyphenate_failure(self, mock_pyphen):
"""Test word hyphenation when word cannot be hyphenated."""
# Mock pyphen behavior for non-hyphenatable word
mock_dic = Mock()
mock_dic.inserted.return_value = "cat" # No hyphens
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("cat", self.font)
result = word.hyphenate()
self.assertFalse(result)
self.assertIsNone(word.hyphenated_parts)
@patch('pyWebLayout.abstract.inline.pyphen')
def test_hyphenate_with_language_override(self, mock_pyphen):
"""Test hyphenation with explicit language parameter."""
mock_dic = Mock()
mock_dic.inserted.return_value = "Wort-teil"
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("Wortteil", self.font)
result = word.hyphenate("de_DE")
self.assertTrue(result)
self.assertEqual(word.hyphenated_parts, ["Wort-", "teil"])
mock_pyphen.Pyphen.assert_called_once_with(lang="de_DE")
def test_dehyphenate(self):
"""Test removing hyphenation from word."""
word = Word("test", self.font)
# Simulate hyphenated state
word._hyphenated_parts = ["test-", "ing"]
word.dehyphenate()
self.assertIsNone(word.hyphenated_parts)
def test_get_hyphenated_part(self):
"""Test getting specific hyphenated parts."""
word = Word("testing", self.font)
# Simulate hyphenated state
word._hyphenated_parts = ["test-", "ing"]
# Test valid indices
self.assertEqual(word.get_hyphenated_part(0), "test-")
self.assertEqual(word.get_hyphenated_part(1), "ing")
# Test invalid index
with self.assertRaises(IndexError):
word.get_hyphenated_part(2)
def test_get_hyphenated_part_not_hyphenated(self):
"""Test getting hyphenated part from non-hyphenated word."""
word = Word("test", self.font)
with self.assertRaises(IndexError) as context:
word.get_hyphenated_part(0)
self.assertIn("Word has not been hyphenated", str(context.exception))
def test_get_hyphenated_part_count(self):
"""Test getting hyphenated part count."""
word = Word("test", self.font)
# Test non-hyphenated word
self.assertEqual(word.get_hyphenated_part_count(), 0)
# Test hyphenated word
word._hyphenated_parts = ["hy-", "phen-", "ated"]
self.assertEqual(word.get_hyphenated_part_count(), 3)
@patch('pyWebLayout.abstract.inline.pyphen')
def test_complex_hyphenation_scenario(self, mock_pyphen):
"""Test complex hyphenation with multiple syllables."""
# Mock pyphen for a complex word
mock_dic = Mock()
mock_dic.inserted.return_value = "un-der-stand-ing"
mock_pyphen.Pyphen.return_value = mock_dic
word = Word("understanding", self.font)
result = word.hyphenate()
self.assertTrue(result)
expected_parts = ["un-", "der-", "stand-", "ing"]
self.assertEqual(word.hyphenated_parts, expected_parts)
self.assertEqual(word.get_hyphenated_part_count(), 4)
# Test getting individual parts
for i, expected_part in enumerate(expected_parts):
self.assertEqual(word.get_hyphenated_part(i), expected_part)
def test_word_create_and_add_to_with_style_override(self):
"""Test Word.create_and_add_to with explicit style parameter."""
@@ -836,45 +694,7 @@ class TestWordFormattedSpanIntegration(unittest.TestCase):
self.assertEqual(words[i].previous, words[i-1])
if i < 4:
self.assertEqual(words[i].next, words[i+1])
@patch('pyWebLayout.abstract.inline.pyphen')
def test_span_with_hyphenated_words(self, mock_pyphen):
"""Test formatted span containing hyphenated words."""
# Mock pyphen
mock_dic = Mock()
mock_pyphen.Pyphen.return_value = mock_dic
def mock_inserted(word, hyphen='-'):
if word == "understanding":
return "un-der-stand-ing"
elif word == "hyphenation":
return "hy-phen-ation"
else:
return word # No hyphenation
mock_dic.inserted.side_effect = mock_inserted
span = FormattedSpan(self.font)
# Add words, some of which can be hyphenated
word1 = span.add_word("The")
word2 = span.add_word("understanding")
word3 = span.add_word("of")
word4 = span.add_word("hyphenation")
# Test hyphenation
self.assertTrue(word2.can_hyphenate())
self.assertTrue(word2.hyphenate())
self.assertFalse(word1.can_hyphenate())
self.assertTrue(word4.can_hyphenate())
self.assertTrue(word4.hyphenate())
# Test hyphenated parts
self.assertEqual(word2.hyphenated_parts, ["un-", "der-", "stand-", "ing"])
self.assertEqual(word4.hyphenated_parts, ["hy-", "phen-", "ation"])
self.assertIsNone(word1.hyphenated_parts)
self.assertIsNone(word3.hyphenated_parts)
def test_multiple_spans_same_style(self):
"""Test creating multiple spans with the same style."""
font = Font()
View File
@@ -11,7 +11,8 @@ from unittest.mock import Mock
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
from pyWebLayout.style.layout import Alignment
from pyWebLayout.style import Font
from pyWebLayout.abstract import Word
from PIL import Image, ImageFont, ImageDraw
class TestAlignmentHandlers(unittest.TestCase):
"""Test cases for the alignment handler system"""
@@ -19,22 +20,32 @@ class TestAlignmentHandlers(unittest.TestCase):
def setUp(self):
"""Set up test fixtures"""
self.font = Font()
self.test_words = ["This", "is", "a", "test", "sentence"]
self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]]
self.line_width = 300
self.line_height = 30
self.spacing = (5, 20) # min_spacing, max_spacing
self.origin = (0, 0)
self.size = (self.line_width, self.line_height)
# 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()
def test_left_alignment_handler_assignment(self):
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
left_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT)
left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT)
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
def test_center_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
center_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER)
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler)
# Check that it's configured for CENTER alignment
@@ -42,7 +53,7 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_right_alignment_handler_assignment(self):
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
right_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT)
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler)
# Check that it's configured for RIGHT alignment
@@ -50,21 +61,20 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_justify_alignment_handler_assignment(self):
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
justify_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY)
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
def test_left_alignment_word_addition(self):
"""Test adding words to a left-aligned line"""
left_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT)
left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
result = left_line.add_word(word)
if result:
# Word didn't fit, should return the word
self.assertEqual(result, word)
result, part = left_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
@@ -75,15 +85,14 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_center_alignment_word_addition(self):
"""Test adding words to a center-aligned line"""
center_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER)
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
result = center_line.add_word(word)
if result:
# Word didn't fit, should return the word
self.assertEqual(result, word)
result, part = center_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
@@ -94,15 +103,14 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_right_alignment_word_addition(self):
"""Test adding words to a right-aligned line"""
right_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT)
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
result = right_line.add_word(word)
if result:
# Word didn't fit, should return the word
self.assertEqual(result, word)
result, part = right_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
@@ -113,15 +121,14 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_justify_alignment_word_addition(self):
"""Test adding words to a justified line"""
justify_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY)
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
result = justify_line.add_word(word)
if result:
# Word didn't fit, should return the word
self.assertEqual(result, word)
result, part = justify_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
@@ -133,7 +140,7 @@ class TestAlignmentHandlers(unittest.TestCase):
def test_handler_spacing_and_position_calculations(self):
"""Test spacing and position calculations for different alignment handlers"""
# Create sample text objects
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
# Test each handler type
handlers = [
@@ -145,7 +152,7 @@ class TestAlignmentHandlers(unittest.TestCase):
for name, handler in handlers:
with self.subTest(handler=name):
spacing_calc, position = handler.calculate_spacing_and_position(
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Check that spacing is a valid number
@@ -156,103 +163,63 @@ class TestAlignmentHandlers(unittest.TestCase):
self.assertIsInstance(position, (int, float))
self.assertGreaterEqual(position, 0)
# Position should be within line width
self.assertLessEqual(position, self.line_width)
# Check that overflow is a boolean
self.assertIsInstance(overflow, bool)
# Position should be within line width (unless overflow)
if not overflow:
self.assertLessEqual(position, self.line_width)
def test_left_handler_spacing_calculation(self):
"""Test specific spacing calculation for left alignment"""
handler = LeftAlignmentHandler()
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
spacing_calc, position = handler.calculate_spacing_and_position(
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Left alignment should have position at 0
self.assertEqual(position, 0)
# Spacing should be minimum spacing for left alignment
self.assertEqual(spacing_calc, self.spacing[0])
# Should not overflow with reasonable text
self.assertFalse(overflow)
def test_center_handler_spacing_calculation(self):
"""Test specific spacing calculation for center alignment"""
handler = CenterRightAlignmentHandler(Alignment.CENTER)
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
spacing_calc, position = handler.calculate_spacing_and_position(
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Center alignment should have position > 0 (centered)
self.assertGreater(position, 0)
# Spacing should be minimum spacing for center alignment
self.assertEqual(spacing_calc, self.spacing[0])
# Center alignment should have position > 0 (centered) if no overflow
if not overflow:
self.assertGreaterEqual(position, 0)
def test_right_handler_spacing_calculation(self):
"""Test specific spacing calculation for right alignment"""
handler = CenterRightAlignmentHandler(Alignment.RIGHT)
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
spacing_calc, position = handler.calculate_spacing_and_position(
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Right alignment should have position at the right edge minus content width
self.assertGreater(position, 0)
# Spacing should be minimum spacing for right alignment
self.assertEqual(spacing_calc, self.spacing[0])
# Right alignment should have position >= 0
self.assertGreaterEqual(position, 0)
def test_justify_handler_spacing_calculation(self):
"""Test specific spacing calculation for justify alignment"""
handler = JustifyAlignmentHandler()
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
spacing_calc, position = handler.calculate_spacing_and_position(
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
text_objects, self.line_width, self.spacing[0], self.spacing[1])
# Justify alignment should have position at 0
self.assertEqual(position, 0)
# Spacing should be calculated to fill the line (between min and max)
self.assertGreaterEqual(spacing_calc, self.spacing[0])
self.assertLessEqual(spacing_calc, self.spacing[1])
def test_hyphenation_decisions(self):
"""Test hyphenation decisions for different alignment handlers"""
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
test_word_width = 50
available_width = 40 # Word doesn't fit
handlers = [
("Left", LeftAlignmentHandler()),
("Center", CenterRightAlignmentHandler(Alignment.CENTER)),
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
("Justify", JustifyAlignmentHandler())
]
for name, handler in handlers:
with self.subTest(handler=name):
should_hyphenate = handler.should_try_hyphenation(
text_objects, test_word_width, available_width, self.spacing[0], self.font)
# Should return a boolean
self.assertIsInstance(should_hyphenate, bool)
def test_hyphenation_decision_logic(self):
"""Test specific hyphenation decision logic"""
text_objects = [Text(word, self.font) for word in ["Hello"]]
# Test with word that doesn't fit
handler = LeftAlignmentHandler()
should_hyphenate_large = handler.should_try_hyphenation(
text_objects, 100, 50, self.spacing[0], self.font)
# Test with word that fits
should_hyphenate_small = handler.should_try_hyphenation(
text_objects, 30, 50, self.spacing[0], self.font)
# Large word should suggest hyphenation, small word should not
self.assertIsInstance(should_hyphenate_large, bool)
self.assertIsInstance(should_hyphenate_small, bool)
# Check spacing is reasonable
self.assertGreaterEqual(spacing_calc, 0)
def test_empty_line_alignment_handlers(self):
"""Test alignment handlers with empty lines"""
@@ -260,14 +227,13 @@ class TestAlignmentHandlers(unittest.TestCase):
for alignment in alignments:
with self.subTest(alignment=alignment):
line = Line(self.spacing, self.origin, self.size, self.font, halign=alignment)
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
# Empty line should still have a handler
self.assertIsNotNone(line._alignment_handler)
# Should be able to render empty line
result = line.render()
self.assertIsNotNone(result)
line.render()
def test_single_word_line_alignment(self):
"""Test alignment handlers with single word lines"""
@@ -275,15 +241,18 @@ class TestAlignmentHandlers(unittest.TestCase):
for alignment in alignments:
with self.subTest(alignment=alignment):
line = Line(self.spacing, self.origin, self.size, self.font, halign=alignment)
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
# Create a test word
test_word = Word("test", self.style)
# Add a single word
result = line.add_word("test")
self.assertIsNone(result) # Should fit
result, part = line.add_word(test_word)
self.assertTrue(result) # Should fit
self.assertIsNone(part) # No overflow part
# Should be able to render single word line
rendered = line.render()
self.assertIsNotNone(rendered)
line.render()
self.assertEqual(len(line.text_objects), 1)
@@ -88,94 +88,7 @@ class TestBox(unittest.TestCase):
np.testing.assert_array_equal(result, [True, False, True, False])
def test_render_default_no_content(self):
"""Test render method with no content"""
box = Box(self.origin, self.size)
result = box.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
self.assertEqual(result.mode, 'RGBA')
def test_render_with_sheet_mode(self):
"""Test render method with sheet providing mode"""
sheet = Image.new('RGB', (200, 100), (255, 0, 0))
box = Box(self.origin, self.size, sheet=sheet)
result = box.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
self.assertEqual(result.mode, 'RGB')
def test_render_with_explicit_mode(self):
"""Test render method with explicit mode"""
box = Box(self.origin, self.size, mode='L') # Grayscale
result = box.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
self.assertEqual(result.mode, 'L')
def test_render_with_content_centered(self):
"""Test render method with content centered"""
box = Box(self.origin, self.size, halign=Alignment.CENTER, valign=Alignment.CENTER)
# Mock content that has a render method
mock_content = Mock()
mock_content.render.return_value = Image.new('RGBA', (50, 30), (255, 0, 0, 255))
box._content = mock_content
result = box.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
mock_content.render.assert_called_once()
def test_render_with_content_left_aligned(self):
"""Test render method with content left-aligned"""
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
# Mock content
mock_content = Mock()
mock_content.render.return_value = Image.new('RGBA', (30, 20), (0, 255, 0, 255))
box._content = mock_content
result = box.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
mock_content.render.assert_called_once()
def test_render_with_content_right_aligned(self):
"""Test render method with content right-aligned"""
box = Box(self.origin, self.size, halign=Alignment.RIGHT, valign=Alignment.BOTTOM)
# Mock content
mock_content = Mock()
mock_content.render.return_value = Image.new('RGBA', (40, 25), (0, 0, 255, 255))
box._content = mock_content
result = box.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
mock_content.render.assert_called_once()
def test_render_content_larger_than_box(self):
"""Test render method when content is larger than box"""
small_box = Box((0, 0), (20, 15))
# Mock content larger than box
mock_content = Mock()
mock_content.render.return_value = Image.new('RGBA', (50, 40), (255, 255, 0, 255))
small_box._content = mock_content
result = small_box.render()
# Should still create box-sized canvas
self.assertEqual(result.size, (20, 15))
mock_content.render.assert_called_once()
def test_properties_access(self):
"""Test that properties can be accessed correctly"""
box = Box(self.origin, self.size, callback=self.callback)
+472
View File
@@ -0,0 +1,472 @@
"""
Unit tests for pyWebLayout.concrete.functional module.
Tests the LinkText, ButtonText, and FormFieldText classes.
"""
import unittest
import numpy as np
from PIL import Image, ImageDraw
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.functional import (
LinkText, ButtonText, FormFieldText,
create_link_text, create_button_text, create_form_field_text
)
from pyWebLayout.abstract.functional import (
Link, Button, Form, FormField, LinkType, FormFieldType
)
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.layout import Alignment
class TestLinkText(unittest.TestCase):
"""Test cases for the LinkText class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
self.callback = Mock()
# Create different types of links
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
self.api_link = Link("/api/settings", LinkType.API, self.callback)
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
# Create a mock ImageDraw.Draw object
self.mock_draw = Mock()
def test_link_text_initialization_internal(self):
"""Test initialization of internal link text"""
link_text = "Go to Chapter 1"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.internal_link)
self.assertEqual(renderable.text, link_text)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.internal_link.execute)
# Check that the font has underline decoration and blue color
self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE)
self.assertEqual(renderable.style.colour, (0, 0, 200))
def test_link_text_initialization_external(self):
"""Test initialization of external link text"""
link_text = "Visit Example"
renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.external_link)
# External links should have darker blue color
self.assertEqual(renderable.style.colour, (0, 0, 180))
def test_link_text_initialization_api(self):
"""Test initialization of API link text"""
link_text = "Settings"
renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.api_link)
# API links should have red color
self.assertEqual(renderable.style.colour, (150, 0, 0))
def test_link_text_initialization_function(self):
"""Test initialization of function link text"""
link_text = "Toggle Theme"
renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable._link, self.function_link)
# Function links should have green color
self.assertEqual(renderable.style.colour, (0, 120, 0))
def test_link_property(self):
"""Test link property accessor"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertEqual(renderable.link, self.internal_link)
def test_set_hovered(self):
"""Test setting hover state"""
link_text = "Hover Test"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
self.assertFalse(renderable._hovered)
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
def test_render_normal_state(self):
"""Test rendering in normal state"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Parent render should be called
mock_parent_render.assert_called_once()
# Should not draw highlight when not hovered
self.mock_draw.rectangle.assert_not_called()
def test_in_object(self):
"""Test in_object method"""
link_text = "Test Link"
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
# Mock width property
renderable._width = 80
# Point inside link
self.assertTrue(renderable.in_object((15, 25)))
# Point outside link
self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self):
"""Test the create_link_text factory function"""
link_text = "Factory Test"
renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw)
self.assertIsInstance(renderable, LinkText)
self.assertEqual(renderable.text, link_text)
self.assertEqual(renderable.link, self.internal_link)
class TestButtonText(unittest.TestCase):
"""Test cases for the ButtonText class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(255, 255, 255)
)
self.callback = Mock()
self.button = Button("Click Me", self.callback)
self.mock_draw = Mock()
def test_button_text_initialization(self):
"""Test basic button text initialization"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertEqual(renderable._button, self.button)
self.assertEqual(renderable.text, "Click Me")
self.assertFalse(renderable._pressed)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.button.execute)
self.assertEqual(renderable._padding, (4, 8, 4, 8))
def test_button_text_with_custom_padding(self):
"""Test button text initialization with custom padding"""
custom_padding = (8, 12, 8, 12)
renderable = ButtonText(
self.button, self.font, self.mock_draw,
padding=custom_padding
)
self.assertEqual(renderable._padding, custom_padding)
def test_button_property(self):
"""Test button property accessor"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertEqual(renderable.button, self.button)
def test_set_pressed(self):
"""Test setting pressed state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertFalse(renderable._pressed)
renderable.set_pressed(True)
self.assertTrue(renderable._pressed)
renderable.set_pressed(False)
self.assertFalse(renderable._pressed)
def test_set_hovered(self):
"""Test setting hover state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
self.assertFalse(renderable._hovered)
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
def test_size_property(self):
"""Test size property includes padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
# The size should be padded size, not just text size
# Since we handle mocks in __init__, use the padded values directly
expected_width = renderable._padded_width
expected_height = renderable._padded_height
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
def test_render_normal_state(self):
"""Test rendering in normal state"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should draw rounded rectangle for button background
self.mock_draw.rounded_rectangle.assert_called_once()
# Parent render should be called for text
mock_parent_render.assert_called_once()
def test_render_disabled_state(self):
"""Test rendering disabled button"""
disabled_button = Button("Disabled", self.callback, enabled=False)
renderable = ButtonText(disabled_button, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should still draw button background
self.mock_draw.rounded_rectangle.assert_called_once()
mock_parent_render.assert_called_once()
def test_in_object_with_padding(self):
"""Test in_object method considers padding"""
renderable = ButtonText(self.button, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
# Point inside button (including padding)
self.assertTrue(renderable.in_object((15, 25)))
# Point outside button
self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self):
"""Test the create_button_text factory function"""
custom_padding = (6, 10, 6, 10)
renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding)
self.assertIsInstance(renderable, ButtonText)
self.assertEqual(renderable.text, "Click Me")
self.assertEqual(renderable.button, self.button)
self.assertEqual(renderable._padding, custom_padding)
class TestFormFieldText(unittest.TestCase):
"""Test cases for the FormFieldText class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
# Create different types of form fields
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
self.mock_draw = Mock()
def test_form_field_text_initialization(self):
"""Test initialization of form field text"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertEqual(renderable._field, self.text_field)
self.assertEqual(renderable.text, "Username")
self.assertFalse(renderable._focused)
self.assertEqual(renderable._field_height, 24)
def test_form_field_text_with_custom_height(self):
"""Test form field text with custom field height"""
custom_height = 40
renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height)
self.assertEqual(renderable._field_height, custom_height)
def test_field_property(self):
"""Test field property accessor"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertEqual(renderable.field, self.text_field)
def test_set_focused(self):
"""Test setting focus state"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
self.assertFalse(renderable._focused)
renderable.set_focused(True)
self.assertTrue(renderable._focused)
renderable.set_focused(False)
self.assertFalse(renderable._focused)
def test_size_includes_field_area(self):
"""Test size property includes field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Size should include label height + gap + field height
expected_height = renderable._style.font_size + 5 + renderable._field_height
expected_width = renderable._field_width # Use the calculated field width
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
def test_render_text_field(self):
"""Test rendering text field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render label
mock_parent_render.assert_called_once()
# Should draw field background rectangle
self.mock_draw.rectangle.assert_called_once()
def test_render_field_with_value(self):
"""Test rendering field with value"""
self.text_field.value = "john_doe"
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render label
mock_parent_render.assert_called_once()
# Should draw field background and value text
self.mock_draw.rectangle.assert_called_once()
self.mock_draw.text.assert_called_once()
def test_render_password_field(self):
"""Test rendering password field with masked value"""
self.password_field.value = "secret123"
renderable = FormFieldText(self.password_field, self.font, self.mock_draw)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render label and field
mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once()
# Should render masked text
self.mock_draw.text.assert_called_once()
# Check that the text call used masked characters
call_args = self.mock_draw.text.call_args[0]
self.assertEqual(call_args[1], "" * len("secret123"))
def test_render_focused_field(self):
"""Test rendering focused field"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_focused(True)
# Mock the parent Text render method
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
renderable.render()
# Should render with focus styling
mock_parent_render.assert_called_once()
self.mock_draw.rectangle.assert_called_once()
def test_handle_click_inside_field(self):
"""Test clicking inside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Click inside field area (below label)
field_area_y = renderable._style.font_size + 5 + 10 # Within field area
field_area_point = (15, field_area_y)
result = renderable.handle_click(field_area_point)
# Should return True and set focused
self.assertTrue(result)
self.assertTrue(renderable._focused)
def test_handle_click_outside_field(self):
"""Test clicking outside field area"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
# Click outside field area
outside_point = (200, 200)
result = renderable.handle_click(outside_point)
# Should return False and not set focused
self.assertFalse(result)
self.assertFalse(renderable._focused)
def test_in_object(self):
"""Test in_object method"""
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
renderable.set_origin(np.array([10, 20]))
# Point inside field (including label and input area)
self.assertTrue(renderable.in_object((15, 25)))
# Point outside field
self.assertFalse(renderable.in_object((200, 200)))
def test_factory_function(self):
"""Test the create_form_field_text factory function"""
custom_height = 30
renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height)
self.assertIsInstance(renderable, FormFieldText)
self.assertEqual(renderable.text, "Username")
self.assertEqual(renderable.field, self.text_field)
self.assertEqual(renderable._field_height, custom_height)
class TestInteractionCallbacks(unittest.TestCase):
"""Test cases for interaction functionality"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(font_size=12, colour=(0, 0, 0))
self.mock_draw = Mock()
self.callback_result = "callback_executed"
self.callback = Mock(return_value=self.callback_result)
def test_link_text_interaction(self):
"""Test that LinkText properly handles interaction"""
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
link = Link("test_function", LinkType.FUNCTION, self.callback)
renderable = LinkText(link, "Test Link", self.font, self.mock_draw)
# Simulate interaction
result = renderable.interact(np.array([10, 10]))
# Should execute the link's callback
self.assertEqual(result, self.callback_result)
def test_button_text_interaction(self):
"""Test that ButtonText properly handles interaction"""
button = Button("Test Button", self.callback)
renderable = ButtonText(button, self.font, self.mock_draw)
# Simulate interaction
result = renderable.interact(np.array([10, 10]))
# Should execute the button's callback
self.assertEqual(result, self.callback_result)
if __name__ == '__main__':
unittest.main()
@@ -7,7 +7,7 @@ import unittest
import os
import tempfile
import numpy as np
from PIL import Image as PILImage
from PIL import Image as PILImage, ImageDraw
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.image import RenderableImage
@@ -31,6 +31,10 @@ class TestRenderableImage(unittest.TestCase):
# Create abstract image objects
self.abstract_image = AbstractImage(self.test_image_path, "Test Image", 100, 80)
self.abstract_image_no_dims = AbstractImage(self.test_image_path, "Test Image")
# Create a canvas and draw object for testing
self.canvas = PILImage.new('RGBA', (400, 300), (255, 255, 255, 255))
self.draw = ImageDraw.Draw(self.canvas)
def tearDown(self):
"""Clean up test fixtures"""
@@ -43,9 +47,10 @@ class TestRenderableImage(unittest.TestCase):
def test_renderable_image_initialization_basic(self):
"""Test basic image initialization"""
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.canvas)
self.assertEqual(renderable._abstract_image, self.abstract_image)
self.assertEqual(renderable._canvas, self.canvas)
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
self.assertEqual(renderable._halign, Alignment.CENTER)
@@ -58,6 +63,7 @@ class TestRenderableImage(unittest.TestCase):
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
@@ -71,26 +77,24 @@ class TestRenderableImage(unittest.TestCase):
"""Test image initialization with custom parameters"""
custom_origin = (20, 30)
custom_size = (120, 90)
custom_callback = Mock()
renderable = RenderableImage(
self.abstract_image,
self.draw,
origin=custom_origin,
size=custom_size,
callback=custom_callback,
halign=Alignment.LEFT,
valign=Alignment.TOP
)
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
self.assertEqual(renderable._callback, custom_callback)
self.assertEqual(renderable._halign, Alignment.LEFT)
self.assertEqual(renderable._valign, Alignment.TOP)
def test_load_image_local_file(self):
"""Test loading image from local file"""
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.draw)
# Image should be loaded
self.assertIsNotNone(renderable._pil_image)
@@ -100,7 +104,7 @@ class TestRenderableImage(unittest.TestCase):
def test_load_image_nonexistent_file(self):
"""Test loading image from nonexistent file"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract)
renderable = RenderableImage(bad_abstract, self.draw)
# Should have error message, no PIL image
self.assertIsNone(renderable._pil_image)
@@ -116,7 +120,7 @@ class TestRenderableImage(unittest.TestCase):
mock_get.return_value = mock_response
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract)
renderable = RenderableImage(url_abstract, self.draw)
# Should successfully load image
self.assertIsNotNone(renderable._pil_image)
@@ -131,7 +135,7 @@ class TestRenderableImage(unittest.TestCase):
mock_get.return_value = mock_response
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
renderable = RenderableImage(url_abstract)
renderable = RenderableImage(url_abstract, self.draw)
# Should have error message
self.assertIsNone(renderable._pil_image)
@@ -147,7 +151,7 @@ class TestRenderableImage(unittest.TestCase):
with patch('builtins.__import__', side_effect=mock_import):
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract)
renderable = RenderableImage(url_abstract, self.draw)
# Should have error message about missing requests
self.assertIsNone(renderable._pil_image)
@@ -156,7 +160,7 @@ class TestRenderableImage(unittest.TestCase):
def test_resize_image_fit_within_bounds(self):
"""Test image resizing to fit within bounds"""
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.draw)
# Original image is 100x80, resize to fit in 50x50
renderable._size = np.array([50, 50])
@@ -173,7 +177,7 @@ class TestRenderableImage(unittest.TestCase):
def test_resize_image_larger_target(self):
"""Test image resizing when target is larger than original"""
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.draw)
# Target size larger than original
renderable._size = np.array([200, 160])
@@ -187,7 +191,7 @@ class TestRenderableImage(unittest.TestCase):
def test_resize_image_no_image(self):
"""Test resize when no image is loaded"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract)
renderable = RenderableImage(bad_abstract, self.draw)
resized = renderable._resize_image()
@@ -195,124 +199,116 @@ class TestRenderableImage(unittest.TestCase):
self.assertIsInstance(resized, PILImage.Image)
self.assertEqual(resized.mode, 'RGBA')
@patch('PIL.ImageDraw.Draw')
def test_draw_error_placeholder(self, mock_draw_class):
def test_draw_error_placeholder(self):
"""Test drawing error placeholder"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract)
canvas = PILImage.new('RGBA', (100, 80), (255, 255, 255, 255))
renderable._draw_error_placeholder(canvas)
# Should draw rectangle and lines for the X
mock_draw.rectangle.assert_called_once()
self.assertEqual(mock_draw.line.call_count, 2) # Two lines for the X
@patch('PIL.ImageDraw.Draw')
@patch('PIL.ImageFont.load_default')
def test_draw_error_placeholder_with_text(self, mock_font, mock_draw_class):
"""Test drawing error placeholder with error message"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
mock_font.return_value = Mock()
# Mock textbbox to return reasonable bounds
mock_draw.textbbox.return_value = (0, 0, 50, 12)
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract)
renderable = RenderableImage(bad_abstract, self.canvas)
renderable._error_message = "File not found"
canvas = PILImage.new('RGBA', (100, 80), (255, 255, 255, 255))
renderable._draw_error_placeholder(canvas)
# Set origin for the placeholder
renderable.set_origin(np.array([10, 20]))
# Should draw rectangle, lines, and text
mock_draw.rectangle.assert_called_once()
self.assertEqual(mock_draw.line.call_count, 2)
mock_draw.text.assert_called() # Error text should be drawn
# Call the error placeholder method
renderable._draw_error_placeholder()
# We can't easily test the actual drawing without complex mocking,
# but we can verify the method doesn't raise an exception
self.assertIsNotNone(renderable._error_message)
def test_draw_error_placeholder_with_text(self):
"""Test drawing error placeholder with error message"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract, self.canvas)
renderable._error_message = "File not found"
# Set origin for the placeholder
renderable.set_origin(np.array([10, 20]))
# Call the error placeholder method
renderable._draw_error_placeholder()
# Verify error message is set
self.assertIsNotNone(renderable._error_message)
self.assertIn("File not found", renderable._error_message)
def test_render_successful_image(self):
"""Test rendering successfully loaded image"""
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.canvas)
renderable.set_origin(np.array([10, 20]))
# Render returns nothing (draws directly into canvas)
result = renderable.render()
self.assertIsInstance(result, PILImage.Image)
self.assertEqual(result.size, tuple(renderable._size))
self.assertEqual(result.mode, 'RGBA')
# Result should be None as it draws directly
self.assertIsNone(result)
# Verify image was loaded
self.assertIsNotNone(renderable._pil_image)
def test_render_failed_image(self):
"""Test rendering when image failed to load"""
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
renderable = RenderableImage(bad_abstract)
renderable = RenderableImage(bad_abstract, self.canvas)
renderable.set_origin(np.array([10, 20]))
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
result = renderable.render()
self.assertIsInstance(result, PILImage.Image)
# Result should be None as it draws directly
self.assertIsNone(result)
mock_draw_error.assert_called_once()
def test_render_with_left_alignment(self):
"""Test rendering with left alignment"""
renderable = RenderableImage(
self.abstract_image,
self.canvas,
halign=Alignment.LEFT,
valign=Alignment.TOP
)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
self.assertIsInstance(result, PILImage.Image)
self.assertEqual(result.size, tuple(renderable._size))
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertEqual(renderable._halign, Alignment.LEFT)
self.assertEqual(renderable._valign, Alignment.TOP)
def test_render_with_right_alignment(self):
"""Test rendering with right alignment"""
renderable = RenderableImage(
self.abstract_image,
self.canvas,
halign=Alignment.RIGHT,
valign=Alignment.BOTTOM
)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
self.assertIsInstance(result, PILImage.Image)
self.assertEqual(result.size, tuple(renderable._size))
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertEqual(renderable._halign, Alignment.RIGHT)
self.assertEqual(renderable._valign, Alignment.BOTTOM)
def test_render_rgba_image_on_rgba_canvas(self):
"""Test rendering RGBA image on RGBA canvas"""
# Create RGBA test image
rgba_image_path = os.path.join(self.temp_dir, "rgba_test.png")
rgba_img = PILImage.new('RGBA', (50, 40), (0, 255, 0, 128)) # Green with transparency
rgba_img.save(rgba_image_path)
try:
rgba_abstract = AbstractImage(rgba_image_path, "RGBA Image", 50, 40)
renderable = RenderableImage(rgba_abstract)
result = renderable.render()
self.assertIsInstance(result, PILImage.Image)
self.assertEqual(result.mode, 'RGBA')
finally:
try:
os.unlink(rgba_image_path)
except:
pass
def test_render_rgb_image_conversion(self):
"""Test rendering RGB image (should be converted to RGBA)"""
# Our test image is RGB, so this should test the conversion path
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.canvas)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
self.assertIsInstance(result, PILImage.Image)
self.assertEqual(result.mode, 'RGBA')
# Result should be None as it draws directly
self.assertIsNone(result)
self.assertIsNotNone(renderable._pil_image)
def test_in_object(self):
"""Test in_object method"""
renderable = RenderableImage(self.abstract_image, origin=(10, 20))
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
# Point inside image
self.assertTrue(renderable.in_object((15, 25)))
@@ -322,7 +318,7 @@ class TestRenderableImage(unittest.TestCase):
def test_in_object_with_numpy_array(self):
"""Test in_object with numpy array point"""
renderable = RenderableImage(self.abstract_image, origin=(10, 20))
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
# Point inside image as numpy array
point = np.array([15, 25])
@@ -335,7 +331,7 @@ class TestRenderableImage(unittest.TestCase):
def test_image_size_calculation_with_abstract_image_dimensions(self):
"""Test that size is calculated from abstract image when available"""
# Abstract image has dimensions 100x80
renderable = RenderableImage(self.abstract_image)
renderable = RenderableImage(self.abstract_image, self.draw)
# Size should match the calculated scaled dimensions
expected_size = self.abstract_image.calculate_scaled_dimensions()
@@ -348,6 +344,7 @@ class TestRenderableImage(unittest.TestCase):
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
@@ -358,11 +355,28 @@ class TestRenderableImage(unittest.TestCase):
def test_image_without_initial_dimensions(self):
"""Test image without initial dimensions in abstract image"""
renderable = RenderableImage(self.abstract_image_no_dims)
renderable = RenderableImage(self.abstract_image_no_dims, self.draw)
# Should still work, using default or calculated size
self.assertIsInstance(renderable._size, np.ndarray)
self.assertEqual(len(renderable._size), 2)
def test_set_origin_method(self):
"""Test the set_origin method"""
renderable = RenderableImage(self.abstract_image, self.draw)
new_origin = np.array([50, 60])
renderable.set_origin(new_origin)
np.testing.assert_array_equal(renderable.origin, new_origin)
def test_properties(self):
"""Test the property methods"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80))
np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
np.testing.assert_array_equal(renderable.size, np.array([100, 80]))
self.assertEqual(renderable.width, 100)
if __name__ == '__main__':
+296
View File
@@ -0,0 +1,296 @@
"""
Unit tests for pyWebLayout.concrete.text module.
Tests the Text and Line classes for text rendering functionality.
"""
import unittest
import numpy as np
import os
from PIL import Image, ImageFont, ImageDraw
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style.layout import Alignment
class TestText(unittest.TestCase):
def setUp(self):
# 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()
def test_init(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
self.assertEqual(text_instance.text, "Test")
self.assertEqual(text_instance.style, self.style)
self.assertIsNone(text_instance.line)
np.testing.assert_array_equal(text_instance.origin, np.array([0, 0]))
def test_from_word(self):
word = Word(text="Test", style=self.style)
text_instance = Text.from_word(word, self.draw)
self.assertEqual(text_instance.text, "Test")
self.assertEqual(text_instance.style, self.style)
def test_set_origin(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
origin = np.array([10, 20])
text_instance.set_origin(origin)
np.testing.assert_array_equal(text_instance.origin, origin)
def test_add_to_line(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
line = Mock()
text_instance.add_line(line)
self.assertEqual(text_instance.line, line)
def test_render(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
# Set a position so we can render without issues
text_instance.set_origin(np.array([10, 50]))
# This should not raise any exceptions with real objects
text_instance.render()
# We can verify the canvas was modified (pixel check)
# After rendering, some pixels should have changed from pure white
# This is a more realistic test than checking mock calls
def test_text_dimensions(self):
"""Test that text dimensions are calculated correctly with real font"""
text_instance = Text(text="Test", style=self.style, draw=self.draw)
# With real objects, we should get actual width measurements
self.assertGreater(text_instance.width, 0)
self.assertIsInstance(text_instance.width, (int, float))
def test_in_object_true(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
# Test with a point that should be inside the text bounds
point = (5, 5)
self.assertTrue(text_instance.in_object(point))
def test_in_object_false(self):
text_instance = Text(text="Test", style=self.style, draw=self.draw)
text_instance.set_origin(np.array([0, 0]))
# Test with a point that should be outside the text bounds
# Use the actual width to ensure we're outside
point = (text_instance.width + 10, text_instance.style.font_size + 10)
self.assertFalse(text_instance.in_object(point))
def test_save_rendered_output(self):
"""Optional test to save rendered output for visual verification"""
text_instance = Text(text="Hello World!", style=self.style, draw=self.draw)
text_instance.set_origin(np.array([50, 100]))
text_instance.render()
# Optionally save the canvas for visual inspection
self._save_test_image("rendered_text.png")
# Verify that something was drawn (canvas is no longer pure white everywhere)
# Convert to array and check if any pixels changed
pixels = np.array(self.canvas)
# Should have some non-white pixels after rendering
self.assertTrue(np.any(pixels != 255))
def _save_test_image(self, filename):
"""Helper method to save test images for visual verification"""
test_output_dir = "test_output"
if not os.path.exists(test_output_dir):
os.makedirs(test_output_dir)
self.canvas.save(os.path.join(test_output_dir, filename))
def _create_fresh_canvas(self):
"""Helper to create a fresh canvas for each test if needed"""
return Image.new('RGB', (800, 600), color='white')
class TestLine(unittest.TestCase):
def setUp(self):
# 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()
def test_line_init(self):
"""Test Line initialization with real objects"""
spacing = (5, 15) # min_spacing, max_spacing
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
size=size,
draw=self.draw,
font=self.style,
halign=Alignment.LEFT
)
self.assertEqual(line._spacing, spacing)
np.testing.assert_array_equal(line._origin, origin)
np.testing.assert_array_equal(line._size, size)
self.assertEqual(len(line.text_objects), 0)
def test_line_add_word_simple(self):
"""Test adding a simple word to a line"""
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
size=size,
draw=self.draw,
font=self.style,
halign=Alignment.LEFT
)
# Create a word to add
word = Word(text="Hello", style=self.style)
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
# If successful, the word should be added
if success:
self.assertEqual(len(line.text_objects), 1)
self.assertEqual(line.text_objects[0].text, "Hello")
def test_line_add_word_until_overflow(self):
"""Test adding a simple word to a line"""
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
size=size,
draw=self.draw,
font=self.style,
halign=Alignment.LEFT
)
# Create a word to add
for i in range(100):
word = Word(text="Amsterdam", style=self.style)
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
# If successful, the word should be added
if overflow_part:
self.assertEqual(overflow_part.text, "dam")
return
self.assertFalse(True)
def test_line_add_word_until_overflow_small(self):
"""Test adding a simple word to a line"""
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
size=size,
draw=self.draw,
font=self.style,
halign=Alignment.LEFT
)
# Create a word to add
for i in range(100):
word = Word(text="Aslan", style=self.style)
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
# If successful, the word should be added
if success == False:
self.assertIsNone(overflow_part)
return
self.assertFalse(True)
def test_line_add_word_until_overflow_long_brute(self):
"""Test adding a simple word to a line"""
spacing = (5, 15)
origin = np.array([0, 0])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
size=size,
draw=self.draw,
font=self.style,
halign=Alignment.LEFT
)
# Create a word to add
for i in range(100):
word = Word(text="AAAAAAAA", style=self.style)
# This test may need adjustment based on the actual implementation
success, overflow_part = line.add_word(word)
# If successful, the word should be added
if overflow_part:
self.assertEqual(overflow_part.text , "AAAA")
return
self.assertFalse(True)
def test_line_render(self):
"""Test line rendering with real objects"""
spacing = (5, 15)
origin = np.array([50, 100])
size = np.array([400, 50])
line = Line(
spacing=spacing,
origin=origin,
size=size,
draw=self.draw,
font=self.style,
halign=Alignment.LEFT
)
# Try to render the line (even if empty)
try:
line.render()
# If no exception, the test passes
self.assertTrue(True)
except Exception as e:
# If there are implementation issues, skip the test
self.skipTest(f"Line render method needs adjustment: {e}")
def _save_test_image(self, filename):
"""Helper method to save test images for visual verification"""
test_output_dir = "test_output"
if not os.path.exists(test_output_dir):
os.makedirs(test_output_dir)
self.canvas.save(os.path.join(test_output_dir, filename))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,189 @@
"""
Test the new Page implementation to verify it meets the requirements:
1. Accepts a PageStyle that defines borders, line spacing and inter-block spacing
2. Makes an image canvas
3. Provides a method for accepting child objects
4. Provides methods for determining canvas size and border size
5. Has a method that calls render on all children
6. Has a method to query a point and determine which child it belongs to
"""
import pytest
import numpy as np
from PIL import Image, ImageDraw
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import Font
from pyWebLayout.core.base import Renderable, Queriable
class SimpleTestRenderable(Renderable, Queriable):
"""A simple test renderable for testing the page system"""
def __init__(self, text: str, size: tuple = (100, 50)):
self._text = text
self._size = size
self._origin = np.array([0, 0])
def render(self):
"""Render returns None - drawing is done via the page's draw object"""
return None
def test_page_creation_with_style():
"""Test creating a page with a PageStyle"""
style = PageStyle(
border_width=2,
border_color=(255, 0, 0),
line_spacing=8,
inter_block_spacing=20,
padding=(15, 15, 15, 15),
background_color=(240, 240, 240)
)
page = Page(size=(800, 600), style=style)
assert page.size == (800, 600)
assert page.style == style
assert page.border_size == 2
def test_page_canvas_and_content_sizes():
"""Test that page correctly calculates canvas and content sizes"""
style = PageStyle(
border_width=5,
padding=(10, 20, 30, 40) # top, right, bottom, left
)
page = Page(size=(800, 600), style=style)
# Canvas size should be page size minus borders
assert page.canvas_size == (790, 590) # 800-10, 600-10 (border on both sides)
# Content size should be canvas minus padding
assert page.content_size == (730, 550) # 790-60, 590-40 (padding left+right, top+bottom)
def test_page_add_remove_children():
"""Test adding and removing children from the page"""
page = Page(size=(800, 600))
# Initially no children
assert len(page.children) == 0
# Add children
child1 = SimpleTestRenderable("Child 1")
child2 = SimpleTestRenderable("Child 2")
page.add_child(child1)
assert len(page.children) == 1
page.add_child(child2)
assert len(page.children) == 2
# Test method chaining
child3 = SimpleTestRenderable("Child 3")
result = page.add_child(child3)
assert result is page # Should return self for chaining
assert len(page.children) == 3
# Remove child
removed = page.remove_child(child2)
assert removed is True
assert len(page.children) == 2
assert child2 not in page.children
# Try to remove non-existent child
removed = page.remove_child(child2)
assert removed is False
# Clear all children
page.clear_children()
assert len(page.children) == 0
def test_page_render():
"""Test that page renders and creates a canvas"""
style = PageStyle(
border_width=2,
border_color=(255, 0, 0),
background_color=(255, 255, 255)
)
page = Page(size=(200, 150), style=style)
# Add a child
child = SimpleTestRenderable("Test child")
page.add_child(child)
# Render the page
image = page.render()
# Check that we got an image
assert isinstance(image, Image.Image)
assert image.size == (200, 150)
assert image.mode == 'RGBA'
# Check that draw object is available
assert page.draw is not None
def test_page_query_point():
"""Test querying points to find children"""
page = Page(size=(400, 300))
# Add children with known positions and sizes
child1 = SimpleTestRenderable("Child 1", (100, 50))
child2 = SimpleTestRenderable("Child 2", (80, 40))
page.add_child(child1).add_child(child2)
# Query points
# Point within first child
found_child = page.query_point((90, 30))
assert found_child == child1
# Point within second child
found_child = page.query_point((30, 30))
assert found_child == child2
# Point outside any child
found_child = page.query_point((300, 250))
assert found_child is None
def test_page_in_object():
"""Test that page correctly implements in_object"""
page = Page(size=(400, 300))
# Points within page bounds
assert page.in_object((0, 0)) is True
assert page.in_object((200, 150)) is True
assert page.in_object((399, 299)) is True
# Points outside page bounds
assert page.in_object((-1, 0)) is False
assert page.in_object((0, -1)) is False
assert page.in_object((400, 299)) is False
assert page.in_object((399, 300)) is False
def test_page_with_borders():
"""Test page rendering with borders"""
style = PageStyle(
border_width=3,
border_color=(128, 128, 128),
background_color=(255, 255, 255)
)
page = Page(size=(100, 100), style=style)
image = page.render()
# Check that image was created
assert isinstance(image, Image.Image)
assert image.size == (100, 100)
# The border should be drawn but we can't easily test pixel values
# Just verify the image exists and has the right properties
-640
View File
@@ -1,640 +0,0 @@
"""
Unit tests for pyWebLayout.concrete.functional module.
Tests the RenderableLink, RenderableButton, RenderableForm, and RenderableFormField classes.
"""
import unittest
import numpy as np
from PIL import Image
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.functional import (
RenderableLink, RenderableButton, RenderableForm, RenderableFormField
)
from pyWebLayout.abstract.functional import (
Link, Button, Form, FormField, LinkType, FormFieldType
)
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.layout import Alignment
class TestRenderableLink(unittest.TestCase):
"""Test cases for the RenderableLink class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
self.callback = Mock()
# Create different types of links
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
self.api_link = Link("/api/settings", LinkType.API, self.callback)
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
def test_renderable_link_initialization_internal(self):
"""Test initialization of internal link"""
link_text = "Go to Chapter 1"
renderable = RenderableLink(self.internal_link, link_text, self.font)
self.assertEqual(renderable._link, self.internal_link)
self.assertEqual(renderable._text_obj.text, link_text)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.internal_link.execute)
# Check that the font has underline decoration and blue color
self.assertEqual(renderable._text_obj.style.decoration, TextDecoration.UNDERLINE)
self.assertEqual(renderable._text_obj.style.colour, (0, 0, 200))
def test_renderable_link_initialization_external(self):
"""Test initialization of external link"""
link_text = "Visit Example"
renderable = RenderableLink(self.external_link, link_text, self.font)
self.assertEqual(renderable._link, self.external_link)
# External links should have darker blue color
self.assertEqual(renderable._text_obj.style.colour, (0, 0, 180))
def test_renderable_link_initialization_api(self):
"""Test initialization of API link"""
link_text = "Settings"
renderable = RenderableLink(self.api_link, link_text, self.font)
self.assertEqual(renderable._link, self.api_link)
# API links should have red color
self.assertEqual(renderable._text_obj.style.colour, (150, 0, 0))
def test_renderable_link_initialization_function(self):
"""Test initialization of function link"""
link_text = "Toggle Theme"
renderable = RenderableLink(self.function_link, link_text, self.font)
self.assertEqual(renderable._link, self.function_link)
# Function links should have green color
self.assertEqual(renderable._text_obj.style.colour, (0, 120, 0))
def test_renderable_link_with_custom_params(self):
"""Test link initialization with custom parameters"""
link_text = "Custom Link"
custom_origin = (10, 20)
custom_size = (100, 30)
custom_callback = Mock()
renderable = RenderableLink(
self.internal_link, link_text, self.font,
origin=custom_origin, size=custom_size, callback=custom_callback
)
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
self.assertEqual(renderable._callback, custom_callback)
def test_link_property(self):
"""Test link property accessor"""
link_text = "Test Link"
renderable = RenderableLink(self.internal_link, link_text, self.font)
self.assertEqual(renderable.link, self.internal_link)
def test_set_hovered(self):
"""Test setting hover state"""
link_text = "Hover Test"
renderable = RenderableLink(self.internal_link, link_text, self.font)
self.assertFalse(renderable._hovered)
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
@patch('PIL.ImageDraw.Draw')
def test_render_normal_state(self, mock_draw_class):
"""Test rendering in normal state"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
link_text = "Test Link"
renderable = RenderableLink(self.internal_link, link_text, self.font)
with patch.object(renderable._text_obj, 'render') as mock_text_render:
mock_text_render.return_value = Image.new('RGBA', (80, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_text_render.assert_called_once()
# Should not draw highlight when not hovered
mock_draw.rectangle.assert_not_called()
@patch('PIL.ImageDraw.Draw')
def test_render_hovered_state(self, mock_draw_class):
"""Test rendering in hovered state"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
link_text = "Test Link"
renderable = RenderableLink(self.internal_link, link_text, self.font)
renderable.set_hovered(True)
with patch.object(renderable._text_obj, 'render') as mock_text_render:
mock_text_render.return_value = Image.new('RGBA', (80, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_text_render.assert_called_once()
# Should draw highlight when hovered
mock_draw.rectangle.assert_called_once()
def test_in_object(self):
"""Test in_object method"""
link_text = "Test Link"
renderable = RenderableLink(self.internal_link, link_text, self.font, origin=(10, 20))
# Point inside link
self.assertTrue(renderable.in_object((15, 25)))
# Point outside link
self.assertFalse(renderable.in_object((200, 200)))
class TestRenderableButton(unittest.TestCase):
"""Test cases for the RenderableButton class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(255, 255, 255)
)
self.callback = Mock()
self.button = Button("Click Me", self.callback)
def test_renderable_button_initialization(self):
"""Test basic button initialization"""
renderable = RenderableButton(self.button, self.font)
self.assertEqual(renderable._button, self.button)
self.assertEqual(renderable._text_obj.text, "Click Me")
self.assertFalse(renderable._pressed)
self.assertFalse(renderable._hovered)
self.assertEqual(renderable._callback, self.button.execute)
self.assertEqual(renderable._border_radius, 4)
def test_renderable_button_with_custom_params(self):
"""Test button initialization with custom parameters"""
custom_padding = (8, 12, 8, 12)
custom_radius = 8
custom_origin = (50, 60)
custom_size = (120, 40)
renderable = RenderableButton(
self.button, self.font,
padding=custom_padding,
border_radius=custom_radius,
origin=custom_origin,
size=custom_size
)
self.assertEqual(renderable._padding, custom_padding)
self.assertEqual(renderable._border_radius, custom_radius)
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
def test_button_property(self):
"""Test button property accessor"""
renderable = RenderableButton(self.button, self.font)
self.assertEqual(renderable.button, self.button)
def test_set_pressed(self):
"""Test setting pressed state"""
renderable = RenderableButton(self.button, self.font)
self.assertFalse(renderable._pressed)
renderable.set_pressed(True)
self.assertTrue(renderable._pressed)
renderable.set_pressed(False)
self.assertFalse(renderable._pressed)
def test_set_hovered(self):
"""Test setting hover state"""
renderable = RenderableButton(self.button, self.font)
self.assertFalse(renderable._hovered)
renderable.set_hovered(True)
self.assertTrue(renderable._hovered)
renderable.set_hovered(False)
self.assertFalse(renderable._hovered)
@patch('PIL.ImageDraw.Draw')
def test_render_normal_state(self, mock_draw_class):
"""Test rendering in normal state"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
renderable = RenderableButton(self.button, self.font)
with patch.object(renderable._text_obj, 'render') as mock_text_render:
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_draw.rounded_rectangle.assert_called_once()
mock_text_render.assert_called_once()
@patch('PIL.ImageDraw.Draw')
def test_render_disabled_state(self, mock_draw_class):
"""Test rendering disabled button"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
disabled_button = Button("Disabled", self.callback, enabled=False)
renderable = RenderableButton(disabled_button, self.font)
with patch.object(renderable._text_obj, 'render') as mock_text_render:
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_draw.rounded_rectangle.assert_called_once()
@patch('PIL.ImageDraw.Draw')
def test_render_pressed_state(self, mock_draw_class):
"""Test rendering pressed button"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
renderable = RenderableButton(self.button, self.font)
renderable.set_pressed(True)
with patch.object(renderable._text_obj, 'render') as mock_text_render:
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_draw.rounded_rectangle.assert_called_once()
@patch('PIL.ImageDraw.Draw')
def test_render_hovered_state(self, mock_draw_class):
"""Test rendering hovered button"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
renderable = RenderableButton(self.button, self.font)
renderable.set_hovered(True)
with patch.object(renderable._text_obj, 'render') as mock_text_render:
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_draw.rounded_rectangle.assert_called_once()
def test_in_object(self):
"""Test in_object method"""
renderable = RenderableButton(self.button, self.font, origin=(10, 20))
# Point inside button
self.assertTrue(renderable.in_object((15, 25)))
# Point outside button
self.assertFalse(renderable.in_object((200, 200)))
class TestRenderableFormField(unittest.TestCase):
"""Test cases for the RenderableFormField class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
# Create different types of form fields
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
def test_renderable_form_field_initialization_text(self):
"""Test initialization of text field"""
renderable = RenderableFormField(self.text_field, self.font)
self.assertEqual(renderable._field, self.text_field)
self.assertEqual(renderable._label_text.text, "Username")
self.assertFalse(renderable._focused)
def test_renderable_form_field_initialization_textarea(self):
"""Test initialization of textarea field"""
renderable = RenderableFormField(self.textarea_field, self.font)
self.assertEqual(renderable._field, self.textarea_field)
# Textarea should have larger default height
self.assertGreater(renderable._size[1], 50)
def test_renderable_form_field_with_custom_params(self):
"""Test field initialization with custom parameters"""
custom_padding = (8, 15, 8, 15)
custom_origin = (25, 35)
custom_size = (200, 60)
renderable = RenderableFormField(
self.text_field, self.font,
padding=custom_padding,
origin=custom_origin,
size=custom_size
)
self.assertEqual(renderable._padding, custom_padding)
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
def test_set_focused(self):
"""Test setting focus state"""
renderable = RenderableFormField(self.text_field, self.font)
self.assertFalse(renderable._focused)
renderable.set_focused(True)
self.assertTrue(renderable._focused)
renderable.set_focused(False)
self.assertFalse(renderable._focused)
@patch('PIL.ImageDraw.Draw')
def test_render_text_field(self, mock_draw_class):
"""Test rendering text field"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
renderable = RenderableFormField(self.text_field, self.font)
with patch.object(renderable._label_text, 'render') as mock_label_render:
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_label_render.assert_called_once()
mock_draw.rectangle.assert_called_once() # Field background
@patch('PIL.ImageDraw.Draw')
def test_render_field_with_value(self, mock_draw_class):
#Test rendering field with value
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
self.text_field.value = "john_doe"
renderable = RenderableFormField(self.text_field, self.font)
with patch.object(renderable._label_text, 'render') as mock_label_render:
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
with patch('pyWebLayout.concrete.functional.Text') as mock_text_class:
mock_text_obj = Mock()
mock_text_obj.render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
mock_text_class.return_value = mock_text_obj
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_label_render.assert_called_once()
mock_text_class.assert_called() # Value text should be created
@patch('PIL.ImageDraw.Draw')
def test_render_password_field(self, mock_draw_class):
"""Test rendering password field with masked value"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
self.password_field.value = "secret123"
renderable = RenderableFormField(self.password_field, self.font)
with patch.object(renderable._label_text, 'render') as mock_label_render:
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
with patch('pyWebLayout.concrete.functional.Text') as mock_text_class:
mock_text_obj = Mock()
mock_text_obj.render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
mock_text_class.return_value = mock_text_obj
result = renderable.render()
self.assertIsInstance(result, Image.Image)
# Check that Text was called with masked characters
mock_text_class.assert_called()
self.assertEqual(mock_text_class.call_args[0][0], "" * len("secret123"))
@patch('PIL.ImageDraw.Draw')
def test_render_focused_field(self, mock_draw_class):
"""Test rendering focused field"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
renderable = RenderableFormField(self.text_field, self.font)
renderable.set_focused(True)
with patch.object(renderable._label_text, 'render') as mock_label_render:
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
mock_draw.rectangle.assert_called_once()
def test_handle_click_inside_field(self):
"""Test clicking inside field area"""
renderable = RenderableFormField(self.text_field, self.font)
# Click inside field area (below label)
field_area_point = (15, 30) # Should be in field area
result = renderable.handle_click(field_area_point)
# Should return True and set focused
self.assertTrue(result)
self.assertTrue(renderable._focused)
def test_handle_click_outside_field(self):
"""Test clicking outside field area"""
renderable = RenderableFormField(self.text_field, self.font)
# Click outside field area
outside_point = (200, 200)
result = renderable.handle_click(outside_point)
# Should return False and not set focused
self.assertFalse(result)
self.assertFalse(renderable._focused)
def test_in_object(self):
"""Test in_object method"""
renderable = RenderableFormField(self.text_field, self.font, origin=(10, 20))
# Point inside field
self.assertTrue(renderable.in_object((15, 25)))
# Point outside field
self.assertFalse(renderable.in_object((200, 200)))
class TestRenderableForm(unittest.TestCase):
"""Test cases for the RenderableForm class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
self.callback = Mock()
self.form = Form("test_form", "/submit", self.callback)
# Add some fields to the form
self.username_field = FormField("username", FormFieldType.TEXT, "Username")
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
self.form.add_field(self.username_field)
self.form.add_field(self.password_field)
def test_renderable_form_initialization(self):
"""Test basic form initialization"""
renderable = RenderableForm(self.form, self.font)
self.assertEqual(renderable._form, self.form)
self.assertEqual(renderable._font, self.font)
self.assertEqual(len(renderable._renderable_fields), 2)
self.assertIsNotNone(renderable._submit_button)
self.assertEqual(renderable._callback, self.form.execute)
def test_renderable_form_with_custom_params(self):
"""Test form initialization with custom parameters"""
custom_spacing = 15
custom_origin = (20, 30)
custom_size = (400, 350)
renderable = RenderableForm(
self.form, self.font,
spacing=custom_spacing,
origin=custom_origin,
size=custom_size
)
self.assertEqual(renderable._spacing, custom_spacing)
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
def test_create_form_elements(self):
"""Test creation of form elements"""
renderable = RenderableForm(self.form, self.font)
# Should create renderable fields for each form field
self.assertEqual(len(renderable._renderable_fields), 2)
self.assertIsInstance(renderable._renderable_fields[0], RenderableFormField)
self.assertIsInstance(renderable._renderable_fields[1], RenderableFormField)
# Should create submit button
self.assertIsNotNone(renderable._submit_button)
self.assertIsInstance(renderable._submit_button, RenderableButton)
def test_calculate_size(self):
"""Test automatic size calculation"""
# Create form without explicit size
renderable = RenderableForm(self.form, self.font)
# Size should be calculated based on fields and button
self.assertGreater(renderable._size[0], 0)
self.assertGreater(renderable._size[1], 0)
def test_layout(self):
"""Test form layout"""
renderable = RenderableForm(self.form, self.font)
renderable.layout()
# All fields should have origins set
for field in renderable._renderable_fields:
self.assertIsNotNone(field._origin)
self.assertGreater(field._origin[1], 0) # Should have positive Y position
# Submit button should have origin set
self.assertIsNotNone(renderable._submit_button._origin)
def test_render(self):
"""Test form rendering"""
renderable = RenderableForm(self.form, self.font)
# Mock field and button rendering
for field in renderable._renderable_fields:
field.render = Mock(return_value=Image.new('RGBA', (150, 40), (255, 255, 255, 255)))
renderable._submit_button.render = Mock(return_value=Image.new('RGBA', (80, 30), (100, 150, 200, 255)))
result = renderable.render()
self.assertIsInstance(result, Image.Image)
# All fields should have been rendered
for field in renderable._renderable_fields:
field.render.assert_called_once()
# Submit button should have been rendered
renderable._submit_button.render.assert_called_once()
def test_handle_click_submit_button(self):
"""Test clicking submit button"""
renderable = RenderableForm(self.form, self.font)
# Mock submit button's in_object method
renderable._submit_button.in_object = Mock(return_value=True)
renderable._submit_button._callback = Mock(return_value="submitted")
result = renderable.handle_click((50, 100))
self.assertEqual(result, "submitted")
renderable._submit_button.in_object.assert_called_once()
renderable._submit_button._callback.assert_called_once()
def test_handle_click_form_field(self):
"""Test clicking form field"""
renderable = RenderableForm(self.form, self.font)
# Mock submit button's in_object to return False
renderable._submit_button.in_object = Mock(return_value=False)
# Mock first field's in_object and handle_click
renderable._renderable_fields[0].in_object = Mock(return_value=True)
renderable._renderable_fields[0].handle_click = Mock(return_value=True)
click_point = (30, 40)
result = renderable.handle_click(click_point)
self.assertTrue(result)
renderable._renderable_fields[0].in_object.assert_called_once()
renderable._renderable_fields[0].handle_click.assert_called_once()
def test_handle_click_outside_elements(self):
"""Test clicking outside all elements"""
renderable = RenderableForm(self.form, self.font)
# Mock all elements to return False for in_object
renderable._submit_button.in_object = Mock(return_value=False)
for field in renderable._renderable_fields:
field.in_object = Mock(return_value=False)
result = renderable.handle_click((1000, 1000))
self.assertIsNone(result)
if __name__ == '__main__':
unittest.main()
-756
View File
@@ -1,756 +0,0 @@
"""
Unit tests for pyWebLayout.concrete.page module.
Tests the Container and Page classes for layout and rendering functionality.
"""
import unittest
import numpy as np
from PIL import Image
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.page import Container, Page
from pyWebLayout.concrete.box import Box
from pyWebLayout.style.layout import Alignment
class TestContainer(unittest.TestCase):
"""Test cases for the Container class"""
def setUp(self):
"""Set up test fixtures"""
self.origin = (0, 0)
self.size = (400, 300)
self.callback = Mock()
# Create mock child elements
self.mock_child1 = Mock()
self.mock_child1._size = np.array([100, 50])
self.mock_child1._origin = np.array([0, 0])
self.mock_child1.render.return_value = Image.new('RGBA', (100, 50), (255, 0, 0, 255))
self.mock_child2 = Mock()
self.mock_child2._size = np.array([120, 60])
self.mock_child2._origin = np.array([0, 0])
self.mock_child2.render.return_value = Image.new('RGBA', (120, 60), (0, 255, 0, 255))
def test_container_initialization_basic(self):
"""Test basic container initialization"""
container = Container(self.origin, self.size)
np.testing.assert_array_equal(container._origin, np.array(self.origin))
np.testing.assert_array_equal(container._size, np.array(self.size))
self.assertEqual(container._direction, 'vertical')
self.assertEqual(container._spacing, 5)
self.assertEqual(len(container._children), 0)
self.assertEqual(container._padding, (10, 10, 10, 10))
self.assertEqual(container._halign, Alignment.CENTER)
self.assertEqual(container._valign, Alignment.CENTER)
def test_container_initialization_with_params(self):
"""Test container initialization with custom parameters"""
custom_direction = 'horizontal'
custom_spacing = 15
custom_padding = (5, 8, 5, 8)
container = Container(
self.origin, self.size,
direction=custom_direction,
spacing=custom_spacing,
callback=self.callback,
halign=Alignment.LEFT,
valign=Alignment.TOP,
padding=custom_padding
)
self.assertEqual(container._direction, custom_direction)
self.assertEqual(container._spacing, custom_spacing)
self.assertEqual(container._callback, self.callback)
self.assertEqual(container._halign, Alignment.LEFT)
self.assertEqual(container._valign, Alignment.TOP)
self.assertEqual(container._padding, custom_padding)
def test_add_child(self):
"""Test adding child elements"""
container = Container(self.origin, self.size)
result = container.add_child(self.mock_child1)
self.assertEqual(len(container._children), 1)
self.assertEqual(container._children[0], self.mock_child1)
self.assertEqual(result, container) # Should return self for chaining
def test_add_multiple_children(self):
"""Test adding multiple child elements"""
container = Container(self.origin, self.size)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
self.assertEqual(len(container._children), 2)
self.assertEqual(container._children[0], self.mock_child1)
self.assertEqual(container._children[1], self.mock_child2)
def test_layout_vertical_centered(self):
"""Test vertical layout with center alignment"""
container = Container(self.origin, self.size, direction='vertical', halign=Alignment.CENTER)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Check that children have been positioned
# First child should be at top padding
expected_x1 = 10 + (380 - 100) // 2 # padding + centered in available width
expected_y1 = 10 # top padding
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1]))
# Second child should be below first child + spacing
expected_x2 = 10 + (380 - 120) // 2 # padding + centered in available width
expected_y2 = 10 + 50 + 5 # top padding + first child height + spacing
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2]))
def test_layout_vertical_left_aligned(self):
"""Test vertical layout with left alignment"""
container = Container(self.origin, self.size, direction='vertical', halign=Alignment.LEFT)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Both children should be left-aligned
expected_x = 10 # left padding
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x, 10]))
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x, 65]))
def test_layout_vertical_right_aligned(self):
"""Test vertical layout with right alignment"""
container = Container(self.origin, self.size, direction='vertical', halign=Alignment.RIGHT)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Children should be right-aligned
expected_x1 = 10 + 380 - 100 # left padding + available width - child width
expected_x2 = 10 + 380 - 120
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, 10]))
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, 65]))
def test_layout_horizontal_centered(self):
"""Test horizontal layout with center alignment"""
container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.CENTER)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Children should be positioned horizontally
expected_x1 = 10 # left padding
expected_x2 = 10 + 100 + 5 # left padding + first child width + spacing
# Vertically centered
expected_y1 = 10 + (280 - 50) // 2 # top padding + centered in available height
expected_y2 = 10 + (280 - 60) // 2
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1]))
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2]))
def test_layout_horizontal_top_aligned(self):
"""Test horizontal layout with top alignment"""
container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.TOP)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Both children should be top-aligned
expected_y = 10 # top padding
np.testing.assert_array_equal(self.mock_child1._origin, np.array([10, expected_y]))
np.testing.assert_array_equal(self.mock_child2._origin, np.array([115, expected_y]))
def test_layout_horizontal_bottom_aligned(self):
"""Test horizontal layout with bottom alignment"""
container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.BOTTOM)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Children should be bottom-aligned
expected_y1 = 10 + 280 - 50 # top padding + available height - child height
expected_y2 = 10 + 280 - 60
np.testing.assert_array_equal(self.mock_child1._origin, np.array([10, expected_y1]))
np.testing.assert_array_equal(self.mock_child2._origin, np.array([115, expected_y2]))
def test_layout_empty_container(self):
"""Test layout with no children"""
container = Container(self.origin, self.size)
# Should not raise an error
container.layout()
self.assertEqual(len(container._children), 0)
def test_layout_with_layoutable_children(self):
"""Test layout with children that are also layoutable"""
# Create a mock child that implements Layoutable
mock_layoutable_child = Mock()
mock_layoutable_child._size = np.array([80, 40])
mock_layoutable_child._origin = np.array([0, 0])
# Make it look like a Layoutable by adding layout method
from pyWebLayout.core.base import Layoutable
mock_layoutable_child.__class__ = type('MockLayoutable', (Mock, Layoutable), {})
mock_layoutable_child.layout = Mock()
container = Container(self.origin, self.size)
container.add_child(mock_layoutable_child)
container.layout()
# Child's layout method should have been called
mock_layoutable_child.layout.assert_called_once()
def test_render_empty_container(self):
"""Test rendering empty container"""
container = Container(self.origin, self.size)
result = container.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_render_with_children(self):
"""Test rendering container with children"""
container = Container(self.origin, self.size)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
result = container.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
# Children should have been rendered
self.mock_child1.render.assert_called_once()
self.mock_child2.render.assert_called_once()
def test_render_calls_layout(self):
"""Test that render calls layout"""
container = Container(self.origin, self.size)
container.add_child(self.mock_child1)
with patch.object(container, 'layout') as mock_layout:
result = container.render()
mock_layout.assert_called_once()
def test_custom_spacing(self):
"""Test container with custom spacing"""
custom_spacing = 20
container = Container(self.origin, self.size, spacing=custom_spacing)
container.add_child(self.mock_child1)
container.add_child(self.mock_child2)
container.layout()
# Second child should be positioned with custom spacing
expected_y2 = 10 + 50 + custom_spacing # top padding + first child height + custom spacing
self.assertEqual(self.mock_child2._origin[1], expected_y2)
class TestPage(unittest.TestCase):
"""Test cases for the Page class"""
def setUp(self):
"""Set up test fixtures"""
self.page_size = (800, 600)
self.background_color = (255, 255, 255)
# Create mock child elements
self.mock_child1 = Mock()
self.mock_child1._size = np.array([200, 100])
self.mock_child1._origin = np.array([0, 0])
self.mock_child1.render.return_value = Image.new('RGBA', (200, 100), (255, 0, 0, 255))
self.mock_child2 = Mock()
self.mock_child2._size = np.array([150, 80])
self.mock_child2._origin = np.array([0, 0])
self.mock_child2.render.return_value = Image.new('RGBA', (150, 80), (0, 255, 0, 255))
def test_page_initialization_basic(self):
"""Test basic page initialization"""
page = Page()
np.testing.assert_array_equal(page._origin, np.array([0, 0]))
np.testing.assert_array_equal(page._size, np.array([800, 600]))
self.assertEqual(page._background_color, (255, 255, 255))
self.assertEqual(page._mode, 'RGBA')
self.assertEqual(page._direction, 'vertical')
self.assertEqual(page._spacing, 10)
self.assertEqual(page._halign, Alignment.CENTER)
self.assertEqual(page._valign, Alignment.TOP)
def test_page_initialization_with_params(self):
"""Test page initialization with custom parameters"""
custom_size = (1024, 768)
custom_background = (240, 240, 240)
custom_mode = 'RGB'
page = Page(
size=custom_size,
background_color=custom_background,
mode=custom_mode
)
np.testing.assert_array_equal(page._size, np.array(custom_size))
self.assertEqual(page._background_color, custom_background)
self.assertEqual(page._mode, custom_mode)
def test_page_add_child(self):
"""Test adding child elements to page"""
page = Page()
page.add_child(self.mock_child1)
page.add_child(self.mock_child2)
self.assertEqual(len(page._children), 2)
self.assertEqual(page._children[0], self.mock_child1)
self.assertEqual(page._children[1], self.mock_child2)
def test_page_layout(self):
"""Test page layout functionality"""
page = Page()
page.add_child(self.mock_child1)
page.add_child(self.mock_child2)
page.layout()
# Children should be positioned vertically, centered horizontally
expected_x1 = (800 - 200) // 2 # Centered horizontally
expected_y1 = 10 # Top padding
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1]))
expected_x2 = (800 - 150) // 2 # Centered horizontally
expected_y2 = 10 + 100 + 10 # Top padding + first child height + spacing
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2]))
def test_page_render_empty(self):
"""Test rendering empty page"""
page = Page(size=self.page_size, background_color=self.background_color)
result = page.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, self.page_size)
self.assertEqual(result.mode, 'RGBA')
# Check that background color is applied
# Sample a pixel from the center to verify background
center_pixel = result.getpixel((400, 300))
self.assertEqual(center_pixel[:3], self.background_color)
def test_page_render_with_children_rgba(self):
"""Test rendering page with children (RGBA mode)"""
page = Page(size=self.page_size, background_color=self.background_color)
page.add_child(self.mock_child1)
page.add_child(self.mock_child2)
result = page.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, self.page_size)
self.assertEqual(result.mode, 'RGBA')
# Children should have been rendered
self.mock_child1.render.assert_called_once()
self.mock_child2.render.assert_called_once()
def test_page_render_with_children_rgb(self):
"""Test rendering page with children (RGB mode)"""
# Create children that return RGB images
rgb_child = Mock()
rgb_child._size = np.array([100, 50])
rgb_child._origin = np.array([0, 0])
rgb_child.render.return_value = Image.new('RGB', (100, 50), (255, 0, 0))
page = Page(size=self.page_size, background_color=self.background_color, mode='RGB')
page.add_child(rgb_child)
result = page.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, self.page_size)
self.assertEqual(result.mode, 'RGB')
rgb_child.render.assert_called_once()
def test_page_render_calls_layout(self):
"""Test that page render calls layout"""
page = Page()
page.add_child(self.mock_child1)
with patch.object(page, 'layout') as mock_layout:
result = page.render()
mock_layout.assert_called_once()
def test_page_inherits_container_functionality(self):
"""Test that Page inherits Container functionality"""
page = Page()
# Should inherit Container methods
self.assertTrue(hasattr(page, 'add_child'))
self.assertTrue(hasattr(page, 'layout'))
self.assertTrue(hasattr(page, '_children'))
self.assertTrue(hasattr(page, '_direction'))
self.assertTrue(hasattr(page, '_spacing'))
def test_page_with_mixed_child_image_modes(self):
"""Test page with children having different image modes"""
# Create children with different modes
rgba_child = Mock()
rgba_child._size = np.array([100, 50])
rgba_child._origin = np.array([0, 0])
rgba_child.render.return_value = Image.new('RGBA', (100, 50), (255, 0, 0, 255))
rgb_child = Mock()
rgb_child._size = np.array([100, 50])
rgb_child._origin = np.array([0, 0])
rgb_child.render.return_value = Image.new('RGB', (100, 50), (0, 255, 0))
page = Page()
page.add_child(rgba_child)
page.add_child(rgb_child)
result = page.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.mode, 'RGBA')
# Both children should have been rendered
rgba_child.render.assert_called_once()
rgb_child.render.assert_called_once()
def test_page_background_color_application(self):
"""Test that background color is properly applied"""
custom_bg = (100, 150, 200)
page = Page(background_color=custom_bg)
result = page.render()
# Sample multiple points to verify background
corners = [(0, 0), (799, 0), (0, 599), (799, 599)]
for corner in corners:
pixel = result.getpixel(corner)
self.assertEqual(pixel[:3], custom_bg)
def test_page_size_constraints(self):
"""Test page with various size constraints"""
small_page = Page(size=(200, 150))
large_page = Page(size=(1920, 1080))
small_result = small_page.render()
large_result = large_page.render()
self.assertEqual(small_result.size, (200, 150))
self.assertEqual(large_result.size, (1920, 1080))
class TestPageBorderMarginRendering(unittest.TestCase):
"""Test cases specifically for border/margin consistency in Page rendering"""
def setUp(self):
"""Set up test fixtures for border/margin tests"""
self.page_size = (400, 300)
self.padding = (20, 15, 25, 10) # top, right, bottom, left
self.background_color = (240, 240, 240)
def test_border_consistency_with_text_content(self):
"""Test that borders/margins are consistent when rendering text content"""
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
# Create page with specific padding
page = Page(size=self.page_size, background_color=self.background_color)
page._padding = self.padding
# Add text content - let Text objects calculate their own dimensions
font = Font(font_size=14)
text1 = Text("First line of text", font)
text2 = Text("Second line of text", font)
page.add_child(text1)
page.add_child(text2)
# Render the page
result = page.render()
# Extract border areas and verify consistency
border_measurements = self._extract_border_measurements(result, self.padding)
self._verify_border_consistency(border_measurements, self.padding)
# Verify content area is correctly positioned
content_area = self._extract_content_area(result, self.padding)
self.assertIsNotNone(content_area)
# Ensure content doesn't bleed into border areas
self._verify_no_content_in_borders(result, self.padding, self.background_color)
def test_border_consistency_with_paragraph_content(self):
"""Test borders/margins with paragraph content that may wrap"""
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style.fonts import Font
# Create a mock paragraph with multiple words
paragraph = Paragraph()
font = Font(font_size=12)
# Add words to create a longer paragraph
words_text = ["This", "is", "a", "longer", "paragraph", "that", "should", "wrap", "across", "multiple", "lines", "to", "test", "margin", "consistency"]
for word_text in words_text:
word = Word(word_text, font)
paragraph.add_word(word)
# Create page with specific padding
page = Page(size=self.page_size, background_color=self.background_color)
page._padding = self.padding
# Render paragraph on page
page.render_blocks([paragraph])
result = page.render()
# Extract and verify border measurements
border_measurements = self._extract_border_measurements(result, self.padding)
self._verify_border_consistency(border_measurements, self.padding)
# Verify content positioning
self._verify_content_within_bounds(result, self.padding)
def test_border_consistency_with_mixed_content(self):
"""Test borders/margins with mixed content types"""
from pyWebLayout.concrete.text import Text
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style.fonts import Font, FontWeight
# Create page with asymmetric padding to test edge cases
asymmetric_padding = (30, 20, 15, 25)
page = Page(size=(500, 400), background_color=self.background_color)
page._padding = asymmetric_padding
# Create mixed content
heading = Heading(HeadingLevel.H2)
heading_font = Font(font_size=18, weight=FontWeight.BOLD)
heading.add_word(Word("Test Heading", heading_font))
paragraph = Paragraph()
para_font = Font(font_size=12)
para_words = ["This", "paragraph", "follows", "the", "heading", "and", "tests", "mixed", "content", "rendering"]
for word_text in para_words:
paragraph.add_word(Word(word_text, para_font))
# Render mixed content
page.render_blocks([heading, paragraph])
result = page.render()
# Verify border consistency with asymmetric padding
border_measurements = self._extract_border_measurements(result, asymmetric_padding)
self._verify_border_consistency(border_measurements, asymmetric_padding)
# Verify no content bleeds into margins
self._verify_no_content_in_borders(result, asymmetric_padding, self.background_color)
def test_border_consistency_with_different_padding_values(self):
"""Test that different padding values maintain consistent borders"""
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
padding_configs = [
(10, 10, 10, 10), # uniform
(5, 15, 5, 15), # symmetric horizontal/vertical
(20, 30, 10, 5), # asymmetric
(0, 5, 0, 5), # minimal top/bottom
]
font = Font(font_size=14)
for padding in padding_configs:
with self.subTest(padding=padding):
# Create a fresh text object for each test to avoid state issues
test_text = Text("Border consistency test", font)
page = Page(size=self.page_size, background_color=self.background_color)
page._padding = padding
page.add_child(test_text)
result = page.render()
# Verify border measurements match expected padding
border_measurements = self._extract_border_measurements(result, padding)
self._verify_border_consistency(border_measurements, padding)
# Verify content area calculation
expected_content_width = self.page_size[0] - padding[1] - padding[3] # width - right - left
expected_content_height = self.page_size[1] - padding[0] - padding[2] # height - top - bottom
content_area = self._extract_content_area(result, padding)
self.assertEqual(content_area['width'], expected_content_width)
self.assertEqual(content_area['height'], expected_content_height)
def test_border_uniformity_across_renders(self):
"""Test that border areas remain uniform across multiple renders"""
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
page = Page(size=self.page_size, background_color=self.background_color)
page._padding = self.padding
font = Font(font_size=12)
text = Text("Consistency test content", font)
page.add_child(text)
# Render multiple times
results = []
for i in range(3):
result = page.render()
results.append(result)
border_measurements = self._extract_border_measurements(result, self.padding)
# Store first measurement as baseline
if i == 0:
baseline_measurements = border_measurements
else:
# Compare with baseline
self._compare_border_measurements(baseline_measurements, border_measurements)
def _extract_border_measurements(self, image, padding):
"""Extract measurements of border/margin areas from rendered image"""
width, height = image.size
top_pad, right_pad, bottom_pad, left_pad = padding
measurements = {
'top_border': {
'area': (0, 0, width, top_pad),
'pixels': self._get_area_pixels(image, (0, 0, width, top_pad))
},
'right_border': {
'area': (width - right_pad, 0, width, height),
'pixels': self._get_area_pixels(image, (width - right_pad, 0, width, height))
},
'bottom_border': {
'area': (0, height - bottom_pad, width, height),
'pixels': self._get_area_pixels(image, (0, height - bottom_pad, width, height))
},
'left_border': {
'area': (0, 0, left_pad, height),
'pixels': self._get_area_pixels(image, (0, 0, left_pad, height))
}
}
return measurements
def _get_area_pixels(self, image, area):
"""Extract pixel data from a specific area of the image"""
if area[2] <= area[0] or area[3] <= area[1]:
return [] # Invalid area
cropped = image.crop(area)
return list(cropped.getdata())
def _verify_border_consistency(self, measurements, expected_padding):
"""Verify that border measurements match expected padding values"""
# Get actual dimensions from the measurements instead of using self.page_size
# This allows the test to work with different page sizes
top_area = measurements['top_border']['area']
width = top_area[2] # right coordinate of top border gives us the width
height = measurements['left_border']['area'][3] # bottom coordinate of left border gives us the height
top_pad, right_pad, bottom_pad, left_pad = expected_padding
# Check area dimensions
self.assertEqual(top_area, (0, 0, width, top_pad))
right_area = measurements['right_border']['area']
self.assertEqual(right_area, (width - right_pad, 0, width, height))
bottom_area = measurements['bottom_border']['area']
self.assertEqual(bottom_area, (0, height - bottom_pad, width, height))
left_area = measurements['left_border']['area']
self.assertEqual(left_area, (0, 0, left_pad, height))
def _extract_content_area(self, image, padding):
"""Extract the content area (area inside borders/margins)"""
width, height = image.size
top_pad, right_pad, bottom_pad, left_pad = padding
content_area = {
'left': left_pad,
'top': top_pad,
'right': width - right_pad,
'bottom': height - bottom_pad,
'width': width - left_pad - right_pad,
'height': height - top_pad - bottom_pad
}
return content_area
def _verify_no_content_in_borders(self, image, padding, background_color):
"""Verify that no content bleeds into the border/margin areas"""
measurements = self._extract_border_measurements(image, padding)
# Check that border areas contain only background color
for border_name, border_data in measurements.items():
pixels = border_data['pixels']
if pixels: # Only check if area is not empty
# Most pixels should be background color (allowing for some anti-aliasing)
bg_count = sum(1 for pixel in pixels if self._is_background_color(pixel, background_color))
total_pixels = len(pixels)
# Allow up to 10% deviation for anti-aliasing effects
bg_ratio = bg_count / total_pixels if total_pixels > 0 else 1.0
self.assertGreaterEqual(bg_ratio, 0.9,
f"Border area '{border_name}' contains too much non-background content. "
f"Background ratio: {bg_ratio:.2f}")
def _is_background_color(self, pixel, background_color, tolerance=10):
"""Check if a pixel is close to the background color within tolerance"""
if len(pixel) >= 3:
r_diff = abs(pixel[0] - background_color[0])
g_diff = abs(pixel[1] - background_color[1])
b_diff = abs(pixel[2] - background_color[2])
return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance
return False
def _verify_content_within_bounds(self, image, padding):
"""Verify that content is positioned within the expected bounds"""
content_area = self._extract_content_area(image, padding)
# Sample the content area to ensure it's not all background
if content_area['width'] > 0 and content_area['height'] > 0:
content_crop = image.crop((
content_area['left'],
content_area['top'],
content_area['right'],
content_area['bottom']
))
# Content area should have some non-background pixels
content_pixels = list(content_crop.getdata())
non_bg_pixels = sum(1 for pixel in content_pixels
if not self._is_background_color(pixel, self.background_color))
# Expect at least some content in the content area
self.assertGreater(non_bg_pixels, 0, "Content area appears to be empty")
def _compare_border_measurements(self, baseline, current):
"""Compare two sets of border measurements for consistency"""
for border_name in baseline.keys():
baseline_area = baseline[border_name]['area']
current_area = current[border_name]['area']
self.assertEqual(baseline_area, current_area,
f"Border area '{border_name}' is inconsistent between renders")
if __name__ == '__main__':
unittest.main()
-365
View File
@@ -1,365 +0,0 @@
"""
Unit tests for pyWebLayout.concrete.text module.
Tests the Text and Line classes for text rendering functionality.
"""
import unittest
import numpy as np
from PIL import Image, ImageFont
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style.layout import Alignment
class TestText(unittest.TestCase):
"""Test cases for the Text class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0),
weight=FontWeight.NORMAL,
style=FontStyle.NORMAL,
decoration=TextDecoration.NONE
)
self.sample_text = "Hello World"
def test_text_initialization(self):
"""Test basic text initialization"""
text = Text(self.sample_text, self.font)
self.assertEqual(text._text, self.sample_text)
self.assertEqual(text._style, self.font)
self.assertIsNone(text._line)
self.assertIsNone(text._previous)
self.assertIsNone(text._next)
np.testing.assert_array_equal(text._origin, np.array([0, 0]))
def test_text_properties(self):
"""Test text property accessors"""
text = Text(self.sample_text, self.font)
self.assertEqual(text.text, self.sample_text)
self.assertEqual(text.style, self.font)
self.assertIsNone(text.line)
# Test size property
self.assertIsInstance(text.size, tuple)
self.assertEqual(len(text.size), 2)
self.assertGreater(text.width, 0)
self.assertGreater(text.height, 0)
def test_set_origin(self):
"""Test setting text origin"""
text = Text(self.sample_text, self.font)
text.set_origin(50, 75)
np.testing.assert_array_equal(text._origin, np.array([50, 75]))
def test_line_assignment(self):
"""Test line assignment"""
text = Text(self.sample_text, self.font)
mock_line = Mock()
text.line = mock_line
self.assertEqual(text.line, mock_line)
self.assertEqual(text._line, mock_line)
def test_add_to_line(self):
"""Test adding text to a line"""
text = Text(self.sample_text, self.font)
mock_line = Mock()
text.add_to_line(mock_line)
self.assertEqual(text._line, mock_line)
@patch('PIL.ImageDraw.Draw')
def test_render_basic(self, mock_draw_class):
"""Test basic text rendering"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
text = Text(self.sample_text, self.font)
result = text.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.mode, 'RGBA')
mock_draw.text.assert_called_once()
@patch('PIL.ImageDraw.Draw')
def test_render_with_background(self, mock_draw_class):
"""Test text rendering with background color"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
font_with_bg = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0),
background=(255, 255, 0, 128) # Yellow background with alpha
)
text = Text(self.sample_text, font_with_bg)
result = text.render()
self.assertIsInstance(result, Image.Image)
mock_draw.rectangle.assert_called_once()
mock_draw.text.assert_called_once()
@patch('PIL.ImageDraw.Draw')
def test_apply_decoration_underline(self, mock_draw_class):
"""Test underline decoration"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
font_underlined = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0),
decoration=TextDecoration.UNDERLINE
)
text = Text(self.sample_text, font_underlined)
text._apply_decoration(mock_draw)
mock_draw.line.assert_called_once()
@patch('PIL.ImageDraw.Draw')
def test_apply_decoration_strikethrough(self, mock_draw_class):
"""Test strikethrough decoration"""
mock_draw = Mock()
mock_draw_class.return_value = mock_draw
font_strikethrough = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0),
decoration=TextDecoration.STRIKETHROUGH
)
text = Text(self.sample_text, font_strikethrough)
text._apply_decoration(mock_draw)
mock_draw.line.assert_called_once()
def test_in_object_point_inside(self):
"""Test in_object method with point inside text"""
text = Text(self.sample_text, self.font)
text.set_origin(10, 20)
# Point inside text bounds
inside_point = np.array([15, 25])
self.assertTrue(text.in_object(inside_point))
def test_in_object_point_outside(self):
"""Test in_object method with point outside text"""
text = Text(self.sample_text, self.font)
text.set_origin(10, 20)
# Point outside text bounds
outside_point = np.array([200, 200])
self.assertFalse(text.in_object(outside_point))
def test_get_size(self):
"""Test get_size method"""
text = Text(self.sample_text, self.font)
size = text.get_size()
self.assertIsInstance(size, tuple)
self.assertEqual(len(size), 2)
self.assertEqual(size, text.size)
class TestLine(unittest.TestCase):
"""Test cases for the Line class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
self.spacing = (5, 10) # min, max spacing
self.origin = (0, 0)
self.size = (200, 20)
def test_line_initialization(self):
"""Test basic line initialization"""
line = Line(self.spacing, self.origin, self.size, self.font)
self.assertEqual(line._spacing, self.spacing)
self.assertEqual(line._font, self.font)
self.assertEqual(len(line._text_objects), 0) # Updated to _text_objects
self.assertEqual(line._current_width, 0)
self.assertIsNone(line._previous)
self.assertIsNone(line._next)
def test_line_initialization_with_previous(self):
"""Test line initialization with previous line"""
previous_line = Mock()
line = Line(self.spacing, self.origin, self.size, self.font, previous=previous_line)
self.assertEqual(line._previous, previous_line)
def test_text_objects_property(self):
"""Test text_objects property"""
line = Line(self.spacing, self.origin, self.size, self.font)
self.assertIsInstance(line.text_objects, list)
self.assertEqual(len(line.text_objects), 0)
def test_set_next(self):
"""Test setting next line"""
line = Line(self.spacing, self.origin, self.size, self.font)
next_line = Mock()
line.set_next(next_line)
self.assertEqual(line._next, next_line)
def test_add_word_fits(self):
"""Test adding word that fits in line"""
line = Line(self.spacing, self.origin, self.size, self.font)
result = line.add_word("short")
self.assertIsNone(result) # Word fits, no overflow
self.assertEqual(len(line._text_objects), 1) # Updated to _text_objects
self.assertGreater(line._current_width, 0)
def test_add_word_overflow(self):
"""Test adding word that doesn't fit"""
# Create a narrow line
narrow_line = Line(self.spacing, self.origin, (50, 20), self.font)
# Add a long word that won't fit
result = narrow_line.add_word("supercalifragilisticexpialidocious")
# Should return the word text indicating overflow
self.assertIsInstance(result, str)
@patch.object(Word, 'hyphenate')
@patch.object(Word, 'get_hyphenated_part')
@patch.object(Word, 'get_hyphenated_part_count')
def test_add_word_hyphenated(self, mock_part_count, mock_get_part, mock_hyphenate):
"""Test adding word that gets hyphenated"""
# Mock hyphenation behavior
mock_hyphenate.return_value = True
mock_get_part.side_effect = lambda i: ["super-", "califragilisticexpialidocious"][i]
mock_part_count.return_value = 2
# Create a font with lower min_hyphenation_width to allow hyphenation in narrow spaces
test_font = Font(
font_path=None,
font_size=12,
colour=(0, 0, 0),
min_hyphenation_width=20 # Allow hyphenation in narrow spaces for testing
)
# Use a narrow line but wide enough for the first hyphenated part
narrow_line = Line(self.spacing, self.origin, (60, 20), test_font)
result = narrow_line.add_word("supercalifragilisticexpialidocious")
# Should return the remaining part after hyphenation
self.assertIsInstance(result, str)
self.assertEqual(result, "califragilisticexpialidocious")
def test_add_multiple_words(self):
"""Test adding multiple words to line"""
line = Line(self.spacing, self.origin, self.size, self.font)
line.add_word("first")
line.add_word("second")
line.add_word("third")
self.assertEqual(len(line._text_objects), 3) # Updated to _text_objects
self.assertGreater(line._current_width, 0)
def test_render_empty_line(self):
"""Test rendering empty line"""
line = Line(self.spacing, self.origin, self.size, self.font)
result = line.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_render_with_words_left_aligned(self):
"""Test rendering line with left alignment"""
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT)
line.add_word("hello")
line.add_word("world")
result = line.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_render_with_words_right_aligned(self):
"""Test rendering line with right alignment"""
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT)
line.add_word("hello")
line.add_word("world")
result = line.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_render_with_words_centered(self):
"""Test rendering line with center alignment"""
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER)
line.add_word("hello")
line.add_word("world")
result = line.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_render_with_words_justified(self):
"""Test rendering line with justified alignment"""
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY)
line.add_word("hello")
line.add_word("world")
line.add_word("test")
result = line.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_render_single_word(self):
"""Test rendering line with single word"""
line = Line(self.spacing, self.origin, self.size, self.font)
line.add_word("single")
result = line.render()
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_text_objects_contain_text_instances(self):
"""Test that text_objects contain Text instances"""
line = Line(self.spacing, self.origin, self.size, self.font)
line.add_word("test")
self.assertEqual(len(line.text_objects), 1)
self.assertIsInstance(line.text_objects[0], Text)
self.assertEqual(line.text_objects[0].text, "test")
def test_text_objects_linked_to_line(self):
"""Test that Text objects are properly linked to the line"""
line = Line(self.spacing, self.origin, self.size, self.font)
line.add_word("test")
text_obj = line.text_objects[0]
self.assertEqual(text_obj.line, line)
if __name__ == '__main__':
unittest.main()
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env python3
"""
Demonstration of the new create_and_add_to pattern in pyWebLayout.
This script shows how the pattern enables automatic style and language inheritance
throughout the document hierarchy without copying strings - using object references instead.
"""
# Mock the style system for this demonstration
class MockFont:
def __init__(self, family="Arial", size=12, language="en-US", background="white"):
self.family = family
self.size = size
self.language = language
self.background = background
def __str__(self):
return f"Font(family={self.family}, size={self.size}, lang={self.language}, bg={self.background})"
# Import the abstract classes
from pyWebLayout.abstract import (
Document, Paragraph, Heading, HeadingLevel, Quote, HList, ListStyle,
Table, TableRow, TableCell, Word, FormattedSpan
)
def demonstrate_create_and_add_pattern():
"""Demonstrate the create_and_add_to pattern with style inheritance."""
print("=== pyWebLayout create_and_add_to Pattern Demonstration ===\n")
# Create a document with a default style
document_style = MockFont(family="Georgia", size=14, language="en-US", background="white")
doc = Document("Style Inheritance Demo", default_style=document_style)
print(f"1. Document created with style: {document_style}")
print(f" Document default style: {doc.default_style}\n")
# Create a paragraph using the new pattern - it inherits the document's style
para1 = Paragraph.create_and_add_to(doc)
print(f"2. Paragraph created with inherited style: {para1.style}")
print(f" Style object ID matches document: {id(para1.style) == id(doc.default_style)}")
print(f" Number of blocks in document: {len(doc.blocks)}\n")
# Create words using the paragraph's create_word method
word1 = para1.create_word("Hello")
word2 = para1.create_word("World")
print(f"3. Words created with inherited paragraph style:")
print(f" Word 1 '{word1.text}' style: {word1.style}")
print(f" Word 2 '{word2.text}' style: {word2.style}")
print(f" Style object IDs match paragraph: {id(word1.style) == id(para1.style)}")
print(f" Word count in paragraph: {para1.word_count}\n")
# Create a quote with a different style
quote_style = MockFont(family="Times", size=13, language="en-US", background="lightgray")
quote = Quote.create_and_add_to(doc, style=quote_style)
print(f"4. Quote created with custom style: {quote.style}")
print(f" Style object ID different from document: {id(quote.style) != id(doc.default_style)}")
# Create a paragraph inside the quote - it inherits the quote's style
quote_para = Paragraph.create_and_add_to(quote)
print(f" Quote paragraph inherits quote style: {quote_para.style}")
print(f" Style object ID matches quote: {id(quote_para.style) == id(quote.style)}\n")
# Create a heading with specific styling
heading_style = MockFont(family="Arial Black", size=18, language="en-US", background="white")
heading = Heading.create_and_add_to(doc, HeadingLevel.H1, style=heading_style)
print(f"5. Heading created with custom style: {heading.style}")
# Add words to the heading
heading.create_word("Chapter")
heading.create_word("One")
print(f" Heading words inherit heading style:")
for i, word in heading.words():
print(f" - Word {i}: '{word.text}' with style: {word.style}")
print()
# Create a list with inherited style
list_obj = HList.create_and_add_to(doc, ListStyle.UNORDERED)
print(f"6. List created with inherited document style: {list_obj.default_style}")
# Create list items that inherit from the list
item1 = list_obj.create_item()
item2 = list_obj.create_item()
print(f" List item 1 style: {item1.style}")
print(f" List item 2 style: {item2.style}")
print(f" Both inherit from list: {id(item1.style) == id(list_obj.default_style)}")
# Create paragraphs in list items
item1_para = item1.create_paragraph()
item2_para = item2.create_paragraph()
print(f" Item 1 paragraph style: {item1_para.style}")
print(f" Item 2 paragraph style: {item2_para.style}")
print(f" Both inherit from list item: {id(item1_para.style) == id(item1.style)}\n")
# Create a table with inherited style
table = Table.create_and_add_to(doc, "Example Table")
print(f"7. Table created with inherited document style: {table.style}")
# Create table rows and cells
header_row = table.create_row("header")
header_cell1 = header_row.create_cell(is_header=True)
header_cell2 = header_row.create_cell(is_header=True)
print(f" Header row style: {header_row.style}")
print(f" Header cell 1 style: {header_cell1.style}")
print(f" Header cell 2 style: {header_cell2.style}")
# Create paragraphs in cells
cell1_para = header_cell1.create_paragraph()
cell2_para = header_cell2.create_paragraph()
print(f" Cell 1 paragraph style: {cell1_para.style}")
print(f" Cell 2 paragraph style: {cell2_para.style}")
# Add words to cell paragraphs
cell1_para.create_word("Name")
cell2_para.create_word("Age")
print(f" All styles inherit properly through the hierarchy\n")
# Create a formatted span to show style inheritance
span = para1.create_span()
span_word = span.add_word("formatted")
print(f"8. FormattedSpan and Word inheritance:")
print(f" Span style: {span.style}")
print(f" Span word style: {span_word.style}")
print(f" Both inherit from paragraph: {id(span.style) == id(para1.style)}")
print()
# Demonstrate the object reference pattern vs string copying
print("9. Object Reference vs String Copying Demonstration:")
print(" - All child elements reference the SAME style object")
print(" - No string copying occurs - efficient memory usage")
print(" - Changes to parent style affect all children automatically")
print()
# Show the complete hierarchy
print("10. Document Structure Summary:")
print(f" Document blocks: {len(doc.blocks)}")
for i, block in enumerate(doc.blocks):
if hasattr(block, 'word_count'):
print(f" - Block {i}: {type(block).__name__} with {block.word_count} words")
elif hasattr(block, 'item_count'):
print(f" - Block {i}: {type(block).__name__} with {block.item_count} items")
elif hasattr(block, 'row_count'):
counts = block.row_count
print(f" - Block {i}: {type(block).__name__} with {counts['total']} total rows")
else:
print(f" - Block {i}: {type(block).__name__}")
print("\n=== Pattern Benefits ===")
print("✓ Automatic style inheritance throughout document hierarchy")
print("✓ Object references instead of string copying (memory efficient)")
print("✓ Consistent API pattern across all container/child relationships")
print("✓ Language and styling properties inherited as objects")
print("✓ Easy to use fluent interface for document building")
print("✓ Type safety with proper return types")
if __name__ == "__main__":
try:
demonstrate_create_and_add_pattern()
except ImportError as e:
print(f"Import error: {e}")
print("Note: This demo requires the pyWebLayout abstract classes")
print("Make sure the pyWebLayout package is in your Python path")
except Exception as e:
print(f"Error during demonstration: {e}")
import traceback
traceback.print_exc()
-174
View File
@@ -1,174 +0,0 @@
"""
Test for line overflow behavior in pyWebLayout/concrete/text.py
This test demonstrates how line overflow is triggered when words cannot fit
in progressively shorter lines. Uses a simple sentence with non-hyphenatable words.
"""
import unittest
import numpy as np
from PIL import ImageFont
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.style import Font, FontStyle, FontWeight
from pyWebLayout.style.layout import Alignment
class TestLineOverflow(unittest.TestCase):
"""Test line overflow behavior with progressively shorter lines."""
def setUp(self):
"""Set up test fixtures with a basic font and simple sentence."""
# Create a simple font for testing
self.font = Font()
# Simple sentence with short, non-hyphenatable words
self.simple_words = ["cat", "dog", "pig", "rat", "ox", "bee", "fly", "ant"]
def test_line_overflow_progression(self):
"""Test that line overflow is triggered as line width decreases."""
# Start with a reasonably wide line that can fit all words
initial_width = 800
line_height = 50
spacing = (5, 20) # min_spacing, max_spacing
# Test results storage
results = []
# Test with progressively shorter lines
for width in range(initial_width, 50, -50): # Decrease by 50px each time
# Create a new line with current width
origin = np.array([0, 0])
size = (width, line_height)
line = Line(spacing, origin, size, self.font, halign=Alignment.LEFT)
# Try to add words until overflow occurs
words_added = []
overflow_word = None
for word in self.simple_words:
result = line.add_word(word, self.font)
if result is None:
# Word fit in line
words_added.append(word)
else:
# Overflow occurred
overflow_word = word
break
# Record the test result
test_result = {
'width': width,
'words_added': words_added.copy(),
'overflow_word': overflow_word,
'total_words_fit': len(words_added)
}
results.append(test_result)
# Print progress for visibility
print(f"Width {width}px: Fit {len(words_added)} words: {' '.join(words_added)}")
if overflow_word:
print(f" -> Overflow on word: '{overflow_word}'")
# Assertions to verify expected behavior
self.assertTrue(len(results) > 0, "Should have test results")
# First (widest) line should fit more words than later lines
first_result = results[0]
last_result = results[-1]
self.assertGreaterEqual(
first_result['total_words_fit'],
last_result['total_words_fit'],
"Wider lines should fit at least as many words as narrower lines"
)
# At some point, overflow should occur (not all words fit)
overflow_occurred = any(r['overflow_word'] is not None for r in results)
self.assertTrue(overflow_occurred, "Line overflow should occur with narrow lines")
# Very narrow lines should fit fewer words
narrow_results = [r for r in results if r['width'] < 200]
if narrow_results:
# At least one narrow line should have triggered overflow
narrow_overflow = any(r['overflow_word'] is not None for r in narrow_results)
self.assertTrue(narrow_overflow, "Narrow lines should trigger overflow")
def test_single_word_overflow(self):
"""Test overflow behavior when even a single word doesn't fit."""
# Create a very narrow line
narrow_width = 30
line_height = 50
spacing = (5, 20)
origin = np.array([0, 0])
size = (narrow_width, line_height)
line = Line(spacing, origin, size, self.font, halign=Alignment.LEFT)
# Try to add a word that likely won't fit
test_word = "elephant" # Longer word that should overflow
result = line.add_word(test_word, self.font)
# The implementation may partially fit the word and return the remaining part
# Some overflow occurred - either full word or remaining part
self.assertIsInstance(result, str, "Should return remaining text as string")
self.assertGreater(len(result), 0, "Remaining text should not be empty")
# Check that some part was fitted
self.assertGreater(len(line.text_objects), 0, "Should have fitted at least some characters")
# The fitted part + remaining part should equal the original word
fitted_text = line.text_objects[0].text if line.text_objects else ""
self.assertEqual(fitted_text + result, test_word,
f"Fitted part '{fitted_text}' + remaining '{result}' should equal '{test_word}'")
print(f"Word '{test_word}' partially fit: '{fitted_text}' fitted, '{result}' remaining")
def test_empty_line_behavior(self):
"""Test behavior when adding words to an empty line."""
width = 300
line_height = 50
spacing = (5, 20)
origin = np.array([0, 0])
size = (width, line_height)
line = Line(spacing, origin, size, self.font, halign=Alignment.LEFT)
# Initially empty
self.assertEqual(len(line.text_objects), 0, "Line should start empty")
# Add first word
result = line.add_word("cat", self.font)
self.assertIsNone(result, "First word should fit in reasonable width")
self.assertEqual(len(line.text_objects), 1, "Should have one text object")
self.assertEqual(line.text_objects[0].text, "cat", "Text should match added word")
def test_progressive_overflow_demonstration(self):
"""Demonstrate the exact point where overflow begins."""
# Use a specific set of short words
words = ["a", "bb", "ccc", "dd", "e"]
# Start wide and narrow down until we find the overflow point
for width in range(200, 10, -10):
origin = np.array([0, 0])
size = (width, 50)
line = Line((3, 15), origin, size, self.font, halign=Alignment.LEFT)
words_that_fit = []
for word in words:
result = line.add_word(word, self.font)
if result is None:
words_that_fit.append(word)
else:
# This is where overflow started
print(f"Overflow at width {width}px: '{' '.join(words_that_fit)}' + '{word}' (overflow)")
self.assertGreater(len(words_that_fit), 0, "Should fit at least some words before overflow")
return # Test successful
# If we get here, no overflow occurred even at minimum width
print("No overflow occurred - all words fit even in narrowest line")
if __name__ == '__main__':
unittest.main(verbosity=2)
-221
View File
@@ -1,221 +0,0 @@
#!/usr/bin/env python3
"""
Test to demonstrate and verify fix for the line splitting bug where
text is lost at line breaks due to improper hyphenation handling.
"""
import unittest
from unittest.mock import patch, Mock
from pyWebLayout.concrete.text import Line
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
from pyWebLayout.style.layout import Alignment
class TestLineSplittingBug(unittest.TestCase):
"""Test cases for the line splitting bug"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None,
font_size=12,
colour=(0, 0, 0),
min_hyphenation_width=20 # Allow hyphenation in narrow spaces for testing
)
self.spacing = (5, 10)
self.origin = (0, 0)
self.size = (100, 20) # Narrow line to force hyphenation
@patch('pyWebLayout.abstract.inline.pyphen')
def test_hyphenation_preserves_word_boundaries(self, mock_pyphen_module):
"""Test that hyphenation properly preserves word boundaries"""
# Mock pyphen to return a multi-part hyphenated word
mock_dic = Mock()
mock_pyphen_module.Pyphen.return_value = mock_dic
# Simulate hyphenating "supercalifragilisticexpialidocious"
# into multiple parts: "super-", "cali-", "fragi-", "listic-", "expiali-", "docious"
mock_dic.inserted.return_value = "super-cali-fragi-listic-expiali-docious"
line = Line(self.spacing, self.origin, self.size, self.font)
# Add the word that will be hyphenated
overflow = line.add_word("supercalifragilisticexpialidocious")
# The overflow should be the next part only, not all remaining parts joined
# In the current buggy implementation, this would return "cali-fragi-listic-expiali-docious"
# But it should return "cali-" (the next single part)
print(f"Overflow returned: '{overflow}'")
# Check that the first part was added to the line
self.assertEqual(len(line.text_objects), 1)
first_word_text = line.text_objects[0].text
self.assertEqual(first_word_text, "super-")
# The overflow should be just the next part, not all parts joined
# This assertion will fail with the current bug, showing the issue
self.assertEqual(overflow, "cali-") # Should be next part only
# NOT this (which is what the bug produces):
# self.assertEqual(overflow, "cali-fragi-listic-expiali-docious")
@patch('pyWebLayout.abstract.inline.pyphen')
def test_single_word_overflow_behavior(self, mock_pyphen_module):
"""Test that overflow returns only the next part, not all remaining parts joined"""
# Mock pyphen to return a simple two-part hyphenated word
mock_dic = Mock()
mock_pyphen_module.Pyphen.return_value = mock_dic
mock_dic.inserted.return_value = "very-long"
# Create a narrow line that will force hyphenation
line = Line(self.spacing, (0, 0), (40, 20), self.font)
# Add the word that will be hyphenated
overflow = line.add_word("verylong")
# Check that the first part was added to the line
self.assertEqual(len(line.text_objects), 1)
first_word_text = line.text_objects[0].text
self.assertEqual(first_word_text, "very-")
# The overflow should be just the next part ("long"), not multiple parts joined
# This tests the core fix for the line splitting bug
self.assertEqual(overflow, "long")
print(f"First part in line: '{first_word_text}'")
print(f"Overflow returned: '{overflow}'")
def test_simple_overflow_case(self):
"""Test a simple word overflow without hyphenation to verify baseline behavior"""
line = Line(self.spacing, self.origin, (50, 20), self.font)
# Add a word that fits
result1 = line.add_word("short")
self.assertIsNone(result1)
# Add a word that doesn't fit (should overflow)
result2 = line.add_word("verylongword")
self.assertEqual(result2, "verylongword")
# Only the first word should be in the line
self.assertEqual(len(line.text_objects), 1)
self.assertEqual(line.text_objects[0].text, "short")
def test_conservative_justified_hyphenation(self):
"""Test that justified alignment is more conservative about mid-sentence hyphenation"""
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
line = Line((5, 15), (0, 0), (200, 20), font, halign=Alignment.JUSTIFY)
with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module:
mock_dic = Mock()
mock_pyphen_module.Pyphen.return_value = mock_dic
mock_dic.inserted.return_value = "test-word"
# Add words that should fit without hyphenation
result1 = line.add_word("This")
result2 = line.add_word("should")
result3 = line.add_word("testword") # Should NOT be hyphenated with conservative settings
self.assertIsNone(result1)
self.assertIsNone(result2)
self.assertIsNone(result3) # Should fit without hyphenation
self.assertEqual(len(line.text_objects), 3)
self.assertEqual([obj.text for obj in line.text_objects], ["This", "should", "testword"])
def test_helper_methods_exist(self):
"""Test that refactored helper methods exist and work"""
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
line = Line((5, 10), (0, 0), (200, 20), font)
# Test helper methods exist and return reasonable values
available_width = line._calculate_available_width(font)
self.assertIsInstance(available_width, int)
self.assertGreater(available_width, 0)
safety_margin = line._get_safety_margin(font)
self.assertIsInstance(safety_margin, int)
self.assertGreaterEqual(safety_margin, 1)
fits = line._fits_with_normal_spacing(50, 100, font)
self.assertIsInstance(fits, bool)
def test_no_cropping_with_safety_margin(self):
"""Test that safety margin prevents text cropping"""
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
# Create a line that's just barely wide enough
line = Line((2, 5), (0, 0), (80, 20), font)
# Add words that should fit with safety margin
result1 = line.add_word("test")
result2 = line.add_word("word")
self.assertIsNone(result1)
self.assertIsNone(result2)
# Verify both words were added
self.assertEqual(len(line.text_objects), 2)
self.assertEqual([obj.text for obj in line.text_objects], ["test", "word"])
def test_modular_word_fitting_strategies(self):
"""Test that word fitting strategies work in proper order"""
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
line = Line((5, 10), (0, 0), (80, 20), font) # Narrower line to force overflow
# Test normal spacing strategy
result1 = line.add_word("short")
self.assertIsNone(result1)
# Test that we can add multiple words
result2 = line.add_word("words")
self.assertIsNone(result2)
# Test overflow handling with a definitely too-long word
result3 = line.add_word("verylongwordthatdefinitelywontfitinnarrowline")
self.assertIsNotNone(result3) # Should return overflow
# Line should have the first two words only
self.assertEqual(len(line.text_objects), 2)
self.assertEqual([obj.text for obj in line.text_objects], ["short", "words"])
def demonstrate_bug():
"""Demonstrate the bug with a practical example"""
print("=" * 60)
print("DEMONSTRATING LINE SPLITTING BUG")
print("=" * 60)
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
# Create a very narrow line that will force hyphenation
line = Line((3, 6), (0, 0), (80, 20), font)
# Try to add a long word that should be hyphenated
with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module:
mock_dic = Mock()
mock_pyphen_module.Pyphen.return_value = mock_dic
mock_dic.inserted.return_value = "hyper-long-example-word"
overflow = line.add_word("hyperlongexampleword")
print(f"Original word: 'hyperlongexampleword'")
print(f"Hyphenated to: 'hyper-long-example-word'")
print(f"First part added to line: '{line.text_objects[0].text if line.text_objects else 'None'}'")
print(f"Overflow returned: '{overflow}'")
print()
print("PROBLEM: The overflow should be 'long-' (next part only)")
print("but instead it returns 'long-example-word' (all remaining parts joined)")
print("This causes word boundary information to be lost!")
if __name__ == "__main__":
# First demonstrate the bug
demonstrate_bug()
print("\n" + "=" * 60)
print("RUNNING UNIT TESTS")
print("=" * 60)
# Run unit tests
unittest.main()
-223
View File
@@ -1,223 +0,0 @@
"""
Basic mono-space font tests for predictable character width behavior.
This test focuses on the fundamental property of mono-space fonts:
every character has the same width, making layout calculations predictable.
"""
import unittest
import os
from PIL import Image
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.layout import Alignment
class TestMonospaceBasics(unittest.TestCase):
"""Basic tests for mono-space font behavior."""
def setUp(self):
"""Set up test with a mono-space font if available."""
# Try to find DejaVu Sans Mono
mono_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
"/System/Library/Fonts/Monaco.ttf",
"C:/Windows/Fonts/consola.ttf"
]
self.mono_font_path = None
for path in mono_paths:
if os.path.exists(path):
self.mono_font_path = path
break
if self.mono_font_path:
self.font = Font(font_path=self.mono_font_path, font_size=12)
# Calculate reference character width
ref_char = Text("M", self.font)
self.char_width = ref_char.width
print(f"Using mono-space font: {self.mono_font_path}")
print(f"Character width: {self.char_width}px")
else:
print("No mono-space font found - tests will be skipped")
def test_character_width_consistency(self):
"""Test that all characters have the same width."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Test a variety of characters
test_chars = "AaBbCc123!@#.,:;'\"()[]{}|-_+=<>"
widths = []
for char in test_chars:
text = Text(char, self.font)
widths.append(text.width)
print(f"'{char}': {text.width}px")
# All widths should be nearly identical
min_width = min(widths)
max_width = max(widths)
variance = max_width - min_width
self.assertLessEqual(variance, 2,
f"Character width variance should be minimal, got {variance}px")
def test_predictable_string_width(self):
"""Test that string width equals character_width * length."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
test_strings = [
"A",
"AB",
"ABC",
"ABCD",
"Hello",
"Hello World",
"123456789"
]
for s in test_strings:
text = Text(s, self.font)
expected_width = len(s) * self.char_width
actual_width = text.width
# Allow small variance for font rendering
diff = abs(actual_width - expected_width)
max_allowed_diff = len(s) + 2 # Small tolerance
print(f"'{s}' ({len(s)} chars): expected {expected_width}px, "
f"actual {actual_width}px, diff {diff}px")
self.assertLessEqual(diff, max_allowed_diff,
f"String '{s}' width should be predictable")
def test_line_capacity_prediction(self):
"""Test that we can predict how many characters fit on a line."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Test with different line widths
test_widths = [100, 200, 300]
for line_width in test_widths:
# Calculate expected character capacity
expected_chars = line_width // self.char_width
# Create a line and fill it with single characters
line = Line(
spacing=(1, 1), # Minimal spacing
origin=(0, 0),
size=(line_width, 20),
font=self.font,
halign=Alignment.LEFT
)
chars_added = 0
for i in range(expected_chars + 5): # Try a few extra
result = line.add_word("X", self.font)
if result is not None: # Doesn't fit
break
chars_added += 1
print(f"Line width {line_width}px: expected ~{expected_chars} chars, "
f"actual {chars_added} chars")
# Should be reasonably close to prediction
self.assertGreaterEqual(chars_added, max(1, expected_chars - 2))
self.assertLessEqual(chars_added, expected_chars + 2)
def test_word_breaking_with_known_widths(self):
"""Test word breaking with known character widths."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Create a line that fits exactly 10 characters
line_width = self.char_width * 10
line = Line(
spacing=(2, 4),
origin=(0, 0),
size=(line_width, 20),
font=self.font,
halign=Alignment.LEFT
)
# Try to add a word that's too long
long_word = "ABCDEFGHIJKLMNOP" # 16 characters
result = line.add_word(long_word, self.font)
# Word should be broken or rejected
if result is None:
self.fail("16-character word should not fit in 10-character line")
else:
print(f"Long word '{long_word}' result: '{result}'")
# Check that some text was added
self.assertGreater(len(line.text_objects), 0,
"Some text should be added to the line")
if line.text_objects:
added_text = line.text_objects[0].text
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
# Added text should be shorter than original
self.assertLess(len(added_text), len(long_word),
"Added text should be shorter than original word")
def test_alignment_visual_differences(self):
"""Test that different alignments produce visually different results."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Use a line width that allows for visible alignment differences
line_width = self.char_width * 20
test_words = ["Hello", "World"]
alignments = [
(Alignment.LEFT, "left"),
(Alignment.CENTER, "center"),
(Alignment.RIGHT, "right"),
(Alignment.JUSTIFY, "justify")
]
results = {}
for alignment, name in alignments:
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(line_width, 20),
font=self.font,
halign=alignment
)
# Add test words
for word in test_words:
result = line.add_word(word, self.font)
if result is not None:
break
# Render the line
line_image = line.render()
results[name] = line_image
# Save for visual inspection
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_path = os.path.join(output_dir, f"mono_align_{name}.png")
line_image.save(output_path)
print(f"Saved {name} alignment test to: {output_path}")
# All alignments should produce valid images
for name, image in results.items():
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (line_width, 20))
if __name__ == '__main__':
unittest.main(verbosity=2)
-343
View File
@@ -1,343 +0,0 @@
"""
Mono-space font testing concepts and demo.
This test demonstrates why mono-space fonts are valuable for testing
rendering, line-breaking, and hyphenation, even when using regular fonts.
"""
import unittest
import os
from PIL import Image
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.page import Page, Container
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.layout import Alignment
class TestMonospaceConcepts(unittest.TestCase):
"""Demonstrate mono-space testing concepts."""
def setUp(self):
"""Set up test with available fonts."""
# Use the project's default font
self.regular_font = Font(font_size=12)
# Analyze character width variance
test_chars = "iIlLmMwW0O"
self.char_analysis = {}
for char in test_chars:
text = Text(char, self.regular_font)
self.char_analysis[char] = text.width
widths = list(self.char_analysis.values())
self.min_width = min(widths)
self.max_width = max(widths)
self.variance = self.max_width - self.min_width
print(f"\nFont analysis:")
print(f"Character width range: {self.min_width}-{self.max_width}px")
print(f"Variance: {self.variance}px")
# Find most uniform character (closest to average)
avg_width = sum(widths) / len(widths)
self.uniform_char = min(self.char_analysis.keys(),
key=lambda c: abs(self.char_analysis[c] - avg_width))
print(f"Most uniform character: '{self.uniform_char}' ({self.char_analysis[self.uniform_char]}px)")
def test_character_width_predictability(self):
"""Show why predictable character widths matter for testing."""
print("\n=== Character Width Predictability Demo ===")
# Compare narrow vs wide characters
narrow_word = "ill" # Narrow characters
wide_word = "WWW" # Wide characters
uniform_word = self.uniform_char * 3 # Uniform characters
narrow_text = Text(narrow_word, self.regular_font)
wide_text = Text(wide_word, self.regular_font)
uniform_text = Text(uniform_word, self.regular_font)
print(f"Same length (3 chars), different widths:")
print(f" '{narrow_word}': {narrow_text.width}px")
print(f" '{wide_word}': {wide_text.width}px")
print(f" '{uniform_word}': {uniform_text.width}px")
# Show the problem this creates for testing
width_ratio = wide_text.width / narrow_text.width
print(f" Width ratio: {width_ratio:.1f}x")
if width_ratio > 1.5:
print(" → This variance makes line capacity unpredictable!")
# With mono-space, all would be ~36px (3 chars × 12px each)
theoretical_mono = 3 * 12
print(f" With mono-space: ~{theoretical_mono}px each")
def test_line_capacity_challenges(self):
"""Show how variable character widths affect line capacity."""
print("\n=== Line Capacity Prediction Challenges ===")
line_width = 120 # Fixed width
# Test with different character types
test_cases = [
("narrow", "i" * 20), # 20 narrow chars
("wide", "W" * 8), # 8 wide chars
("mixed", "Hello World"), # Mixed realistic text
("uniform", self.uniform_char * 15) # 15 uniform chars
]
print(f"Line width: {line_width}px")
for name, test_text in test_cases:
text_obj = Text(test_text, self.regular_font)
fits = "YES" if text_obj.width <= line_width else "NO"
print(f" {name:8}: '{test_text[:15]}...' ({len(test_text)} chars)")
print(f" Width: {text_obj.width}px, Fits: {fits}")
print("\nWith mono-space fonts:")
char_width = 12 # Theoretical mono-space width
capacity = line_width // char_width
print(f" Predictable capacity: ~{capacity} characters")
print(f" Any {capacity}-character string would fit")
def test_word_breaking_complexity(self):
"""Demonstrate word breaking complexity with variable widths."""
print("\n=== Word Breaking Complexity Demo ===")
# Create a narrow line
line_width = 80
line = Line(
spacing=(2, 4),
origin=(0, 0),
size=(line_width, 20),
font=self.regular_font,
halign=Alignment.LEFT
)
# Test different word types
test_words = [
("narrow", "illillill"), # 9 narrow chars
("wide", "WWWWW"), # 5 wide chars
("mixed", "Hello"), # 5 mixed chars
]
print(f"Line width: {line_width}px")
for word_type, word in test_words:
# Create fresh line for each test
test_line = Line(
spacing=(2, 4),
origin=(0, 0),
size=(line_width, 20),
font=self.regular_font,
halign=Alignment.LEFT
)
word_obj = Text(word, self.regular_font)
result = test_line.add_word(word, self.regular_font)
fits = "YES" if result is None else "NO"
print(f" {word_type:6}: '{word}' ({len(word)} chars, {word_obj.width}px) → {fits}")
if result is not None and test_line.text_objects:
added = test_line.text_objects[0].text
print(f" Added: '{added}', Remaining: '{result}'")
print("\nWith mono-space fonts, word fitting would be predictable:")
char_width = 12
capacity = line_width // char_width
print(f" Any word ≤ {capacity} characters would fit")
print(f" Any word > {capacity} characters would need breaking")
def test_alignment_consistency(self):
"""Show how alignment behavior varies with character widths."""
print("\n=== Alignment Consistency Demo ===")
line_width = 150
# Test different alignments with various text
test_texts = [
"ill ill ill", # Narrow characters
"WWW WWW WWW", # Wide characters
"The cat sat", # Mixed characters
]
alignments = [
(Alignment.LEFT, "LEFT"),
(Alignment.CENTER, "CENTER"),
(Alignment.RIGHT, "RIGHT"),
(Alignment.JUSTIFY, "JUSTIFY")
]
results = {}
for align_enum, align_name in alignments:
print(f"\n{align_name} alignment:")
for text in test_texts:
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(line_width, 20),
font=self.regular_font,
halign=align_enum
)
# Add words to line
words = text.split()
for word in words:
result = line.add_word(word, self.regular_font)
if result is not None:
break
# Render and save
line_image = line.render()
# Calculate text coverage
text_obj = Text(text.replace(" ", ""), self.regular_font)
coverage = text_obj.width / line_width
print(f" '{text}': {coverage:.1%} line coverage")
# Save example
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
filename = f"align_{align_name.lower()}_{text.replace(' ', '_')}.png"
output_path = os.path.join(output_dir, filename)
line_image.save(output_path)
print("\nWith mono-space fonts:")
print(" - Alignment calculations would be simpler")
print(" - Spacing distribution would be more predictable")
print(" - Visual consistency would be higher")
def test_hyphenation_decision_factors(self):
"""Show factors affecting hyphenation decisions."""
print("\n=== Hyphenation Decision Factors ===")
# Test word that might benefit from hyphenation
test_word = "development" # 11 characters
word_obj = Text(test_word, self.regular_font)
print(f"Test word: '{test_word}' ({len(test_word)} chars, {word_obj.width}px)")
# Test different line widths
test_widths = [60, 80, 100, 120, 140]
for width in test_widths:
line = Line(
spacing=(2, 4),
origin=(0, 0),
size=(width, 20),
font=self.regular_font,
halign=Alignment.LEFT
)
result = line.add_word(test_word, self.regular_font)
if result is None:
status = "FITS completely"
elif line.text_objects:
added = line.text_objects[0].text
status = f"PARTIAL: '{added}' + '{result}'"
else:
status = "REJECTED completely"
# Calculate utilization
utilization = word_obj.width / width
print(f" Width {width:3}px ({utilization:>5.1%} util): {status}")
print("\nWith mono-space fonts:")
char_width = 12
word_width_mono = len(test_word) * char_width # 132px
print(f" Word would be exactly {word_width_mono}px")
print(f" Hyphenation decisions would be based on character count")
print(f" Line capacity would be width ÷ {char_width}px per char")
def test_create_visual_comparison(self):
"""Create visual comparison showing the difference."""
print("\n=== Creating Visual Comparison ===")
# Create a page showing the problems with variable width fonts
page = Page(size=(600, 400))
# Create test content
test_text = "The quick brown fox jumps over lazy dogs with varying character widths."
# Split into words and create multiple lines with different alignments
words = test_text.split()
# Create container for demonstration
demo_container = Container(
origin=(0, 0),
size=(580, 380),
direction='vertical',
spacing=5,
padding=(10, 10, 10, 10)
)
alignments = [
(Alignment.LEFT, "Left Aligned"),
(Alignment.CENTER, "Center Aligned"),
(Alignment.RIGHT, "Right Aligned"),
(Alignment.JUSTIFY, "Justified")
]
for align_enum, title in alignments:
# Add title
from pyWebLayout.style.fonts import FontWeight
title_text = Text(title + ":", Font(font_size=14, weight=FontWeight.BOLD))
demo_container.add_child(title_text)
# Create line with this alignment
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(560, 20),
font=self.regular_font,
halign=align_enum
)
# Add as many words as fit
for word in words[:6]: # Limit to first 6 words
result = line.add_word(word, self.regular_font)
if result is not None:
break
demo_container.add_child(line)
# Add demo to page
page.add_child(demo_container)
# Render and save
page_image = page.render()
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_path = os.path.join(output_dir, "monospace_concepts_demo.png")
page_image.save(output_path)
print(f"Visual demonstration saved to: {output_path}")
print("This shows why mono-space fonts make testing more predictable!")
# Validation
self.assertIsInstance(page_image, Image.Image)
self.assertEqual(page_image.size, (600, 400))
if __name__ == '__main__':
# Ensure output directory exists
if not os.path.exists("test_output"):
os.makedirs("test_output")
unittest.main(verbosity=2)
-348
View File
@@ -1,348 +0,0 @@
"""
Mono-space font hyphenation tests.
Tests hyphenation behavior with mono-space fonts where character widths
are predictable, making it easier to verify hyphenation logic and
line-breaking decisions.
"""
import unittest
import os
from PIL import Image
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.layout import Alignment
from pyWebLayout.abstract.inline import Word
class TestMonospaceHyphenation(unittest.TestCase):
"""Test hyphenation behavior with mono-space fonts."""
def setUp(self):
"""Set up test with mono-space font."""
# Try to find a mono-space font
mono_paths = [
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" ,
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
self.mono_font_path = None
for path in mono_paths:
if os.path.exists(path):
self.mono_font_path = path
break
if self.mono_font_path:
self.font = Font(
font_path=self.mono_font_path,
font_size=14,
min_hyphenation_width=20
)
# Calculate character width
ref_char = Text("M", self.font)
self.char_width = ref_char.width
print(f"Using mono-space font: {os.path.basename(self.mono_font_path)}")
print(f"Character width: {self.char_width}px")
else:
print("No mono-space font found - hyphenation tests will be skipped")
def test_hyphenation_basic_functionality(self):
"""Test basic hyphenation with known words."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Test words that should hyphenate
test_words = [
"hyphenation",
"development",
"information",
"character",
"beautiful",
"computer"
]
for word_text in test_words:
word = Word(word_text, self.font)
if word.hyphenate():
parts_count = word.get_hyphenated_part_count()
print(f"\nWord: '{word_text}' -> {parts_count} parts")
# Collect all parts
parts = []
for i in range(parts_count):
part = word.get_hyphenated_part(i)
parts.append(part)
print(f" Part {i}: '{part}' ({len(part)} chars)")
# Verify that parts reconstruct the original word
reconstructed = ''.join(parts).replace('-', '')
self.assertEqual(reconstructed, word_text,
f"Hyphenated parts should reconstruct '{word_text}'")
# Test that each part has predictable width
for i, part in enumerate(parts):
text_obj = Text(part, self.font)
expected_width = len(part) * self.char_width
actual_width = text_obj.width
# Allow small variance for hyphen rendering
diff = abs(actual_width - expected_width)
max_diff = 5 # pixels tolerance for hyphen
self.assertLessEqual(diff, max_diff,
f"Part '{part}' width should be predictable")
else:
print(f"Word '{word_text}' cannot be hyphenated")
def test_hyphenation_line_fitting(self):
"""Test that hyphenation helps words fit on lines."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Create a line that's too narrow for long words
narrow_width = self.char_width * 12 # 12 characters
line = Line(
spacing=(2, 4),
origin=(0, 0),
size=(narrow_width, 20),
font=self.font,
halign=Alignment.LEFT
)
# Test with a word that needs hyphenation
long_word = "hyphenation" # 11 characters - should barely fit or need hyphenation
result = line.add_word(long_word, self.font)
print(f"\nTesting word '{long_word}' in {narrow_width}px line:")
print(f"Line capacity: ~{narrow_width // self.char_width} characters")
if result is None:
# Word fit completely
print("Word fit completely on line")
self.assertGreater(len(line.text_objects), 0, "Line should have text")
added_text = line.text_objects[0].text
print(f"Added text: '{added_text}'")
else:
# Word was hyphenated or rejected
print(f"Word result: '{result}'")
if len(line.text_objects) > 0:
added_text = line.text_objects[0].text
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
print(f"Remaining: '{result}' ({len(result)} chars)")
# Added part should be shorter than original
self.assertLess(len(added_text), len(long_word),
"Hyphenated part should be shorter than original")
# Remaining part should be shorter than original
self.assertLess(len(result), len(long_word),
"Remaining part should be shorter than original")
else:
print("No text was added to line")
def test_hyphenation_vs_no_hyphenation(self):
"""Compare behavior with and without hyphenation enabled."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Create fonts with and without hyphenation
font_with_hyphen = Font(
font_path=self.mono_font_path,
font_size=14,
min_hyphenation_width=20
)
font_no_hyphen = Font(
font_path=self.mono_font_path,
font_size=14,
)
# Test with a word that benefits from hyphenation
test_word = "development" # 11 characters
line_width = self.char_width * 8 # 8 characters - too narrow
# Test with hyphenation enabled
line_with_hyphen = Line(
spacing=(2, 4),
origin=(0, 0),
size=(line_width, 20),
font=font_with_hyphen,
halign=Alignment.LEFT
)
result_with_hyphen = line_with_hyphen.add_word(test_word, font_with_hyphen)
# Test without hyphenation
line_no_hyphen = Line(
spacing=(2, 4),
origin=(0, 0),
size=(line_width, 20),
font=font_no_hyphen,
halign=Alignment.LEFT
)
result_no_hyphen = line_no_hyphen.add_word(test_word, font_no_hyphen)
print(f"\nTesting '{test_word}' in {line_width}px line:")
print(f"With hyphenation: {result_with_hyphen}")
print(f"Without hyphenation: {result_no_hyphen}")
# With hyphenation, we might get partial content
# Without hyphenation, word should be rejected entirely
if result_with_hyphen is None:
print("Word fit completely with hyphenation")
elif len(line_with_hyphen.text_objects) > 0:
added_with_hyphen = line_with_hyphen.text_objects[0].text
print(f"Added with hyphenation: '{added_with_hyphen}'")
if result_no_hyphen is None:
print("Word fit completely without hyphenation")
elif len(line_no_hyphen.text_objects) > 0:
added_no_hyphen = line_no_hyphen.text_objects[0].text
print(f"Added without hyphenation: '{added_no_hyphen}'")
def test_hyphenation_quality_metrics(self):
"""Test hyphenation quality with different line widths."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
test_word = "information" # 11 characters
# Test with different line widths
test_widths = [
self.char_width * 6, # Very narrow
self.char_width * 8, # Narrow
self.char_width * 10, # Medium
self.char_width * 12, # Wide enough
]
print(f"\nTesting hyphenation quality for '{test_word}':")
for width in test_widths:
capacity = width // self.char_width
line = Line(
spacing=(2, 4),
origin=(0, 0),
size=(width, 20),
font=self.font,
halign=Alignment.LEFT
)
result = line.add_word(test_word, self.font)
print(f"\nLine width: {width}px (~{capacity} chars)")
if result is None:
print(" Word fit completely")
if line.text_objects:
added = line.text_objects[0].text
print(f" Added: '{added}'")
else:
print(f" Result: '{result}'")
if line.text_objects:
added = line.text_objects[0].text
print(f" Added: '{added}' ({len(added)} chars)")
print(f" Remaining: '{result}' ({len(result)} chars)")
# Calculate hyphenation efficiency
chars_used = len(added) - added.count('-') # Don't count hyphens
efficiency = chars_used / len(test_word)
print(f" Efficiency: {efficiency:.2%}")
def test_multiple_words_with_hyphenation(self):
"""Test adding multiple words where hyphenation affects spacing."""
if not self.mono_font_path:
self.skipTest("No mono-space font available")
# Create a line that forces interesting hyphenation decisions
line_width = self.char_width * 20 # 20 characters
line = Line(
spacing=(3, 6),
origin=(0, 0),
size=(line_width, 20),
font=self.font,
halign=Alignment.JUSTIFY
)
# Test words that might need hyphenation
test_words = ["The", "development", "of", "hyphenation"]
print(f"\nAdding words to {line_width}px line (~{line_width // self.char_width} chars):")
words_added = []
for word in test_words:
result = line.add_word(word, self.font)
if result is None:
print(f" '{word}' - fit completely")
words_added.append(word)
else:
print(f" '{word}' - result: '{result}'")
if line.text_objects:
last_added = line.text_objects[-1].text
print(f" Added: '{last_added}'")
words_added.append(last_added)
break
print(f"Final line contains {len(line.text_objects)} text objects")
# Render the line to test spacing
line_image = line.render()
# Save for visual inspection
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_path = os.path.join(output_dir, "mono_hyphenation_multiword.png")
line_image.save(output_path)
print(f"Saved multi-word hyphenation test to: {output_path}")
# Basic validation
self.assertIsInstance(line_image, Image.Image)
self.assertEqual(line_image.size, (line_width, 20))
def save_hyphenation_example(self, test_name: str, lines: list):
"""Save a visual example of hyphenation behavior."""
from pyWebLayout.concrete.page import Container
# Create a container for multiple lines
container = Container(
origin=(0, 0),
size=(400, len(lines) * 25),
direction='vertical',
spacing=5,
padding=(10, 10, 10, 10)
)
# Add each line to the container
for i, line in enumerate(lines):
line._origin = (0, i * 25)
container.add_child(line)
# Render the container
container_image = container.render()
# Save the image
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_path = os.path.join(output_dir, f"mono_hyphen_{test_name}.png")
container_image.save(output_path)
print(f"Saved hyphenation example '{test_name}' to: {output_path}")
if __name__ == '__main__':
unittest.main(verbosity=2)
-483
View File
@@ -1,483 +0,0 @@
"""
Comprehensive mono-space font tests for rendering, line-breaking, and hyphenation.
Mono-space fonts provide predictable behavior for testing layout algorithms
since each character has the same width. This makes it easier to verify
correct text flow, line breaking, and hyphenation behavior.
"""
import unittest
import os
from PIL import Image, ImageFont
import numpy as np
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.page import Page, Container
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle
from pyWebLayout.style.layout import Alignment
from pyWebLayout.abstract.inline import Word
class TestMonospaceRendering(unittest.TestCase):
"""Test rendering behavior with mono-space fonts."""
def setUp(self):
"""Set up test fixtures with mono-space font."""
# Try to find a mono-space font on the system
self.monospace_font_path = self._find_monospace_font()
# Create mono-space font instances for testing
self.mono_font_12 = Font(
font_path=self.monospace_font_path,
font_size=12,
colour=(0, 0, 0)
)
self.mono_font_16 = Font(
font_path=self.monospace_font_path,
font_size=16,
colour=(0, 0, 0)
)
# Calculate character width for mono-space font
test_char = Text("X", self.mono_font_12)
self.char_width_12 = test_char.width
test_char_16 = Text("X", self.mono_font_16)
self.char_width_16 = test_char_16.width
print(f"Mono-space character width (12pt): {self.char_width_12}px")
print(f"Mono-space character width (16pt): {self.char_width_16}px")
def _find_monospace_font(self):
"""Find a suitable mono-space font on the system."""
# Common mono-space font paths
possible_fonts = [
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" ,
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
]
for font_path in possible_fonts:
if os.path.exists(font_path):
return font_path
# If no mono-space font found, return None to use default
print("Warning: No mono-space font found, using default font")
return None
def test_character_width_consistency(self):
"""Test that all characters have the same width in mono-space font."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Test various characters to ensure consistent width
test_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"
widths = []
for char in test_chars:
text_obj = Text("A"+char+"A", self.mono_font_12)
widths.append(text_obj.width)
# All widths should be the same (or very close due to rendering differences)
min_width = min(widths)
max_width = max(widths)
width_variance = max_width - min_width
print(f"Character width range: {min_width}-{max_width}px (variance: {width_variance}px)")
# Allow small variance for anti-aliasing effects
self.assertLessEqual(width_variance, 2, "Mono-space characters should have consistent width")
def test_predictable_text_width(self):
"""Test that text width is predictable based on character count."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
test_strings = [
"A",
"AB",
"ABC",
"ABCD",
"ABCDE",
"ABCDEFGHIJ",
"ABCDEFGHIJKLMNOPQRST"
]
for text_str in test_strings:
text_obj = Text(text_str, self.mono_font_12)
expected_width = len(text_str) * self.char_width_12
actual_width = text_obj.width
# Allow small variance for rendering differences
width_diff = abs(actual_width - expected_width)
print(f"Text '{text_str}': expected {expected_width}px, actual {actual_width}px, diff {width_diff}px")
self.assertLessEqual(width_diff, len(text_str) + 2,
f"Text width should be predictable for '{text_str}'")
def test_line_capacity_calculation(self):
"""Test that we can predict how many characters fit on a line."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Create lines of different widths
line_widths = [100, 200, 300, 500, 800]
for line_width in line_widths:
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(line_width, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Calculate expected capacity
# Account for spacing between words (minimum 3px)
chars_per_word = 10 # Average word length for estimation
word_width = chars_per_word * self.char_width_12
# Estimate how many words can fit
estimated_words = line_width // (word_width + 3) # +3 for minimum spacing
# Test by adding words until line is full
words_added = 0
test_word = "A" * chars_per_word # 10-character word
while True:
result = line.add_word(test_word, self.mono_font_12)
if result is not None: # Word didn't fit
break
words_added += 1
# Prevent infinite loop
if words_added > 50:
break
print(f"Line width {line_width}px: estimated {estimated_words} words, actual {words_added} words")
# The actual should be reasonably close to estimated
self.assertGreaterEqual(words_added, max(1, estimated_words - 2),
f"Should fit at least {max(1, estimated_words - 2)} words")
self.assertLessEqual(words_added, estimated_words + 2,
f"Should not fit more than {estimated_words + 2} words")
def test_word_breaking_behavior(self):
"""Test word breaking and hyphenation with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Create a narrow line that forces word breaking
narrow_width = self.char_width_12 * 15 # Space for about 15 characters
line = Line(
spacing=(2, 6),
origin=(0, 0),
size=(narrow_width, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Test with a long word that should be hyphenated
long_word = "supercalifragilisticexpialidocious" # 34 characters
result = line.add_word(long_word, self.mono_font_12)
# The word should be partially added (hyphenated) or rejected
if result is None:
# Word fit completely (shouldn't happen with our narrow line)
self.fail("Long word should not fit completely in narrow line")
else:
# Word was partially added or rejected
remaining_text = result
# Check that some text was added to the line
self.assertGreater(len(line.text_objects), 0, "Some text should be added to line")
# Check that remaining text is shorter than original
if remaining_text:
self.assertLess(len(remaining_text), len(long_word),
"Remaining text should be shorter than original")
print(f"Original word: '{long_word}' ({len(long_word)} chars)")
if line.text_objects:
added_text = line.text_objects[0].text
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
print(f"Remaining: '{remaining_text}' ({len(remaining_text)} chars)")
def test_alignment_with_monospace(self):
"""Test different alignment modes with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
line_width = self.char_width_12 * 20 # 20 characters wide
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
test_words = ["HELLO", "WORLD", "TEST"] # Known character counts: 5, 5, 4
for alignment in alignments:
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(line_width, 20),
font=self.mono_font_12,
halign=alignment
)
# Add all test words
for word in test_words:
result = line.add_word(word, self.mono_font_12)
if result is not None:
break # Word didn't fit
# Render the line to test alignment
line_image = line.render()
# Basic validation that line rendered successfully
self.assertIsInstance(line_image, Image.Image)
self.assertEqual(line_image.size, (line_width, 20))
print(f"Line with {alignment.name} alignment rendered successfully")
def test_hyphenation_points(self):
"""Test hyphenation at specific points with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Test words that should hyphenate at predictable points
test_cases = [
("hyphenation", ["hy-", "phen-", "ation"]), # Expected breaks
("computer", ["com-", "put-", "er"]),
("beautiful", ["beau-", "ti-", "ful"]),
("information", ["in-", "for-", "ma-", "tion"])
]
for word, expected_parts in test_cases:
# Create Word object for hyphenation testing
word_obj = Word(word, self.mono_font_12)
if word_obj.hyphenate():
parts_count = word_obj.get_hyphenated_part_count()
print(f"Word '{word}' hyphenated into {parts_count} parts:")
actual_parts = []
for i in range(parts_count):
part = word_obj.get_hyphenated_part(i)
actual_parts.append(part)
print(f" Part {i}: '{part}'")
# Verify that parts can be rendered and have expected widths
for part in actual_parts:
text_obj = Text(part, self.mono_font_12)
expected_width = len(part) * self.char_width_12
# Allow variance for hyphen and rendering differences
width_diff = abs(text_obj.width - expected_width)
self.assertLessEqual(width_diff, 13,
f"Hyphenated part '{part}' should have predictable width")
else:
print(f"Word '{word}' could not be hyphenated")
def test_line_overflow_scenarios(self):
"""Test various line overflow scenarios with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Test case 1: Single character that barely fits
char_line = Line(
spacing=(1, 3),
origin=(0, 0),
size=(self.char_width_12 + 2, 20), # Just enough for one character
font=self.mono_font_12,
halign=Alignment.LEFT
)
result = char_line.add_word("A", self.mono_font_12)
self.assertIsNone(result, "Single character should fit in character-sized line")
# Test case 2: Word that's exactly the line width
exact_width = self.char_width_12 * 5 # Exactly 5 characters
exact_line = Line(
spacing=(0, 2),
origin=(0, 0),
size=(exact_width, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
result = exact_line.add_word("HELLO", self.mono_font_12) # Exactly 5 characters
# This might fit or might not depending on margins - test that it behaves consistently
print(f"Word 'HELLO' in exact-width line: {'fit' if result is None else 'did not fit'}")
# Test case 3: Multiple short words vs one long word
multi_word_line = Line(
spacing=(3, 6),
origin=(0, 0),
size=(self.char_width_12 * 20, 20), # 20 characters
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Add multiple short words
short_words = ["CAT", "DOG", "BIRD", "FISH"] # 3 chars each
words_added = 0
for word in short_words:
result = multi_word_line.add_word(word, self.mono_font_12)
if result is not None:
break
words_added += 1
print(f"Added {words_added} short words to 20-character line")
# Should be able to add at least 2 words (3 chars + 3 spacing + 3 chars = 9 chars)
self.assertGreaterEqual(words_added, 2, "Should fit at least 2 short words")
def test_spacing_calculation_accuracy(self):
"""Test that spacing calculations are accurate with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
line_width = self.char_width_12 * 30 # 30 characters
# Test justify alignment which distributes spacing
justify_line = Line(
spacing=(2, 10),
origin=(0, 0),
size=(line_width, 20),
font=self.mono_font_12,
halign=Alignment.JUSTIFY
)
# Add words that should allow for even spacing
words = ["WORD", "WORD", "WORD"] # 3 words, 4 characters each = 12 characters
# Remaining space: 30 - 12 = 18 characters for spacing
# 2 spaces between 3 words = 9 characters per space
for word in words:
result = justify_line.add_word(word, self.mono_font_12)
if result is not None:
break
# Render and verify
line_image = justify_line.render()
self.assertIsInstance(line_image, Image.Image)
print(f"Justified line with calculated spacing rendered successfully")
# Test that text objects are positioned correctly
text_objects = justify_line.text_objects
if len(text_objects) >= 2:
# Calculate actual spacing between words
first_word_end = text_objects[0].width
second_word_start = 0 # This would need to be calculated from positioning
print(f"Added {len(text_objects)} words to justified line")
def save_test_output(self, test_name: str, image: Image.Image):
"""Save test output image for visual inspection."""
output_dir = "test_output"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
output_path = os.path.join(output_dir, f"monospace_{test_name}.png")
image.save(output_path)
print(f"Test output saved to: {output_path}")
def test_complete_paragraph_layout(self):
"""Test a complete paragraph layout with mono-space fonts."""
if self.monospace_font_path is None:
self.skipTest("No mono-space font available")
# Create a page for paragraph layout
page = Page(size=(800, 600))
# Test paragraph with known character counts
test_text = (
"This is a test paragraph with mono-space font rendering. "
"Each character should have exactly the same width, making "
"line breaking and text flow calculations predictable and "
"testable. We can verify that word wrapping occurs at the "
"expected positions based on character counts and spacing."
)
# Create container for the paragraph
paragraph_container = Container(
origin=(0, 0),
size=(400, 200), # Fixed width for predictable wrapping
direction='vertical',
spacing=2,
padding=(10, 10, 10, 10)
)
# Split text into words and create lines
words = test_text.split()
current_line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(380, 20), # 400 - 20 for padding
font=self.mono_font_12,
halign=Alignment.LEFT
)
lines_created = 0
words_processed = 0
for word in words:
result = current_line.add_word(word, self.mono_font_12)
if result is not None:
# Word didn't fit, start new line
if len(current_line.text_objects) > 0:
paragraph_container.add_child(current_line)
lines_created += 1
# Create new line
current_line = Line(
spacing=(3, 8),
origin=(0, lines_created * 22), # 20 height + 2 spacing
size=(380, 20),
font=self.mono_font_12,
halign=Alignment.LEFT
)
# Try to add the word to the new line
result = current_line.add_word(word, self.mono_font_12)
if result is not None:
# Word still doesn't fit, might need hyphenation
print(f"Warning: Word '{word}' doesn't fit even on new line")
else:
words_processed += 1
else:
words_processed += 1
# Add the last line if it has content
if len(current_line.text_objects) > 0:
paragraph_container.add_child(current_line)
lines_created += 1
# Add paragraph to page
page.add_child(paragraph_container)
# Render the complete page
page_image = page.render()
print(f"Paragraph layout: {words_processed}/{len(words)} words processed, {lines_created} lines created")
# Save output for visual inspection
self.save_test_output("paragraph_layout", page_image)
# Basic validation
self.assertGreater(lines_created, 1, "Should create multiple lines")
self.assertGreater(words_processed, len(words) * 0.8, "Should process most words")
if __name__ == '__main__':
# Create output directory for test results
if not os.path.exists("test_output"):
os.makedirs("test_output")
unittest.main(verbosity=2)
-299
View File
@@ -1,299 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for multi-line text rendering and line wrapping functionality.
"""
import unittest
import os
from PIL import Image, ImageDraw
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style import Font, FontStyle, FontWeight
from pyWebLayout.style.layout import Alignment
class TestMultilineRendering(unittest.TestCase):
"""Test cases for multi-line text rendering"""
def setUp(self):
"""Set up test fixtures"""
self.font_style = Font(
font_path=None,
font_size=12,
colour=(0, 0, 0, 255)
)
# Clean up any existing test images
self.test_images = []
def tearDown(self):
"""Clean up after tests"""
# Clean up test images
for img in self.test_images:
if os.path.exists(img):
os.remove(img)
def _create_multiline_test(self, sentence, line_width, line_height, font_size=14):
"""
Helper method to test rendering a sentence across multiple lines
Args:
sentence: The sentence to render
line_width: Width of each line in pixels
line_height: Height of each line in pixels
font_size: Font size to use
Returns:
tuple: (actual_lines_used, lines_list, combined_image)
"""
font_style = Font(
font_path=None,
font_size=font_size,
colour=(0, 0, 0, 255)
)
# Split sentence into words
words = sentence.split()
# Create lines and distribute words
lines = []
words_remaining = words.copy()
while words_remaining:
# Create a new line
current_line = Line(
spacing=(3, 8), # min, max spacing
origin=(0, len(lines) * line_height),
size=(line_width, line_height),
font=font_style,
halign=Alignment.LEFT
)
lines.append(current_line)
# Add words to current line until it's full
words_added_to_line = []
while words_remaining:
word = words_remaining[0]
result = current_line.add_word(word)
if result is None:
# Word fit in the line
words_added_to_line.append(word)
words_remaining.pop(0)
else:
# Word didn't fit, try next line
break
# If no words were added to this line, break to avoid infinite loop
if not words_added_to_line:
# Force add the word to avoid infinite loop
current_line.add_word(words_remaining[0])
words_remaining.pop(0)
# Create combined image showing all lines
total_height = len(lines) * line_height
combined_image = Image.new('RGBA', (line_width, total_height), (255, 255, 255, 255))
for i, line in enumerate(lines):
line_img = line.render()
y_pos = i * line_height
combined_image.paste(line_img, (0, y_pos), line_img)
# Add a subtle line border for visualization
draw = ImageDraw.Draw(combined_image)
draw.rectangle([(0, y_pos), (line_width-1, y_pos + line_height-1)], outline=(200, 200, 200), width=1)
return len(lines), lines, combined_image
def test_two_line_sentence(self):
"""Test sentence that should wrap to two lines"""
sentence = "This is a simple test sentence that should wrap to exactly two lines."
line_width = 200
expected_lines = 2
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Save test image
filename = "test_multiline_1_two_line_sentence.png"
combined_image.save(filename)
self.test_images.append(filename)
# Assertions
self.assertGreaterEqual(actual_lines, 1, "Should have at least one line")
self.assertLessEqual(actual_lines, 3, "Should not exceed 3 lines for this sentence")
self.assertTrue(os.path.exists(filename), "Test image should be created")
# Check that all lines have content
for i, line in enumerate(lines):
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
def test_three_line_sentence(self):
"""Test sentence that should wrap to three lines"""
sentence = "This is a much longer sentence that contains many more words and should definitely wrap across three lines when rendered with the specified width constraints."
line_width = 280
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Save test image
filename = "test_multiline_2_three_line_sentence.png"
combined_image.save(filename)
self.test_images.append(filename)
# Assertions
self.assertGreaterEqual(actual_lines, 2, "Should have at least two lines")
self.assertLessEqual(actual_lines, 5, "Should not exceed 5 lines for this sentence")
self.assertTrue(os.path.exists(filename), "Test image should be created")
def test_four_line_sentence(self):
"""Test sentence that should wrap to four lines"""
sentence = "Here we have an even longer sentence with significantly more content that will require four lines to properly display all the text when using the constrained width setting."
line_width = 200
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Save test image
filename = "test_multiline_3_four_line_sentence.png"
combined_image.save(filename)
self.test_images.append(filename)
# Assertions
self.assertGreaterEqual(actual_lines, 3, "Should have at least three lines")
self.assertLessEqual(actual_lines, 6, "Should not exceed 6 lines for this sentence")
self.assertTrue(os.path.exists(filename), "Test image should be created")
def test_single_line_sentence(self):
"""Test short sentence that should fit on one line"""
sentence = "Short sentence."
line_width = 300
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Save test image
filename = "test_multiline_4_single_line_sentence.png"
combined_image.save(filename)
self.test_images.append(filename)
# Assertions
self.assertEqual(actual_lines, 1, "Short sentence should fit on one line")
self.assertGreater(len(lines[0].text_objects), 0, "Line should have content")
self.assertTrue(os.path.exists(filename), "Test image should be created")
def test_long_words_sentence(self):
"""Test sentence with long words that might need special handling"""
sentence = "This sentence has some really long words like supercalifragilisticexpialidocious that might need hyphenation."
line_width = 150
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Save test image
filename = "test_multiline_5_sentence_with_long_words.png"
combined_image.save(filename)
self.test_images.append(filename)
# Assertions
self.assertGreaterEqual(actual_lines, 2, "Should have at least two lines")
self.assertTrue(os.path.exists(filename), "Test image should be created")
def test_fixed_width_scenarios(self):
"""Test specific width scenarios to verify line utilization"""
sentence = "The quick brown fox jumps over the lazy dog near the riverbank."
widths = [300, 200, 150, 100, 80]
for width in widths:
with self.subTest(width=width):
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, width, 20, font_size=12
)
# Assertions
self.assertGreater(actual_lines, 0, f"Should have lines for width {width}")
self.assertIsInstance(combined_image, Image.Image)
# Save test image
filename = f"test_width_{width}px.png"
combined_image.save(filename)
self.test_images.append(filename)
self.assertTrue(os.path.exists(filename), f"Test image should be created for width {width}")
# Check line utilization
for j, line in enumerate(lines):
self.assertGreater(len(line.text_objects), 0, f"Line {j+1} should have content")
self.assertGreaterEqual(line._current_width, 0, f"Line {j+1} should have positive width")
def test_line_word_distribution(self):
"""Test that words are properly distributed across lines"""
sentence = "This is a test sentence with several words to distribute."
line_width = 200
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Check that each line has words
total_words = 0
for i, line in enumerate(lines):
word_count = len(line.text_objects)
self.assertGreater(word_count, 0, f"Line {i+1} should have at least one word")
total_words += word_count
# Total words should match original sentence
original_words = len(sentence.split())
self.assertEqual(total_words, original_words, "All words should be distributed across lines")
def test_line_width_constraints(self):
"""Test that lines respect width constraints"""
sentence = "Testing width constraints with this sentence."
line_width = 150
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Check that no line exceeds the specified width (with some tolerance for edge cases)
for i, line in enumerate(lines):
# Current width should not significantly exceed line width
# Allow some tolerance for edge cases where words are force-fitted
self.assertLessEqual(line._current_width, line_width + 50,
f"Line {i+1} width should not significantly exceed limit")
def test_empty_sentence(self):
"""Test handling of empty sentence"""
sentence = ""
line_width = 200
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Should handle empty sentence gracefully
self.assertIsInstance(actual_lines, int)
self.assertIsInstance(lines, list)
self.assertIsInstance(combined_image, Image.Image)
def test_single_word_sentence(self):
"""Test handling of single word sentence"""
sentence = "Hello"
line_width = 200
actual_lines, lines, combined_image = self._create_multiline_test(
sentence, line_width, 25, font_size=12
)
# Single word should fit on one line
self.assertEqual(actual_lines, 1, "Single word should fit on one line")
self.assertEqual(len(lines[0].text_objects), 1, "Line should have exactly one word")
if __name__ == '__main__':
unittest.main()
-191
View File
@@ -1,191 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for the new external pagination system.
Tests the BlockPaginator and handler architecture to ensure it works correctly
with different block types using the unittest framework.
"""
import unittest
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.fonts import Font
from pyWebLayout.typesetting.block_pagination import BlockPaginator, PaginationResult
class TestNewPaginationSystem(unittest.TestCase):
"""Test cases for the new pagination system."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font(font_size=16)
self.heading_font = Font(font_size=20)
def create_test_paragraph(self, text: str, font: Font = None) -> Paragraph:
"""Create a test paragraph with the given text."""
if font is None:
font = self.font
paragraph = Paragraph(font)
words = text.split()
for word_text in words:
word = Word(word_text, font)
paragraph.add_word(word)
return paragraph
def create_test_heading(self, text: str, level: HeadingLevel = HeadingLevel.H1) -> Heading:
"""Create a test heading with the given text."""
heading = Heading(level, self.heading_font)
words = text.split()
for word_text in words:
word = Word(word_text, self.heading_font)
heading.add_word(word)
return heading
def test_paragraph_pagination(self):
"""Test paragraph pagination with line breaking."""
# Create a long paragraph
long_text = " ".join(["This is a very long paragraph that should be broken across multiple lines."] * 10)
paragraph = self.create_test_paragraph(long_text)
# Create a page with limited height
page = Page(size=(400, 200)) # Small page
# Test the pagination handler
paginator = BlockPaginator()
result = paginator.paginate_block(paragraph, page, available_height=100)
# Assertions
self.assertIsInstance(result, PaginationResult)
self.assertIsInstance(result.success, bool)
self.assertIsInstance(result.height_used, (int, float))
self.assertGreaterEqual(result.height_used, 0)
def test_page_filling(self):
"""Test filling a page with multiple blocks."""
# Create test blocks
blocks = [
self.create_test_heading("Chapter 1: Introduction"),
self.create_test_paragraph("This is the first paragraph of the chapter. It contains some introductory text."),
self.create_test_paragraph("This is the second paragraph. It has more content and should flow nicely."),
self.create_test_heading("Section 1.1: Overview", HeadingLevel.H2),
self.create_test_paragraph("This is a paragraph under the section. It has even more content that might not fit on the same page."),
self.create_test_paragraph("This is another long paragraph that definitely won't fit. " * 20),
]
# Create a page
page = Page(size=(600, 400))
# Fill the page
next_index, remainder_blocks = page.fill_with_blocks(blocks)
# Assertions
self.assertIsInstance(next_index, int)
self.assertGreaterEqual(next_index, 0)
self.assertLessEqual(next_index, len(blocks))
self.assertIsInstance(remainder_blocks, list)
self.assertGreaterEqual(len(page._children), 0)
# Try to render the page
try:
page_image = page.render()
self.assertIsNotNone(page_image)
self.assertEqual(len(page_image.size), 2) # Should have width and height
except Exception as e:
self.fail(f"Page rendering failed: {e}")
def test_multi_page_creation(self):
"""Test creating multiple pages from a list of blocks."""
# Create many test blocks
blocks = []
for i in range(5): # Reduced for faster testing
blocks.append(self.create_test_heading(f"Chapter {i+1}"))
for j in range(2): # Reduced for faster testing
long_text = f"This is paragraph {j+1} of chapter {i+1}. " * 10
blocks.append(self.create_test_paragraph(long_text))
self.assertGreater(len(blocks), 0)
# Create pages until all blocks are processed
pages = []
remaining_blocks = blocks
page_count = 0
while remaining_blocks and page_count < 10: # Safety limit
page = Page(size=(600, 400))
next_index, remainder_blocks = page.fill_with_blocks(remaining_blocks)
if page._children:
pages.append(page)
page_count += 1
# Update remaining blocks
if remainder_blocks:
remaining_blocks = remainder_blocks
elif next_index < len(remaining_blocks):
remaining_blocks = remaining_blocks[next_index:]
else:
remaining_blocks = []
# Safety check to prevent infinite loops
if not page._children and remaining_blocks:
break
# Assertions
self.assertGreater(len(pages), 0, "Should create at least one page")
self.assertLessEqual(page_count, 10, "Should not exceed safety limit")
# Try to render a few pages
rendered_count = 0
for page in pages[:2]: # Test first 2 pages
try:
page_image = page.render()
rendered_count += 1
self.assertIsNotNone(page_image)
except Exception as e:
self.fail(f"Page rendering failed: {e}")
self.assertGreater(rendered_count, 0, "Should render at least one page")
def test_empty_blocks_list(self):
"""Test handling of empty blocks list."""
page = Page(size=(600, 400))
next_index, remainder_blocks = page.fill_with_blocks([])
self.assertEqual(next_index, 0)
self.assertEqual(len(remainder_blocks), 0)
self.assertEqual(len(page._children), 0)
def test_single_block(self):
"""Test handling of single block."""
blocks = [self.create_test_paragraph("Single paragraph test.")]
page = Page(size=(600, 400))
next_index, remainder_blocks = page.fill_with_blocks(blocks)
self.assertEqual(next_index, 1)
self.assertEqual(len(remainder_blocks), 0)
self.assertGreater(len(page._children), 0)
def test_pagination_result_properties(self):
"""Test PaginationResult object properties."""
paragraph = self.create_test_paragraph("Test paragraph for pagination result.")
page = Page(size=(400, 200))
paginator = BlockPaginator()
result = paginator.paginate_block(paragraph, page, available_height=100)
# Test that result has expected properties
self.assertTrue(hasattr(result, 'success'))
self.assertTrue(hasattr(result, 'height_used'))
self.assertTrue(hasattr(result, 'remainder'))
self.assertTrue(hasattr(result, 'can_continue'))
if __name__ == '__main__':
unittest.main()
-257
View File
@@ -1,257 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for paragraph layout fixes.
Tests the paragraph layout system and page rendering functionality.
"""
import unittest
import os
from PIL import Image
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.fonts import Font
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout
from pyWebLayout.style.layout import Alignment
class TestParagraphLayoutFix(unittest.TestCase):
"""Test cases for paragraph layout fixes"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(font_size=14)
self.words_text = [
"This", "is", "a", "very", "long", "paragraph", "that", "should",
"definitely", "wrap", "across", "multiple", "lines", "when", "rendered",
"in", "a", "narrow", "width", "container", "to", "test", "the",
"paragraph", "layout", "system", "and", "ensure", "proper", "line",
"breaking", "functionality", "works", "correctly", "as", "expected."
]
# Clean up any existing test images
test_images = [
"test_paragraph_layout_output.png",
"test_small_page.png",
"test_large_page.png"
]
for img in test_images:
if os.path.exists(img):
os.remove(img)
def tearDown(self):
"""Clean up after tests"""
# Clean up test images after each test
test_images = [
"test_paragraph_layout_output.png",
"test_small_page.png",
"test_large_page.png"
]
for img in test_images:
if os.path.exists(img):
os.remove(img)
def test_paragraph_layout_directly(self):
"""Test the paragraph layout system directly"""
# Create a paragraph with multiple words
paragraph = Paragraph()
# Add many words to force line breaking
for word_text in self.words_text:
word = Word(word_text, self.font)
paragraph.add_word(word)
# Create paragraph layout with narrow width to force wrapping
layout = ParagraphLayout(
line_width=300, # Narrow width
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=Alignment.LEFT
)
# Layout the paragraph
lines = layout.layout_paragraph(paragraph)
# Assertions
self.assertGreater(len(lines), 1, "Should have multiple lines")
self.assertIsInstance(lines, list)
# Check each line has content
for i, line in enumerate(lines):
if hasattr(line, 'text_objects'):
word_count = len(line.text_objects)
self.assertGreater(word_count, 0, f"Line {i+1} should have words")
def test_page_with_long_paragraph(self):
"""Test a page with manual content creation"""
# Since Page doesn't support HTML loading, test basic page functionality
# Create a page with narrower width
page = Page(size=(400, 600))
# Verify page creation
self.assertEqual(page._size[0], 400)
self.assertEqual(page._size[1], 600)
self.assertIsInstance(page._children, list)
# Try to render the empty page
image = page.render()
# Assertions
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (400, 600))
# Save for inspection
image.save("test_paragraph_layout_output.png")
self.assertTrue(os.path.exists("test_paragraph_layout_output.png"))
def test_simple_text_vs_paragraph(self):
"""Test different page configurations"""
# Test 1: Small page
page1 = Page(size=(400, 200))
self.assertIsInstance(page1._children, list)
# Test 2: Large page
page2 = Page(size=(800, 400))
self.assertIsInstance(page2._children, list)
# Render both
img1 = page1.render()
img2 = page2.render()
# Verify renders
self.assertIsInstance(img1, Image.Image)
self.assertIsInstance(img2, Image.Image)
self.assertEqual(img1.size, (400, 200))
self.assertEqual(img2.size, (800, 400))
# Save images
img1.save("test_small_page.png")
img2.save("test_large_page.png")
# Verify files were created
self.assertTrue(os.path.exists("test_small_page.png"))
self.assertTrue(os.path.exists("test_large_page.png"))
def test_paragraph_creation_with_words(self):
"""Test creating paragraphs with multiple words"""
paragraph = Paragraph()
# Add words to paragraph
for word_text in self.words_text[:5]: # Use first 5 words
word = Word(word_text, self.font)
paragraph.add_word(word)
# Verify paragraph has words
self.assertGreater(len(paragraph._words), 0)
def test_paragraph_layout_configuration(self):
"""Test different paragraph layout configurations"""
layouts = [
# Wide layout
ParagraphLayout(
line_width=600,
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=Alignment.LEFT
),
# Narrow layout
ParagraphLayout(
line_width=200,
line_height=16,
word_spacing=(2, 6),
line_spacing=2,
halign=Alignment.CENTER
),
# Justified layout
ParagraphLayout(
line_width=400,
line_height=18,
word_spacing=(4, 10),
line_spacing=4,
halign=Alignment.JUSTIFY
)
]
# Create test paragraph
paragraph = Paragraph()
for word_text in self.words_text[:10]: # Use first 10 words
word = Word(word_text, self.font)
paragraph.add_word(word)
# Test each layout
for i, layout in enumerate(layouts):
with self.subTest(layout=i):
lines = layout.layout_paragraph(paragraph)
self.assertIsInstance(lines, list)
if len(paragraph._words) > 0:
self.assertGreater(len(lines), 0, f"Layout {i} should produce lines")
def test_empty_paragraph_layout(self):
"""Test laying out an empty paragraph"""
paragraph = Paragraph()
layout = ParagraphLayout(
line_width=300,
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=Alignment.LEFT
)
lines = layout.layout_paragraph(paragraph)
# Empty paragraph should still return a list (might be empty)
self.assertIsInstance(lines, list)
def test_single_word_paragraph(self):
"""Test paragraph with single word"""
paragraph = Paragraph()
word = Word("Hello", self.font)
paragraph.add_word(word)
layout = ParagraphLayout(
line_width=300,
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=Alignment.LEFT
)
lines = layout.layout_paragraph(paragraph)
# Single word should produce at least one line
self.assertGreater(len(lines), 0)
if len(lines) > 0 and hasattr(lines[0], 'text_objects'):
self.assertGreater(len(lines[0].text_objects), 0)
def test_different_alignments(self):
"""Test paragraph layout with different alignments"""
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
# Create test paragraph
paragraph = Paragraph()
for word_text in self.words_text[:8]:
word = Word(word_text, self.font)
paragraph.add_word(word)
for alignment in alignments:
with self.subTest(alignment=alignment):
layout = ParagraphLayout(
line_width=300,
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=alignment
)
lines = layout.layout_paragraph(paragraph)
self.assertIsInstance(lines, list)
if len(paragraph._words) > 0:
self.assertGreater(len(lines), 0)
if __name__ == '__main__':
unittest.main()
-402
View File
@@ -1,402 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for the paragraph layout system with pagination and state management.
"""
import unittest
import os
from PIL import Image, ImageDraw
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphRenderingState, ParagraphLayoutResult
from pyWebLayout.style.layout import Alignment
class TestParagraphLayoutSystem(unittest.TestCase):
"""Test cases for the paragraph layout system"""
def setUp(self):
"""Set up test fixtures"""
self.font_style = Font(
font_path=None,
font_size=12,
colour=(0, 0, 0, 255)
)
# Clean up any existing test images
self.test_images = []
def tearDown(self):
"""Clean up after tests"""
# Clean up test images
for img in self.test_images:
if os.path.exists(img):
os.remove(img)
def _create_test_paragraph(self, text: str) -> Paragraph:
"""Helper method to create a test paragraph with the given text."""
paragraph = Paragraph(style=self.font_style)
# Split text into words and add them to the paragraph
words = text.split()
for word_text in words:
word = Word(word_text, self.font_style)
paragraph.add_word(word)
return paragraph
def test_basic_paragraph_layout(self):
"""Test basic paragraph layout without height constraints."""
text = "This is a test paragraph that should be laid out across multiple lines based on the available width."
paragraph = self._create_test_paragraph(text)
# Create layout manager
layout = ParagraphLayout(
line_width=200,
line_height=20,
word_spacing=(3, 8),
line_spacing=2,
halign=Alignment.LEFT
)
# Layout the paragraph
lines = layout.layout_paragraph(paragraph)
# Assertions
self.assertIsInstance(lines, list)
self.assertGreater(len(lines), 0, "Should generate at least one line")
# Check that all lines have content
for i, line in enumerate(lines):
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
# Calculate total height
total_height = layout.calculate_paragraph_height(paragraph)
self.assertGreater(total_height, 0, "Total height should be positive")
# Create visual representation
if lines:
canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255))
for i, line in enumerate(lines):
line_img = line.render()
y_pos = i * (layout.line_height + layout.line_spacing)
canvas.paste(line_img, (0, y_pos), line_img)
filename = "test_basic_paragraph_layout.png"
canvas.save(filename)
self.test_images.append(filename)
self.assertTrue(os.path.exists(filename), "Test image should be created")
def test_pagination_with_height_constraint(self):
"""Test paragraph layout with height constraints (pagination)."""
text = "This is a much longer paragraph that will definitely need to be split across multiple pages. It contains many words and should demonstrate how the pagination system works when we have height constraints. The system should be able to break the paragraph at appropriate points and provide information about remaining content that needs to be rendered on subsequent pages."
paragraph = self._create_test_paragraph(text)
layout = ParagraphLayout(
line_width=180,
line_height=18,
word_spacing=(2, 6),
line_spacing=3,
halign=Alignment.LEFT
)
# Test with different page heights
page_heights = [60, 100, 150]
for page_height in page_heights:
with self.subTest(page_height=page_height):
result = layout.layout_paragraph_with_pagination(paragraph, page_height)
# Assertions
self.assertIsInstance(result, ParagraphLayoutResult)
self.assertIsInstance(result.lines, list)
self.assertGreaterEqual(result.total_height, 0)
self.assertIsInstance(result.is_complete, bool)
if result.state:
self.assertIsInstance(result.state, ParagraphRenderingState)
self.assertGreaterEqual(result.state.current_word_index, 0)
self.assertGreaterEqual(result.state.current_char_index, 0)
self.assertGreaterEqual(result.state.rendered_lines, 0)
# Create visual representation
if result.lines:
canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
# Add a border to show the page boundary
draw = ImageDraw.Draw(canvas)
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
for i, line in enumerate(result.lines):
line_img = line.render()
y_pos = i * (layout.line_height + layout.line_spacing)
if y_pos + layout.line_height <= page_height:
canvas.paste(line_img, (0, y_pos), line_img)
filename = f"test_pagination_{page_height}px.png"
canvas.save(filename)
self.test_images.append(filename)
self.assertTrue(os.path.exists(filename), f"Test image should be created for page height {page_height}")
def test_state_management(self):
"""Test state saving and restoration for resumable rendering."""
text = "This is a test of the state management system. We will render part of this paragraph, save the state, and then continue rendering from where we left off. This demonstrates how the system can handle interruptions and resume rendering later."
paragraph = self._create_test_paragraph(text)
layout = ParagraphLayout(
line_width=150,
line_height=16,
word_spacing=(2, 5),
line_spacing=2,
halign=Alignment.LEFT
)
# First page - render with height constraint
page_height = 50
result1 = layout.layout_paragraph_with_pagination(paragraph, page_height)
# Assertions for first page
self.assertIsInstance(result1, ParagraphLayoutResult)
self.assertGreater(len(result1.lines), 0, "First page should have lines")
if result1.state:
# Save the state
state_json = result1.state.to_json()
self.assertIsInstance(state_json, str, "State should serialize to JSON string")
# Create image for first page
if result1.lines:
canvas1 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
draw = ImageDraw.Draw(canvas1)
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
for i, line in enumerate(result1.lines):
line_img = line.render()
y_pos = i * (layout.line_height + layout.line_spacing)
canvas1.paste(line_img, (0, y_pos), line_img)
filename1 = "test_state_page1.png"
canvas1.save(filename1)
self.test_images.append(filename1)
self.assertTrue(os.path.exists(filename1), "First page image should be created")
# Continue from saved state on second page
if not result1.is_complete and result1.remaining_paragraph:
# Restore state
restored_state = ParagraphRenderingState.from_json(state_json)
self.assertIsInstance(restored_state, ParagraphRenderingState)
self.assertEqual(restored_state.current_word_index, result1.state.current_word_index)
self.assertEqual(restored_state.current_char_index, result1.state.current_char_index)
# Continue rendering
result2 = layout.layout_paragraph_with_pagination(result1.remaining_paragraph, page_height)
self.assertIsInstance(result2, ParagraphLayoutResult)
# Create image for second page
if result2.lines:
canvas2 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
draw = ImageDraw.Draw(canvas2)
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
for i, line in enumerate(result2.lines):
line_img = line.render()
y_pos = i * (layout.line_height + layout.line_spacing)
canvas2.paste(line_img, (0, y_pos), line_img)
filename2 = "test_state_page2.png"
canvas2.save(filename2)
self.test_images.append(filename2)
self.assertTrue(os.path.exists(filename2), "Second page image should be created")
def test_long_word_handling(self):
"""Test handling of long words that require force-fitting."""
text = "This paragraph contains supercalifragilisticexpialidocious and other extraordinarily long words that should be handled gracefully."
paragraph = self._create_test_paragraph(text)
layout = ParagraphLayout(
line_width=120, # Narrow width to force long word issues
line_height=18,
word_spacing=(2, 5),
line_spacing=2,
halign=Alignment.LEFT
)
result = layout.layout_paragraph_with_pagination(paragraph, 200) # Generous height
# Assertions
self.assertIsInstance(result, ParagraphLayoutResult)
self.assertGreater(len(result.lines), 0, "Should generate lines even with long words")
self.assertIsInstance(result.is_complete, bool)
# Verify that all lines have content
for i, line in enumerate(result.lines):
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
# Create visual representation
if result.lines:
total_height = len(result.lines) * (layout.line_height + layout.line_spacing)
canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255))
for i, line in enumerate(result.lines):
line_img = line.render()
y_pos = i * (layout.line_height + layout.line_spacing)
canvas.paste(line_img, (0, y_pos), line_img)
filename = "test_long_word_handling.png"
canvas.save(filename)
self.test_images.append(filename)
self.assertTrue(os.path.exists(filename), "Long word test image should be created")
def test_multiple_page_scenario(self):
"""Test a realistic multi-page scenario."""
text = """This is a comprehensive test of the paragraph layout system with pagination support.
The system needs to handle various scenarios including normal word wrapping, hyphenation of long words,
state management for resumable rendering, and proper text flow across multiple pages.
When a paragraph is too long to fit on a single page, the system should break it at appropriate
points and maintain state information so that rendering can be resumed on the next page.
This is essential for document processing applications where content needs to be paginated
across multiple pages or screens.
The system also needs to handle edge cases such as very long words that don't fit on a single line,
ensuring that no text is lost and that the rendering process can continue gracefully even
when encountering challenging content.""".replace('\n', ' ').replace(' ', ' ')
paragraph = self._create_test_paragraph(text)
layout = ParagraphLayout(
line_width=200,
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=Alignment.JUSTIFY
)
page_height = 80 # Small pages to force pagination
pages = []
current_paragraph = paragraph
page_num = 1
while current_paragraph and page_num <= 10: # Safety limit
result = layout.layout_paragraph_with_pagination(current_paragraph, page_height)
# Assertions
self.assertIsInstance(result, ParagraphLayoutResult)
if result.lines:
# Create page image
canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
draw = ImageDraw.Draw(canvas)
# Page border
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(100, 100, 100), width=1)
# Page number
draw.text((5, page_height-15), f"Page {page_num}", fill=(150, 150, 150))
# Content
for i, line in enumerate(result.lines):
line_img = line.render()
y_pos = i * (layout.line_height + layout.line_spacing)
if y_pos + layout.line_height <= page_height - 20: # Leave space for page number
canvas.paste(line_img, (0, y_pos), line_img)
pages.append(canvas)
filename = f"test_multipage_page_{page_num}.png"
canvas.save(filename)
self.test_images.append(filename)
self.assertTrue(os.path.exists(filename), f"Page {page_num} image should be created")
# Continue with remaining content
current_paragraph = result.remaining_paragraph
page_num += 1
# Assertions
self.assertGreater(len(pages), 1, "Should generate multiple pages")
self.assertLessEqual(page_num, 11, "Should not exceed safety limit")
def test_empty_paragraph(self):
"""Test handling of empty paragraph"""
paragraph = self._create_test_paragraph("")
layout = ParagraphLayout(
line_width=200,
line_height=20,
word_spacing=(3, 8),
line_spacing=2,
halign=Alignment.LEFT
)
lines = layout.layout_paragraph(paragraph)
# Should handle empty paragraph gracefully
self.assertIsInstance(lines, list)
def test_single_word_paragraph(self):
"""Test paragraph with single word"""
paragraph = self._create_test_paragraph("Hello")
layout = ParagraphLayout(
line_width=200,
line_height=20,
word_spacing=(3, 8),
line_spacing=2,
halign=Alignment.LEFT
)
lines = layout.layout_paragraph(paragraph)
# Single word should produce one line
self.assertGreater(len(lines), 0, "Single word should produce at least one line")
if len(lines) > 0:
self.assertGreater(len(lines[0].text_objects), 0, "Line should have content")
def test_different_alignments(self):
"""Test paragraph layout with different alignments"""
text = "This is a test paragraph for alignment testing with multiple words."
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
for alignment in alignments:
with self.subTest(alignment=alignment):
paragraph = self._create_test_paragraph(text)
layout = ParagraphLayout(
line_width=200,
line_height=20,
word_spacing=(3, 8),
line_spacing=2,
halign=alignment
)
lines = layout.layout_paragraph(paragraph)
# Should generate lines regardless of alignment
self.assertIsInstance(lines, list)
if len(paragraph._words) > 0:
self.assertGreater(len(lines), 0, f"Should generate lines for {alignment}")
def test_calculate_paragraph_height(self):
"""Test paragraph height calculation"""
text = "This is a test paragraph for height calculation."
paragraph = self._create_test_paragraph(text)
layout = ParagraphLayout(
line_width=200,
line_height=20,
word_spacing=(3, 8),
line_spacing=2,
halign=Alignment.LEFT
)
height = layout.calculate_paragraph_height(paragraph)
# Height should be positive
self.assertGreater(height, 0, "Paragraph height should be positive")
self.assertIsInstance(height, (int, float))
if __name__ == '__main__':
unittest.main()
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
"""
Test runner for pyWebLayout.
This script runs all unit tests and provides a summary of results.
"""
import unittest
import sys
import os
# Add the project root to the Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def run_all_tests():
"""Run all unit tests and return the result."""
# Discover and run all tests
loader = unittest.TestLoader()
start_dir = os.path.dirname(os.path.abspath(__file__))
suite = loader.discover(start_dir, pattern='test_*.py')
# Run tests with detailed output
runner = unittest.TextTestRunner(
verbosity=2,
stream=sys.stdout,
descriptions=True,
failfast=False
)
result = runner.run(suite)
# Print summary
print("\n" + "="*70)
print("TEST SUMMARY")
print("="*70)
print(f"Tests run: {result.testsRun}")
print(f"Failures: {len(result.failures)}")
print(f"Errors: {len(result.errors)}")
print(f"Skipped: {len(result.skipped) if hasattr(result, 'skipped') else 0}")
if result.failures:
print(f"\nFAILURES ({len(result.failures)}):")
for test, traceback in result.failures:
print(f"- {test}")
if result.errors:
print(f"\nERRORS ({len(result.errors)}):")
for test, traceback in result.errors:
print(f"- {test}")
success = len(result.failures) == 0 and len(result.errors) == 0
print(f"\nResult: {'PASSED' if success else 'FAILED'}")
print("="*70)
return success
def run_specific_test(test_module):
"""Run a specific test module."""
loader = unittest.TestLoader()
suite = loader.loadTestsFromName(test_module)
runner = unittest.TextTestRunner(verbosity=2)
result = runner.run(suite)
return len(result.failures) == 0 and len(result.errors) == 0
if __name__ == '__main__':
if len(sys.argv) > 1:
# Run specific test
test_name = sys.argv[1]
if not test_name.startswith('test_'):
test_name = f'test_{test_name}'
if not test_name.endswith('.py'):
test_name = f'{test_name}.py'
module_name = test_name[:-3] # Remove .py extension
success = run_specific_test(module_name)
else:
# Run all tests
success = run_all_tests()
sys.exit(0 if success else 1)
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for simple pagination logic without EPUB dependencies.
Tests basic pagination functionality using the unittest framework.
"""
import unittest
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
class TestSimplePagination(unittest.TestCase):
"""Test cases for simple pagination functionality."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font(font_size=16)
self.page_size = (700, 550)
self.max_page_height = 510 # Leave room for padding
def create_test_paragraph(self, text_content: str) -> Paragraph:
"""Create a test paragraph with the given text."""
paragraph = Paragraph()
words = text_content.split()
for word_text in words:
word = Word(word_text, self.font)
paragraph.add_word(word)
return paragraph
def test_single_paragraph_pagination(self):
"""Test pagination with a single paragraph."""
text = "This is a simple paragraph for testing pagination functionality."
paragraph = self.create_test_paragraph(text)
page = Page(size=self.page_size)
# Convert block to renderable
renderable = page._convert_block_to_renderable(paragraph)
self.assertIsNotNone(renderable, "Should convert paragraph to renderable")
# Add to page
page.add_child(renderable)
self.assertEqual(len(page._children), 1)
# Layout should work
try:
page.layout()
except Exception as e:
self.fail(f"Layout failed: {e}")
# Render should work
try:
rendered_image = page.render()
self.assertIsNotNone(rendered_image)
self.assertEqual(rendered_image.size, self.page_size)
except Exception as e:
self.fail(f"Render failed: {e}")
def test_multiple_paragraphs_same_page(self):
"""Test adding multiple small paragraphs to the same page."""
paragraphs = [
"First short paragraph.",
"Second short paragraph.",
"Third short paragraph."
]
page = Page(size=self.page_size)
for i, text in enumerate(paragraphs):
paragraph = self.create_test_paragraph(text)
renderable = page._convert_block_to_renderable(paragraph)
self.assertIsNotNone(renderable, f"Should convert paragraph {i+1}")
page.add_child(renderable)
self.assertEqual(len(page._children), len(paragraphs))
# Layout should work with multiple children
try:
page.layout()
except Exception as e:
self.fail(f"Layout with multiple paragraphs failed: {e}")
# Calculate page height
max_bottom = self.calculate_page_height(page)
self.assertLessEqual(max_bottom, self.max_page_height, "Page should not exceed height limit")
def test_page_overflow_detection(self):
"""Test detection of page overflow."""
# Create a very long paragraph that should cause overflow
long_text = " ".join(["This is a very long paragraph with many words."] * 20)
paragraph = self.create_test_paragraph(long_text)
page = Page(size=self.page_size)
renderable = page._convert_block_to_renderable(paragraph)
page.add_child(renderable)
try:
page.layout()
max_bottom = self.calculate_page_height(page)
# Very long content might exceed page height
# This is expected behavior for testing overflow detection
self.assertIsInstance(max_bottom, (int, float))
except Exception as e:
# Layout might fail with very long content, which is acceptable
self.assertIsInstance(e, Exception)
def test_page_height_calculation(self):
"""Test page height calculation method."""
page = Page(size=self.page_size)
# Empty page should have height 0
height = self.calculate_page_height(page)
self.assertEqual(height, 0)
# Add content and check height increases
paragraph = self.create_test_paragraph("Test content for height calculation.")
renderable = page._convert_block_to_renderable(paragraph)
page.add_child(renderable)
page.layout()
height_with_content = self.calculate_page_height(page)
self.assertGreater(height_with_content, 0)
def test_multi_page_scenario(self):
"""Test creating multiple pages from content."""
# Create test content
test_paragraphs = [
"This is the first paragraph with some content.",
"Here is a second paragraph with different content.",
"The third paragraph continues with more text.",
"Fourth paragraph here with additional content.",
"Fifth paragraph with even more content for testing."
]
pages = []
current_page = Page(size=self.page_size)
for i, text in enumerate(test_paragraphs):
paragraph = self.create_test_paragraph(text)
renderable = current_page._convert_block_to_renderable(paragraph)
if renderable:
# Store current state for potential rollback
children_backup = current_page._children.copy()
# Add to current page
current_page.add_child(renderable)
try:
current_page.layout()
max_bottom = self.calculate_page_height(current_page)
# Check if page is too full
if max_bottom > self.max_page_height and len(current_page._children) > 1:
# Rollback and start new page
current_page._children = children_backup
pages.append(current_page)
# Start new page with current content
current_page = Page(size=self.page_size)
current_page.add_child(renderable)
current_page.layout()
except Exception:
# Layout failed, rollback
current_page._children = children_backup
# Add final page if it has content
if current_page._children:
pages.append(current_page)
# Assertions
self.assertGreater(len(pages), 0, "Should create at least one page")
# Test rendering all pages
for i, page in enumerate(pages):
with self.subTest(page=i+1):
self.assertGreater(len(page._children), 0, f"Page {i+1} should have content")
try:
rendered_image = page.render()
self.assertIsNotNone(rendered_image)
self.assertEqual(rendered_image.size, self.page_size)
except Exception as e:
self.fail(f"Page {i+1} render failed: {e}")
def test_empty_paragraph_handling(self):
"""Test handling of empty paragraphs."""
empty_paragraph = self.create_test_paragraph("")
page = Page(size=self.page_size)
# Empty paragraph should still be convertible
renderable = page._convert_block_to_renderable(empty_paragraph)
if renderable: # Some implementations might return None for empty content
page.add_child(renderable)
try:
page.layout()
rendered_image = page.render()
self.assertIsNotNone(rendered_image)
except Exception as e:
self.fail(f"Empty paragraph handling failed: {e}")
def test_conversion_error_handling(self):
"""Test handling of blocks that can't be converted."""
paragraph = self.create_test_paragraph("Test content")
page = Page(size=self.page_size)
# This should normally work
renderable = page._convert_block_to_renderable(paragraph)
self.assertIsNotNone(renderable, "Normal paragraph should convert successfully")
def calculate_page_height(self, page):
"""Helper method to calculate current page height."""
max_bottom = 0
for child in page._children:
if hasattr(child, '_origin') and hasattr(child, '_size'):
child_bottom = child._origin[1] + child._size[1]
max_bottom = max(max_bottom, child_bottom)
return max_bottom
if __name__ == '__main__':
unittest.main()
-285
View File
@@ -1,285 +0,0 @@
#!/usr/bin/env python3
"""
Unit tests for text rendering fixes.
Tests the fixes for text cropping and line length issues.
"""
import unittest
import os
from PIL import Image, ImageFont
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.style import Font, FontStyle, FontWeight
from pyWebLayout.style.layout import Alignment
class TestTextRenderingFix(unittest.TestCase):
"""Test cases for text rendering fixes"""
def setUp(self):
"""Set up test fixtures"""
self.font_style = Font(
font_path=None, # Use default font
font_size=16,
colour=(0, 0, 0, 255),
weight=FontWeight.NORMAL,
style=FontStyle.NORMAL
)
# Clean up any existing test images
self.test_images = []
def tearDown(self):
"""Clean up after tests"""
# Clean up test images
for img in self.test_images:
if os.path.exists(img):
os.remove(img)
def test_text_cropping_fix(self):
"""Test that text is no longer cropped at the beginning and end"""
# Test with text that might have overhang (like italic or characters with descenders)
test_texts = [
"Hello World!",
"Typography",
"gjpqy", # Characters with descenders
"AWVT", # Characters that might have overhang
"Italic Text"
]
for i, text_content in enumerate(test_texts):
with self.subTest(text=text_content):
text = Text(text_content, self.font_style)
# Verify dimensions are reasonable
self.assertGreater(text.width, 0, f"Text '{text_content}' should have positive width")
self.assertGreater(text.height, 0, f"Text '{text_content}' should have positive height")
# Render the text
rendered = text.render()
# Verify rendered image
self.assertIsInstance(rendered, Image.Image)
self.assertGreater(rendered.size[0], 0, "Rendered image should have positive width")
self.assertGreater(rendered.size[1], 0, "Rendered image should have positive height")
# Save for visual inspection
output_path = f"test_text_{i}_{text_content.replace(' ', '_').replace('!', '')}.png"
rendered.save(output_path)
self.test_images.append(output_path)
self.assertTrue(os.path.exists(output_path), f"Test image should be created for '{text_content}'")
def test_line_length_fix(self):
"""Test that lines are using the full available width properly"""
font_style = Font(
font_path=None,
font_size=14,
colour=(0, 0, 0, 255)
)
# Create a line with specific width
line_width = 300
line_height = 20
spacing = (5, 10) # min, max spacing
line = Line(
spacing=spacing,
origin=(0, 0),
size=(line_width, line_height),
font=font_style,
halign=Alignment.LEFT
)
# Add words to the line
words = ["This", "is", "a", "test", "of", "line", "length", "calculation"]
words_added = 0
for word in words:
result = line.add_word(word)
if result:
# Word didn't fit
break
else:
words_added += 1
# Assertions
self.assertGreater(words_added, 0, "Should have added at least one word")
self.assertGreaterEqual(line._current_width, 0, "Line width should be non-negative")
self.assertLessEqual(line._current_width, line_width, "Line width should not exceed maximum")
# Render the line
rendered_line = line.render()
self.assertIsInstance(rendered_line, Image.Image)
self.assertEqual(rendered_line.size, (line_width, line_height))
# Save for inspection
output_path = "test_line_length.png"
rendered_line.save(output_path)
self.test_images.append(output_path)
self.assertTrue(os.path.exists(output_path), "Line test image should be created")
def test_justification(self):
"""Test text justification to ensure proper spacing"""
font_style = Font(
font_path=None,
font_size=12,
colour=(0, 0, 0, 255)
)
alignments = [
(Alignment.LEFT, "left"),
(Alignment.CENTER, "center"),
(Alignment.RIGHT, "right"),
(Alignment.JUSTIFY, "justify")
]
for alignment, name in alignments:
with self.subTest(alignment=name):
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(250, 18),
font=font_style,
halign=alignment
)
# Add some words
words = ["Testing", "text", "alignment", "and", "spacing"]
for word in words:
line.add_word(word)
# Verify line has content
self.assertGreater(len(line.text_objects), 0, f"{name} alignment should have text objects")
# Render and verify
rendered = line.render()
self.assertIsInstance(rendered, Image.Image)
self.assertEqual(rendered.size, (250, 18))
# Save for inspection
output_path = f"test_alignment_{name}.png"
rendered.save(output_path)
self.test_images.append(output_path)
self.assertTrue(os.path.exists(output_path), f"Alignment test image should be created for {name}")
def test_text_dimensions_consistency(self):
"""Test that text dimensions are consistent between calculation and rendering"""
test_texts = ["Short", "Medium length text", "Very long text that might cause issues"]
for text_content in test_texts:
with self.subTest(text=text_content):
text = Text(text_content, self.font_style)
# Get calculated dimensions
calc_width = text.width
calc_height = text.height
calc_size = text.size
# Verify consistency
self.assertEqual(calc_size, (calc_width, calc_height))
self.assertGreater(calc_width, 0)
self.assertGreater(calc_height, 0)
# Render and check dimensions match expectation
rendered = text.render()
self.assertIsInstance(rendered, Image.Image)
# Note: rendered size might differ slightly due to margins/padding
self.assertGreaterEqual(rendered.size[0], calc_width - 10) # Allow small tolerance
self.assertGreaterEqual(rendered.size[1], calc_height - 10)
def test_different_font_sizes(self):
"""Test text rendering with different font sizes"""
font_sizes = [8, 12, 16, 20, 24]
test_text = "Sample Text"
for font_size in font_sizes:
with self.subTest(font_size=font_size):
font = Font(
font_path=None,
font_size=font_size,
colour=(0, 0, 0, 255)
)
text = Text(test_text, font)
# Larger fonts should generally produce larger text
self.assertGreater(text.width, 0)
self.assertGreater(text.height, 0)
# Render should work
rendered = text.render()
self.assertIsInstance(rendered, Image.Image)
def test_empty_text_handling(self):
"""Test handling of empty text"""
text = Text("", self.font_style)
# Should handle empty text gracefully
self.assertGreaterEqual(text.width, 0)
self.assertGreaterEqual(text.height, 0)
# Should be able to render
rendered = text.render()
self.assertIsInstance(rendered, Image.Image)
def test_line_multiple_words(self):
"""Test adding multiple words to a line"""
font_style = Font(font_path=None, font_size=12, colour=(0, 0, 0, 255))
line = Line(
spacing=(3, 8),
origin=(0, 0),
size=(200, 20),
font=font_style,
halign=Alignment.LEFT
)
words = ["One", "Two", "Three", "Four", "Five"]
added_words = []
for word in words:
result = line.add_word(word)
if result is None:
added_words.append(word)
else:
break
# Should have added at least some words
self.assertGreater(len(added_words), 0)
self.assertEqual(len(line.text_objects), len(added_words))
# Verify text objects contain correct text
for i, text_obj in enumerate(line.text_objects):
self.assertEqual(text_obj.text, added_words[i])
def test_line_spacing_constraints(self):
"""Test that line spacing respects min/max constraints"""
font_style = Font(font_path=None, font_size=12, colour=(0, 0, 0, 255))
min_spacing = 3
max_spacing = 10
line = Line(
spacing=(min_spacing, max_spacing),
origin=(0, 0),
size=(300, 20),
font=font_style,
halign=Alignment.JUSTIFY # Justify will test spacing limits
)
# Add multiple words
words = ["Test", "spacing", "constraints", "here"]
for word in words:
line.add_word(word)
# Render the line
rendered = line.render()
self.assertIsInstance(rendered, Image.Image)
# Line should respect spacing constraints (this is more of a system test)
self.assertGreater(len(line.text_objects), 1, "Should have multiple words for spacing test")
if __name__ == '__main__':
unittest.main()