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
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"])