Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2026-08-08 20:35:15 +00:00
commit 735face593
312 changed files with 91102 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
"""
Test suite for pyWebLayout.
This package contains comprehensive unit tests for all components of the pyWebLayout library,
organized by module and functionality.
"""
View File
+600
View File
@@ -0,0 +1,600 @@
"""
Unit tests for abstract block elements.
Tests the core abstract block classes that form the foundation of the document model.
"""
import unittest
import os
import tempfile
import shutil
import threading
import time
from PIL import Image as PILImage
from pyWebLayout.abstract.block import (
BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList,
ListStyle, ListItem, Table, TableRow, TableCell, HorizontalRule,
Image
)
from pyWebLayout.abstract.inline import Word, LineBreak
from pyWebLayout.style import Font
# Flask server for testing URL functionality
try:
from flask import Flask, send_file
FLASK_AVAILABLE = True
except ImportError:
FLASK_AVAILABLE = False
class TestBlockElements(unittest.TestCase):
"""Test cases for basic block elements."""
def test_paragraph_creation(self):
"""Test creating and using paragraphs."""
paragraph = Paragraph()
self.assertEqual(paragraph.block_type, BlockType.PARAGRAPH)
self.assertEqual(paragraph.word_count, 0)
self.assertIsNone(paragraph.parent)
# Add words
font = Font()
word1 = Word("Hello", font)
word2 = Word("World", font)
paragraph.add_word(word1)
paragraph.add_word(word2)
self.assertEqual(paragraph.word_count, 2)
# Test word iteration
words = list(paragraph.words_iter())
self.assertEqual(len(words), 2)
self.assertEqual(words[0][1].text, "Hello")
self.assertEqual(words[1][1].text, "World")
def test_heading_levels(self):
"""Test heading creation with different levels."""
h1 = Heading(HeadingLevel.H1)
h3 = Heading(HeadingLevel.H3)
h6 = Heading(HeadingLevel.H6)
self.assertEqual(h1.level, HeadingLevel.H1)
self.assertEqual(h3.level, HeadingLevel.H3)
self.assertEqual(h6.level, HeadingLevel.H6)
self.assertEqual(h1.block_type, BlockType.HEADING)
# Test level modification
h1.level = HeadingLevel.H2
self.assertEqual(h1.level, HeadingLevel.H2)
def test_quote_nesting(self):
"""Test blockquote with nested content."""
quote = Quote()
# Add nested paragraphs
p1 = Paragraph()
p2 = Paragraph()
quote.add_block(p1)
quote.add_block(p2)
self.assertEqual(p1.parent, quote)
self.assertEqual(p2.parent, quote)
# Test block iteration
blocks = list(quote.blocks())
self.assertEqual(len(blocks), 2)
self.assertEqual(blocks[0], p1)
self.assertEqual(blocks[1], p2)
def test_code_block(self):
"""Test code block functionality."""
code = CodeBlock("python")
self.assertEqual(code.language, "python")
self.assertEqual(code.line_count, 0)
# Add code lines
code.add_line("def hello():")
code.add_line(" print('Hello!')")
self.assertEqual(code.line_count, 2)
# Test line iteration
lines = list(code.lines())
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0][1], "def hello():")
self.assertEqual(lines[1][1], " print('Hello!')")
# Test language modification
code.language = "javascript"
self.assertEqual(code.language, "javascript")
def test_list_creation(self):
"""Test list creation and item management."""
# Unordered list
ul = HList(ListStyle.UNORDERED)
self.assertEqual(ul.style, ListStyle.UNORDERED)
self.assertEqual(ul.item_count, 0)
# Add list items
item1 = ListItem()
item2 = ListItem()
ul.add_item(item1)
ul.add_item(item2)
self.assertEqual(ul.item_count, 2)
self.assertEqual(item1.parent, ul)
self.assertEqual(item2.parent, ul)
# Test item iteration
items = list(ul.items())
self.assertEqual(len(items), 2)
# Test list style change
ul.style = ListStyle.ORDERED
self.assertEqual(ul.style, ListStyle.ORDERED)
def test_definition_list(self):
"""Test definition list with terms."""
dl = HList(ListStyle.DEFINITION)
# Add definition items with terms
dt1 = ListItem(term="Python")
dt2 = ListItem(term="JavaScript")
dl.add_item(dt1)
dl.add_item(dt2)
self.assertEqual(dt1.term, "Python")
self.assertEqual(dt2.term, "JavaScript")
# Test term modification
dt1.term = "Python 3"
self.assertEqual(dt1.term, "Python 3")
def test_table_structure(self):
"""Test table, row, and cell structure."""
table = Table(caption="Test Table")
self.assertEqual(table.caption, "Test Table")
self.assertEqual(table.row_count["total"], 0)
# Create rows and cells
header_row = TableRow()
data_row = TableRow()
# Header cells
h1 = TableCell(is_header=True)
h2 = TableCell(is_header=True)
header_row.add_cell(h1)
header_row.add_cell(h2)
# Data cells
d1 = TableCell(is_header=False)
d2 = TableCell(is_header=False, colspan=2)
data_row.add_cell(d1)
data_row.add_cell(d2)
# Add rows to table
table.add_row(header_row, "header")
table.add_row(data_row, "body")
# Test structure
self.assertEqual(table.row_count["header"], 1)
self.assertEqual(table.row_count["body"], 1)
self.assertEqual(table.row_count["total"], 2)
# Test cell properties
self.assertTrue(h1.is_header)
self.assertFalse(d1.is_header)
self.assertEqual(d2.colspan, 2)
self.assertEqual(d2.rowspan, 1) # Default
# Test row cell count
self.assertEqual(header_row.cell_count, 2)
self.assertEqual(data_row.cell_count, 2)
def test_table_sections(self):
"""Test table header, body, and footer sections."""
table = Table()
# Add rows to different sections
header = TableRow()
body1 = TableRow()
body2 = TableRow()
footer = TableRow()
table.add_row(header, "header")
table.add_row(body1, "body")
table.add_row(body2, "body")
table.add_row(footer, "footer")
# Test section iteration
header_rows = list(table.header_rows())
body_rows = list(table.body_rows())
footer_rows = list(table.footer_rows())
self.assertEqual(len(header_rows), 1)
self.assertEqual(len(body_rows), 2)
self.assertEqual(len(footer_rows), 1)
# Test all_rows iteration
all_rows = list(table.all_rows())
self.assertEqual(len(all_rows), 4)
# Check section labels
sections = [section for section, row in all_rows]
self.assertEqual(sections, ["header", "body", "body", "footer"])
def test_image_loading(self):
"""Test image element properties."""
# Test with basic properties
img = Image("test.jpg", "Test image", 100, 200)
self.assertEqual(img.source, "test.jpg")
self.assertEqual(img.alt_text, "Test image")
self.assertEqual(img.width, 100)
self.assertEqual(img.height, 200)
# Test property modification
img.source = "new.png"
img.alt_text = "New image"
img.width = 150
img.height = 300
self.assertEqual(img.source, "new.png")
self.assertEqual(img.alt_text, "New image")
self.assertEqual(img.width, 150)
self.assertEqual(img.height, 300)
# Test dimensions tuple
self.assertEqual(img.get_dimensions(), (150, 300))
def test_aspect_ratio_calculation(self):
"""Test image aspect ratio calculations."""
# Test with specified dimensions
img = Image("test.jpg", width=400, height=200)
self.assertEqual(img.get_aspect_ratio(), 2.0) # 400/200
# Test with only one dimension
img2 = Image("test.jpg", width=300)
self.assertIsNone(img2.get_aspect_ratio()) # No height specified
# Test scaled dimensions
scaled = img.calculate_scaled_dimensions(max_width=200, max_height=150)
# Should scale down proportionally
self.assertEqual(scaled[0], 200) # Width limited by max_width
self.assertEqual(scaled[1], 100) # Height scaled proportionally
def test_simple_elements(self):
"""Test simple block elements."""
hr = HorizontalRule()
br = LineBreak()
self.assertEqual(hr.block_type, BlockType.HORIZONTAL_RULE)
self.assertEqual(br.block_type, BlockType.LINE_BREAK)
# These elements have no additional properties
self.assertIsNone(hr.parent)
self.assertIsNone(br.parent)
class TestImagePIL(unittest.TestCase):
"""Test cases for Image class with PIL functionality."""
@classmethod
def setUpClass(cls):
"""Set up temporary directory and test images."""
cls.temp_dir = tempfile.mkdtemp()
cls.sample_image_path = "tests/data/sample_image.jpg"
# Create test images in different formats
cls._create_test_images()
# Start Flask server for URL testing if Flask is available
if FLASK_AVAILABLE:
cls._start_flask_server()
@classmethod
def tearDownClass(cls):
"""Clean up temporary directory and stop Flask server."""
shutil.rmtree(cls.temp_dir, ignore_errors=True)
if FLASK_AVAILABLE and hasattr(cls, 'flask_thread'):
cls.flask_server_running = False
cls.flask_server.shutdown()
cls.flask_server.server_close()
cls.flask_thread.join(timeout=2)
@classmethod
def _create_test_images(cls):
"""Create test images in different formats."""
# Load the sample image
if os.path.exists(cls.sample_image_path):
with PILImage.open(cls.sample_image_path) as img:
cls.original_size = img.size
# Save in different formats
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
img.save(cls.jpg_path, "JPEG")
img.save(cls.png_path, "PNG")
img.save(cls.bmp_path, "BMP")
# Convert to RGB for GIF (GIF doesn't support transparency from RGBA)
rgb_img = img.convert("RGB")
rgb_img.save(cls.gif_path, "GIF")
else:
# Create a simple test image if sample doesn't exist
cls.original_size = (100, 100)
test_img = PILImage.new("RGB", cls.original_size, (255, 0, 0))
cls.jpg_path = os.path.join(cls.temp_dir, "test.jpg")
cls.png_path = os.path.join(cls.temp_dir, "test.png")
cls.bmp_path = os.path.join(cls.temp_dir, "test.bmp")
cls.gif_path = os.path.join(cls.temp_dir, "test.gif")
test_img.save(cls.jpg_path, "JPEG")
test_img.save(cls.png_path, "PNG")
test_img.save(cls.bmp_path, "BMP")
test_img.save(cls.gif_path, "GIF")
@classmethod
def _start_flask_server(cls):
"""Start a Flask server for URL testing."""
import urllib.request
import urllib.error
from werkzeug.serving import make_server
cls.flask_app = Flask(__name__)
@cls.flask_app.route('/test.jpg')
def serve_test_image():
return send_file(cls.jpg_path, mimetype='image/jpeg')
@cls.flask_app.route('/health')
def health_check():
return 'OK', 200
# Bind to an ephemeral port so concurrent/leftover test runs can't clash
cls.flask_server = make_server('127.0.0.1', 0, cls.flask_app, threaded=True)
cls.flask_port = cls.flask_server.server_port
cls.flask_server_running = True
cls.flask_thread = threading.Thread(target=cls.flask_server.serve_forever, daemon=True)
cls.flask_thread.start()
# Wait for server to be ready with health check.
# Generous, because this now raises rather than falling through
# silently: on a loaded CI runner the accept loop can take several
# seconds to get scheduled, and a spurious failure here is worse than
# a slow one. The loop exits as soon as the server answers.
max_wait = 30
wait_interval = 0.1 # Check every 100ms
elapsed = 0
while elapsed < max_wait:
try:
with urllib.request.urlopen(f'http://127.0.0.1:{cls.flask_port}/health', timeout=1) as response:
if response.status == 200:
return
except (urllib.error.URLError, ConnectionRefusedError, OSError):
pass
time.sleep(wait_interval)
elapsed += wait_interval
raise RuntimeError(
f"Test Flask server did not become ready on port {cls.flask_port} within {max_wait}s")
def test_image_url_detection(self):
"""Test URL detection functionality."""
img = Image()
# Test URL detection
self.assertTrue(img._is_url("http://example.com/image.jpg"))
self.assertTrue(img._is_url("https://example.com/image.png"))
self.assertTrue(img._is_url("ftp://example.com/image.gif"))
# Test non-URL detection
self.assertFalse(img._is_url("image.jpg"))
self.assertFalse(img._is_url("/path/to/image.png"))
self.assertFalse(img._is_url("../relative/path.gif"))
self.assertFalse(img._is_url(""))
def test_load_local_image_jpg(self):
"""Test loading local JPG image."""
img = Image(self.jpg_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.jpg_path)
self.assertEqual(pil_img.size, self.original_size)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
def test_load_local_image_png(self):
"""Test loading local PNG image."""
img = Image(self.png_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.png_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_local_image_bmp(self):
"""Test loading local BMP image."""
img = Image(self.bmp_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.bmp_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_local_image_gif(self):
"""Test loading local GIF image."""
img = Image(self.gif_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.gif_path)
self.assertEqual(pil_img.size, self.original_size)
def test_load_nonexistent_image(self):
"""Test loading non-existent image."""
img = Image("nonexistent.jpg")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_load_empty_source(self):
"""Test loading with empty source."""
img = Image("")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_auto_update_dimensions(self):
"""Test automatic dimension updating."""
img = Image(self.jpg_path, width=50, height=50) # Wrong initial dimensions
# Test with auto-update enabled (default)
file_path, pil_img = img.load_image_data(auto_update_dimensions=True)
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
def test_no_auto_update_dimensions(self):
"""Test loading without automatic dimension updating."""
original_width, original_height = 50, 50
img = Image(self.jpg_path, width=original_width, height=original_height)
# Test with auto-update disabled
file_path, pil_img = img.load_image_data(auto_update_dimensions=False)
self.assertEqual(img.width, original_width) # Should remain unchanged
self.assertEqual(img.height, original_height) # Should remain unchanged
def test_get_image_info(self):
"""Test getting detailed image information."""
img = Image(self.jpg_path)
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertIn('format', info)
self.assertIn('mode', info)
self.assertIn('size', info)
self.assertIn('width', info)
self.assertIn('height', info)
self.assertEqual(info['size'], self.original_size)
self.assertEqual(info['width'], self.original_size[0])
self.assertEqual(info['height'], self.original_size[1])
def test_get_image_info_different_formats(self):
"""Test getting image info for different formats."""
formats_and_paths = [
('JPEG', self.jpg_path),
('PNG', self.png_path),
('BMP', self.bmp_path),
('GIF', self.gif_path),
]
for expected_format, path in formats_and_paths:
with self.subTest(format=expected_format):
img = Image(path)
info = img.get_image_info()
self.assertEqual(info['format'], expected_format)
self.assertEqual(info['size'], self.original_size)
def test_get_image_info_nonexistent(self):
"""Test getting image info for non-existent image."""
img = Image("nonexistent.jpg")
info = img.get_image_info()
self.assertEqual(info, {})
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_load_image_from_url(self):
"""Test loading image from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertIsNotNone(file_path)
self.assertTrue(file_path.endswith('.tmp')) # Should be a temp file
self.assertEqual(pil_img.size, self.original_size)
# Check that dimensions were updated
self.assertEqual(img.width, self.original_size[0])
self.assertEqual(img.height, self.original_size[1])
@unittest.skipUnless(FLASK_AVAILABLE, "Flask not available for URL testing")
def test_get_image_info_from_url(self):
"""Test getting image info from URL."""
url = f"http://127.0.0.1:{self.flask_port}/test.jpg"
img = Image(url)
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertEqual(info['format'], 'JPEG')
self.assertEqual(info['size'], self.original_size)
def test_load_invalid_url(self):
"""Test loading from invalid URL."""
img = Image("http://nonexistent.domain/image.jpg")
file_path, pil_img = img.load_image_data()
self.assertIsNone(pil_img)
self.assertIsNone(file_path)
def test_multiple_loads_cleanup(self):
"""Test that multiple loads don't leave temp files."""
img = Image(self.jpg_path)
# Load multiple times
for _ in range(3):
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
def test_original_sample_image(self):
"""Test loading the original sample image if it exists."""
if os.path.exists(self.sample_image_path):
img = Image(self.sample_image_path)
file_path, pil_img = img.load_image_data()
self.assertIsNotNone(pil_img)
self.assertEqual(file_path, self.sample_image_path)
# Test that we can get image info
info = img.get_image_info()
self.assertIsInstance(info, dict)
self.assertIn('format', info)
self.assertIn('size', info)
if __name__ == '__main__':
unittest.main()
+696
View File
@@ -0,0 +1,696 @@
"""
Unit tests for abstract document elements.
Tests the Document, Chapter, Book, and MetadataType classes that handle
document structure and metadata management.
"""
import unittest
from pyWebLayout.abstract.document import Document, Chapter, Book, MetadataType
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, BlockType
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
class TestMetadataType(unittest.TestCase):
"""Test cases for MetadataType enum."""
def test_metadata_types(self):
"""Test that all expected metadata types exist."""
expected_types = [
'TITLE', 'AUTHOR', 'DESCRIPTION', 'KEYWORDS', 'LANGUAGE',
'PUBLICATION_DATE', 'MODIFIED_DATE', 'PUBLISHER', 'IDENTIFIER',
'COVER_IMAGE', 'CUSTOM'
]
for type_name in expected_types:
self.assertTrue(hasattr(MetadataType, type_name))
# Test custom type has expected value
self.assertEqual(MetadataType.CUSTOM.value, 100)
class TestDocument(unittest.TestCase):
"""Test cases for Document class."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.font = Font()
def test_document_creation(self):
"""Test document creation with basic parameters."""
self.assertEqual(self.doc.get_title(), "Test Document")
self.assertEqual(self.doc.get_metadata(MetadataType.LANGUAGE), "en-US")
self.assertEqual(len(self.doc.blocks), 0)
def test_document_creation_minimal(self):
"""Test document creation with minimal parameters."""
doc = Document()
self.assertIsNone(doc.get_title())
self.assertEqual(doc.get_metadata(MetadataType.LANGUAGE), "en-US")
def test_metadata_management(self):
"""Test setting and getting metadata."""
# Set various metadata types
self.doc.set_metadata(MetadataType.AUTHOR, "John Doe")
self.doc.set_metadata(MetadataType.DESCRIPTION, "A test document")
self.doc.set_metadata(MetadataType.KEYWORDS, ["test", "document"])
# Test retrieval
self.assertEqual(self.doc.get_metadata(MetadataType.AUTHOR), "John Doe")
self.assertEqual(
self.doc.get_metadata(
MetadataType.DESCRIPTION),
"A test document")
self.assertEqual(
self.doc.get_metadata(
MetadataType.KEYWORDS), [
"test", "document"])
# Test non-existent metadata
self.assertIsNone(self.doc.get_metadata(MetadataType.PUBLISHER))
def test_title_convenience_methods(self):
"""Test title getter and setter convenience methods."""
# Test setting title
self.doc.set_title("New Title")
self.assertEqual(self.doc.get_title(), "New Title")
# Test that it's also in metadata
self.assertEqual(self.doc.get_metadata(MetadataType.TITLE), "New Title")
def test_block_management(self):
"""Test adding and managing blocks."""
# Create some blocks
para1 = Paragraph()
para2 = Paragraph()
heading = Heading(HeadingLevel.H1)
# Add blocks
self.doc.add_block(para1)
self.doc.add_block(heading)
self.doc.add_block(para2)
# Test blocks list
self.assertEqual(len(self.doc.blocks), 3)
self.assertEqual(self.doc.blocks[0], para1)
self.assertEqual(self.doc.blocks[1], heading)
self.assertEqual(self.doc.blocks[2], para2)
def test_anchor_management(self):
"""Test named anchor functionality."""
heading = Heading(HeadingLevel.H1)
para = Paragraph()
# Add anchors
self.doc.add_anchor("intro", heading)
self.doc.add_anchor("content", para)
# Test retrieval
self.assertEqual(self.doc.get_anchor("intro"), heading)
self.assertEqual(self.doc.get_anchor("content"), para)
self.assertIsNone(self.doc.get_anchor("nonexistent"))
def test_resource_management(self):
"""Test document resource management."""
# Add various resources
self.doc.add_resource("image1", {"type": "image", "path": "test.jpg"})
self.doc.add_resource("style1", {"type": "css", "content": "body {}"})
# Test retrieval
image = self.doc.get_resource("image1")
self.assertEqual(image["type"], "image")
self.assertEqual(image["path"], "test.jpg")
style = self.doc.get_resource("style1")
self.assertEqual(style["type"], "css")
# Test non-existent resource
self.assertIsNone(self.doc.get_resource("nonexistent"))
def test_stylesheet_management(self):
"""Test stylesheet addition."""
# Add stylesheets
css1 = {"href": "style.css", "type": "text/css"}
css2 = {"href": "theme.css", "type": "text/css"}
self.doc.add_stylesheet(css1)
self.doc.add_stylesheet(css2)
# Test that stylesheets are stored
self.assertEqual(len(self.doc._stylesheets), 2)
self.assertEqual(self.doc._stylesheets[0], css1)
self.assertEqual(self.doc._stylesheets[1], css2)
def test_script_management(self):
"""Test script addition."""
# Add scripts
script1 = "console.log('Hello');"
script2 = "document.ready(function(){});"
self.doc.add_script(script1)
self.doc.add_script(script2)
# Test that scripts are stored
self.assertEqual(len(self.doc._scripts), 2)
self.assertEqual(self.doc._scripts[0], script1)
self.assertEqual(self.doc._scripts[1], script2)
def test_find_blocks_by_type(self):
"""Test finding blocks by type."""
# Create blocks of different types
para1 = Paragraph()
para2 = Paragraph()
heading1 = Heading(HeadingLevel.H1)
heading2 = Heading(HeadingLevel.H2)
# Add blocks to document
self.doc.add_block(para1)
self.doc.add_block(heading1)
self.doc.add_block(para2)
self.doc.add_block(heading2)
# Test finding paragraphs
paragraphs = self.doc.find_blocks_by_type(BlockType.PARAGRAPH)
self.assertEqual(len(paragraphs), 2)
self.assertIn(para1, paragraphs)
self.assertIn(para2, paragraphs)
# Test finding headings
headings = self.doc.find_blocks_by_type(BlockType.HEADING)
self.assertEqual(len(headings), 2)
self.assertIn(heading1, headings)
self.assertIn(heading2, headings)
def test_find_headings(self):
"""Test finding heading blocks specifically."""
# Create mixed blocks
para = Paragraph()
h1 = Heading(HeadingLevel.H1)
h2 = Heading(HeadingLevel.H2)
# Add words to headings for title extraction
word1 = Word("Chapter", self.font)
word2 = Word("One", self.font)
h1.add_word(word1)
h1.add_word(word2)
word3 = Word("Section", self.font)
h2.add_word(word3)
self.doc.add_block(para)
self.doc.add_block(h1)
self.doc.add_block(h2)
# Test finding headings
headings = self.doc.find_headings()
self.assertEqual(len(headings), 2)
self.assertIn(h1, headings)
self.assertIn(h2, headings)
self.assertNotIn(para, headings)
def test_generate_table_of_contents(self):
"""Test table of contents generation."""
# Create headings with content
h1 = Heading(HeadingLevel.H1)
h2 = Heading(HeadingLevel.H2)
h3 = Heading(HeadingLevel.H3)
# Add words to headings
h1.add_word(Word("Introduction", self.font))
h2.add_word(Word("Getting", self.font))
h2.add_word(Word("Started", self.font))
h3.add_word(Word("Installation", self.font))
self.doc.add_block(h1)
self.doc.add_block(h2)
self.doc.add_block(h3)
# Generate TOC
toc = self.doc.generate_table_of_contents()
# Test TOC structure
self.assertEqual(len(toc), 3)
# Test first entry
level, title, block = toc[0]
self.assertEqual(level, 1) # H1
self.assertEqual(title, "Introduction")
self.assertEqual(block, h1)
# Test second entry
level, title, block = toc[1]
self.assertEqual(level, 2) # H2
self.assertEqual(title, "Getting Started")
self.assertEqual(block, h2)
# Test third entry
level, title, block = toc[2]
self.assertEqual(level, 3) # H3
self.assertEqual(title, "Installation")
self.assertEqual(block, h3)
class TestChapter(unittest.TestCase):
"""Test cases for Chapter class."""
def setUp(self):
"""Set up test fixtures."""
self.chapter = Chapter("Test Chapter", 1)
def test_chapter_creation(self):
"""Test chapter creation."""
self.assertEqual(self.chapter.title, "Test Chapter")
self.assertEqual(self.chapter.level, 1)
self.assertEqual(len(self.chapter.blocks), 0)
def test_chapter_creation_minimal(self):
"""Test chapter creation with minimal parameters."""
chapter = Chapter()
self.assertIsNone(chapter.title)
self.assertEqual(chapter.level, 1)
def test_title_property(self):
"""Test title property getter and setter."""
# Test setter
self.chapter.title = "New Chapter Title"
self.assertEqual(self.chapter.title, "New Chapter Title")
# Test setting to None
self.chapter.title = None
self.assertIsNone(self.chapter.title)
def test_level_property(self):
"""Test level property."""
self.assertEqual(self.chapter.level, 1)
# Level should be read-only (no setter test)
# This is by design based on the class definition
def test_block_management(self):
"""Test adding blocks to chapter."""
para1 = Paragraph()
para2 = Paragraph()
heading = Heading(HeadingLevel.H2)
# Add blocks
self.chapter.add_block(para1)
self.chapter.add_block(heading)
self.chapter.add_block(para2)
# Test blocks list
self.assertEqual(len(self.chapter.blocks), 3)
self.assertEqual(self.chapter.blocks[0], para1)
self.assertEqual(self.chapter.blocks[1], heading)
self.assertEqual(self.chapter.blocks[2], para2)
def test_metadata_management(self):
"""Test chapter metadata."""
# Set metadata
self.chapter.set_metadata("author", "Jane Doe")
self.chapter.set_metadata("word_count", 1500)
self.chapter.set_metadata("tags", ["intro", "basics"])
# Test retrieval
self.assertEqual(self.chapter.get_metadata("author"), "Jane Doe")
self.assertEqual(self.chapter.get_metadata("word_count"), 1500)
self.assertEqual(self.chapter.get_metadata("tags"), ["intro", "basics"])
# Test non-existent metadata
self.assertIsNone(self.chapter.get_metadata("nonexistent"))
class TestBook(unittest.TestCase):
"""Test cases for Book class."""
def setUp(self):
"""Set up test fixtures."""
self.book = Book("Test Book", "Author Name", "en-US")
def test_book_creation(self):
"""Test book creation with all parameters."""
self.assertEqual(self.book.get_title(), "Test Book")
self.assertEqual(self.book.get_author(), "Author Name")
self.assertEqual(self.book.get_metadata(MetadataType.LANGUAGE), "en-US")
self.assertEqual(len(self.book.chapters), 0)
def test_book_creation_minimal(self):
"""Test book creation with minimal parameters."""
book = Book()
self.assertIsNone(book.get_title())
self.assertIsNone(book.get_author())
self.assertEqual(book.get_metadata(MetadataType.LANGUAGE), "en-US")
def test_book_creation_partial(self):
"""Test book creation with partial parameters."""
book = Book(title="Just Title")
self.assertEqual(book.get_title(), "Just Title")
self.assertIsNone(book.get_author())
def test_author_convenience_methods(self):
"""Test author getter and setter convenience methods."""
# Test setting author
self.book.set_author("New Author")
self.assertEqual(self.book.get_author(), "New Author")
# Test that it's also in metadata
self.assertEqual(self.book.get_metadata(MetadataType.AUTHOR), "New Author")
def test_chapter_management(self):
"""Test adding and managing chapters."""
# Create chapters
ch1 = Chapter("Introduction", 1)
ch2 = Chapter("Getting Started", 1)
ch3 = Chapter("Advanced Topics", 1)
# Add chapters
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
# Test chapters list
self.assertEqual(len(self.book.chapters), 3)
self.assertEqual(self.book.chapters[0], ch1)
self.assertEqual(self.book.chapters[1], ch2)
self.assertEqual(self.book.chapters[2], ch3)
def test_create_chapter(self):
"""Test creating chapters through the book."""
# Create chapter with title and level
ch1 = self.book.create_chapter("Chapter 1", 1)
self.assertEqual(ch1.title, "Chapter 1")
self.assertEqual(ch1.level, 1)
self.assertEqual(len(self.book.chapters), 1)
self.assertEqual(self.book.chapters[0], ch1)
# Create chapter with minimal parameters
ch2 = self.book.create_chapter()
self.assertIsNone(ch2.title)
self.assertEqual(ch2.level, 1)
self.assertEqual(len(self.book.chapters), 2)
def test_generate_book_toc(self):
"""Test table of contents generation for book."""
# Create chapters with different levels
ch1 = Chapter("Introduction", 1)
ch2 = Chapter("Getting Started", 1)
ch3 = Chapter("Basic Concepts", 2)
ch4 = Chapter("Advanced Topics", 1)
ch5 = Chapter("Best Practices", 2)
# Add chapters to book
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
self.book.add_chapter(ch4)
self.book.add_chapter(ch5)
# Generate TOC
toc = self.book.generate_table_of_contents()
# Test TOC structure
self.assertEqual(len(toc), 5)
# Test entries
expected = [
(1, "Introduction", ch1),
(1, "Getting Started", ch2),
(2, "Basic Concepts", ch3),
(1, "Advanced Topics", ch4),
(2, "Best Practices", ch5)
]
for i, (exp_level, exp_title, exp_chapter) in enumerate(expected):
level, title, chapter = toc[i]
self.assertEqual(level, exp_level)
self.assertEqual(title, exp_title)
self.assertEqual(chapter, exp_chapter)
def test_generate_book_toc_with_untitled_chapters(self):
"""Test TOC generation with chapters that have no title."""
# Create chapters, some without titles
ch1 = Chapter("Introduction", 1)
ch2 = Chapter(None, 1) # No title
ch3 = Chapter("Conclusion", 1)
self.book.add_chapter(ch1)
self.book.add_chapter(ch2)
self.book.add_chapter(ch3)
# Generate TOC
toc = self.book.generate_table_of_contents()
# Should only include chapters with titles
self.assertEqual(len(toc), 2)
level, title, chapter = toc[0]
self.assertEqual(title, "Introduction")
self.assertEqual(chapter, ch1)
level, title, chapter = toc[1]
self.assertEqual(title, "Conclusion")
self.assertEqual(chapter, ch3)
def test_book_inherits_document_features(self):
"""Test that Book inherits all Document functionality."""
# Test that book can use all document methods
# Add blocks directly to book
para = Paragraph()
self.book.add_block(para)
self.assertEqual(len(self.book.blocks), 1)
# Test metadata
self.book.set_metadata(MetadataType.PUBLISHER, "Test Publisher")
self.assertEqual(
self.book.get_metadata(
MetadataType.PUBLISHER),
"Test Publisher")
# Test anchors
heading = Heading(HeadingLevel.H1)
self.book.add_anchor("preface", heading)
self.assertEqual(self.book.get_anchor("preface"), heading)
class TestDocumentFontRegistry(unittest.TestCase):
"""Test cases for Document font registry functionality."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
def test_get_or_create_font_creates_new_font(self):
"""Test that get_or_create_font creates a new font when none exists."""
font = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
self.assertEqual(font.font_size, 14)
self.assertEqual(font.colour, (255, 0, 0))
self.assertEqual(font.weight, FontWeight.BOLD)
# Check that font is stored in registry
self.assertEqual(len(self.doc._fonts), 1)
def test_get_or_create_font_reuses_existing_font(self):
"""Test that get_or_create_font reuses existing fonts."""
# Create first font
font1 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create second font with same properties
font2 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should return the same font object
self.assertIs(font1, font2)
# Should only have one font in registry
self.assertEqual(len(self.doc._fonts), 1)
def test_get_or_create_font_creates_different_fonts(self):
"""Test that different font properties create different fonts."""
# Create first font
font1 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create font with different size
font2 = self.doc.get_or_create_font(
font_size=16,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create font with different color
font3 = self.doc.get_or_create_font(
font_size=14,
colour=(0, 255, 0),
weight=FontWeight.BOLD
)
# Create font with different weight
font4 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.NORMAL
)
# All should be different objects
self.assertIsNot(font1, font2)
self.assertIsNot(font1, font3)
self.assertIsNot(font1, font4)
self.assertIsNot(font2, font3)
self.assertIsNot(font2, font4)
self.assertIsNot(font3, font4)
# Should have four fonts in registry
self.assertEqual(len(self.doc._fonts), 4)
def test_get_or_create_font_with_all_parameters(self):
"""Test get_or_create_font with all parameters."""
font = self.doc.get_or_create_font(
font_path="path/to/font.ttf",
font_size=18,
colour=(128, 64, 192),
weight=FontWeight.BOLD,
style=FontStyle.ITALIC,
decoration=TextDecoration.UNDERLINE,
background=(255, 255, 255, 128),
language="fr_FR",
min_hyphenation_width=80
)
self.assertEqual(font._font_path, "path/to/font.ttf")
self.assertEqual(font.font_size, 18)
self.assertEqual(font.colour, (128, 64, 192))
self.assertEqual(font.weight, FontWeight.BOLD)
self.assertEqual(font.style, FontStyle.ITALIC)
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
self.assertEqual(font.background, (255, 255, 255, 128))
self.assertEqual(font.language, "fr_FR")
self.assertEqual(font.min_hyphenation_width, 80)
def test_get_or_create_font_with_defaults(self):
"""Test get_or_create_font with default values."""
font = self.doc.get_or_create_font()
# Should create font with default values
self.assertIsNotNone(font)
self.assertEqual(font.font_size, 16) # Default font size
self.assertEqual(font.colour, (0, 0, 0)) # Default black color
self.assertEqual(font.weight, FontWeight.NORMAL)
self.assertEqual(font.style, FontStyle.NORMAL)
self.assertEqual(font.decoration, TextDecoration.NONE)
class TestChapterFontRegistry(unittest.TestCase):
"""Test cases for Chapter font registry functionality."""
def setUp(self):
"""Set up test fixtures."""
self.doc = Document("Test Document", "en-US")
self.chapter = Chapter("Test Chapter", 1, parent=self.doc)
def test_chapter_uses_parent_font_registry(self):
"""Test that chapter uses parent document's font registry."""
# Create font through chapter - should delegate to parent
font1 = self.chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create same font through document - should return same object
font2 = self.doc.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should be the same font object
self.assertIs(font1, font2)
# Should be stored in document's registry, not chapter's
self.assertEqual(len(self.doc._fonts), 1)
self.assertEqual(len(self.chapter._fonts), 0)
def test_chapter_without_parent_manages_own_fonts(self):
"""Test that chapter without parent manages its own fonts."""
# Create chapter without parent
standalone_chapter = Chapter("Standalone Chapter", 1)
# Create font through chapter
font1 = standalone_chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create same font again - should reuse
font2 = standalone_chapter.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should be the same font object
self.assertIs(font1, font2)
# Should be stored in chapter's own registry
self.assertEqual(len(standalone_chapter._fonts), 1)
def test_chapter_parent_assignment(self):
"""Test that chapter parent assignment works correctly."""
# Create chapter with parent
chapter_with_parent = Chapter("Chapter with Parent", 1, parent=self.doc)
self.assertEqual(chapter_with_parent._parent, self.doc)
# Create chapter without parent
chapter_without_parent = Chapter("Chapter without Parent", 1)
self.assertIsNone(chapter_without_parent._parent)
class TestBookFontRegistry(unittest.TestCase):
"""Test cases for Book font registry functionality."""
def setUp(self):
"""Set up test fixtures."""
self.book = Book("Test Book", "Author Name", "en-US")
def test_book_inherits_document_font_registry(self):
"""Test that Book inherits Document's font registry functionality."""
# Create font through book
font1 = self.book.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Create same font again - should reuse
font2 = self.book.get_or_create_font(
font_size=14,
colour=(255, 0, 0),
weight=FontWeight.BOLD
)
# Should be the same font object
self.assertIs(font1, font2)
# Should have one font in registry
self.assertEqual(len(self.book._fonts), 1)
if __name__ == '__main__':
unittest.main()
+529
View File
@@ -0,0 +1,529 @@
"""
Unit tests for abstract functional elements.
Tests the Link, Button, Form, FormField, and related classes that handle
interactive functionality and user interface elements.
"""
import unittest
from unittest.mock import Mock
from pyWebLayout.abstract.functional import (
Link, LinkType, Button, Form, FormField, FormFieldType
)
class TestLinkType(unittest.TestCase):
"""Test cases for LinkType enum."""
def test_link_types(self):
"""Test that all expected link types exist."""
expected_types = ['INTERNAL', 'EXTERNAL', 'API', 'FUNCTION']
for type_name in expected_types:
self.assertTrue(hasattr(LinkType, type_name))
# Test specific values
self.assertEqual(LinkType.INTERNAL.value, 1)
self.assertEqual(LinkType.EXTERNAL.value, 2)
self.assertEqual(LinkType.API.value, 3)
self.assertEqual(LinkType.FUNCTION.value, 4)
class TestLink(unittest.TestCase):
"""Test cases for Link class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="callback_result")
def test_link_creation_minimal(self):
"""Test link creation with minimal parameters."""
link = Link("test-location")
self.assertEqual(link.location, "test-location")
self.assertEqual(link.link_type, LinkType.INTERNAL) # Default
self.assertEqual(link.params, {})
self.assertIsNone(link.title)
self.assertIsNone(link._callback)
def test_link_creation_full(self):
"""Test link creation with all parameters."""
params = {"param1": "value1", "param2": "value2"}
link = Link(
location="https://example.com",
link_type=LinkType.EXTERNAL,
callback=self.mock_callback,
params=params,
title="Example Link"
)
self.assertEqual(link.location, "https://example.com")
self.assertEqual(link.link_type, LinkType.EXTERNAL)
self.assertEqual(link.params, params)
self.assertEqual(link.title, "Example Link")
self.assertEqual(link._callback, self.mock_callback)
def test_internal_link_execution(self):
"""Test executing internal links."""
link = Link("#section1", LinkType.INTERNAL)
result = link.execute()
# Internal links should return the location
self.assertEqual(result, "#section1")
def test_external_link_execution(self):
"""Test executing external links."""
link = Link("https://example.com", LinkType.EXTERNAL)
result = link.execute()
# External links should return the location
self.assertEqual(result, "https://example.com")
def test_api_link_execution(self):
"""Test executing API links with callback."""
params = {"action": "save", "id": 123}
link = Link(
location="/api/save",
link_type=LinkType.API,
callback=self.mock_callback,
params=params
)
result = link.execute()
# Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with(
"/api/save", None, action="save", id=123)
self.assertEqual(result, "callback_result")
def test_function_link_execution(self):
"""Test executing function links with callback."""
params = {"data": "test"}
link = Link(
location="save_document",
link_type=LinkType.FUNCTION,
callback=self.mock_callback,
params=params
)
result = link.execute()
# Should call callback with location, point (None when not provided), and params
self.mock_callback.assert_called_once_with("save_document", None, data="test")
self.assertEqual(result, "callback_result")
def test_api_link_without_callback(self):
"""Test API link without callback returns location."""
link = Link("/api/endpoint", LinkType.API)
result = link.execute()
# Without callback, should return location
self.assertEqual(result, "/api/endpoint")
def test_function_link_without_callback(self):
"""Test function link without callback returns location."""
link = Link("function_name", LinkType.FUNCTION)
result = link.execute()
# Without callback, should return location
self.assertEqual(result, "function_name")
def test_link_properties(self):
"""Test link property access."""
params = {"key": "value"}
link = Link(
location="test",
link_type=LinkType.API,
params=params,
title="Test Title"
)
# Test all property getters
self.assertEqual(link.location, "test")
self.assertEqual(link.link_type, LinkType.API)
self.assertEqual(link.params, params)
self.assertEqual(link.title, "Test Title")
class TestButton(unittest.TestCase):
"""Test cases for Button class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="button_clicked")
def test_button_creation_minimal(self):
"""Test button creation with minimal parameters."""
button = Button("Click Me", self.mock_callback)
self.assertEqual(button.label, "Click Me")
self.assertEqual(button._callback, self.mock_callback)
self.assertEqual(button.params, {})
self.assertTrue(button.enabled)
def test_button_creation_full(self):
"""Test button creation with all parameters."""
params = {"action": "submit", "form_id": "test_form"}
button = Button(
label="Submit",
callback=self.mock_callback,
params=params,
enabled=False
)
self.assertEqual(button.label, "Submit")
self.assertEqual(button._callback, self.mock_callback)
self.assertEqual(button.params, params)
self.assertFalse(button.enabled)
def test_button_label_property(self):
"""Test button label getter and setter."""
button = Button("Original", self.mock_callback)
# Test getter
self.assertEqual(button.label, "Original")
# Test setter
button.label = "New Label"
self.assertEqual(button.label, "New Label")
def test_button_enabled_property(self):
"""Test button enabled getter and setter."""
button = Button("Test", self.mock_callback, enabled=True)
# Test initial state
self.assertTrue(button.enabled)
# Test setter
button.enabled = False
self.assertFalse(button.enabled)
button.enabled = True
self.assertTrue(button.enabled)
def test_button_execute_enabled(self):
"""Test executing enabled button."""
params = {"data": "test_data"}
button = Button("Test", self.mock_callback, params=params, enabled=True)
result = button.execute()
# Should call callback with point (None when not provided) and params
self.mock_callback.assert_called_once_with(None, data="test_data")
self.assertEqual(result, "button_clicked")
def test_button_execute_disabled(self):
"""Test executing disabled button."""
button = Button("Test", self.mock_callback, enabled=False)
result = button.execute()
# Should not call callback and return None
self.mock_callback.assert_not_called()
self.assertIsNone(result)
def test_button_execute_no_callback(self):
"""Test executing button without callback."""
button = Button("Test", None, enabled=True)
result = button.execute()
# Should return None
self.assertIsNone(result)
class TestFormFieldType(unittest.TestCase):
"""Test cases for FormFieldType enum."""
def test_form_field_types(self):
"""Test that all expected form field types exist."""
expected_types = [
'TEXT', 'PASSWORD', 'CHECKBOX', 'RADIO', 'SELECT', 'TEXTAREA',
'NUMBER', 'DATE', 'TIME', 'EMAIL', 'URL', 'COLOR', 'RANGE', 'HIDDEN'
]
for type_name in expected_types:
self.assertTrue(hasattr(FormFieldType, type_name))
# Test some specific values
self.assertEqual(FormFieldType.TEXT.value, 1)
self.assertEqual(FormFieldType.PASSWORD.value, 2)
self.assertEqual(FormFieldType.HIDDEN.value, 14)
class TestFormField(unittest.TestCase):
"""Test cases for FormField class."""
def test_form_field_creation_minimal(self):
"""Test form field creation with minimal parameters."""
field = FormField("username", FormFieldType.TEXT)
self.assertEqual(field.name, "username")
self.assertEqual(field.field_type, FormFieldType.TEXT)
self.assertEqual(field.label, "username") # Default to name
self.assertIsNone(field.value)
self.assertFalse(field.required)
self.assertEqual(field.options, [])
self.assertIsNone(field.form)
def test_form_field_creation_full(self):
"""Test form field creation with all parameters."""
options = [("value1", "Label 1"), ("value2", "Label 2")]
field = FormField(
name="country",
field_type=FormFieldType.SELECT,
label="Country",
value="value1",
required=True,
options=options
)
self.assertEqual(field.name, "country")
self.assertEqual(field.field_type, FormFieldType.SELECT)
self.assertEqual(field.label, "Country")
self.assertEqual(field.value, "value1")
self.assertTrue(field.required)
self.assertEqual(field.options, options)
def test_form_field_value_property(self):
"""Test form field value getter and setter."""
field = FormField("test", FormFieldType.TEXT, value="initial")
# Test getter
self.assertEqual(field.value, "initial")
# Test setter
field.value = "new_value"
self.assertEqual(field.value, "new_value")
def test_form_field_form_property(self):
"""Test form field form getter and setter."""
field = FormField("test", FormFieldType.TEXT)
mock_form = Mock()
# Initial state
self.assertIsNone(field.form)
# Test setter
field.form = mock_form
self.assertEqual(field.form, mock_form)
def test_form_field_properties(self):
"""Test all form field property getters."""
options = [("opt1", "Option 1")]
field = FormField(
name="test_field",
field_type=FormFieldType.CHECKBOX,
label="Test Field",
value=True,
required=True,
options=options
)
# Test all getters
self.assertEqual(field.name, "test_field")
self.assertEqual(field.field_type, FormFieldType.CHECKBOX)
self.assertEqual(field.label, "Test Field")
self.assertTrue(field.value)
self.assertTrue(field.required)
self.assertEqual(field.options, options)
class TestForm(unittest.TestCase):
"""Test cases for Form class."""
def setUp(self):
"""Set up test fixtures."""
self.mock_callback = Mock(return_value="form_submitted")
def test_form_creation_minimal(self):
"""Test form creation with minimal parameters."""
form = Form("test_form")
self.assertEqual(form.form_id, "test_form")
self.assertIsNone(form.action)
self.assertIsNone(form._callback)
self.assertEqual(len(form._fields), 0)
def test_form_creation_full(self):
"""Test form creation with all parameters."""
form = Form(
form_id="contact_form",
action="/submit",
callback=self.mock_callback
)
self.assertEqual(form.form_id, "contact_form")
self.assertEqual(form.action, "/submit")
self.assertEqual(form._callback, self.mock_callback)
def test_form_field_management(self):
"""Test adding and retrieving form fields."""
form = Form("test_form")
# Create fields
field1 = FormField("username", FormFieldType.TEXT, value="john")
field2 = FormField("password", FormFieldType.PASSWORD, value="secret")
field3 = FormField("email", FormFieldType.EMAIL, value="john@example.com")
# Add fields
form.add_field(field1)
form.add_field(field2)
form.add_field(field3)
# Test that fields are stored correctly
self.assertEqual(len(form._fields), 3)
# Test field retrieval
self.assertEqual(form.get_field("username"), field1)
self.assertEqual(form.get_field("password"), field2)
self.assertEqual(form.get_field("email"), field3)
self.assertIsNone(form.get_field("nonexistent"))
# Test that fields have form reference
self.assertEqual(field1.form, form)
self.assertEqual(field2.form, form)
self.assertEqual(field3.form, form)
def test_form_get_values(self):
"""Test getting form values."""
form = Form("test_form")
# Add fields with values
form.add_field(FormField("name", FormFieldType.TEXT, value="John Doe"))
form.add_field(FormField("age", FormFieldType.NUMBER, value=30))
form.add_field(FormField("subscribe", FormFieldType.CHECKBOX, value=True))
# Get values
values = form.get_values()
expected = {
"name": "John Doe",
"age": 30,
"subscribe": True
}
self.assertEqual(values, expected)
def test_form_get_values_empty(self):
"""Test getting values from empty form."""
form = Form("empty_form")
values = form.get_values()
self.assertEqual(values, {})
def test_form_execute_with_callback(self):
"""Test executing form with callback."""
form = Form("test_form", callback=self.mock_callback)
# Add some fields
form.add_field(FormField("field1", FormFieldType.TEXT, value="value1"))
form.add_field(FormField("field2", FormFieldType.TEXT, value="value2"))
result = form.execute()
# Should call callback with form_id and values
expected_values = {"field1": "value1", "field2": "value2"}
self.mock_callback.assert_called_once_with("test_form", expected_values)
self.assertEqual(result, "form_submitted")
def test_form_execute_without_callback(self):
"""Test executing form without callback."""
form = Form("test_form")
# Add a field
form.add_field(FormField("test", FormFieldType.TEXT, value="test_value"))
result = form.execute()
# Should return the form values
expected = {"test": "test_value"}
self.assertEqual(result, expected)
def test_form_properties(self):
"""Test form property getters."""
form = Form("test_form", action="/submit")
self.assertEqual(form.form_id, "test_form")
self.assertEqual(form.action, "/submit")
class TestFormIntegration(unittest.TestCase):
"""Integration tests for form functionality."""
def test_complete_form_workflow(self):
"""Test a complete form creation and submission workflow."""
# Create form
form = Form("registration_form", action="/register")
# Add various field types
form.add_field(FormField(
"username", FormFieldType.TEXT,
label="Username", required=True, value="testuser"
))
form.add_field(FormField(
"password", FormFieldType.PASSWORD,
label="Password", required=True, value="secret123"
))
form.add_field(FormField(
"email", FormFieldType.EMAIL,
label="Email", required=True, value="test@example.com"
))
form.add_field(
FormField(
"country", FormFieldType.SELECT, label="Country", value="US", options=[
("US", "United States"), ("CA", "Canada"), ("UK", "United Kingdom")]))
form.add_field(FormField(
"newsletter", FormFieldType.CHECKBOX,
label="Subscribe to newsletter", value=True
))
# Test form state
self.assertEqual(len(form._fields), 5)
# Test individual field access
username_field = form.get_field("username")
self.assertEqual(username_field.value, "testuser")
self.assertTrue(username_field.required)
# Test getting all values
values = form.get_values()
expected = {
"username": "testuser",
"password": "secret123",
"email": "test@example.com",
"country": "US",
"newsletter": True
}
self.assertEqual(values, expected)
# Test form submission
result = form.execute()
self.assertEqual(result, expected)
def test_form_field_modification(self):
"""Test modifying form fields after creation."""
form = Form("test_form")
# Add field
field = FormField("test", FormFieldType.TEXT, value="initial")
form.add_field(field)
# Modify field value
field.value = "modified"
# Test that form reflects the change
values = form.get_values()
self.assertEqual(values["test"], "modified")
# Test getting the modified field
retrieved_field = form.get_field("test")
self.assertEqual(retrieved_field.value, "modified")
if __name__ == '__main__':
unittest.main()
+859
View File
@@ -0,0 +1,859 @@
"""
Unit tests for abstract inline elements.
Tests the Word and FormattedSpan classes that handle inline text elements
and formatting within documents.
"""
import unittest
from unittest.mock import Mock
from pyWebLayout.abstract.inline import Word, FormattedSpan, LineBreak
from pyWebLayout.style import Font
class TestWord(unittest.TestCase):
"""Test cases for Word class."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font()
# Note: Font background is a tuple (255, 255, 255, 0) by default
# Note: Font language is set via constructor parameter (language - with typo)
def test_word_creation_minimal(self):
"""Test word creation with minimal parameters."""
word = Word("hello", self.font)
self.assertEqual(word.text, "hello")
self.assertEqual(word.style, self.font)
self.assertIsNone(word.previous)
self.assertIsNone(word.next)
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."""
word1 = Word("first", self.font)
word2 = Word("second", self.font, previous=word1)
self.assertEqual(word2.previous, word1)
self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2)
self.assertIsNone(word2.next)
def test_word_creation_with_background_override(self):
"""Test word creation with background color override."""
word = Word("test", self.font, background="yellow")
self.assertEqual(word.background, "yellow")
# Original font background should be unchanged - it's a tuple
self.assertEqual(word.style.background, (255, 255, 255, 0))
def test_word_properties(self):
"""Test word property getters."""
word1 = Word("first", self.font)
word2 = Word("second", self.font, background="blue", previous=word1)
# Test all properties
self.assertEqual(word2.text, "second")
self.assertEqual(word2.style, self.font)
self.assertEqual(word2.background, "blue")
self.assertEqual(word2.previous, word1)
self.assertIsNone(word2.next)
def test_add_next_word(self):
"""Test linking words with add_next method."""
word1 = Word("first", self.font)
word2 = Word("second", self.font)
word3 = Word("third", self.font)
# Link words
word1.add_next(word2)
word2.add_next(word3)
# Test forward links
self.assertEqual(word1.next, word2)
self.assertEqual(word2.next, word3)
self.assertIsNone(word3.next)
# Backward links should remain as set in constructor
self.assertIsNone(word1.previous)
self.assertIsNone(word2.previous)
self.assertIsNone(word3.previous)
def test_word_chain(self):
"""Test creating a chain of linked words."""
word1 = Word("first", self.font)
word2 = Word("second", self.font, previous=word1)
word3 = Word("third", self.font, previous=word2)
# Test complete chain
self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2)
self.assertEqual(word2.previous, word1)
self.assertEqual(word2.next, word3)
self.assertEqual(word3.previous, word2)
self.assertIsNone(word3.next)
def test_word_create_and_add_to_with_style_override(self):
"""Test Word.create_and_add_to with explicit style parameter."""
# Create alternate font
alt_font = Font()
# Create mock container
mock_container = Mock()
mock_container.style = self.font
mock_container.add_word = Mock()
# Ensure _words doesn't interfere
del mock_container._words
# Create word with style override
word = Word.create_and_add_to("test", mock_container, style=alt_font)
# Test that word uses the override style, not container style
self.assertEqual(word.style, alt_font)
self.assertNotEqual(word.style, self.font)
def test_word_create_and_add_to_with_background_override(self):
"""Test Word.create_and_add_to with explicit background parameter."""
# Create mock container
mock_container = Mock()
mock_container.style = self.font
mock_container.background = "container_bg"
mock_container.add_word = Mock()
# Ensure _words doesn't interfere
del mock_container._words
# Create word with background override
word = Word.create_and_add_to("test", mock_container, background="override_bg")
# Test that word uses the override background
self.assertEqual(word.background, "override_bg")
def test_word_create_and_add_to_inherit_container_background(self):
"""Test Word.create_and_add_to inheriting background from container."""
# Create mock container with background
mock_container = Mock()
mock_container.style = self.font
mock_container.background = "container_bg"
mock_container.add_word = Mock()
# Ensure _words doesn't interfere
del mock_container._words
# Create word without background override
word = Word.create_and_add_to("test", mock_container)
# Test that word inherits container background
self.assertEqual(word.background, "container_bg")
def test_word_create_and_add_to_with_words_list_previous(self):
"""Test Word.create_and_add_to linking with previous word from _words list."""
# Create mock container with _words list
mock_container = Mock()
mock_container.style = self.font
mock_container.add_word = Mock()
# Create existing word and add to container's _words list
existing_word = Word("previous", self.font)
mock_container._words = [existing_word]
# Create new word
word = Word.create_and_add_to("current", mock_container)
# Test that words are linked
self.assertEqual(word.previous, existing_word)
self.assertEqual(existing_word.next, word)
def test_word_create_and_add_to_with_words_method_previous(self):
"""Test Word.create_and_add_to linking with previous word from words() method."""
# Create a simple container that implements words() method
class SimpleContainer:
def __init__(self, font):
self.style = font
self.existing_word = Word("previous", font)
def words(self):
yield ("span1", self.existing_word)
def add_word(self, word):
pass # Simple implementation
container = SimpleContainer(self.font)
# Create new word
word = Word.create_and_add_to("current", container)
# Test that words are linked
self.assertEqual(word.previous, container.existing_word)
self.assertEqual(container.existing_word.next, word)
def test_word_create_and_add_to_no_style_error(self):
"""Test Word.create_and_add_to raises error when container has no style."""
# Create container without style
class BadContainer:
def add_word(self, word):
pass
container = BadContainer()
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
Word.create_and_add_to("test", container)
self.assertIn("must have a 'style' property", str(context.exception))
def test_word_create_and_add_to_no_add_word_error(self):
"""Test Word.create_and_add_to raises error when container has no add_word method."""
# Create container without add_word
class BadContainer:
def __init__(self, font):
self.style = font
container = BadContainer(self.font)
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
Word.create_and_add_to("test", container)
self.assertIn("must have an 'add_word' method", str(context.exception))
def test_word_create_and_add_to_parameter_inspection_word_object(self):
"""Test Word.create_and_add_to with add_word method that expects Word object."""
# Create container with add_word method that has 'word' parameter name
class WordObjectContainer:
def __init__(self, font):
self.style = font
self.added_words = []
def add_word(self, word): # Parameter named 'word' indicates it expects Word object
self.added_words.append(word)
container = WordObjectContainer(self.font)
# Create and add word
word = Word.create_and_add_to("test", container)
# Test that the Word object was passed to add_word
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
self.assertIsInstance(container.added_words[0], Word)
def test_word_create_and_add_to_parameter_inspection_word_obj(self):
"""Test Word.create_and_add_to with add_word method that has 'word_obj' parameter."""
class WordObjContainer:
def __init__(self, font):
self.style = font
self.added_words = []
def add_word(self, word_obj): # Parameter named 'word_obj' indicates it expects Word object
self.added_words.append(word_obj)
container = WordObjContainer(self.font)
word = Word.create_and_add_to("test", container)
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
def test_word_create_and_add_to_parameter_inspection_word_object_param(self):
"""Test Word.create_and_add_to with add_word method that has 'word_object' parameter."""
class WordObjectContainer:
def __init__(self, font):
self.style = font
self.added_words = []
def add_word(self, word_object): # Parameter named 'word_object' indicates it expects Word object
self.added_words.append(word_object)
container = WordObjectContainer(self.font)
word = Word.create_and_add_to("test", container)
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
def test_word_create_and_add_to_parameter_inspection_text_fallback_with_words_list(
self):
"""Test Word.create_and_add_to with add_word that expects text but container has _words list."""
class TextExpectingContainer:
def __init__(self, font):
self.style = font
self._words = [] # Has _words list
self.add_word_calls = []
def add_word(self, text): # Parameter named 'text' suggests it expects string
# This simulates FormattedSpan.add_word behavior
self.add_word_calls.append(text)
# In real FormattedSpan, this would create a Word internally
container = TextExpectingContainer(self.font)
word = Word.create_and_add_to("test", container)
# Word should be added to _words list directly, not via add_word
self.assertEqual(len(container._words), 1)
self.assertEqual(container._words[0], word)
# add_word should not have been called since it expects text
self.assertEqual(len(container.add_word_calls), 0)
def test_word_create_and_add_to_parameter_inspection_fallback_without_words_list(
self):
"""Test Word.create_and_add_to fallback when container doesn't have _words list."""
class TextExpectingContainer:
def __init__(self, font):
self.style = font
# No _words list
self.added_words = []
def add_word(self, text): # Parameter suggests text but we'll pass Word as fallback
self.added_words.append(text)
container = TextExpectingContainer(self.font)
word = Word.create_and_add_to("test", container)
# Should fallback to calling add_word with Word object
self.assertEqual(len(container.added_words), 1)
self.assertEqual(container.added_words[0], word)
def test_word_create_and_add_to_no_parameters_edge_case(self):
"""Test Word.create_and_add_to with add_word method that has no parameters."""
class NoParamsContainer:
def __init__(self, font):
self.style = font
self.add_word_called = False
def add_word(self): # No parameters - edge case
self.add_word_called = True
container = NoParamsContainer(self.font)
# The current implementation will fail when calling add_word(word) with a
# no-parameter method
with self.assertRaises(TypeError) as context:
Word.create_and_add_to("test", container)
self.assertIn(
"takes 1 positional argument but 2 were given", str(
context.exception))
def test_word_create_and_add_to_linking_behavior_with_existing_words(self):
"""Test Word.create_and_add_to properly links with existing words in container."""
# Create container with existing words
class ContainerWithWords:
def __init__(self, font):
self.style = font
self._words = []
# Add an existing word
existing_word = Word("existing", font)
self._words.append(existing_word)
def add_word(self, word):
self._words.append(word)
container = ContainerWithWords(self.font)
existing_word = container._words[0]
# Create new word
new_word = Word.create_and_add_to("new", container)
# Test linking
self.assertEqual(new_word.previous, existing_word)
self.assertEqual(existing_word.next, new_word)
self.assertEqual(len(container._words), 2)
self.assertEqual(container._words[1], new_word)
def test_word_create_and_add_to_linking_behavior_with_words_method(self):
"""Test Word.create_and_add_to properly links with words from container.words() method."""
class ContainerWithWordsMethod:
def __init__(self, font):
self.style = font
self.existing_word1 = Word("first", font)
self.existing_word2 = Word("second", font)
self.existing_word1.add_next(self.existing_word2)
self.added_words = []
def words(self):
yield ("span1", self.existing_word1)
yield ("span2", self.existing_word2)
def add_word(self, word):
self.added_words.append(word)
container = ContainerWithWordsMethod(self.font)
# Create new word
new_word = Word.create_and_add_to("third", container)
# Should link to the last word returned by words() method
self.assertEqual(new_word.previous, container.existing_word2)
self.assertEqual(container.existing_word2.next, new_word)
def test_word_create_and_add_to_linking_behavior_empty_words_method(self):
"""Test Word.create_and_add_to with empty words() method."""
class EmptyWordsContainer:
def __init__(self, font):
self.style = font
def words(self):
# Empty iterator
return iter([])
def add_word(self, word):
pass
container = EmptyWordsContainer(self.font)
# Create word
word = Word.create_and_add_to("test", container)
# Should have no previous word
self.assertIsNone(word.previous)
def test_word_create_and_add_to_linking_behavior_words_method_exception(self):
"""Test Word.create_and_add_to with words() method that raises exception."""
class ExceptionWordsContainer:
def __init__(self, font):
self.style = font
def words(self):
raise TypeError("Error in words method")
def add_word(self, word):
pass
container = ExceptionWordsContainer(self.font)
# Create word - should handle exception gracefully
word = Word.create_and_add_to("test", container)
# Should have no previous word due to exception
self.assertIsNone(word.previous)
class TestFormattedSpan(unittest.TestCase):
"""Test cases for FormattedSpan class."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font()
# Font background is a tuple, not a string
def test_formatted_span_creation_minimal(self):
"""Test formatted span creation with minimal parameters."""
span = FormattedSpan(self.font)
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, self.font.background)
self.assertEqual(len(span.words), 0)
def test_formatted_span_creation_with_background(self):
"""Test formatted span creation with background override."""
span = FormattedSpan(self.font, background="yellow")
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, "yellow")
self.assertNotEqual(span.background, self.font.background)
def test_formatted_span_properties(self):
"""Test formatted span property getters."""
span = FormattedSpan(self.font, background="blue")
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, "blue")
self.assertIsInstance(span.words, list)
self.assertEqual(len(span.words), 0)
def test_add_single_word(self):
"""Test adding a single word to formatted span."""
span = FormattedSpan(self.font)
word = span.add_word("hello")
# Test returned word
self.assertIsInstance(word, Word)
self.assertEqual(word.text, "hello")
self.assertEqual(word.style, self.font)
self.assertEqual(word.background, self.font.background)
self.assertIsNone(word.previous)
# Test span state
self.assertEqual(len(span.words), 1)
self.assertEqual(span.words[0], word)
def test_add_multiple_words(self):
"""Test adding multiple words to formatted span."""
span = FormattedSpan(self.font)
word1 = span.add_word("first")
word2 = span.add_word("second")
word3 = span.add_word("third")
# Test span contains all words
self.assertEqual(len(span.words), 3)
self.assertEqual(span.words[0], word1)
self.assertEqual(span.words[1], word2)
self.assertEqual(span.words[2], word3)
# Test word linking
self.assertIsNone(word1.previous)
self.assertEqual(word1.next, word2)
self.assertEqual(word2.previous, word1)
self.assertEqual(word2.next, word3)
self.assertEqual(word3.previous, word2)
self.assertIsNone(word3.next)
def test_add_word_with_background_override(self):
"""Test that added words inherit span background."""
span = FormattedSpan(self.font, background="red")
word = span.add_word("test")
# Word should inherit span's background
self.assertEqual(word.background, "red")
self.assertEqual(word.style, self.font)
def test_word_style_consistency(self):
"""Test that all words in span have consistent style."""
span = FormattedSpan(self.font, background="green")
words = []
for text in ["this", "is", "a", "test"]:
words.append(span.add_word(text))
# All words should have same style and background
for word in words:
self.assertEqual(word.style, self.font)
self.assertEqual(word.background, "green")
def test_word_chain_integrity(self):
"""Test that word chain is properly maintained."""
span = FormattedSpan(self.font)
words = []
for i in range(5):
words.append(span.add_word(f"word{i}"))
# Test complete chain
for i in range(5):
word = words[i]
# Test previous link
if i == 0:
self.assertIsNone(word.previous)
else:
self.assertEqual(word.previous, words[i - 1])
# Test next link
if i == 4:
self.assertIsNone(word.next)
else:
self.assertEqual(word.next, words[i + 1])
def test_empty_span_operations(self):
"""Test operations on empty formatted span."""
span = FormattedSpan(self.font)
# Test empty state
self.assertEqual(len(span.words), 0)
self.assertEqual(span.words, [])
# Add first word
first_word = span.add_word("first")
self.assertIsNone(first_word.previous)
self.assertIsNone(first_word.next)
def test_formatted_span_create_and_add_to_with_container_style(self):
"""Test FormattedSpan.create_and_add_to with container that has style property."""
# Create mock container with style and add_span method
mock_container = Mock()
mock_container.style = self.font
mock_container.add_span = Mock()
# Remove background so it inherits from font
del mock_container.background
# Create and add span
span = FormattedSpan.create_and_add_to(mock_container)
# Test that span was created with correct properties
self.assertIsInstance(span, FormattedSpan)
self.assertEqual(span.style, self.font)
self.assertEqual(span.background, self.font.background)
# Test that add_span was called
mock_container.add_span.assert_called_once_with(span)
def test_formatted_span_create_and_add_to_with_style_override(self):
"""Test FormattedSpan.create_and_add_to with explicit style parameter."""
# Create alternate font
alt_font = Font()
# Create mock container
mock_container = Mock()
mock_container.style = self.font
mock_container.add_span = Mock()
# Create span with style override
span = FormattedSpan.create_and_add_to(mock_container, style=alt_font)
# Test that span uses the override style, not container style
self.assertEqual(span.style, alt_font)
self.assertNotEqual(span.style, self.font)
def test_formatted_span_create_and_add_to_with_background_override(self):
"""Test FormattedSpan.create_and_add_to with explicit background parameter."""
# Create mock container
mock_container = Mock()
mock_container.style = self.font
mock_container.background = "container_bg"
mock_container.add_span = Mock()
# Create span with background override
span = FormattedSpan.create_and_add_to(mock_container, background="override_bg")
# Test that span uses the override background
self.assertEqual(span.background, "override_bg")
def test_formatted_span_create_and_add_to_inherit_container_background(self):
"""Test FormattedSpan.create_and_add_to inheriting background from container."""
# Create mock container with background
mock_container = Mock()
mock_container.style = self.font
mock_container.background = "container_bg"
mock_container.add_span = Mock()
# Create span without background override
span = FormattedSpan.create_and_add_to(mock_container)
# Test that span inherits container background
self.assertEqual(span.background, "container_bg")
def test_formatted_span_create_and_add_to_no_style_error(self):
"""Test FormattedSpan.create_and_add_to raises error when container has no style."""
# Create mock container without style
mock_container = Mock()
del mock_container.style
mock_container.add_span = Mock()
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
FormattedSpan.create_and_add_to(mock_container)
self.assertIn("must have a 'style' property", str(context.exception))
def test_formatted_span_create_and_add_to_no_add_span_error(self):
"""Test FormattedSpan.create_and_add_to raises error when container has no add_span method."""
# Create mock container without add_span
mock_container = Mock()
mock_container.style = self.font
del mock_container.add_span
# Test that AttributeError is raised
with self.assertRaises(AttributeError) as context:
FormattedSpan.create_and_add_to(mock_container)
self.assertIn("must have an 'add_span' method", str(context.exception))
class TestWordFormattedSpanIntegration(unittest.TestCase):
"""Integration tests between Word and FormattedSpan classes."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font()
# Font background is a tuple, not a string
def test_sentence_creation(self):
"""Test creating a complete sentence with formatted span."""
span = FormattedSpan(self.font)
sentence_words = ["The", "quick", "brown", "fox", "jumps"]
words = []
for word_text in sentence_words:
words.append(span.add_word(word_text))
# Test sentence structure
self.assertEqual(len(span.words), 5)
# Test word content
for i, expected_text in enumerate(sentence_words):
self.assertEqual(words[i].text, expected_text)
# Test linking
for i in range(5):
if i > 0:
self.assertEqual(words[i].previous, words[i - 1])
if i < 4:
self.assertEqual(words[i].next, words[i + 1])
def test_multiple_spans_same_style(self):
"""Test creating multiple spans with the same style."""
font = Font()
span1 = FormattedSpan(font)
span2 = FormattedSpan(font)
# Add words to both spans
span1_words = [span1.add_word("First"), span1.add_word("span")]
span2_words = [span2.add_word("Second"), span2.add_word("span")]
# Test that spans are independent
self.assertEqual(len(span1.words), 2)
self.assertEqual(len(span2.words), 2)
# Test that words in different spans are not linked
self.assertIsNone(span1_words[1].next)
self.assertIsNone(span2_words[0].previous)
# But words within spans are linked
self.assertEqual(span1_words[0].next, span1_words[1])
self.assertEqual(span2_words[1].previous, span2_words[0])
def test_span_style_inheritance(self):
"""Test that words properly inherit span styling."""
font = Font()
# Font background is a tuple (255, 255, 255, 0)
# Test with span background override
span = FormattedSpan(font, background="lightgreen")
word1 = span.add_word("styled")
word2 = span.add_word("text")
# Both words should have span's background, not font's
self.assertEqual(word1.background, "lightgreen")
self.assertEqual(word2.background, "lightgreen")
# But they should have font's other properties
self.assertEqual(word1.style, font)
self.assertEqual(word2.style, font)
def test_word_modification_after_creation(self):
"""Test modifying words after they've been added to span."""
span = FormattedSpan(self.font)
word = span.add_word("original")
# Verify initial state
self.assertEqual(word.text, "original")
self.assertEqual(len(span.words), 1)
# Words are immutable by design (no setter for text property)
# But we can test that the reference is maintained
self.assertEqual(span.words[0], word)
self.assertEqual(span.words[0].text, "original")
class TestLineBreak(unittest.TestCase):
"""Test cases for LineBreak class."""
def test_line_break_creation(self):
"""Test line break creation."""
line_break = LineBreak()
# Test initial state
self.assertIsNotNone(line_break.block_type)
self.assertIsNone(line_break.parent)
def test_line_break_block_type(self):
"""Test line break block type property."""
line_break = LineBreak()
# Import BlockType to verify the correct type
from pyWebLayout.abstract.block import BlockType
self.assertEqual(line_break.block_type, BlockType.LINE_BREAK)
def test_line_break_parent_property(self):
"""Test line break parent property getter and setter."""
line_break = LineBreak()
# Test initial state
self.assertIsNone(line_break.parent)
# Test setter
mock_parent = Mock()
line_break.parent = mock_parent
self.assertEqual(line_break.parent, mock_parent)
# Test setting to None
line_break.parent = None
self.assertIsNone(line_break.parent)
def test_line_break_create_and_add_to_with_add_line_break(self):
"""Test LineBreak.create_and_add_to with container that has add_line_break method."""
# Create mock container with add_line_break method
mock_container = Mock()
mock_container.add_line_break = Mock()
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
# Test that add_line_break was called
mock_container.add_line_break.assert_called_once_with(line_break)
def test_line_break_create_and_add_to_with_add_element(self):
"""Test LineBreak.create_and_add_to with container that has add_element method."""
# Create mock container without add_line_break but with add_element
mock_container = Mock()
del mock_container.add_line_break # Ensure no add_line_break method
mock_container.add_element = Mock()
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
# Test that add_element was called
mock_container.add_element.assert_called_once_with(line_break)
def test_line_break_create_and_add_to_with_add_word(self):
"""Test LineBreak.create_and_add_to with container that has add_word method."""
# Create mock container with only add_word method
mock_container = Mock()
del mock_container.add_line_break # Ensure no add_line_break method
del mock_container.add_element # Ensure no add_element method
mock_container.add_word = Mock()
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
# Test that add_word was called
mock_container.add_word.assert_called_once_with(line_break)
def test_line_break_create_and_add_to_fallback(self):
"""Test LineBreak.create_and_add_to fallback behavior when no add methods available."""
# Create mock container without any add methods
mock_container = Mock()
del mock_container.add_line_break
del mock_container.add_element
del mock_container.add_word
# Create and add line break
line_break = LineBreak.create_and_add_to(mock_container)
# Test that line break was created
self.assertIsInstance(line_break, LineBreak)
# Test that parent was set manually
self.assertEqual(line_break.parent, mock_container)
if __name__ == '__main__':
unittest.main()
+60
View File
@@ -0,0 +1,60 @@
"""
Simplified unit tests for abstract document elements using test mixins.
This demonstrates how test mixins can eliminate duplication and simplify tests.
"""
import unittest
from pyWebLayout.abstract.document import Document, Chapter
from tests.mixins.font_registry_tests import FontRegistryTestMixin, FontRegistryParentDelegationTestMixin
from tests.mixins.metadata_tests import MetadataContainerTestMixin
class TestDocumentFontRegistry(FontRegistryTestMixin, unittest.TestCase):
"""Test FontRegistry behavior for Document - simplified with mixin."""
def create_test_object(self):
"""Create a Document instance for testing."""
return Document("Test Document", "en-US")
class TestDocumentMetadata(MetadataContainerTestMixin, unittest.TestCase):
"""Test MetadataContainer behavior for Document - simplified with mixin."""
def create_test_object(self):
"""Create a Document instance for testing."""
return Document("Test Document", "en-US")
class TestChapterFontRegistry(FontRegistryTestMixin, unittest.TestCase):
"""Test FontRegistry behavior for Chapter - simplified with mixin."""
def create_test_object(self):
"""Create a Chapter instance for testing."""
return Chapter("Test Chapter", level=1)
class TestChapterFontRegistryParentDelegation(
FontRegistryParentDelegationTestMixin,
unittest.TestCase):
"""Test FontRegistry parent delegation for Chapter - simplified with mixin."""
def create_parent(self):
"""Create a Document as parent."""
return Document("Parent Document", "en-US")
def create_child(self, parent):
"""Create a Chapter with parent reference."""
return Chapter("Child Chapter", level=1, parent=parent)
class TestChapterMetadata(MetadataContainerTestMixin, unittest.TestCase):
"""Test MetadataContainer behavior for Chapter - simplified with mixin."""
def create_test_object(self):
"""Create a Chapter instance for testing."""
return Chapter("Test Chapter", level=1)
if __name__ == '__main__':
unittest.main()
+194
View File
@@ -0,0 +1,194 @@
"""
Unit tests for LinkedWord and LinkedImage classes.
"""
import unittest
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.block import LinkedImage
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font
class TestLinkedWord(unittest.TestCase):
"""Test cases for LinkedWord class."""
def setUp(self):
"""Set up test fixtures."""
self.font = Font(font_size=16)
self.location = "https://example.com"
def test_linked_word_creation(self):
"""Test creating a LinkedWord."""
linked_word = LinkedWord(
text="example",
style=self.font,
location=self.location,
link_type=LinkType.EXTERNAL
)
self.assertEqual(linked_word.text, "example")
self.assertEqual(linked_word.location, self.location)
self.assertEqual(linked_word.link_type, LinkType.EXTERNAL)
self.assertIsNone(linked_word.link_callback)
def test_linked_word_inherits_from_word(self):
"""Test that LinkedWord inherits Word properties."""
linked_word = LinkedWord(
text="test",
style=self.font,
location=self.location
)
# Should have Word properties
self.assertEqual(linked_word.text, "test")
self.assertEqual(linked_word.style, self.font)
self.assertIsNone(linked_word.previous)
self.assertIsNone(linked_word.next)
def test_linked_word_with_callback(self):
"""Test LinkedWord with a callback function."""
callback_called = []
def test_callback(location, **params):
callback_called.append((location, params))
return "navigated"
linked_word = LinkedWord(
text="click",
style=self.font,
location=self.location,
link_type=LinkType.FUNCTION,
callback=test_callback,
params={"source": "test"}
)
linked_word.execute_link()
self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location)
self.assertIn("text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["text"], "click")
self.assertEqual(callback_called[0][1]["source"], "test")
def test_linked_word_execute_external_link(self):
"""Test executing an external link returns the location."""
linked_word = LinkedWord(
text="link",
style=self.font,
location=self.location,
link_type=LinkType.EXTERNAL
)
result = linked_word.execute_link()
self.assertEqual(result, self.location)
def test_linked_word_with_title(self):
"""Test LinkedWord with title/tooltip."""
linked_word = LinkedWord(
text="hover",
style=self.font,
location=self.location,
title="Click to visit example.com"
)
self.assertEqual(linked_word.link_title, "Click to visit example.com")
def test_linked_word_chain(self):
"""Test chaining multiple LinkedWords."""
word1 = LinkedWord(
text="click",
style=self.font,
location=self.location
)
word2 = LinkedWord(
text="here",
style=self.font,
location=self.location,
previous=word1
)
# Check chain
self.assertEqual(word1.next, word2)
self.assertEqual(word2.previous, word1)
class TestLinkedImage(unittest.TestCase):
"""Test cases for LinkedImage class."""
def setUp(self):
"""Set up test fixtures."""
self.source = "logo.png"
self.alt_text = "Company Logo"
self.location = "https://example.com/home"
def test_linked_image_creation(self):
"""Test creating a LinkedImage."""
linked_image = LinkedImage(
source=self.source,
alt_text=self.alt_text,
location=self.location,
width=100,
height=50,
link_type=LinkType.EXTERNAL
)
self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertEqual(linked_image.location, self.location)
self.assertEqual(linked_image.width, 100)
self.assertEqual(linked_image.height, 50)
self.assertEqual(linked_image.link_type, LinkType.EXTERNAL)
def test_linked_image_inherits_from_image(self):
"""Test that LinkedImage inherits Image properties."""
linked_image = LinkedImage(
source=self.source,
alt_text=self.alt_text,
location=self.location
)
# Should have Image properties and methods
self.assertEqual(linked_image.source, self.source)
self.assertEqual(linked_image.alt_text, self.alt_text)
self.assertIsNotNone(linked_image.get_dimensions)
def test_linked_image_with_callback(self):
"""Test LinkedImage with a callback function."""
callback_called = []
def image_callback(location, **params):
callback_called.append((location, params))
return "image_clicked"
linked_image = LinkedImage(
source=self.source,
alt_text=self.alt_text,
location=self.location,
link_type=LinkType.FUNCTION,
callback=image_callback
)
linked_image.execute_link()
self.assertEqual(len(callback_called), 1)
self.assertEqual(callback_called[0][0], self.location)
self.assertIn("alt_text", callback_called[0][1])
self.assertEqual(callback_called[0][1]["alt_text"], self.alt_text)
self.assertIn("source", callback_called[0][1])
def test_linked_image_execute_internal_link(self):
"""Test executing an internal link returns the location."""
linked_image = LinkedImage(
source=self.source,
alt_text=self.alt_text,
location="#section2",
link_type=LinkType.INTERNAL
)
result = linked_image.execute_link()
self.assertEqual(result, "#section2")
if __name__ == '__main__':
unittest.main()
View File
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env python3
"""
Unit tests for the alignment handler system.
Tests the various alignment handlers (Left, Center, Right, Justify) and their integration with Line objects.
"""
import unittest
from pyWebLayout.concrete.text import (
Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
)
from pyWebLayout.style import Alignment
from pyWebLayout.style import Font
from pyWebLayout.abstract import Word
from PIL import Image, ImageDraw
class TestAlignmentHandlers(unittest.TestCase):
"""Test cases for the alignment handler system"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font()
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.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.draw,
font=self.style,
halign=Alignment.CENTER)
self.assertIsInstance(
center_line._alignment_handler,
CenterRightAlignmentHandler)
# Check that it's configured for CENTER alignment
self.assertEqual(center_line._alignment_handler._alignment, Alignment.CENTER)
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.draw,
font=self.style,
halign=Alignment.RIGHT)
self.assertIsInstance(
right_line._alignment_handler,
CenterRightAlignmentHandler)
# Check that it's configured for RIGHT alignment
self.assertEqual(right_line._alignment_handler._alignment, Alignment.RIGHT)
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.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.draw,
halign=Alignment.LEFT)
# Add words until line is full or we run out
words_added = 0
for word in self.test_words:
result, part = left_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(left_line.text_objects), words_added)
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.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, part = center_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(center_line.text_objects), words_added)
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.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, part = right_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(right_line.text_objects), words_added)
def test_justify_alignment_word_addition(self):
"""Test adding words to a justified line"""
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, part = justify_line.add_word(word)
if not result:
# Word didn't fit
break
else:
words_added += 1
# Should have added at least some words
self.assertGreater(words_added, 0)
self.assertEqual(len(justify_line.text_objects), words_added)
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.style, self.draw)
for word in ["Hello", "World"]]
# Test each handler type
handlers = [
("Left", LeftAlignmentHandler()),
("Center", CenterRightAlignmentHandler(Alignment.CENTER)),
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
("Justify", JustifyAlignmentHandler())
]
for name, handler in handlers:
with self.subTest(handler=name):
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
self.assertIsInstance(spacing_calc, (int, float))
self.assertGreaterEqual(spacing_calc, 0)
# Check that position is a valid number
self.assertIsInstance(position, (int, float))
self.assertGreaterEqual(position, 0)
# 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.style, self.draw)
for word in ["Hello", "World"]]
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)
# 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.style, self.draw)
for word in ["Hello", "World"]]
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) 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.style, self.draw)
for word in ["Hello", "World"]]
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 >= 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.style, self.draw)
for word in ["Hello", "World"]]
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)
# Check spacing is reasonable
self.assertGreaterEqual(spacing_calc, 0)
def test_empty_line_alignment_handlers(self):
"""Test alignment handlers with empty lines"""
alignments = [
Alignment.LEFT,
Alignment.CENTER,
Alignment.RIGHT,
Alignment.JUSTIFY]
for alignment in alignments:
with self.subTest(alignment=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
line.render()
def test_single_word_line_alignment(self):
"""Test alignment handlers with single word lines"""
alignments = [
Alignment.LEFT,
Alignment.CENTER,
Alignment.RIGHT,
Alignment.JUSTIFY]
for alignment in alignments:
with self.subTest(alignment=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, 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
line.render()
self.assertEqual(len(line.text_objects), 1)
if __name__ == '__main__':
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
"""
Regression tests for word spacing under each alignment (spec S13).
Only justified text stretches word gaps to fill the measure. Left, centre and
right aligned text use a natural, constant word space and leave a ragged edge;
previously they distributed the residual space across the gaps, which produced
text that looked justified but did not reach the margin, with a right edge that
wobbled by several pixels from line to line.
The final line of a justified paragraph is also not stretched.
"""
import pytest
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import (
CenterRightAlignmentHandler,
JustifyAlignmentHandler,
LeftAlignmentHandler,
Line,
)
from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.style import Alignment, Font
from pyWebLayout.style.page_style import PageStyle
PAGE = (500, 400)
PADDING = (20, 20, 20, 20)
@pytest.fixture
def font():
return Font(font_size=14)
def lay_out(font, alignment, text, size=PAGE):
page = Page(size=size, style=PageStyle(border_width=0, padding=PADDING))
paragraph = Paragraph(font)
for word in text.split():
paragraph.add_word(Word(word, font))
paragraph_layouter(paragraph, page, alignment_override=alignment)
return page
def rendered_lines(page):
lines = [c for c in page.children if isinstance(c, Line) and c._text_objects]
for line in lines:
line.render()
return lines
def gaps_of(line):
"""Observed pixel gaps between consecutive words on a rendered line."""
tos = line._text_objects
return [int(tos[i + 1]._origin[0]) - (int(tos[i]._origin[0]) + int(tos[i].width))
for i in range(len(tos) - 1)]
BODY = ("Paragraph text that is automatically laid out when this paragraph does "
"not fit on the current page the layouter will create a new page for it "
"which differs from using an explicit page break marker in the source ") * 2
class TestLeftAlignmentUsesConstantSpacing:
def test_gaps_are_uniform_within_a_line(self, font):
page = lay_out(font, Alignment.LEFT, BODY)
for line in rendered_lines(page):
gaps = gaps_of(line)
if len(gaps) > 1:
assert max(gaps) - min(gaps) <= 1, \
f"left-aligned gaps should be constant, got {gaps}"
def test_gaps_are_uniform_across_lines(self, font):
"""The regression: each line got its own stretch factor."""
page = lay_out(font, Alignment.LEFT, BODY)
all_gaps = [g for line in rendered_lines(page) for g in gaps_of(line)]
assert max(all_gaps) - min(all_gaps) <= 1, \
f"spacing must not vary line to line, got {sorted(set(all_gaps))}"
def test_lines_do_not_reach_the_right_margin(self, font):
"""Left-aligned text is ragged; a flush right edge means it was stretched."""
page = lay_out(font, Alignment.LEFT, BODY)
right = page.content_rect[0] + page.content_rect[2]
ends = [max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
for line in rendered_lines(page)]
assert not all(right - e <= 1 for e in ends), \
"every line reached the margin exactly - text was justified, not left aligned"
def test_handler_returns_natural_spacing(self, font):
handler = LeftAlignmentHandler()
from pyWebLayout.concrete.text import Text
from PIL import Image, ImageDraw
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
texts = [Text(w, font, draw) for w in ["Hello", "World"]]
spacing, position, overflow = handler.calculate_spacing_and_position(
texts, 400, 3, 7, natural_spacing=5)
assert spacing == 5, "natural spacing should be used verbatim when it fits"
assert position == 0
assert not overflow
def test_handler_clamps_natural_spacing_to_bounds(self, font):
handler = LeftAlignmentHandler()
from pyWebLayout.concrete.text import Text
from PIL import Image, ImageDraw
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
texts = [Text(w, font, draw) for w in ["Hello", "World"]]
assert handler.calculate_spacing_and_position(
texts, 400, 3, 7, natural_spacing=99)[0] == 7
assert handler.calculate_spacing_and_position(
texts, 400, 3, 7, natural_spacing=1)[0] == 3
class TestJustifyStillFills:
def test_body_lines_reach_the_margin(self, font):
page = lay_out(font, Alignment.JUSTIFY, BODY)
lines = rendered_lines(page)
right = page.content_rect[0] + page.content_rect[2]
for line in lines:
if line.is_paragraph_end:
continue
end = max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
assert right - end <= 2, f"justified line fell {right - end}px short"
def test_last_line_is_not_stretched(self, font):
page = lay_out(font, Alignment.JUSTIFY,
BODY + " and then a deliberately short tail.")
lines = rendered_lines(page)
last = [line for line in lines if line.is_paragraph_end]
assert last, "the final line of a completed paragraph must be marked"
gaps = gaps_of(last[-1])
if gaps:
assert max(gaps) <= 8, \
f"final line was justified across the measure, gaps={gaps}"
def test_continued_paragraph_keeps_justification(self, font):
"""A paragraph split across pages: its lines are not paragraph ends."""
page = lay_out(font, Alignment.JUSTIFY, BODY * 6, size=(500, 200))
lines = rendered_lines(page)
assert lines, "the page should hold some lines"
assert not any(line.is_paragraph_end for line in lines), \
"an unfinished paragraph has no final line on this page"
class TestCentreAndRight:
def test_centre_uses_constant_spacing_and_is_centred(self, font):
page = lay_out(font, Alignment.CENTER, BODY)
right = page.content_rect[0] + page.content_rect[2]
left = page.content_rect[0]
for line in rendered_lines(page):
tos = line._text_objects
# Float extents: integer truncation of each end would itself skew the
# comparison by a pixel.
start = float(tos[0]._origin[0])
end = float(tos[-1]._origin[0]) + tos[-1].width
# Equal margins either side, within rounding of the half-space.
assert abs((start - left) - (right - end)) <= 2, \
f"line not centred: left margin {start - left}, right {right - end}"
def test_right_aligned_lines_end_at_the_margin(self, font):
page = lay_out(font, Alignment.RIGHT, BODY)
right = page.content_rect[0] + page.content_rect[2]
for line in rendered_lines(page):
end = max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
assert right - end <= 2, f"right-aligned line fell {right - end}px short"
+129
View File
@@ -0,0 +1,129 @@
"""
Regression tests for the page draw/canvas lifecycle (spec S3).
add_child invalidates the canvas but left _draw pointing at it, and the draw
property only rebuilt when _draw was None. Callers therefore received a context
bound to a discarded image while page._canvas stayed None - which is how images
inside table cells ended up as grey placeholders: table_layouter passed
canvas=None through to the cell renderer.
Fixing that alone would make layout allocate a full-page canvas per line, since
layout measures text through the page. Measurement now goes through a dedicated
scratch context.
"""
import pytest
from PIL import Image
from pyWebLayout.abstract.block import Image as AbstractImage, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
@pytest.fixture
def font():
return Font(font_size=12)
@pytest.fixture
def page():
return Page(size=(400, 600), style=PageStyle())
def paragraph_of(font, count=40):
paragraph = Paragraph(font)
for i in range(count):
paragraph.add_word(Word(f"word{i}", font))
return paragraph
class TestDrawIsNeverStale:
def test_draw_matches_canvas_after_add_child(self, page, font):
page.draw # force canvas creation
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
assert page.draw.im is page._canvas.im, \
"draw must be bound to the page's current canvas"
def test_canvas_is_present_after_layout(self, page, font):
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
page.draw
assert page._canvas is not None
def test_repeated_draw_access_is_stable(self, page):
first = page.draw
assert page.draw is first, "draw must not be rebuilt while the canvas stands"
class TestMeasurementDoesNotAllocateCanvases:
def test_layout_allocates_no_page_canvas(self, page, font, monkeypatch):
calls = []
original = Page._create_canvas
def counting(self):
calls.append(1)
return original(self)
monkeypatch.setattr(Page, "_create_canvas", counting)
DocumentLayouter(page).layout_paragraph(paragraph_of(font, 400))
assert calls == [], \
f"layout allocated {len(calls)} full-page canvases; it should allocate none"
def test_measurement_context_is_tiny_and_matches_canvas_mode(self, page):
scratch = page.measurement_draw
assert scratch.im.size == (1, 1)
assert scratch.mode == Page._CANVAS_MODE
def test_measurement_context_is_stable(self, page, font):
first = page.measurement_draw
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
assert page.measurement_draw is first, \
"the scratch context must survive canvas invalidation"
class TestRenderIsRepeatable:
def test_two_renders_are_identical(self, page, font):
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
first = page.render().copy()
second = page.render().copy()
assert first.tobytes() == second.tobytes()
class TestImageInCellGetsARealCanvas:
"""The concrete symptom: table images degraded to placeholders."""
@pytest.fixture
def image_path(self, tmp_path):
path = tmp_path / "swatch.png"
Image.new("RGB", (40, 30), (10, 200, 10)).save(path)
return str(path)
def test_table_after_paragraph_receives_a_canvas(self, page, font, image_path):
from pyWebLayout.abstract.block import Table, TableCell, TableRow
from pyWebLayout.layout.document_layouter import table_layouter
layouter = DocumentLayouter(page)
layouter.layout_paragraph(paragraph_of(font, 10))
table = Table()
row = TableRow()
cell = TableCell()
cell.add_block(AbstractImage(image_path))
row.add_cell(cell)
table.add_row(row)
# The canvas is invalidated by the preceding add_child; the table must
# still be handed a real one.
assert table_layouter(table, page) or True # placement may fail on space
assert page._canvas is not None, \
"table layout must not run against a None canvas"
+101
View File
@@ -0,0 +1,101 @@
"""
Unit tests for pyWebLayout.concrete.box module.
Tests the Box class which handles basic box model rendering with alignment.
"""
import unittest
import numpy as np
from PIL import Image
from unittest.mock import Mock
from pyWebLayout.concrete.box import Box
from pyWebLayout.style import Alignment
class TestBox(unittest.TestCase):
"""Test cases for the Box class"""
def setUp(self):
"""Set up test fixtures"""
self.origin = (10, 20)
self.size = (100, 50)
self.callback = Mock()
def test_box_initialization_basic(self):
"""Test basic box initialization"""
box = Box(self.origin, self.size)
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50]))
np.testing.assert_array_equal(box._end, np.array([110, 70]))
self.assertIsNone(box._callback)
self.assertIsNone(box._sheet)
self.assertIsNone(box._mode)
self.assertEqual(box._halign, Alignment.CENTER)
self.assertEqual(box._valign, Alignment.CENTER)
def test_box_initialization_with_callback(self):
"""Test box initialization with callback"""
box = Box(self.origin, self.size, callback=self.callback)
self.assertEqual(box._callback, self.callback)
def test_box_initialization_with_sheet(self):
"""Test box initialization with image sheet"""
sheet = Image.new('RGBA', (200, 100), (255, 255, 255, 255))
box = Box(self.origin, self.size, sheet=sheet)
self.assertEqual(box._sheet, sheet)
self.assertEqual(box._mode, 'RGBA')
def test_box_initialization_with_mode(self):
"""Test box initialization with explicit mode"""
box = Box(self.origin, self.size, mode='RGB')
self.assertEqual(box._mode, 'RGB')
def test_box_initialization_with_alignment(self):
"""Test box initialization with custom alignment"""
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
self.assertEqual(box._halign, Alignment.LEFT)
self.assertEqual(box._valign, Alignment.TOP)
def test_in_shape_point_inside(self):
"""Test in_shape method with point inside box"""
box = Box(self.origin, self.size)
# Test point inside
self.assertTrue(box.in_shape(np.array([50, 40])))
self.assertTrue(box.in_shape(np.array([10, 20]))) # Top-left corner
self.assertTrue(box.in_shape(np.array([109, 69]))) # Just inside bottom-right
def test_in_shape_point_outside(self):
"""Test in_shape method with point outside box"""
box = Box(self.origin, self.size)
# Test points outside
self.assertFalse(box.in_shape(np.array([5, 15]))) # Before origin
self.assertFalse(box.in_shape(np.array([110, 70]))) # At end (exclusive)
self.assertFalse(box.in_shape(np.array([150, 100]))) # Far outside
def test_in_shape_multiple_points(self):
"""Test in_shape method with array of points"""
box = Box(self.origin, self.size)
points = np.array([[50, 40], [5, 15], [109, 69], [110, 70]])
result = box.in_shape(points)
np.testing.assert_array_equal(result, [True, False, True, False])
def test_properties_access(self):
"""Test that properties can be accessed correctly"""
box = Box(self.origin, self.size, callback=self.callback)
# Test that origin property works (should be available via inheritance)
np.testing.assert_array_equal(box._origin, np.array([10, 20]))
np.testing.assert_array_equal(box._size, np.array([100, 50]))
if __name__ == '__main__':
unittest.main()
+499
View File
@@ -0,0 +1,499 @@
"""
Unit tests for pyWebLayout.concrete.functional module.
Tests the LinkText, ButtonText, and FormFieldText classes.
"""
import unittest
import numpy as np
from unittest.mock import Mock, patch
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, FormField, LinkType, FormFieldType
)
from pyWebLayout.style import Font, TextDecoration
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 - origin is at baseline (10, 20), so test at baseline Y
self.assertTrue(renderable.in_object((15, 20)))
# 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. The label's
# height is its ink height (ascent + descent), not the nominal font size.
ascent, descent = renderable._style.font.getmetrics()
expected_height = (ascent + descent) + FormFieldText.LABEL_GAP \
+ 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"
# Link callback: receives (location, point, **params)
def link_callback(location, point, **params):
return "callback_executed"
self.link_callback = link_callback
# Button callback: receives (point, **params)
def button_callback(point, **params):
return "callback_executed"
self.button_callback = button_callback
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.link_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.button_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()
+386
View File
@@ -0,0 +1,386 @@
"""
Unit tests for pyWebLayout.concrete.image module.
Tests the RenderableImage class for image loading, scaling, and rendering functionality.
"""
import unittest
import os
import tempfile
import numpy as np
from PIL import Image as PILImage, ImageDraw
from unittest.mock import Mock, patch
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.abstract.block import Image as AbstractImage
from pyWebLayout.style import Alignment
class TestRenderableImage(unittest.TestCase):
"""Test cases for the RenderableImage class"""
def setUp(self):
"""Set up test fixtures"""
# Create a temporary test image
self.temp_dir = tempfile.mkdtemp()
self.test_image_path = os.path.join(self.temp_dir, "test_image.png")
# Create a simple test image
test_img = PILImage.new('RGB', (100, 80), (255, 0, 0)) # Red image
test_img.save(self.test_image_path)
# 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"""
# Clean up temporary files
try:
os.unlink(self.test_image_path)
os.rmdir(self.temp_dir)
except BaseException:
pass
def test_renderable_image_initialization_basic(self):
"""Test basic image initialization"""
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)
self.assertEqual(renderable._valign, Alignment.CENTER)
def test_renderable_image_initialization_with_constraints(self):
"""Test image initialization with size constraints"""
max_width = 50
max_height = 40
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
self.assertEqual(renderable._abstract_image, self.abstract_image)
# Size should be constrained
self.assertLessEqual(renderable._size[0], max_width)
self.assertLessEqual(renderable._size[1], max_height)
def test_renderable_image_initialization_with_custom_params(self):
"""Test image initialization with custom parameters"""
custom_origin = (20, 30)
custom_size = (120, 90)
renderable = RenderableImage(
self.abstract_image,
self.draw,
origin=custom_origin,
size=custom_size,
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._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, self.draw)
# Image should be loaded
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
self.assertEqual(renderable._pil_image.size, (100, 80))
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, self.draw)
# Should have error message, no PIL image
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
@patch('requests.get')
def test_load_image_url_success(self, mock_get):
"""Test loading image from URL (success)"""
# Create a mock response
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = open(self.test_image_path, 'rb').read()
mock_get.return_value = mock_response
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract, self.draw)
# Should successfully load image
self.assertIsNotNone(renderable._pil_image)
self.assertIsNone(renderable._error_message)
@patch('requests.get')
def test_load_image_url_failure(self, mock_get):
"""Test loading image from URL (failure)"""
# Mock a failed request
mock_response = Mock()
mock_response.status_code = 404
mock_get.return_value = mock_response
url_abstract = AbstractImage(
"https://example.com/notfound.png",
"Bad URL Image")
renderable = RenderableImage(url_abstract, self.draw)
# Should have error message
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
def test_load_image_no_requests_library(self):
"""Test loading URL image when requests library is not available"""
# Mock the import to raise ImportError for requests
def mock_import(name, *args, **kwargs):
if name == 'requests':
raise ImportError("No module named 'requests'")
return __import__(name, *args, **kwargs)
with patch('builtins.__import__', side_effect=mock_import):
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
renderable = RenderableImage(url_abstract, self.draw)
# Should have error message about missing requests
self.assertIsNone(renderable._pil_image)
self.assertIsNotNone(renderable._error_message)
self.assertIn("Requests library not available", renderable._error_message)
def test_resize_image_fit_within_bounds(self):
"""Test image resizing to fit within bounds"""
renderable = RenderableImage(self.abstract_image, self.draw)
# Original image is 100x80, resize to fit in 50x50
renderable._size = np.array([50, 50])
resized = renderable._resize_image()
self.assertIsInstance(resized, PILImage.Image)
# Should maintain aspect ratio and fit within bounds
self.assertLessEqual(resized.width, 50)
self.assertLessEqual(resized.height, 50)
# Check aspect ratio is maintained (approximately)
original_ratio = 100 / 80
new_ratio = resized.width / resized.height
self.assertAlmostEqual(original_ratio, new_ratio, delta=0.1)
def test_resize_image_larger_target(self):
"""Test image resizing when target is larger than original"""
renderable = RenderableImage(self.abstract_image, self.draw)
# Target size larger than original
renderable._size = np.array([200, 160])
resized = renderable._resize_image()
self.assertIsInstance(resized, PILImage.Image)
# Should scale up to fill the space while maintaining aspect ratio
self.assertGreater(resized.width, 100)
self.assertGreater(resized.height, 80)
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, self.draw)
resized = renderable._resize_image()
# Should return a placeholder image
self.assertIsInstance(resized, PILImage.Image)
self.assertEqual(resized.mode, 'RGBA')
def test_draw_error_placeholder(self):
"""Test drawing error placeholder"""
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()
# 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, self.canvas)
renderable.set_origin(np.array([10, 20]))
# Render returns nothing (draws directly into canvas)
result = renderable.render()
# 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, self.canvas)
renderable.set_origin(np.array([10, 20]))
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
result = renderable.render()
# 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()
# 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()
# 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_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, self.canvas)
renderable.set_origin(np.array([10, 20]))
result = renderable.render()
# 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, self.draw, origin=(10, 20))
# Point inside image
self.assertTrue(renderable.in_object((15, 25)))
# Point outside image
self.assertFalse(renderable.in_object((200, 200)))
def test_in_object_with_numpy_array(self):
"""Test in_object with numpy array point"""
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
# Point inside image as numpy array
point = np.array([15, 25])
self.assertTrue(renderable.in_object(point))
# Point outside image as numpy array
point = np.array([200, 200])
self.assertFalse(renderable.in_object(point))
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, self.draw)
# Size should match the calculated scaled dimensions
expected_size = self.abstract_image.calculate_scaled_dimensions()
np.testing.assert_array_equal(renderable._size, np.array(expected_size))
def test_image_size_calculation_with_constraints(self):
"""Test size calculation with max constraints"""
max_width = 60
max_height = 50
renderable = RenderableImage(
self.abstract_image,
self.draw,
max_width=max_width,
max_height=max_height
)
# Size should respect constraints
self.assertLessEqual(renderable._size[0], max_width)
self.assertLessEqual(renderable._size[1], max_height)
def test_image_without_initial_dimensions(self):
"""Test image without initial dimensions in abstract image"""
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__':
unittest.main()
+321
View File
@@ -0,0 +1,321 @@
"""
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, ImageDraw
from unittest.mock import Mock
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Alignment
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
class TestText(unittest.TestCase):
def setUp(self):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
# Create a consistent test Font object using bundled font
self.style = create_default_test_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)
# Set origin at baseline position (50, 50)
text_instance.set_origin(np.array([50, 50]))
# Test with a point that should be inside the text bounds
# The text origin is at the baseline (50, 50)
# Visual bounds are: top = 50 - ascent, bottom = 50 + descent
# So a point at (55, 50) should be inside (at baseline)
point = (55, 50)
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):
# Ensure consistent font usage across tests
ensure_consistent_font_in_tests()
# Create a real PIL image (canvas) for testing
self.canvas = Image.new('RGB', (800, 600), color='white')
# Create a real ImageDraw object
self.draw = ImageDraw.Draw(self.canvas)
# Create a consistent test Font object using bundled font
self.style = create_default_test_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 words until line is full or overflow occurs"""
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
)
# Add words until the line is full
words_added = 0
for i in range(100):
word = Word(text="Amsterdam", style=self.style)
success, overflow_part = line.add_word(word)
if overflow_part:
# Word was hyphenated - overflow occurred
self.assertIsNotNone(overflow_part.text)
return
elif not success:
# Line is full, word couldn't be added
self.assertGreater(
words_added, 0, "Should have added at least one word before line filled")
return
else:
# Word was added successfully
words_added += 1
self.fail("Expected line to fill or overflow to occur but reached max iterations")
def test_line_add_word_until_overflow_small(self):
"""Test adding small words until line is full (no overflow expected)"""
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 not success:
self.assertIsNone(overflow_part)
return
self.fail("Expected line to reach capacity but reached max iterations")
def test_line_add_word_until_overflow_long_brute(self):
"""Test adding words until line is full - tests brute force hyphenation with longer word"""
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,
min_word_length_for_brute_force=6 # Lower threshold to enable hyphenation for shorter words
)
# Use a longer word to trigger brute force hyphenation
words_added = 0
for i in range(100):
# 8 A's to ensure it's long enough
word = Word(text="AAAAAAAA", style=self.style)
success, overflow_part = line.add_word(word)
if overflow_part:
# Word was hyphenated - verify overflow part exists
self.assertIsNotNone(overflow_part.text)
self.assertGreater(len(overflow_part.text), 0)
return
elif not success:
# Line is full, word couldn't be added
self.assertGreater(
words_added, 0, "Should have added at least one word before line filled")
return
else:
words_added += 1
self.fail("Expected line to fill or overflow to occur but reached max iterations")
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()
+313
View File
@@ -0,0 +1,313 @@
"""
Unit tests for DynamicPage class.
"""
import pytest
from PIL import Image
from pyWebLayout.concrete.dynamic_page import DynamicPage, SizeConstraints
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
class TestSizeConstraints:
"""Test SizeConstraints dataclass."""
def test_default_constraints(self):
"""Test default constraint values."""
constraints = SizeConstraints()
assert constraints.min_width is None
assert constraints.max_width is None
assert constraints.min_height is None
assert constraints.max_height is None
def test_custom_constraints(self):
"""Test custom constraint values."""
constraints = SizeConstraints(
min_width=100,
max_width=500,
min_height=50,
max_height=1000
)
assert constraints.min_width == 100
assert constraints.max_width == 500
assert constraints.min_height == 50
assert constraints.max_height == 1000
class TestDynamicPage:
"""Test DynamicPage class."""
def test_initialization(self):
"""Test DynamicPage initialization."""
page = DynamicPage()
assert page.size == (0, 0) # Starts with zero size
assert not page._is_measured
assert not page._is_laid_out
assert page._render_offset == 0
assert page.constraints is not None
def test_initialization_with_constraints(self):
"""Test initialization with custom constraints."""
constraints = SizeConstraints(min_width=200, max_width=800)
page = DynamicPage(constraints=constraints)
assert page.constraints.min_width == 200
assert page.constraints.max_width == 800
def test_initialization_with_style(self):
"""Test initialization with custom style."""
style = PageStyle(border_width=2, padding=(10, 20, 10, 20))
page = DynamicPage(style=style)
assert page.style.border_width == 2
assert page.style.padding_top == 10
def test_measure_empty_page(self):
"""Test measuring an empty page."""
page = DynamicPage()
width, height = page.measure()
# Empty page should have minimal size (just padding/borders)
assert width > 0 # At least padding/borders
assert height > 0
assert page._is_measured
def test_measure_with_constraints(self):
"""Test measuring respects constraints."""
constraints = SizeConstraints(min_width=300, min_height=200)
page = DynamicPage(constraints=constraints)
width, height = page.measure()
assert width >= 300
assert height >= 200
def test_measure_caching(self):
"""Test that measurement is cached."""
page = DynamicPage()
# First measurement
size1 = page.measure()
# Second measurement should return cached value
size2 = page.measure()
assert size1 == size2
assert page._is_measured
def test_get_min_width(self):
"""Test get_min_width."""
page = DynamicPage()
min_width = page.get_min_width()
assert min_width > 0
assert isinstance(min_width, int)
def test_get_preferred_width(self):
"""Test get_preferred_width."""
page = DynamicPage()
pref_width = page.get_preferred_width()
assert pref_width > 0
assert isinstance(pref_width, int)
def test_measure_content_height(self):
"""Test measure_content_height."""
page = DynamicPage()
content_height = page.measure_content_height()
assert content_height > 0
assert isinstance(content_height, int)
def test_layout(self):
"""Test layout method."""
page = DynamicPage()
target_size = (400, 600)
page.layout(target_size)
assert page.size == target_size
assert page._is_laid_out
assert page._dirty # Should be marked for re-render
def test_render_without_layout(self):
"""Test rendering without explicit layout (auto-sizing)."""
page = DynamicPage()
image = page.render()
assert isinstance(image, Image.Image)
assert image.size[0] > 0
assert image.size[1] > 0
def test_render_with_layout(self):
"""Test rendering after explicit layout."""
page = DynamicPage()
page.layout((500, 700))
image = page.render()
assert isinstance(image, Image.Image)
assert image.size == (500, 700)
def test_add_child_invalidates_cache(self):
"""Test that adding a child invalidates measurement caches."""
page = DynamicPage()
# Measure to populate cache
page.measure()
assert page._is_measured
# Add a child (mock renderable)
class MockRenderable:
def __init__(self):
self.size = (100, 50)
self._origin = (0, 0)
@property
def origin(self):
return self._origin
def render(self):
pass
page.add_child(MockRenderable())
# Caches should be invalidated
assert not page._is_measured
assert page._intrinsic_size is None
def test_clear_children_invalidates_cache(self):
"""Test that clearing children invalidates caches."""
page = DynamicPage()
# Measure to populate cache
page.measure()
assert page._is_measured
# Clear children
page.clear_children()
# Caches should be invalidated
assert not page._is_measured
def test_pagination_reset(self):
"""Test pagination reset."""
page = DynamicPage()
page._render_offset = 100
page.reset_pagination()
assert page._render_offset == 0
def test_has_more_content_false(self):
"""Test has_more_content when all content is rendered."""
page = DynamicPage()
# Set render offset to total height
total_height = page.measure_content_height()
page._render_offset = total_height
assert not page.has_more_content()
def test_has_more_content_true(self):
"""Test has_more_content when content remains."""
page = DynamicPage()
# Offset is less than total
page._render_offset = 0
assert page.has_more_content()
def test_min_width_measurement(self):
"""Test min width measures longest word."""
page = DynamicPage()
# Min width should be at least padding/borders
min_width = page.get_min_width()
assert min_width > 0
def test_invalidate_caches(self):
"""Test cache invalidation."""
page = DynamicPage()
# Populate caches
page.measure()
page.get_min_width()
page.get_preferred_width()
page.measure_content_height()
assert page._is_measured
assert page._intrinsic_size is not None
assert page._min_width_cache is not None
assert page._preferred_width_cache is not None
assert page._content_height_cache is not None
# Invalidate
page.invalidate_caches()
assert not page._is_measured
assert page._intrinsic_size is None
assert page._min_width_cache is None
assert page._preferred_width_cache is None
assert page._content_height_cache is None
assert not page._is_laid_out
def test_measure_with_available_width(self):
"""Test measurement with available_width constraint."""
page = DynamicPage()
width, height = page.measure(available_width=300)
# Width should respect available_width
assert width <= 300
def test_constraints_override_available_width(self):
"""Test that constraints override available_width."""
constraints = SizeConstraints(min_width=400)
page = DynamicPage(constraints=constraints)
width, height = page.measure(available_width=300)
# Should use min_width constraint, not available_width
assert width >= 400
def test_render_partial_empty_page(self):
"""Test partial rendering on empty page."""
page = DynamicPage()
rendered = page.render_partial(available_height=100)
assert rendered >= 0
assert isinstance(rendered, int)
def test_method_chaining_add_child(self):
"""Test that add_child returns self for chaining."""
page = DynamicPage()
class MockRenderable:
def __init__(self):
self.size = (50, 50)
self._origin = (0, 0)
@property
def origin(self):
return self._origin
result = page.add_child(MockRenderable())
assert result is page
def test_method_chaining_clear_children(self):
"""Test that clear_children returns self for chaining."""
page = DynamicPage()
result = page.clear_children()
assert result is page
if __name__ == '__main__':
pytest.main([__file__, '-v'])
+140
View File
@@ -0,0 +1,140 @@
"""
Regression tests for form field label geometry (spec S15).
Text renders with a baseline anchor, so drawing the label at the field's origin
put its glyphs above that origin - outside the box the field claims through size
and in_object. Stacked fields therefore had each label overprinting the input box
of the field before it.
"""
import numpy as np
import pytest
from PIL import Image, ImageDraw
from pyWebLayout.abstract.functional import Form, FormField, FormFieldType
from pyWebLayout.concrete.functional import FormFieldText
from pyWebLayout.concrete.page import Page
from pyWebLayout.layout.document_layouter import form_layouter
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
ORIGIN = (10, 40)
FIELD_HEIGHT = 24
@pytest.fixture
def font():
return Font(font_size=12, colour=(0, 0, 0))
@pytest.fixture
def canvas():
image = Image.new("RGB", (300, 200), (255, 255, 255))
return image, ImageDraw.Draw(image)
def make_field(font, draw, label="Email Address"):
field = FormField(name="email", field_type=FormFieldType.TEXT, label=label)
renderable = FormFieldText(field, font, draw, field_height=FIELD_HEIGHT)
renderable.set_origin(np.array(list(ORIGIN)))
return renderable
def ink_rows(image, x_range, y_range):
pixels = image.convert("RGB").load()
return [y for y in y_range
if any(sum(pixels[x, y]) < 400 for x in x_range)]
class TestLabelStaysInsideTheFieldBox:
def test_label_ink_is_below_the_origin(self, font, canvas):
image, draw = canvas
renderable = make_field(font, draw)
renderable.render()
rows = ink_rows(image, range(ORIGIN[0], ORIGIN[0] + 140),
range(0, ORIGIN[1]))
assert not rows, \
f"label drew above its own origin, at rows {rows}"
def test_label_and_box_do_not_overlap(self, font, canvas):
image, draw = canvas
renderable = make_field(font, draw)
renderable.render()
ascent, descent = font.font.getmetrics()
label_bottom = ORIGIN[1] + ascent + descent
box_top = renderable.field_area_offset + ORIGIN[1]
assert box_top >= label_bottom, \
"the input box must start below the label's descenders"
def test_reported_height_covers_everything_drawn(self, font, canvas):
image, draw = canvas
renderable = make_field(font, draw)
renderable.render()
top, bottom = ORIGIN[1], ORIGIN[1] + int(renderable.size[1])
rows = ink_rows(image, range(ORIGIN[0], ORIGIN[0] + 200), range(0, 200))
assert min(rows) >= top, "ink above the field's declared box"
assert max(rows) < bottom, "ink below the field's declared box"
class TestStackedFieldsDoNotCollide:
def test_form_layout_leaves_labels_clear(self, font):
page = Page(size=(300, 400), style=PageStyle())
form = Form("signup")
for name in ["Username", "Email Address", "Password"]:
form.add_field(FormField(name=name.lower().replace(" ", "_"),
field_type=FormFieldType.TEXT, label=name))
ok, ids = form_layouter(form, page, font)
assert ok and len(ids) == 3
fields = [c for c in page.children if isinstance(c, FormFieldText)]
assert len(fields) == 3
for earlier, later in zip(fields, fields[1:]):
earlier_bottom = earlier.origin[1] + earlier.size[1]
assert later.origin[1] >= earlier_bottom, \
"fields overlap: a label would print over the preceding input box"
def test_rendered_form_has_no_ink_collisions(self, font):
"""Every field's ink stays within its own declared bounds."""
page = Page(size=(300, 400), style=PageStyle())
form = Form("signup")
for name in ["Username", "Email Address"]:
form.add_field(FormField(name=name.lower(), field_type=FormFieldType.TEXT,
label=name))
form_layouter(form, page, font)
image = page.render()
fields = [c for c in page.children if isinstance(c, FormFieldText)]
for field in fields:
top = int(field.origin[1])
bottom = top + int(field.size[1])
rows = ink_rows(image, range(int(field.origin[0]),
int(field.origin[0] + field.size[0])),
range(max(0, top - 6), top))
assert not rows, f"ink found just above a field at y={top}"
class TestClickTargetsFollowTheLayout:
def test_click_in_the_input_area_focuses(self, font, canvas):
_, draw = canvas
renderable = make_field(font, draw)
inside = (5, renderable.field_area_offset + FIELD_HEIGHT // 2)
assert renderable.handle_click(inside) is True
assert renderable._focused is True
def test_click_on_the_label_does_not_focus(self, font, canvas):
_, draw = canvas
renderable = make_field(font, draw)
on_label = (5, 2)
assert renderable.handle_click(on_label) is False
@@ -0,0 +1,124 @@
"""
Regression tests for vertical centring of text in buttons and form fields.
Both placed the baseline at `top + height/2 + descent/2`. Centring text whose
visual height is ascent+descent inside a box of height H puts the baseline at
`top + H/2 + (ascent-descent)/2`; the two agree only when ascent == 2*descent.
Real fonts have a much larger ratio - DejaVu is nearer 4:1 - so the text sat
several pixels high, hugging the top edge of the button.
The button was also sized from the nominal font size rather than the text's
actual visual height, leaving it too short to centre anything in.
"""
import numpy as np
import pytest
from PIL import Image, ImageDraw
from pyWebLayout.abstract.functional import Button, FormField, FormFieldType
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
from pyWebLayout.style import Font
CANVAS = (300, 120)
PADDING = (6, 10, 6, 10) # top, right, bottom, left
@pytest.fixture
def draw_ctx():
image = Image.new("RGB", CANVAS, (255, 255, 255))
return image, ImageDraw.Draw(image)
def ink_rows(image, box):
"""
Rows within box that carry text ink.
Only the central columns are sampled: the button has rounded corners, so the
page background shows through at the extremes of every row and would read as
white text on all of them.
"""
x0, y0, x1, y1 = box
inset = (x1 - x0) // 4
pixels = image.convert("RGB").load()
rows = []
for y in range(y0, y1):
for x in range(x0 + inset, x1 - inset):
r, g, b = pixels[x, y]
# Button text is white on a blue fill; look for near-white ink.
if r > 240 and g > 240 and b > 240:
rows.append(y)
break
return rows
class TestButtonTextCentring:
@pytest.mark.parametrize("font_size", [10, 14, 20])
def test_text_is_vertically_centred(self, draw_ctx, font_size):
image, draw = draw_ctx
font = Font(font_size=font_size, colour=(255, 255, 255))
button = ButtonText(Button(label="Save Document", callback=lambda p: None),
font, draw, padding=PADDING)
button.set_origin(np.array([20, 20]))
button.render()
x0, y0 = 20, 20
x1 = x0 + int(button.size[0])
y1 = y0 + int(button.size[1])
rows = ink_rows(image, (x0, y0, x1, y1))
assert rows, "the button should have visible text"
gap_above = min(rows) - y0
gap_below = y1 - max(rows) - 1
assert abs(gap_above - gap_below) <= 2, (
f"text not centred at size {font_size}: "
f"{gap_above}px above, {gap_below}px below")
def test_button_is_tall_enough_for_its_text(self):
font = Font(font_size=14, colour=(255, 255, 255))
image = Image.new("RGB", CANVAS, (255, 255, 255))
draw = ImageDraw.Draw(image)
button = ButtonText(Button(label="Cancel", callback=lambda p: None),
font, draw, padding=PADDING)
ascent, descent = font.font.getmetrics()
assert int(button.size[1]) >= ascent + descent + PADDING[0] + PADDING[2], \
"button height must accommodate the text's visual height, not the nominal size"
def test_text_stays_inside_the_button(self, draw_ctx):
image, draw = draw_ctx
font = Font(font_size=14, colour=(255, 255, 255))
button = ButtonText(Button(label="Save Document", callback=lambda p: None),
font, draw, padding=PADDING)
button.set_origin(np.array([20, 20]))
button.render()
y0, y1 = 20, 20 + int(button.size[1])
rows = ink_rows(image, (20, y0, 20 + int(button.size[0]), y1))
assert min(rows) >= y0, "text escaped above the button"
assert max(rows) < y1, "text escaped below the button"
class TestFormFieldValueCentring:
def test_value_is_centred_in_the_input_box(self):
image = Image.new("RGB", (300, 120), (0, 0, 0))
draw = ImageDraw.Draw(image)
font = Font(font_size=12, colour=(0, 0, 0))
field = FormField(name="who", field_type=FormFieldType.TEXT, value="Hello")
renderable = FormFieldText(field, font, draw, field_height=28)
renderable.set_origin(np.array([10, 10]))
renderable.render()
field_y = 10 + font.font_size + 5
pixels = image.convert("RGB").load()
rows = [y for y in range(field_y, field_y + 28)
if any(pixels[x, y] == (0, 0, 0) for x in range(12, 200))]
assert rows, "the field value should be visible"
gap_above = min(rows) - field_y
gap_below = (field_y + 28) - max(rows) - 1
assert abs(gap_above - gap_below) <= 3, (
f"field value not centred: {gap_above}px above, {gap_below}px below")
@@ -0,0 +1,140 @@
"""
Test that LinkedWord objects remain as LinkText even when hyphenated.
This is a regression test for the bug where hyphenated LinkedWords
were being converted to regular Text objects instead of LinkText.
"""
import unittest
from PIL import Image, ImageDraw
from pyWebLayout.concrete.text import Line
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
from pyWebLayout.style import Font, Alignment
class TestLinkedWordHyphenation(unittest.TestCase):
"""Test that LinkedWords become LinkText objects even when hyphenated."""
def setUp(self):
"""Set up test canvas and drawing context."""
self.canvas = Image.new('RGB', (800, 600), color='white')
self.draw = ImageDraw.Draw(self.canvas)
self.font = Font(font_size=12)
def test_short_linkedword_no_hyphenation(self):
"""Test that a short LinkedWord that fits becomes a LinkText."""
# Create a line with enough space
line = Line(
spacing=(5, 15),
origin=(0, 0),
size=(200, 30),
draw=self.draw,
halign=Alignment.LEFT
)
# Create a LinkedWord that will fit without hyphenation
linked_word = LinkedWord(
text="click",
style=self.font,
location="action:test",
link_type=LinkType.API
)
# Add the word to the line
success, overflow = line.add_word(linked_word)
# Verify it was added successfully
self.assertTrue(success)
self.assertIsNone(overflow)
# Verify it became a LinkText object
self.assertEqual(len(line._text_objects), 1)
self.assertIsInstance(line._text_objects[0], LinkText)
self.assertEqual(line._text_objects[0].link.location, "action:test")
def test_long_linkedword_with_hyphenation(self):
"""Test that a long LinkedWord that needs hyphenation preserves LinkText."""
# Create a narrow line to force hyphenation
line = Line(
spacing=(5, 15),
origin=(0, 0),
size=(80, 30),
draw=self.draw,
halign=Alignment.LEFT
)
# Create a long LinkedWord that will need hyphenation
linked_word = LinkedWord(
text="https://example.com/very-long-url",
style=self.font,
location="https://example.com/very-long-url",
link_type=LinkType.EXTERNAL
)
# Add the word to the line
success, overflow = line.add_word(linked_word)
# The word should either:
# 1. Fit completely and be a LinkText
# 2. Be hyphenated, and BOTH parts should be LinkText
if overflow is not None:
# Word was hyphenated
# The first part should be in the line
self.assertTrue(success)
self.assertGreater(len(line._text_objects), 0)
# Both parts should be LinkText (this is the bug we're testing for)
for text_obj in line._text_objects:
self.assertIsInstance(
text_obj,
LinkText,
f"Hyphenated LinkedWord part should be LinkText, got {type(text_obj)}"
)
self.assertEqual(text_obj.link.location, linked_word.location)
# The overflow should also be LinkText if it's hyphenated
if isinstance(overflow, LinkText):
self.assertEqual(overflow.link.location, linked_word.location)
else:
# Word fit without hyphenation
self.assertTrue(success)
self.assertEqual(len(line._text_objects), 1)
self.assertIsInstance(line._text_objects[0], LinkText)
def test_linkedword_title_preserved_after_hyphenation(self):
"""Test that link metadata (title) is preserved when hyphenated."""
# Create a narrow line
line = Line(
spacing=(5, 15),
origin=(0, 0),
size=(60, 30),
draw=self.draw,
halign=Alignment.LEFT
)
# Create a LinkedWord with title that will likely be hyphenated
linked_word = LinkedWord(
text="documentation",
style=self.font,
location="https://docs.example.com",
link_type=LinkType.EXTERNAL,
title="View Documentation"
)
# Add the word
success, overflow = line.add_word(linked_word)
# Verify metadata is preserved
if overflow is not None:
# If hyphenated, both parts should have link metadata
for text_obj in line._text_objects:
if isinstance(text_obj, LinkText):
self.assertEqual(text_obj.link.location, "https://docs.example.com")
self.assertEqual(text_obj.link.title, "View Documentation")
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,329 @@
"""
Unit tests for 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 unittest
import numpy as np
from PIL import Image
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.page_style import PageStyle
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
class TestPageImplementation(unittest.TestCase):
"""Test cases for the Page class implementation"""
def setUp(self):
"""Set up test fixtures"""
self.basic_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)
)
self.page_size = (800, 600)
def test_page_creation_with_style(self):
"""Test creating a page with a PageStyle"""
page = Page(size=self.page_size, style=self.basic_style)
self.assertEqual(page.size, self.page_size)
self.assertEqual(page.style, self.basic_style)
self.assertEqual(page.border_size, 2)
def test_page_creation_without_style(self):
"""Test creating a page without a PageStyle (should use defaults)"""
page = Page(size=self.page_size)
self.assertEqual(page.size, self.page_size)
self.assertIsNotNone(page.style)
def test_page_canvas_and_content_sizes(self):
"""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=self.page_size, style=style)
# Canvas size should be page size minus borders
expected_canvas_size = (790, 590) # 800-10, 600-10 (border on both sides)
self.assertEqual(page.canvas_size, expected_canvas_size)
# Content size should be canvas minus padding
# 790-60, 590-40 (padding left+right, top+bottom)
expected_content_size = (730, 550)
self.assertEqual(page.content_size, expected_content_size)
def test_page_add_remove_children(self):
"""Test adding and removing children from the page"""
page = Page(size=self.page_size)
# Initially no children
self.assertEqual(len(page.children), 0)
# Add children
child1 = SimpleTestRenderable("Child 1")
child2 = SimpleTestRenderable("Child 2")
page.add_child(child1)
self.assertEqual(len(page.children), 1)
self.assertIn(child1, page.children)
page.add_child(child2)
self.assertEqual(len(page.children), 2)
self.assertIn(child2, page.children)
# Test method chaining
child3 = SimpleTestRenderable("Child 3")
result = page.add_child(child3)
self.assertIs(result, page) # Should return self for chaining
self.assertEqual(len(page.children), 3)
self.assertIn(child3, page.children)
# Remove childce youll notice is that responses dont stream
# character-by-character like other providers. Instead, Claude Code
# processes your full request before sending back the complete response.
removed = page.remove_child(child2)
self.assertTrue(removed)
self.assertEqual(len(page.children), 2)
self.assertNotIn(child2, page.children)
# Try to remove non-existent child
removed = page.remove_child(child2)
self.assertFalse(removed)
# Clear all children
page.clear_children()
self.assertEqual(len(page.children), 0)
def test_page_render(self):
"""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
self.assertIsInstance(image, Image.Image)
self.assertEqual(image.size, (200, 150))
self.assertEqual(image.mode, 'RGBA')
# Check that draw object is available
self.assertIsNotNone(page.draw)
def test_page_query_point(self):
"""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
result = page.query_point((90, 30))
self.assertIsNotNone(result)
self.assertEqual(result.object, child1)
# Point within second child
result = page.query_point((30, 30))
self.assertIsNotNone(result)
self.assertEqual(result.object, child2)
# Point outside any child - returns QueryResult with object_type "empty"
result = page.query_point((300, 250))
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "empty")
def test_page_in_object(self):
"""Test that page correctly implements in_object"""
page = Page(size=(400, 300))
# Points within page bounds
self.assertTrue(page.in_object((0, 0)))
self.assertTrue(page.in_object((200, 150)))
self.assertTrue(page.in_object((399, 299)))
# Points outside page bounds
self.assertFalse(page.in_object((-1, 0)))
self.assertFalse(page.in_object((0, -1)))
self.assertFalse(page.in_object((400, 299)))
self.assertFalse(page.in_object((399, 300)))
def test_page_with_borders(self):
"""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
self.assertIsInstance(image, Image.Image)
self.assertEqual(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
def test_page_border_size_property(self):
"""Test that border_size property returns correct value"""
# Test with border
style_with_border = PageStyle(border_width=5)
page_with_border = Page(size=self.page_size, style=style_with_border)
self.assertEqual(page_with_border.border_size, 5)
# Test without border
style_no_border = PageStyle(border_width=0)
page_no_border = Page(size=self.page_size, style=style_no_border)
self.assertEqual(page_no_border.border_size, 0)
def test_page_style_properties(self):
"""Test that page correctly exposes style properties"""
page = Page(size=self.page_size, style=self.basic_style)
# Test that style properties are accessible
self.assertEqual(page.style.border_width, 2)
self.assertEqual(page.style.border_color, (255, 0, 0))
self.assertEqual(page.style.line_spacing, 8)
self.assertEqual(page.style.inter_block_spacing, 20)
self.assertEqual(page.style.padding, (15, 15, 15, 15))
self.assertEqual(page.style.background_color, (240, 240, 240))
def test_page_children_list_operations(self):
"""Test that children list behaves correctly"""
page = Page(size=self.page_size)
# Test that children is initially empty list
self.assertIsInstance(page.children, list)
self.assertEqual(len(page.children), 0)
# Test adding multiple children
children = [
SimpleTestRenderable(f"Child {i}")
for i in range(5)
]
for child in children:
page.add_child(child)
self.assertEqual(len(page.children), 5)
# Test that children are in the correct order
for i, child in enumerate(page.children):
self.assertEqual(child._text, f"Child {i}")
def test_page_can_fit_line_boundary_checking(self):
"""Test that can_fit_line correctly checks bottom boundary"""
# Create page with known dimensions
# Page: 800x600, border: 40, padding: (10, 10, 10, 10)
# Content area starts at y=50 (border + padding_top = 40 + 10)
# Content area ends at y=550 (height - border - padding_bottom = 600 - 40 - 10)
style = PageStyle(
border_width=40,
padding=(10, 10, 10, 10)
)
page = Page(size=(800, 600), style=style)
# Initial y_offset should be at border + padding_top = 50
self.assertEqual(page._current_y_offset, 50)
# Test 1: Line that fits comfortably
line_height = 20
_max_y = 600 - 40 - 10 # 550
self.assertTrue(page.can_fit_line(line_height))
# Would end at 50 + 20 = 70, well within 550
# Test 2: Simulate adding lines to fill the page
# Available height: 550 - 50 = 500 pixels
# With 20-pixel lines, we can fit 25 lines exactly
for i in range(24): # Add 24 lines
self.assertTrue(page.can_fit_line(20), f"Line {i + 1} should fit")
# Simulate adding a line by updating y_offset
page._current_y_offset += 20
# After 24 lines: y_offset = 50 + (24 * 20) = 530
self.assertEqual(page._current_y_offset, 530)
# Test 3: One more 20-pixel line should fit (530 + 20 = 550, exactly at
# boundary)
self.assertTrue(page.can_fit_line(20))
page._current_y_offset += 20
self.assertEqual(page._current_y_offset, 550)
# Test 4: Now another line should NOT fit (550 + 20 = 570 > 550)
self.assertFalse(page.can_fit_line(20))
# Test 5: Even a 1-pixel line should not fit (550 + 1 = 551 > 550)
self.assertFalse(page.can_fit_line(1))
# Test 6: Edge case - exactly at boundary, 0-height line should fit
self.assertTrue(page.can_fit_line(0))
def test_page_can_fit_line_with_different_styles(self):
"""Test can_fit_line with different page styles"""
# Test with no border or padding
style_no_border = PageStyle(border_width=0, padding=(0, 0, 0, 0))
page_no_border = Page(size=(100, 100), style=style_no_border)
# With no border/padding, y_offset starts at 0
self.assertEqual(page_no_border._current_y_offset, 0)
# Can fit a 100-pixel line exactly
self.assertTrue(page_no_border.can_fit_line(100))
# Cannot fit a 101-pixel line
self.assertFalse(page_no_border.can_fit_line(101))
# Test with large border and padding
style_large = PageStyle(border_width=20, padding=(15, 15, 15, 15))
page_large = Page(size=(200, 200), style=style_large)
# y_offset starts at border + padding_top = 20 + 15 = 35
self.assertEqual(page_large._current_y_offset, 35)
# Max y = 200 - 20 - 15 = 165
# Available height = 165 - 35 = 130 pixels
self.assertTrue(page_large.can_fit_line(130))
self.assertFalse(page_large.can_fit_line(131))
if __name__ == '__main__':
unittest.main()
+133
View File
@@ -0,0 +1,133 @@
"""
Regression tests for page content geometry (spec S2).
Content must be laid out inside the content box - the page box less its border
and padding - on all four sides. Horizontal padding was previously ignored on the
left, shifting every line left by padding_left and leaving a gutter of
padding_left + padding_right on the right, so lines appeared to break early.
"""
import pytest
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
PADDING = (40, 30, 40, 20) # top, right, bottom, left - deliberately asymmetric
@pytest.fixture
def font():
return Font(font_size=12)
def filled_page(size, style, font, word_count=120):
page = Page(size=size, style=style)
paragraph = Paragraph(font)
for i in range(word_count):
paragraph.add_word(Word(f"word{i}", font))
DocumentLayouter(page).layout_paragraph(paragraph)
return page
class TestContentBox:
"""content_origin / content_rect describe the box content lives in."""
def test_content_origin_includes_border_and_padding(self):
page = Page(size=(400, 300), style=PageStyle(border_width=2, padding=PADDING))
assert page.content_origin == (2 + 20, 2 + 40)
def test_content_rect_subtracts_both_paddings(self):
page = Page(size=(400, 300), style=PageStyle(border_width=2, padding=PADDING))
x, y, w, h = page.content_rect
assert (x, y) == (22, 42)
assert w == 400 - 2 * 2 - 20 - 30
assert h == 300 - 2 * 2 - 40 - 40
def test_page_origin_offsets_the_content_box(self):
"""A page placed inside another surface reports absolute coordinates."""
page = Page(size=(100, 50), style=PageStyle(border_width=1, padding=(5, 5, 5, 5)),
origin=(200, 300))
assert page.content_origin == (206, 306)
def test_remaining_height_respects_bottom_padding(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = Page(size=(400, 300), style=style)
# Nothing laid out yet: the whole content box is available.
assert page.remaining_height == page.content_rect[3]
class TestLinePlacement:
"""Lines must start after the left padding and end before the right padding."""
def test_first_line_starts_at_content_origin(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font)
line = page.children[0]
assert int(line.origin[0]) == page.content_origin[0]
assert int(line.origin[1]) == page.content_origin[1]
def test_line_width_matches_content_width(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font)
line = page.children[0]
assert int(line.size[0]) == page.content_rect[2]
def test_no_line_extends_past_the_right_content_edge(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font)
right_edge = page.content_rect[0] + page.content_rect[2]
for line in page.children:
assert int(line.origin[0]) + int(line.size[0]) <= right_edge
def test_ink_stays_inside_the_content_box(self, font):
"""The rendered pixels, not just the boxes, respect the padding."""
style = PageStyle(border_width=0, padding=PADDING,
background_color=(255, 255, 255))
page = filled_page((400, 300), style, font)
image = page.render().convert("L")
pixels = image.load()
inked_x = [x for x in range(400) for y in range(300) if pixels[x, y] < 128]
assert inked_x, "the page should have text on it"
x0, _, w, _ = page.content_rect
assert min(inked_x) >= x0
assert max(inked_x) <= x0 + w
def test_right_gutter_is_not_double_width(self, font):
"""
The regression: text was shifted left by padding_left, so the right gutter
was padding_left + padding_right wide while the left gutter was zero.
"""
style = PageStyle(border_width=0, padding=(10, 30, 10, 30))
page = filled_page((400, 300), style, font, word_count=200)
image = page.render().convert("L")
pixels = image.load()
inked_x = [x for x in range(400) for y in range(300) if pixels[x, y] < 128]
left_gutter = min(inked_x)
right_gutter = 400 - max(inked_x)
# Justification means the right edge is not always exactly flush, so allow
# slack - but the two gutters must be comparable, not 0 vs 60.
assert abs(left_gutter - right_gutter) < 25, \
f"asymmetric gutters: left={left_gutter} right={right_gutter}"
class TestBlockBottomBoundary:
"""Blocks must not be placed into the bottom padding."""
def test_lines_stop_before_bottom_padding(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font, word_count=500)
bottom_edge = page.content_rect[1] + page.content_rect[3]
for line in page.children:
assert int(line.origin[1]) <= bottom_edge
+652
View File
@@ -0,0 +1,652 @@
"""
Tests for table rendering components.
This module tests:
- TableStyle: Styling configuration for tables
- TableCellRenderer: Individual cell rendering
- TableRowRenderer: Row rendering with multiple cells
- TableRenderer: Complete table rendering
"""
import pytest
from PIL import Image, ImageDraw
from pyWebLayout.concrete.table import (
TableStyle,
TableCellRenderer,
TableRowRenderer,
TableRenderer
)
from pyWebLayout.abstract.block import (
Table, TableRow, TableCell, Paragraph, Heading, HeadingLevel
)
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sample_font():
"""Create a standard font for testing."""
return Font(font_size=12, colour=(0, 0, 0))
@pytest.fixture
def sample_canvas():
"""Create a PIL canvas for rendering."""
return Image.new('RGB', (800, 600), color=(255, 255, 255))
@pytest.fixture
def sample_draw(sample_canvas):
"""Create a PIL ImageDraw object."""
return ImageDraw.Draw(sample_canvas)
@pytest.fixture
def default_table_style():
"""Create default table style."""
return TableStyle()
@pytest.fixture
def custom_table_style():
"""Create custom table style."""
return TableStyle(
border_width=2,
border_color=(100, 100, 100),
cell_padding=(10, 10, 10, 10),
header_bg_color=(200, 200, 200),
cell_bg_color=(250, 250, 250),
alternate_row_color=(240, 240, 240),
cell_spacing=5
)
@pytest.fixture
def simple_table(sample_font):
"""Create a simple table with header and body."""
table = Table()
table.caption = "Test Table"
# Header row
header_row = TableRow()
header_cell1 = TableCell(is_header=True)
header_p1 = Paragraph(sample_font)
header_p1.add_word(Word("Column", sample_font))
header_p1.add_word(Word("1", sample_font))
header_cell1.add_block(header_p1)
header_cell2 = TableCell(is_header=True)
header_p2 = Paragraph(sample_font)
header_p2.add_word(Word("Column", sample_font))
header_p2.add_word(Word("2", sample_font))
header_cell2.add_block(header_p2)
header_row.add_cell(header_cell1)
header_row.add_cell(header_cell2)
table.add_row(header_row, section="header")
# Body row
body_row = TableRow()
body_cell1 = TableCell()
body_p1 = Paragraph(sample_font)
body_p1.add_word(Word("Data", sample_font))
body_p1.add_word(Word("1", sample_font))
body_cell1.add_block(body_p1)
body_cell2 = TableCell()
body_p2 = Paragraph(sample_font)
body_p2.add_word(Word("Data", sample_font))
body_p2.add_word(Word("2", sample_font))
body_cell2.add_block(body_p2)
body_row.add_cell(body_cell1)
body_row.add_cell(body_cell2)
table.add_row(body_row, section="body")
return table
# ============================================================================
# TableStyle Tests
# ============================================================================
class TestTableStyle:
"""Tests for TableStyle dataclass."""
def test_default_initialization(self):
"""Test TableStyle with default values."""
style = TableStyle()
assert style.border_width == 1
assert style.border_color == (0, 0, 0)
assert style.cell_padding == (5, 5, 5, 5)
assert style.header_bg_color == (240, 240, 240)
assert style.header_text_bold is True
assert style.cell_bg_color == (255, 255, 255)
assert style.alternate_row_color == (250, 250, 250)
assert style.cell_spacing == 0
def test_custom_initialization(self):
"""Test TableStyle with custom values."""
style = TableStyle(
border_width=3,
border_color=(255, 0, 0),
cell_padding=(10, 15, 20, 25),
header_bg_color=(100, 100, 100),
header_text_bold=False,
cell_bg_color=(200, 200, 200),
alternate_row_color=None,
cell_spacing=10
)
assert style.border_width == 3
assert style.border_color == (255, 0, 0)
assert style.cell_padding == (10, 15, 20, 25)
assert style.header_bg_color == (100, 100, 100)
assert style.header_text_bold is False
assert style.cell_bg_color == (200, 200, 200)
assert style.alternate_row_color is None
assert style.cell_spacing == 10
def test_all_attributes_accessible(self, custom_table_style):
"""Test that all style attributes are accessible."""
# Verify all attributes exist and are accessible
assert hasattr(custom_table_style, 'border_width')
assert hasattr(custom_table_style, 'border_color')
assert hasattr(custom_table_style, 'cell_padding')
assert hasattr(custom_table_style, 'header_bg_color')
assert hasattr(custom_table_style, 'header_text_bold')
assert hasattr(custom_table_style, 'cell_bg_color')
assert hasattr(custom_table_style, 'alternate_row_color')
assert hasattr(custom_table_style, 'cell_spacing')
# ============================================================================
# TableCellRenderer Tests
# ============================================================================
class TestTableCellRenderer:
"""Tests for TableCellRenderer."""
def test_initialization(self, sample_font, sample_draw, default_table_style):
"""Test TableCellRenderer initialization."""
cell = TableCell()
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(100, 50),
draw=sample_draw,
style=default_table_style
)
assert cell_renderer._cell == cell
# Origin and size may be numpy arrays, so compare values
import numpy as np
assert np.array_equal(cell_renderer._origin, (10, 10))
assert np.array_equal(cell_renderer._size, (100, 50))
assert cell_renderer._draw == sample_draw
assert cell_renderer._style == default_table_style
assert cell_renderer._is_header_section is False
def test_initialization_with_header(
self,
sample_font,
sample_draw,
default_table_style):
"""Test TableCellRenderer initialization for header cell."""
cell = TableCell(is_header=True)
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(100, 50),
draw=sample_draw,
style=default_table_style,
is_header_section=True
)
assert cell_renderer._is_header_section is True
def test_render_empty_cell(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering an empty cell."""
cell = TableCell()
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(100, 50),
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = cell_renderer.render()
# Render returns None (draws directly on canvas)
assert result is None
def test_render_cell_with_text(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a cell with text content."""
cell = TableCell()
paragraph = Paragraph(sample_font)
paragraph.add_word(Word("Test", sample_font))
paragraph.add_word(Word("Content", sample_font))
cell.add_block(paragraph)
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(200, 50),
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = cell_renderer.render()
assert result is None
def test_render_header_cell(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a header cell with different styling."""
cell = TableCell(is_header=True)
paragraph = Paragraph(sample_font)
paragraph.add_word(Word("Header", sample_font))
cell.add_block(paragraph)
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(200, 50),
draw=sample_draw,
style=default_table_style,
is_header_section=True,
canvas=sample_canvas
)
result = cell_renderer.render()
assert result is None
def test_render_cell_with_heading(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a cell with heading content."""
cell = TableCell()
heading = Heading(HeadingLevel.H2, sample_font)
heading.add_word(Word("Heading", sample_font))
cell.add_block(heading)
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(200, 50),
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = cell_renderer.render()
assert result is None
def test_in_object(self, sample_font, sample_draw, default_table_style):
"""Test in_object method for point detection."""
cell = TableCell()
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(100, 50),
draw=sample_draw,
style=default_table_style
)
# Point inside cell
assert cell_renderer.in_object((50, 30))
# Point outside cell
assert not cell_renderer.in_object((150, 30))
assert not cell_renderer.in_object((50, 100))
def test_properties_access(self, sample_font, sample_draw, default_table_style):
"""Test accessing cell renderer properties."""
import numpy as np
cell = TableCell()
cell_renderer = TableCellRenderer(
cell,
origin=(10, 10),
size=(100, 50),
draw=sample_draw,
style=default_table_style
)
# May be numpy arrays
assert np.array_equal(cell_renderer._origin, (10, 10))
assert np.array_equal(cell_renderer._size, (100, 50))
# ============================================================================
# TableRowRenderer Tests
# ============================================================================
class TestTableRowRenderer:
"""Tests for TableRowRenderer."""
def test_initialization(self, sample_font, sample_draw, default_table_style):
"""Test TableRowRenderer initialization."""
row = TableRow()
column_widths = [100, 150, 200]
row_renderer = TableRowRenderer(
row,
origin=(10, 10),
column_widths=column_widths,
row_height=50,
draw=sample_draw,
style=default_table_style
)
assert row_renderer._row == row
assert row_renderer._column_widths == column_widths
assert row_renderer._row_height == 50
assert row_renderer._draw == sample_draw
assert row_renderer._style == default_table_style
assert row_renderer._is_header_section is False
def test_render_empty_row(self, sample_font, sample_draw, default_table_style):
"""Test rendering an empty row."""
row = TableRow()
row_renderer = TableRowRenderer(
row,
origin=(10, 10),
column_widths=[100, 100],
row_height=50,
draw=sample_draw,
style=default_table_style
)
result = row_renderer.render()
assert result is None
def test_render_row_with_cells(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a row with multiple cells."""
row = TableRow()
# Add cells to row
for i in range(3):
cell = TableCell()
paragraph = Paragraph(sample_font)
paragraph.add_word(Word(f"Cell{i}", sample_font))
cell.add_block(paragraph)
row.add_cell(cell)
row_renderer = TableRowRenderer(
row,
origin=(10, 10),
column_widths=[100, 100, 100],
row_height=50,
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = row_renderer.render()
assert result is None
# Verify cells were created
assert len(row_renderer._cell_renderers) == 3
def test_render_row_with_colspan(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a row with cells that span multiple columns."""
row = TableRow()
# Cell with colspan=2
cell1 = TableCell(colspan=2)
paragraph1 = Paragraph(sample_font)
paragraph1.add_word(Word("Spanning", sample_font))
cell1.add_block(paragraph1)
row.add_cell(cell1)
# Normal cell
cell2 = TableCell()
paragraph2 = Paragraph(sample_font)
paragraph2.add_word(Word("Normal", sample_font))
cell2.add_block(paragraph2)
row.add_cell(cell2)
row_renderer = TableRowRenderer(
row,
origin=(10, 10),
column_widths=[100, 100, 100],
row_height=50,
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = row_renderer.render()
assert result is None
# ============================================================================
# TableRenderer Tests
# ============================================================================
class TestTableRenderer:
"""Tests for TableRenderer."""
def test_initialization(self, simple_table, sample_draw, default_table_style):
"""Test TableRenderer initialization."""
import numpy as np
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style
)
assert table_renderer._table == simple_table
assert np.array_equal(table_renderer._origin, (10, 10))
assert table_renderer._available_width == 600
assert table_renderer._draw == sample_draw
assert table_renderer._style == default_table_style
def test_dimension_calculation(
self,
simple_table,
sample_draw,
default_table_style):
"""Test table dimension calculation."""
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style
)
# Check that dimensions were calculated
assert len(table_renderer._column_widths) == 2
assert len(table_renderer._row_heights) == 3 # header, body, footer
assert all(width > 0 for width in table_renderer._column_widths)
def test_render_simple_table(
self,
simple_table,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a complete simple table."""
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = table_renderer.render()
assert result is None
# Verify rows were created
assert len(table_renderer._row_renderers) == 2 # 1 header + 1 body
def test_render_table_with_caption(
self,
simple_table,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a table with caption."""
simple_table.caption = "Test Table Caption"
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = table_renderer.render()
assert result is None
def test_height_property(self, simple_table, sample_draw, default_table_style):
"""Test table height property."""
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style
)
height = table_renderer.height
assert isinstance(height, int)
assert height > 0
def test_width_property(self, simple_table, sample_draw, default_table_style):
"""Test table width property."""
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style
)
width = table_renderer.width
assert isinstance(width, int)
assert width > 0
assert width <= 600 # Should not exceed available width
def test_empty_table(self, sample_draw, default_table_style):
"""Test rendering an empty table."""
empty_table = Table()
table_renderer = TableRenderer(
empty_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style
)
# Should handle gracefully
assert table_renderer is not None
def test_table_with_footer(
self,
sample_font,
sample_draw,
sample_canvas,
default_table_style):
"""Test rendering a table with footer rows."""
table = Table()
# Add header
header_row = TableRow()
header_cell = TableCell(is_header=True)
header_p = Paragraph(sample_font)
header_p.add_word(Word("Header", sample_font))
header_cell.add_block(header_p)
header_row.add_cell(header_cell)
table.add_row(header_row, section="header")
# Add body
body_row = TableRow()
body_cell = TableCell()
body_p = Paragraph(sample_font)
body_p.add_word(Word("Body", sample_font))
body_cell.add_block(body_p)
body_row.add_cell(body_cell)
table.add_row(body_row, section="body")
# Add footer
footer_row = TableRow()
footer_cell = TableCell()
footer_p = Paragraph(sample_font)
footer_p.add_word(Word("Footer", sample_font))
footer_cell.add_block(footer_p)
footer_row.add_cell(footer_cell)
table.add_row(footer_row, section="footer")
table_renderer = TableRenderer(
table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style,
canvas=sample_canvas
)
result = table_renderer.render()
assert result is None
assert len(table_renderer._row_renderers) == 3 # header + body + footer
def test_in_object(self, simple_table, sample_draw, default_table_style):
"""Test in_object method for table."""
table_renderer = TableRenderer(
simple_table,
origin=(10, 10),
available_width=600,
draw=sample_draw,
style=default_table_style
)
# Point inside table
assert table_renderer.in_object((50, 50))
# Point outside table
assert not table_renderer.in_object((1000, 1000))
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+1
View File
@@ -0,0 +1 @@
"""Tests for core pyWebLayout functionality."""
+244
View File
@@ -0,0 +1,244 @@
"""
Unit tests for the bounded usage-ranked caches.
Covers the guarantees the text rendering path depends on: that the bounds are never
exceeded, that eviction prefers the least-used entries, that aging lets a new
working set displace an old one, and that document-frequency seeding survives a
scan of unfamiliar keys.
"""
import unittest
from pyWebLayout.core.cache import (
UsageCache,
SizedUsageCache,
DEFAULT_AGING_INTERVAL,
)
class TestUsageCache(unittest.TestCase):
"""Entry-count-bounded cache."""
def test_rejects_invalid_bounds(self):
for bad in (0, -1):
with self.assertRaises(ValueError):
UsageCache(bad)
with self.assertRaises(ValueError):
UsageCache(4, aging_interval=0)
with self.assertRaises(ValueError):
UsageCache(4, eviction_sample=0)
def test_stores_and_returns_values(self):
cache = UsageCache(4)
cache.put('a', 1)
self.assertEqual(cache.get('a'), 1)
self.assertIsNone(cache.get('missing'))
self.assertIn('a', cache)
self.assertEqual(len(cache), 1)
def test_never_exceeds_max_entries(self):
cache = UsageCache(10)
for i in range(500):
cache.put(i, i)
self.assertLessEqual(len(cache), 10)
self.assertEqual(cache.stats()['entries'], 10)
def test_evicts_least_used(self):
# One hot key among many cold ones must survive a long cold scan. The
# sample is smaller than the cache, so this is probabilistic in principle;
# a hot key's count is far enough above the rest to make it reliable.
cache = UsageCache(20, eviction_sample=8)
cache.put('hot', 'value')
for _ in range(200):
cache.get('hot')
for i in range(400):
cache.put(f'cold{i}', i)
cache.get('hot')
self.assertEqual(cache.get('hot'), 'value')
def test_repeated_put_does_not_duplicate(self):
cache = UsageCache(10)
for _ in range(50):
cache.put('a', 1)
self.assertEqual(len(cache), 1)
def test_put_updates_existing_value(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.put('a', 2)
self.assertEqual(cache.get('a'), 2)
def test_seeded_count_outranks_fresh_entries(self):
"""A document-frequency seed must survive a scan of unseen keys."""
cache = UsageCache(20, eviction_sample=8)
cache.put('frequent', 'value', count=5000)
for i in range(400):
cache.put(f'new{i}', i)
self.assertEqual(cache.get('frequent'), 'value')
def test_aging_lets_a_new_working_set_take_over(self):
"""Without aging, stale high counts lock the cache permanently."""
cache = UsageCache(20, aging_interval=50, eviction_sample=8)
for i in range(20):
cache.put(f'old{i}', i, count=10000)
# A completely different working set, each key used a few times.
for round_ in range(60):
for i in range(10):
key = f'new{i}'
if cache.get(key) is None:
cache.put(key, i)
survivors = sum(1 for i in range(10) if f'new{i}' in cache)
self.assertGreater(survivors, 0,
"aging should let the new working set displace the old")
self.assertGreater(cache.stats()['agings'], 0)
def test_aging_can_be_disabled(self):
cache = UsageCache(10, aging_interval=None)
for i in range(100):
cache.put(i, i)
self.assertEqual(cache.stats()['agings'], 0)
def test_resize_evicts_immediately(self):
cache = UsageCache(100)
for i in range(100):
cache.put(i, i)
cache.resize(10)
self.assertEqual(len(cache), 10)
with self.assertRaises(ValueError):
cache.resize(0)
def test_clear_empties_but_keeps_counters(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.get('a')
cache.clear()
self.assertEqual(len(cache), 0)
self.assertNotIn('a', cache)
self.assertEqual(cache.stats()['hits'], 1)
def test_stats_track_hits_and_misses(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.get('a')
cache.get('a')
cache.get('b')
stats = cache.stats()
self.assertEqual(stats['hits'], 2)
self.assertEqual(stats['misses'], 1)
self.assertAlmostEqual(stats['hit_rate'], 2 / 3)
self.assertEqual(stats['max_entries'], 10)
def test_internal_slot_list_stays_consistent(self):
"""Eviction swaps the tail into the freed slot; indices must stay valid."""
cache = UsageCache(8)
for i in range(300):
cache.put(i, i)
for key in list(cache._entries):
self.assertEqual(cache._slots[cache._entries[key][2]], key)
self.assertEqual(len(cache._slots), len(cache._entries))
class TestSizedUsageCache(unittest.TestCase):
"""Byte-bounded cache, as used for glyph bitmaps."""
@staticmethod
def sizer(value):
return value
def test_rejects_invalid_bounds(self):
for bad in (0, -1):
with self.assertRaises(ValueError):
SizedUsageCache(bad, self.sizer)
def test_never_exceeds_max_bytes(self):
cache = SizedUsageCache(1000, self.sizer)
for i in range(500):
cache.put(i, 100)
self.assertLessEqual(cache.total_bytes, 1000)
def test_tracks_total_bytes(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.put('b', 250)
self.assertEqual(cache.total_bytes, 350)
def test_oversized_value_is_not_retained(self):
"""One huge entry must not flush everything else out."""
cache = SizedUsageCache(1000, self.sizer)
cache.put('small', 100)
cache.put('huge', 5000)
self.assertNotIn('huge', cache)
self.assertIn('small', cache)
self.assertEqual(cache.total_bytes, 100)
def test_replacing_a_value_remeasures_it(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.put('a', 300)
self.assertEqual(cache.total_bytes, 300)
self.assertEqual(len(cache), 1)
def test_evicts_least_used(self):
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
cache.put('hot', 100)
for _ in range(200):
cache.get('hot')
for i in range(400):
cache.put(f'cold{i}', 100)
cache.get('hot')
self.assertIn('hot', cache)
def test_seeded_count_outranks_fresh_entries(self):
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
cache.put('frequent', 100, count=5000)
for i in range(400):
cache.put(f'new{i}', 100)
self.assertIn('frequent', cache)
def test_resize_evicts_immediately(self):
cache = SizedUsageCache(10000, self.sizer)
for i in range(100):
cache.put(i, 100)
cache.resize(500)
self.assertLessEqual(cache.total_bytes, 500)
def test_clear_resets_byte_accounting(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.clear()
self.assertEqual(cache.total_bytes, 0)
self.assertEqual(len(cache), 0)
def test_stats_report_bounds(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
stats = cache.stats()
self.assertEqual(stats['total_bytes'], 100)
self.assertEqual(stats['max_bytes'], 1000)
self.assertEqual(stats['entries'], 1)
def test_bookkeeping_stays_consistent_under_churn(self):
"""Byte total and slot list must not drift over many evictions."""
cache = SizedUsageCache(2000, self.sizer, aging_interval=97)
for i in range(2000):
cache.put(i, (i % 7 + 1) * 50)
if i % 3 == 0:
cache.get(i)
self.assertEqual(cache.total_bytes,
sum(cache._sizes[k] for k in cache._entries))
self.assertEqual(len(cache._slots), len(cache._entries))
self.assertLessEqual(cache.total_bytes, cache.max_bytes)
class TestDefaults(unittest.TestCase):
def test_aging_is_enabled_by_default(self):
self.assertIsNotNone(DEFAULT_AGING_INTERVAL)
self.assertGreater(DEFAULT_AGING_INTERVAL, 0)
self.assertIsNotNone(UsageCache(4)._aging_interval)
if __name__ == '__main__':
unittest.main()
+352
View File
@@ -0,0 +1,352 @@
"""
Unit tests for the highlight system.
Tests Highlight, HighlightColor, HighlightManager, and integration with query system.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from pyWebLayout.core.highlight import (
Highlight,
HighlightColor,
HighlightManager,
create_highlight_from_query_result
)
from pyWebLayout.core.query import QueryResult, SelectionRange
class TestHighlightColor(unittest.TestCase):
"""Test HighlightColor enum"""
def test_colors_defined(self):
"""Test all expected colors are defined"""
expected_colors = ['YELLOW', 'GREEN', 'BLUE', 'PINK', 'ORANGE', 'PURPLE', 'RED']
for color_name in expected_colors:
self.assertTrue(hasattr(HighlightColor, color_name))
color = getattr(HighlightColor, color_name)
self.assertIsInstance(color.value, tuple)
self.assertEqual(len(color.value), 4) # RGBA
def test_yellow_is_default(self):
"""Test yellow highlight color"""
yellow = HighlightColor.YELLOW.value
self.assertEqual(yellow, (255, 255, 0, 100))
class TestHighlight(unittest.TestCase):
"""Test Highlight dataclass"""
def test_init_basic(self):
"""Test basic Highlight creation"""
highlight = Highlight(
id="test-id",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="Hello"
)
self.assertEqual(highlight.id, "test-id")
self.assertEqual(len(highlight.bounds), 1)
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
self.assertEqual(highlight.color, (255, 255, 0, 100))
self.assertEqual(highlight.text, "Hello")
self.assertIsNone(highlight.note)
self.assertEqual(highlight.tags, [])
def test_init_with_metadata(self):
"""Test Highlight with full metadata"""
highlight = Highlight(
id="test-id",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="Hello",
note="Important word",
tags=["important", "keyword"],
timestamp=1234567890.0,
start_word_index=5,
end_word_index=5
)
self.assertEqual(highlight.note, "Important word")
self.assertEqual(highlight.tags, ["important", "keyword"])
self.assertEqual(highlight.timestamp, 1234567890.0)
self.assertEqual(highlight.start_word_index, 5)
self.assertEqual(highlight.end_word_index, 5)
def test_to_dict(self):
"""Test Highlight serialization"""
highlight = Highlight(
id="test-id",
bounds=[(10, 20, 50, 15), (70, 20, 40, 15)],
color=(255, 255, 0, 100),
text="Hello world",
note="Test note",
tags=["test"],
timestamp=1234567890.0
)
data = highlight.to_dict()
self.assertEqual(data['id'], "test-id")
self.assertEqual(len(data['bounds']), 2)
self.assertEqual(data['bounds'][0], (10, 20, 50, 15))
self.assertEqual(data['color'], (255, 255, 0, 100))
self.assertEqual(data['text'], "Hello world")
self.assertEqual(data['note'], "Test note")
self.assertEqual(data['tags'], ["test"])
self.assertEqual(data['timestamp'], 1234567890.0)
def test_from_dict(self):
"""Test Highlight deserialization"""
data = {
'id': "test-id",
'bounds': [[10, 20, 50, 15], [70, 20, 40, 15]],
'color': [255, 255, 0, 100],
'text': "Hello world",
'note': "Test note",
'tags': ["test"],
'timestamp': 1234567890.0,
'start_word_index': 5,
'end_word_index': 6
}
highlight = Highlight.from_dict(data)
self.assertEqual(highlight.id, "test-id")
self.assertEqual(len(highlight.bounds), 2)
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
self.assertEqual(highlight.color, (255, 255, 0, 100))
self.assertEqual(highlight.text, "Hello world")
self.assertEqual(highlight.note, "Test note")
self.assertEqual(highlight.tags, ["test"])
self.assertEqual(highlight.start_word_index, 5)
self.assertEqual(highlight.end_word_index, 6)
class TestHighlightManager(unittest.TestCase):
"""Test HighlightManager class"""
def setUp(self):
"""Create temporary directory for highlights"""
self.temp_dir = tempfile.mkdtemp()
self.manager = HighlightManager(
document_id="test-doc",
highlights_dir=self.temp_dir
)
def tearDown(self):
"""Clean up temporary directory"""
shutil.rmtree(self.temp_dir)
def test_init(self):
"""Test HighlightManager initialization"""
self.assertEqual(self.manager.document_id, "test-doc")
self.assertEqual(self.manager.highlights_dir, Path(self.temp_dir))
self.assertEqual(len(self.manager.highlights), 0)
def test_add_highlight(self):
"""Test adding a highlight"""
highlight = Highlight(
id="test-1",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="Test"
)
self.manager.add_highlight(highlight)
self.assertEqual(len(self.manager.highlights), 1)
self.assertIn("test-1", self.manager.highlights)
self.assertEqual(self.manager.highlights["test-1"], highlight)
def test_remove_highlight(self):
"""Test removing a highlight"""
highlight = Highlight(
id="test-1",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="Test"
)
self.manager.add_highlight(highlight)
self.assertEqual(len(self.manager.highlights), 1)
result = self.manager.remove_highlight("test-1")
self.assertTrue(result)
self.assertEqual(len(self.manager.highlights), 0)
def test_remove_nonexistent_highlight(self):
"""Test removing a highlight that doesn't exist"""
result = self.manager.remove_highlight("nonexistent")
self.assertFalse(result)
def test_get_highlight(self):
"""Test getting a highlight by ID"""
highlight = Highlight(
id="test-1",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="Test"
)
self.manager.add_highlight(highlight)
retrieved = self.manager.get_highlight("test-1")
self.assertIsNotNone(retrieved)
self.assertEqual(retrieved.id, "test-1")
self.assertEqual(retrieved.text, "Test")
def test_list_highlights(self):
"""Test listing all highlights"""
highlight1 = Highlight(
id="test-1",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="First"
)
highlight2 = Highlight(
id="test-2",
bounds=[(100, 20, 50, 15)],
color=(100, 255, 100, 100),
text="Second"
)
self.manager.add_highlight(highlight1)
self.manager.add_highlight(highlight2)
highlights = self.manager.list_highlights()
self.assertEqual(len(highlights), 2)
self.assertIn(highlight1, highlights)
self.assertIn(highlight2, highlights)
def test_clear_all(self):
"""Test clearing all highlights"""
highlight1 = Highlight(
id="test-1",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="First"
)
highlight2 = Highlight(
id="test-2",
bounds=[(100, 20, 50, 15)],
color=(100, 255, 100, 100),
text="Second"
)
self.manager.add_highlight(highlight1)
self.manager.add_highlight(highlight2)
self.assertEqual(len(self.manager.highlights), 2)
self.manager.clear_all()
self.assertEqual(len(self.manager.highlights), 0)
def test_persistence(self):
"""Test that highlights are persisted to disk"""
highlight = Highlight(
id="test-1",
bounds=[(10, 20, 50, 15)],
color=(255, 255, 0, 100),
text="Persisted"
)
self.manager.add_highlight(highlight)
# Create new manager for same document
new_manager = HighlightManager(
document_id="test-doc",
highlights_dir=self.temp_dir
)
# Should load existing highlights
self.assertEqual(len(new_manager.highlights), 1)
self.assertIn("test-1", new_manager.highlights)
self.assertEqual(new_manager.highlights["test-1"].text, "Persisted")
def test_get_highlights_for_page(self):
"""Test filtering highlights by page bounds"""
# Highlight on page
highlight1 = Highlight(
id="test-1",
bounds=[(100, 100, 50, 15)],
color=(255, 255, 0, 100),
text="On page"
)
# Highlight off page
highlight2 = Highlight(
id="test-2",
bounds=[(1000, 1000, 50, 15)],
color=(255, 255, 0, 100),
text="Off page"
)
self.manager.add_highlight(highlight1)
self.manager.add_highlight(highlight2)
# Page bounds (0, 0, 800, 1000)
page_bounds = (0, 0, 800, 1000)
page_highlights = self.manager.get_highlights_for_page(page_bounds)
self.assertEqual(len(page_highlights), 1)
self.assertEqual(page_highlights[0].id, "test-1")
class TestCreateHighlightFromQueryResult(unittest.TestCase):
"""Test create_highlight_from_query_result function"""
def test_create_from_single_result(self):
"""Test creating highlight from single QueryResult"""
result = QueryResult(
object=object(),
object_type="text",
bounds=(10, 20, 50, 15),
text="Hello"
)
highlight = create_highlight_from_query_result(
result,
color=(255, 255, 0, 100),
note="Test note",
tags=["test"]
)
self.assertIsNotNone(highlight.id)
self.assertEqual(len(highlight.bounds), 1)
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
self.assertEqual(highlight.color, (255, 255, 0, 100))
self.assertEqual(highlight.text, "Hello")
self.assertEqual(highlight.note, "Test note")
self.assertEqual(highlight.tags, ["test"])
self.assertIsNotNone(highlight.timestamp)
def test_create_from_selection_range(self):
"""Test creating highlight from SelectionRange"""
results = [
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
]
sel_range = SelectionRange((10, 20), (80, 35), results)
highlight = create_highlight_from_query_result(
sel_range,
color=(100, 255, 100, 100),
note="Multi-word"
)
self.assertIsNotNone(highlight.id)
self.assertEqual(len(highlight.bounds), 2)
self.assertEqual(highlight.bounds[0], (10, 20, 30, 15))
self.assertEqual(highlight.bounds[1], (45, 20, 35, 15))
self.assertEqual(highlight.color, (100, 255, 100, 100))
self.assertEqual(highlight.text, "Hello world")
self.assertEqual(highlight.note, "Multi-word")
if __name__ == '__main__':
unittest.main()
+433
View File
@@ -0,0 +1,433 @@
"""
Unit tests for the query system (pixel-to-content mapping).
Tests the QueryResult, SelectionRange, and query_point functionality
across Page, Line, and Text classes.
"""
import unittest
import numpy as np
from PIL import Image, ImageDraw
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.functional import Link, LinkType
from pyWebLayout.style.page_style import PageStyle
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
class TestQueryResult(unittest.TestCase):
"""Test QueryResult dataclass"""
def test_init_basic(self):
"""Test basic QueryResult creation"""
obj = object()
result = QueryResult(
object=obj,
object_type="text",
bounds=(100, 200, 50, 20)
)
self.assertEqual(result.object, obj)
self.assertEqual(result.object_type, "text")
self.assertEqual(result.bounds, (100, 200, 50, 20))
self.assertIsNone(result.text)
self.assertFalse(result.is_interactive)
def test_init_with_metadata(self):
"""Test QueryResult with full metadata"""
obj = object()
result = QueryResult(
object=obj,
object_type="link",
bounds=(100, 200, 50, 20),
text="Click here",
is_interactive=True,
link_target="chapter2"
)
self.assertEqual(result.text, "Click here")
self.assertTrue(result.is_interactive)
self.assertEqual(result.link_target, "chapter2")
def test_to_dict(self):
"""Test QueryResult serialization"""
result = QueryResult(
object=object(),
object_type="link",
bounds=(100, 200, 50, 20),
text="Click here",
is_interactive=True,
link_target="chapter2"
)
d = result.to_dict()
self.assertEqual(d['object_type'], "link")
self.assertEqual(d['bounds'], (100, 200, 50, 20))
self.assertEqual(d['text'], "Click here")
self.assertTrue(d['is_interactive'])
self.assertEqual(d['link_target'], "chapter2")
class TestSelectionRange(unittest.TestCase):
"""Test SelectionRange dataclass"""
def test_init(self):
"""Test SelectionRange creation"""
results = []
sel_range = SelectionRange(
start_point=(10, 20),
end_point=(100, 30),
results=results
)
self.assertEqual(sel_range.start_point, (10, 20))
self.assertEqual(sel_range.end_point, (100, 30))
self.assertEqual(sel_range.results, results)
def test_text_property(self):
"""Test concatenated text extraction"""
results = [
QueryResult(object(), "text", (0, 0, 0, 0), text="Hello"),
QueryResult(object(), "text", (0, 0, 0, 0), text="world"),
QueryResult(object(), "text", (0, 0, 0, 0), text="test")
]
sel_range = SelectionRange((0, 0), (100, 100), results)
self.assertEqual(sel_range.text, "Hello world test")
def test_bounds_list_property(self):
"""Test bounds list extraction"""
results = [
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
]
sel_range = SelectionRange((0, 0), (100, 100), results)
bounds = sel_range.bounds_list
self.assertEqual(len(bounds), 2)
self.assertEqual(bounds[0], (10, 20, 30, 15))
self.assertEqual(bounds[1], (45, 20, 35, 15))
def test_to_dict(self):
"""Test SelectionRange serialization"""
results = [
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
]
sel_range = SelectionRange((10, 20), (80, 35), results)
d = sel_range.to_dict()
self.assertEqual(d['start'], (10, 20))
self.assertEqual(d['end'], (80, 35))
self.assertEqual(d['text'], "Hello world")
self.assertEqual(d['word_count'], 2)
self.assertEqual(len(d['bounds']), 2)
class TestTextQueryPoint(unittest.TestCase):
"""Test Text class in_object (from Queriable mixin)"""
def setUp(self):
ensure_consistent_font_in_tests()
self.canvas = Image.new('RGB', (800, 600), color='white')
self.draw = ImageDraw.Draw(self.canvas)
self.font = create_default_test_font()
def test_in_object_hit(self):
"""Test in_object returns True for point inside text"""
text = Text("Hello", self.font, self.draw)
text.set_origin(np.array([100, 100]))
# Point inside text bounds
# Origin is at baseline (100, 100), so test a point slightly above (at ascent/2)
# and to the right
self.assertTrue(text.in_object(np.array([110, 100])))
def test_in_object_miss(self):
"""Test in_object returns False for point outside text"""
text = Text("Hello", self.font, self.draw)
text.set_origin(np.array([100, 100]))
# Point outside text bounds
self.assertFalse(text.in_object(np.array([50, 50])))
self.assertFalse(text.in_object(np.array([200, 200])))
class TestLineQueryPoint(unittest.TestCase):
"""Test Line.query_point method"""
def setUp(self):
ensure_consistent_font_in_tests()
self.canvas = Image.new('RGB', (800, 600), color='white')
self.draw = ImageDraw.Draw(self.canvas)
self.font = create_default_test_font()
def test_query_point_finds_text(self):
"""Test Line.query_point finds a text object"""
line = Line(
spacing=(5, 10),
origin=np.array([50, 100]),
size=(700, 30),
draw=self.draw,
font=self.font
)
# Add text objects
word1 = Word("Hello", self.font)
word2 = Word("world", self.font)
line.add_word(word1)
line.add_word(word2)
line.render()
# Query a point that should hit first word
# (after rendering, text objects have positions set)
if len(line._text_objects) > 0:
text_obj = line._text_objects[0]
# Origin is at baseline, so query at baseline position (Y = origin[1])
# with X offset into the text
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
result = line.query_point(point)
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "text")
self.assertIsNotNone(result.text)
def test_query_point_miss(self):
"""Test Line.query_point returns None for miss"""
line = Line(
spacing=(5, 10),
origin=np.array([50, 100]),
size=(700, 30),
draw=self.draw,
font=self.font
)
word1 = Word("Hello", self.font)
line.add_word(word1)
line.render()
# Query far outside line bounds
result = line.query_point((10, 10))
self.assertIsNone(result)
def test_query_point_finds_link(self):
"""Test Line.query_point correctly identifies links"""
line = Line(
spacing=(5, 10),
origin=np.array([50, 100]),
size=(700, 30),
draw=self.draw,
font=self.font
)
# Create a linked word
from pyWebLayout.abstract.inline import LinkedWord
linked_word = LinkedWord("Click", self.font, "chapter2", LinkType.INTERNAL)
line.add_word(linked_word)
line.render()
# Query the link
if len(line._text_objects) > 0:
text_obj = line._text_objects[0]
# Origin is at baseline, query at baseline Y position
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
result = line.query_point(point)
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "link")
self.assertTrue(result.is_interactive)
self.assertEqual(result.link_target, "chapter2")
class TestPageQueryPoint(unittest.TestCase):
"""Test Page.query_point method"""
def setUp(self):
ensure_consistent_font_in_tests()
self.page = Page(size=(800, 1000), style=PageStyle())
self.font = create_default_test_font()
def test_query_point_empty_page(self):
"""Test querying empty page returns empty result"""
result = self.page.query_point((400, 500))
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "empty")
self.assertEqual(result.object, self.page)
def test_query_point_finds_line(self):
"""Test Page.query_point traverses to Line"""
line = Line(
spacing=(5, 10),
origin=np.array([50, 100]),
size=(700, 30),
draw=self.page.draw,
font=self.font
)
word = Word("Hello", self.font)
line.add_word(word)
line.render()
self.page.add_child(line)
# Query a point inside the line
if len(line._text_objects) > 0:
text_obj = line._text_objects[0]
# Origin is at baseline, query at baseline Y position
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
result = self.page.query_point(point)
# Should traverse Page → Line → Text
self.assertIsNotNone(result)
self.assertEqual(result.object_type, "text")
self.assertEqual(result.parent_page, self.page)
def test_query_point_multiple_lines(self):
"""Test Page.query_point with multiple lines"""
# Add two lines at different Y positions
line1 = Line(
spacing=(5, 10),
origin=np.array([50, 100]),
size=(700, 30),
draw=self.page.draw,
font=self.font
)
line2 = Line(
spacing=(5, 10),
origin=np.array([50, 150]),
size=(700, 30),
draw=self.page.draw,
font=self.font
)
word1 = Word("First", self.font)
word2 = Word("Second", self.font)
line1.add_word(word1)
line2.add_word(word2)
line1.render()
line2.render()
self.page.add_child(line1)
self.page.add_child(line2)
# Query first line
if len(line1._text_objects) > 0:
text_obj1 = line1._text_objects[0]
# Origin is at baseline, query at baseline Y position
point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1]))
result1 = self.page.query_point(point1)
self.assertIsNotNone(result1)
self.assertEqual(result1.text, "First")
# Query second line
if len(line2._text_objects) > 0:
text_obj2 = line2._text_objects[0]
# Origin is at baseline, query at baseline Y position
point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1]))
result2 = self.page.query_point(point2)
self.assertIsNotNone(result2)
self.assertEqual(result2.text, "Second")
class TestPageQueryRange(unittest.TestCase):
"""Test Page.query_range method for text selection"""
def setUp(self):
ensure_consistent_font_in_tests()
self.page = Page(size=(800, 1000), style=PageStyle())
self.font = create_default_test_font()
def test_query_range_single_line(self):
"""Test selecting text within a single line"""
line = Line(
spacing=(5, 10),
origin=np.array([50, 100]),
size=(700, 30),
draw=self.page.draw,
font=self.font
)
# Add multiple words
words = [Word(text, self.font) for text in ["Hello", "world", "test"]]
for word in words:
line.add_word(word)
line.render()
self.page.add_child(line)
if len(line._text_objects) >= 2:
# Select from first to second word
start_text = line._text_objects[0]
end_text = line._text_objects[1]
# Origin is at baseline, query at baseline Y position
start_point = (
int(start_text._origin[0] + 5), int(start_text._origin[1]))
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1]))
sel_range = self.page.query_range(start_point, end_point)
self.assertIsNotNone(sel_range)
self.assertGreater(len(sel_range.results), 0)
self.assertIn("Hello", sel_range.text)
def test_query_range_invalid(self):
"""Test query_range with invalid points returns empty"""
sel_range = self.page.query_range((10, 10), (20, 20))
self.assertEqual(len(sel_range.results), 0)
self.assertEqual(sel_range.text, "")
class TestPageMakeQueryResult(unittest.TestCase):
"""Test Page._make_query_result helper"""
def setUp(self):
ensure_consistent_font_in_tests()
self.page = Page(size=(800, 1000), style=PageStyle())
self.font = create_default_test_font()
self.draw = self.page.draw
def test_make_query_result_text(self):
"""Test packaging regular Text object"""
text = Text("Hello", self.font, self.draw)
text.set_origin(np.array([100, 200]))
result = self.page._make_query_result(text, (105, 205))
self.assertEqual(result.object_type, "text")
self.assertEqual(result.text, "Hello")
self.assertFalse(result.is_interactive)
def test_make_query_result_link(self):
"""Test packaging LinkText object"""
link = Link(location="chapter2", link_type=LinkType.INTERNAL, callback=None)
link_text = LinkText(link, "Click here", self.font, self.draw)
link_text.set_origin(np.array([100, 200]))
result = self.page._make_query_result(link_text, (105, 205))
self.assertEqual(result.object_type, "link")
self.assertEqual(result.text, "Click here")
self.assertTrue(result.is_interactive)
self.assertEqual(result.link_target, "chapter2")
if __name__ == '__main__':
unittest.main()
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 676 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More