Tables now use ""dynamic page" allowing the contents to be anything that can be rendered ion a page.
Python CI / test (3.10) (push) Successful in 2m22s
Python CI / test (3.12) (push) Successful in 2m13s
Python CI / test (3.13) (push) Successful in 2m9s

This commit is contained in:
2025-11-11 18:10:47 +01:00
parent 889f27e1a3
commit 3bcd1bffb5
13 changed files with 2399 additions and 14 deletions
+313
View File
@@ -0,0 +1,313 @@
"""
Unit tests for DynamicPage class.
"""
import pytest
from PIL import Image
from pyWebLayout.concrete.dynamic_page import DynamicPage, SizeConstraints
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.style.fonts import Font
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
class TestSizeConstraints:
"""Test SizeConstraints dataclass."""
def test_default_constraints(self):
"""Test default constraint values."""
constraints = SizeConstraints()
assert constraints.min_width is None
assert constraints.max_width is None
assert constraints.min_height is None
assert constraints.max_height is None
def test_custom_constraints(self):
"""Test custom constraint values."""
constraints = SizeConstraints(
min_width=100,
max_width=500,
min_height=50,
max_height=1000
)
assert constraints.min_width == 100
assert constraints.max_width == 500
assert constraints.min_height == 50
assert constraints.max_height == 1000
class TestDynamicPage:
"""Test DynamicPage class."""
def test_initialization(self):
"""Test DynamicPage initialization."""
page = DynamicPage()
assert page.size == (0, 0) # Starts with zero size
assert not page._is_measured
assert not page._is_laid_out
assert page._render_offset == 0
assert page.constraints is not None
def test_initialization_with_constraints(self):
"""Test initialization with custom constraints."""
constraints = SizeConstraints(min_width=200, max_width=800)
page = DynamicPage(constraints=constraints)
assert page.constraints.min_width == 200
assert page.constraints.max_width == 800
def test_initialization_with_style(self):
"""Test initialization with custom style."""
style = PageStyle(border_width=2, padding=(10, 20, 10, 20))
page = DynamicPage(style=style)
assert page.style.border_width == 2
assert page.style.padding_top == 10
def test_measure_empty_page(self):
"""Test measuring an empty page."""
page = DynamicPage()
width, height = page.measure()
# Empty page should have minimal size (just padding/borders)
assert width > 0 # At least padding/borders
assert height > 0
assert page._is_measured
def test_measure_with_constraints(self):
"""Test measuring respects constraints."""
constraints = SizeConstraints(min_width=300, min_height=200)
page = DynamicPage(constraints=constraints)
width, height = page.measure()
assert width >= 300
assert height >= 200
def test_measure_caching(self):
"""Test that measurement is cached."""
page = DynamicPage()
# First measurement
size1 = page.measure()
# Second measurement should return cached value
size2 = page.measure()
assert size1 == size2
assert page._is_measured
def test_get_min_width(self):
"""Test get_min_width."""
page = DynamicPage()
min_width = page.get_min_width()
assert min_width > 0
assert isinstance(min_width, int)
def test_get_preferred_width(self):
"""Test get_preferred_width."""
page = DynamicPage()
pref_width = page.get_preferred_width()
assert pref_width > 0
assert isinstance(pref_width, int)
def test_measure_content_height(self):
"""Test measure_content_height."""
page = DynamicPage()
content_height = page.measure_content_height()
assert content_height > 0
assert isinstance(content_height, int)
def test_layout(self):
"""Test layout method."""
page = DynamicPage()
target_size = (400, 600)
page.layout(target_size)
assert page.size == target_size
assert page._is_laid_out
assert page._dirty # Should be marked for re-render
def test_render_without_layout(self):
"""Test rendering without explicit layout (auto-sizing)."""
page = DynamicPage()
image = page.render()
assert isinstance(image, Image.Image)
assert image.size[0] > 0
assert image.size[1] > 0
def test_render_with_layout(self):
"""Test rendering after explicit layout."""
page = DynamicPage()
page.layout((500, 700))
image = page.render()
assert isinstance(image, Image.Image)
assert image.size == (500, 700)
def test_add_child_invalidates_cache(self):
"""Test that adding a child invalidates measurement caches."""
page = DynamicPage()
# Measure to populate cache
page.measure()
assert page._is_measured
# Add a child (mock renderable)
class MockRenderable:
def __init__(self):
self.size = (100, 50)
self._origin = (0, 0)
@property
def origin(self):
return self._origin
def render(self):
pass
page.add_child(MockRenderable())
# Caches should be invalidated
assert not page._is_measured
assert page._intrinsic_size is None
def test_clear_children_invalidates_cache(self):
"""Test that clearing children invalidates caches."""
page = DynamicPage()
# Measure to populate cache
page.measure()
assert page._is_measured
# Clear children
page.clear_children()
# Caches should be invalidated
assert not page._is_measured
def test_pagination_reset(self):
"""Test pagination reset."""
page = DynamicPage()
page._render_offset = 100
page.reset_pagination()
assert page._render_offset == 0
def test_has_more_content_false(self):
"""Test has_more_content when all content is rendered."""
page = DynamicPage()
# Set render offset to total height
total_height = page.measure_content_height()
page._render_offset = total_height
assert not page.has_more_content()
def test_has_more_content_true(self):
"""Test has_more_content when content remains."""
page = DynamicPage()
# Offset is less than total
page._render_offset = 0
assert page.has_more_content()
def test_min_width_measurement(self):
"""Test min width measures longest word."""
page = DynamicPage()
# Min width should be at least padding/borders
min_width = page.get_min_width()
assert min_width > 0
def test_invalidate_caches(self):
"""Test cache invalidation."""
page = DynamicPage()
# Populate caches
page.measure()
page.get_min_width()
page.get_preferred_width()
page.measure_content_height()
assert page._is_measured
assert page._intrinsic_size is not None
assert page._min_width_cache is not None
assert page._preferred_width_cache is not None
assert page._content_height_cache is not None
# Invalidate
page.invalidate_caches()
assert not page._is_measured
assert page._intrinsic_size is None
assert page._min_width_cache is None
assert page._preferred_width_cache is None
assert page._content_height_cache is None
assert not page._is_laid_out
def test_measure_with_available_width(self):
"""Test measurement with available_width constraint."""
page = DynamicPage()
width, height = page.measure(available_width=300)
# Width should respect available_width
assert width <= 300
def test_constraints_override_available_width(self):
"""Test that constraints override available_width."""
constraints = SizeConstraints(min_width=400)
page = DynamicPage(constraints=constraints)
width, height = page.measure(available_width=300)
# Should use min_width constraint, not available_width
assert width >= 400
def test_render_partial_empty_page(self):
"""Test partial rendering on empty page."""
page = DynamicPage()
rendered = page.render_partial(available_height=100)
assert rendered >= 0
assert isinstance(rendered, int)
def test_method_chaining_add_child(self):
"""Test that add_child returns self for chaining."""
page = DynamicPage()
class MockRenderable:
def __init__(self):
self.size = (50, 50)
self._origin = (0, 0)
@property
def origin(self):
return self._origin
result = page.add_child(MockRenderable())
assert result is page
def test_method_chaining_clear_children(self):
"""Test that clear_children returns self for chaining."""
page = DynamicPage()
result = page.clear_children()
assert result is page
if __name__ == '__main__':
pytest.main([__file__, '-v'])
+397
View File
@@ -0,0 +1,397 @@
"""
Unit tests for table column width optimization.
"""
import pytest
from pyWebLayout.layout.table_optimizer import (
optimize_table_layout,
sample_table_rows,
extract_html_column_widths,
parse_html_width,
distribute_column_widths,
get_column_count,
calculate_table_overhead
)
from pyWebLayout.abstract.block import Table
from pyWebLayout.concrete.table import TableStyle
class TestParseHtmlWidth:
"""Test HTML width parsing."""
def test_parse_int(self):
assert parse_html_width(100) == 100
def test_parse_px_string(self):
assert parse_html_width("150px") == 150
def test_parse_plain_number_string(self):
assert parse_html_width("200") == 200
def test_parse_percentage_returns_none(self):
assert parse_html_width("50%") is None
def test_parse_invalid_string(self):
assert parse_html_width("invalid") is None
def test_parse_with_whitespace(self):
assert parse_html_width(" 120px ") == 120
class TestDistributeColumnWidths:
"""Test column width distribution."""
def test_distribute_with_no_fixed_columns(self):
min_widths = [50, 60, 70]
pref_widths = [100, 120, 140]
available = 360
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Should use preferred widths (they fit)
assert result == [100, 120, 140]
def test_distribute_when_preferred_fits(self):
min_widths = [50, 50]
pref_widths = [100, 100]
available = 250
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Preferred widths fit, extra 50px distributed proportionally (25px each)
assert result == [125, 125]
def test_distribute_when_must_use_minimum(self):
min_widths = [100, 100]
pref_widths = [200, 200]
available = 150
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Can't even fit minimum, but force it anyway
assert result == [100, 100]
def test_distribute_proportional(self):
min_widths = [50, 50]
pref_widths = [200, 100]
available = 200 # Between min and pref totals
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Should distribute proportionally
assert len(result) == 2
assert result[0] + result[1] == 200
# First column should get more (higher pref)
assert result[0] > result[1]
def test_distribute_with_fixed_columns(self):
min_widths = [50, 50, 50]
pref_widths = [100, 100, 100]
available = 300
fixed = {1: 80} # Second column fixed at 80
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Second column should be 80
assert result[1] == 80
# Other columns share remaining space
assert result[0] + result[2] == 220
def test_distribute_all_fixed(self):
min_widths = [50, 50]
pref_widths = [100, 100]
available = 300
fixed = {0: 120, 1: 150}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
assert result == [120, 150]
def test_distribute_empty(self):
result = distribute_column_widths([], [], 100, {})
assert result == []
class TestGetColumnCount:
"""Test column counting."""
def test_empty_table(self):
table = Table()
assert get_column_count(table) == 0
def test_table_with_header(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["A", "B", "C"]:
cell = TableCell(is_header=True)
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="header")
assert get_column_count(table) == 3
def test_table_with_body(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["1", "2"]:
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
assert get_column_count(table) == 2
class TestSampleTableRows:
"""Test row sampling."""
def test_sample_small_table(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
for text in ["1", "2"]:
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
sampled = sample_table_rows(table, sample_size=5)
# Should get all rows (only 2)
assert len(sampled) == 2
def test_sample_large_table(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
for i in range(20):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(str(i), font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
sampled = sample_table_rows(table, sample_size=5)
# Should get only 5 body rows
assert len(sampled) == 5
def test_sample_with_header_body_footer(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
# 3 header rows
for i in range(3):
row = TableRow()
cell = TableCell(is_header=True)
para = Paragraph(font)
para.add_word(Word(f"H{i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="header")
# 10 body rows
for i in range(10):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(f"B{i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
# 2 footer rows
for i in range(2):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(f"F{i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="footer")
sampled = sample_table_rows(table, sample_size=2)
# Should get 2 from each section = 6 total
assert len(sampled) == 6
class TestExtractHtmlColumnWidths:
"""Test HTML width extraction."""
def test_no_widths(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["A", "B"]:
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
widths = extract_html_column_widths(table)
assert widths == [None, None]
def test_cell_width_attributes(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
cell1 = TableCell()
cell1.width = "100px"
para1 = Paragraph(font)
para1.add_word(Word("A", font))
cell1.add_block(para1)
row.add_cell(cell1)
cell2 = TableCell()
cell2.width = "150"
para2 = Paragraph(font)
para2.add_word(Word("B", font))
cell2.add_block(para2)
row.add_cell(cell2)
table.add_row(row, section="body")
widths = extract_html_column_widths(table)
assert widths == [100, 150]
class TestCalculateTableOverhead:
"""Test table overhead calculation."""
def test_basic_overhead(self):
style = TableStyle(border_width=1, cell_spacing=0)
overhead = calculate_table_overhead(3, style)
# 3 columns = 4 borders (n+1)
assert overhead == 4
def test_with_cell_spacing(self):
style = TableStyle(border_width=1, cell_spacing=5)
overhead = calculate_table_overhead(3, style)
# 4 borders + 2 spacings (n-1)
assert overhead == 4 + 10
def test_thicker_borders(self):
style = TableStyle(border_width=3, cell_spacing=0)
overhead = calculate_table_overhead(2, style)
# 2 columns = 3 borders * 3px
assert overhead == 9
class TestOptimizeTableLayout:
"""Test full table optimization."""
def test_optimize_simple_table(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["Short", "A bit longer text"]:
cell = TableCell()
para = Paragraph(font)
for word in text.split():
para.add_word(Word(word, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
style = TableStyle()
widths = optimize_table_layout(table, available_width=400, style=style)
# Should return 2 column widths
assert len(widths) == 2
# Second column should be wider
assert widths[1] > widths[0]
def test_optimize_empty_table(self):
table = Table()
widths = optimize_table_layout(table, available_width=400)
assert widths == []
def test_optimize_respects_sample_size(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
# Create 20 rows but only first 5 should be sampled
for i in range(20):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(f"Data {i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
widths = optimize_table_layout(table, available_width=400, sample_size=5)
# Should return width for 1 column
assert len(widths) == 1
if __name__ == '__main__':
pytest.main([__file__, '-v'])