Update coverage badges [skip ci]
This commit is contained in:
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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'])
|
||||
@@ -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 you’ll notice is that responses don’t 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()
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user