This commit is contained in:
parent
5c0b22569a
commit
b65c35d96d
600
tests/concrete/test_table_rendering.py
Normal file
600
tests/concrete/test_table_rendering.py
Normal file
@ -0,0 +1,600 @@
|
|||||||
|
"""
|
||||||
|
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,
|
||||||
|
Image as AbstractImage
|
||||||
|
)
|
||||||
|
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)) == True
|
||||||
|
|
||||||
|
# Point outside cell
|
||||||
|
assert cell_renderer.in_object((150, 30)) == False
|
||||||
|
assert cell_renderer.in_object((50, 100)) == False
|
||||||
|
|
||||||
|
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)) == True
|
||||||
|
|
||||||
|
# Point outside table
|
||||||
|
assert table_renderer.in_object((1000, 1000)) == False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
878
tests/layout/test_ereader_layout.py
Normal file
878
tests/layout/test_ereader_layout.py
Normal file
@ -0,0 +1,878 @@
|
|||||||
|
"""
|
||||||
|
Tests for the ereader layout system components.
|
||||||
|
|
||||||
|
This module tests:
|
||||||
|
- RenderingPosition: Position tracking and serialization
|
||||||
|
- ChapterNavigator: Chapter detection and navigation
|
||||||
|
- FontScaler: Font scaling utilities
|
||||||
|
- BidirectionalLayouter: Forward/backward page rendering
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyWebLayout.layout.ereader_layout import (
|
||||||
|
RenderingPosition,
|
||||||
|
ChapterNavigator,
|
||||||
|
ChapterInfo,
|
||||||
|
FontScaler,
|
||||||
|
BidirectionalLayouter
|
||||||
|
)
|
||||||
|
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Table, HList
|
||||||
|
from pyWebLayout.abstract.inline import Word
|
||||||
|
from pyWebLayout.style import Font
|
||||||
|
from pyWebLayout.style.page_style import PageStyle
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 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_blocks_with_headings(sample_font):
|
||||||
|
"""Create a sample document structure with headings for testing."""
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
# H1 - Chapter 1
|
||||||
|
h1 = Heading(HeadingLevel.H1, sample_font)
|
||||||
|
h1.add_word(Word("Chapter", sample_font))
|
||||||
|
h1.add_word(Word("One", sample_font))
|
||||||
|
blocks.append(h1)
|
||||||
|
|
||||||
|
# Paragraph
|
||||||
|
p1 = Paragraph(sample_font)
|
||||||
|
p1.add_word(Word("This", sample_font))
|
||||||
|
p1.add_word(Word("is", sample_font))
|
||||||
|
p1.add_word(Word("content", sample_font))
|
||||||
|
blocks.append(p1)
|
||||||
|
|
||||||
|
# H2 - Section 1.1
|
||||||
|
h2 = Heading(HeadingLevel.H2, sample_font)
|
||||||
|
h2.add_word(Word("Section", sample_font))
|
||||||
|
h2.add_word(Word("1.1", sample_font))
|
||||||
|
blocks.append(h2)
|
||||||
|
|
||||||
|
# Another paragraph
|
||||||
|
p2 = Paragraph(sample_font)
|
||||||
|
p2.add_word(Word("More", sample_font))
|
||||||
|
p2.add_word(Word("text", sample_font))
|
||||||
|
blocks.append(p2)
|
||||||
|
|
||||||
|
# H1 - Chapter 2
|
||||||
|
h1_2 = Heading(HeadingLevel.H1, sample_font)
|
||||||
|
h1_2.add_word(Word("Chapter", sample_font))
|
||||||
|
h1_2.add_word(Word("Two", sample_font))
|
||||||
|
blocks.append(h1_2)
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_page_style():
|
||||||
|
"""Create a standard page style for testing."""
|
||||||
|
return PageStyle()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# RenderingPosition Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestRenderingPosition:
|
||||||
|
"""Tests for the RenderingPosition dataclass."""
|
||||||
|
|
||||||
|
def test_default_initialization(self):
|
||||||
|
"""Test RenderingPosition initializes with default values."""
|
||||||
|
pos = RenderingPosition()
|
||||||
|
assert pos.chapter_index == 0
|
||||||
|
assert pos.block_index == 0
|
||||||
|
assert pos.word_index == 0
|
||||||
|
assert pos.table_row == 0
|
||||||
|
assert pos.table_col == 0
|
||||||
|
assert pos.list_item_index == 0
|
||||||
|
assert pos.remaining_pretext is None
|
||||||
|
assert pos.page_y_offset == 0
|
||||||
|
|
||||||
|
def test_custom_initialization(self):
|
||||||
|
"""Test RenderingPosition with custom values."""
|
||||||
|
pos = RenderingPosition(
|
||||||
|
chapter_index=2,
|
||||||
|
block_index=5,
|
||||||
|
word_index=10,
|
||||||
|
table_row=1,
|
||||||
|
table_col=2,
|
||||||
|
list_item_index=3,
|
||||||
|
remaining_pretext="test",
|
||||||
|
page_y_offset=100
|
||||||
|
)
|
||||||
|
assert pos.chapter_index == 2
|
||||||
|
assert pos.block_index == 5
|
||||||
|
assert pos.word_index == 10
|
||||||
|
assert pos.table_row == 1
|
||||||
|
assert pos.table_col == 2
|
||||||
|
assert pos.list_item_index == 3
|
||||||
|
assert pos.remaining_pretext == "test"
|
||||||
|
assert pos.page_y_offset == 100
|
||||||
|
|
||||||
|
def test_to_dict_serialization(self):
|
||||||
|
"""Test serialization to dictionary."""
|
||||||
|
pos = RenderingPosition(
|
||||||
|
chapter_index=1,
|
||||||
|
block_index=2,
|
||||||
|
word_index=3
|
||||||
|
)
|
||||||
|
result = pos.to_dict()
|
||||||
|
|
||||||
|
assert isinstance(result, dict)
|
||||||
|
assert result['chapter_index'] == 1
|
||||||
|
assert result['block_index'] == 2
|
||||||
|
assert result['word_index'] == 3
|
||||||
|
assert 'table_row' in result
|
||||||
|
assert 'remaining_pretext' in result
|
||||||
|
|
||||||
|
def test_from_dict_deserialization(self):
|
||||||
|
"""Test deserialization from dictionary."""
|
||||||
|
data = {
|
||||||
|
'chapter_index': 2,
|
||||||
|
'block_index': 4,
|
||||||
|
'word_index': 6,
|
||||||
|
'table_row': 1,
|
||||||
|
'table_col': 0,
|
||||||
|
'list_item_index': 0,
|
||||||
|
'remaining_pretext': None,
|
||||||
|
'page_y_offset': 50
|
||||||
|
}
|
||||||
|
pos = RenderingPosition.from_dict(data)
|
||||||
|
|
||||||
|
assert pos.chapter_index == 2
|
||||||
|
assert pos.block_index == 4
|
||||||
|
assert pos.word_index == 6
|
||||||
|
assert pos.page_y_offset == 50
|
||||||
|
|
||||||
|
def test_round_trip_serialization(self):
|
||||||
|
"""Test serialization and deserialization round trip."""
|
||||||
|
original = RenderingPosition(
|
||||||
|
chapter_index=3,
|
||||||
|
block_index=7,
|
||||||
|
word_index=15,
|
||||||
|
remaining_pretext="hyphen-",
|
||||||
|
page_y_offset=200
|
||||||
|
)
|
||||||
|
|
||||||
|
# Serialize and deserialize
|
||||||
|
data = original.to_dict()
|
||||||
|
restored = RenderingPosition.from_dict(data)
|
||||||
|
|
||||||
|
assert original == restored
|
||||||
|
|
||||||
|
def test_copy_creates_independent_copy(self):
|
||||||
|
"""Test that copy() creates an independent copy."""
|
||||||
|
original = RenderingPosition(
|
||||||
|
chapter_index=1,
|
||||||
|
block_index=2,
|
||||||
|
word_index=3
|
||||||
|
)
|
||||||
|
|
||||||
|
copy = original.copy()
|
||||||
|
|
||||||
|
# Verify values match
|
||||||
|
assert copy.chapter_index == original.chapter_index
|
||||||
|
assert copy.block_index == original.block_index
|
||||||
|
assert copy.word_index == original.word_index
|
||||||
|
|
||||||
|
# Modify copy and verify original is unchanged
|
||||||
|
copy.chapter_index = 99
|
||||||
|
copy.word_index = 100
|
||||||
|
|
||||||
|
assert original.chapter_index == 1
|
||||||
|
assert original.word_index == 3
|
||||||
|
|
||||||
|
def test_equality_same_values(self):
|
||||||
|
"""Test equality comparison with same values."""
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=2)
|
||||||
|
|
||||||
|
assert pos1 == pos2
|
||||||
|
|
||||||
|
def test_equality_different_values(self):
|
||||||
|
"""Test equality comparison with different values."""
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=3)
|
||||||
|
|
||||||
|
assert pos1 != pos2
|
||||||
|
|
||||||
|
def test_equality_with_non_position(self):
|
||||||
|
"""Test equality comparison with non-RenderingPosition object."""
|
||||||
|
pos = RenderingPosition()
|
||||||
|
|
||||||
|
assert pos != "not a position"
|
||||||
|
assert pos != 42
|
||||||
|
assert pos != None
|
||||||
|
|
||||||
|
def test_hashability(self):
|
||||||
|
"""Test that RenderingPosition is hashable and can be used in sets/dicts."""
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=2)
|
||||||
|
pos3 = RenderingPosition(chapter_index=1, block_index=3)
|
||||||
|
|
||||||
|
# Test in set
|
||||||
|
position_set = {pos1, pos2, pos3}
|
||||||
|
assert len(position_set) == 2 # pos1 and pos2 should be same
|
||||||
|
|
||||||
|
# Test as dict key
|
||||||
|
position_dict = {pos1: "value1"}
|
||||||
|
assert position_dict[pos2] == "value1" # pos2 should access same key
|
||||||
|
|
||||||
|
def test_hash_consistency(self):
|
||||||
|
"""Test that hash values are consistent."""
|
||||||
|
pos1 = RenderingPosition(chapter_index=5, block_index=10)
|
||||||
|
pos2 = RenderingPosition(chapter_index=5, block_index=10)
|
||||||
|
|
||||||
|
assert hash(pos1) == hash(pos2)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# ChapterNavigator Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestChapterNavigator:
|
||||||
|
"""Tests for the ChapterNavigator class."""
|
||||||
|
|
||||||
|
def test_initialization(self, sample_blocks_with_headings):
|
||||||
|
"""Test ChapterNavigator initialization."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
assert navigator.blocks == sample_blocks_with_headings
|
||||||
|
assert len(navigator.chapters) > 0
|
||||||
|
|
||||||
|
def test_build_chapter_map_finds_headings(self, sample_blocks_with_headings):
|
||||||
|
"""Test that chapter map correctly identifies headings."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
# Should find 3 headings: H1, H2, H1
|
||||||
|
assert len(navigator.chapters) == 3
|
||||||
|
|
||||||
|
def test_chapter_info_properties(self, sample_blocks_with_headings):
|
||||||
|
"""Test ChapterInfo contains correct properties."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
first_chapter = navigator.chapters[0]
|
||||||
|
assert isinstance(first_chapter, ChapterInfo)
|
||||||
|
assert first_chapter.title == "Chapter One"
|
||||||
|
assert first_chapter.level == HeadingLevel.H1
|
||||||
|
assert isinstance(first_chapter.position, RenderingPosition)
|
||||||
|
assert first_chapter.block_index == 0
|
||||||
|
|
||||||
|
def test_heading_text_extraction(self, sample_blocks_with_headings):
|
||||||
|
"""Test extraction of heading text from Word objects."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
# Check extracted titles
|
||||||
|
titles = [ch.title for ch in navigator.chapters]
|
||||||
|
assert "Chapter One" in titles
|
||||||
|
assert "Section 1.1" in titles
|
||||||
|
assert "Chapter Two" in titles
|
||||||
|
|
||||||
|
def test_chapter_index_tracking(self, sample_blocks_with_headings):
|
||||||
|
"""Test that chapter indices are tracked correctly for H1 headings."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
# Note: The current implementation increments AFTER adding the chapter
|
||||||
|
# So chapter_index values are: H1=0 (then inc to 1), H2=1, H1=1 (then inc to 2)
|
||||||
|
# First H1 should be chapter 0
|
||||||
|
assert navigator.chapters[0].position.chapter_index == 0
|
||||||
|
# H2 gets chapter_index 1 (after first H1 increment)
|
||||||
|
assert navigator.chapters[1].position.chapter_index == 1
|
||||||
|
# Second H1 also gets chapter_index 1 (then increments to 2)
|
||||||
|
assert navigator.chapters[2].position.chapter_index == 1
|
||||||
|
|
||||||
|
def test_get_table_of_contents(self, sample_blocks_with_headings):
|
||||||
|
"""Test generation of table of contents."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
toc = navigator.get_table_of_contents()
|
||||||
|
|
||||||
|
assert isinstance(toc, list)
|
||||||
|
assert len(toc) == 3
|
||||||
|
|
||||||
|
# Each entry should be (title, level, position)
|
||||||
|
for entry in toc:
|
||||||
|
assert isinstance(entry, tuple)
|
||||||
|
assert len(entry) == 3
|
||||||
|
assert isinstance(entry[0], str) # title
|
||||||
|
assert isinstance(entry[1], HeadingLevel) # level
|
||||||
|
assert isinstance(entry[2], RenderingPosition) # position
|
||||||
|
|
||||||
|
def test_get_chapter_position_exact_match(self, sample_blocks_with_headings):
|
||||||
|
"""Test finding chapter by exact title match."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
position = navigator.get_chapter_position("Chapter One")
|
||||||
|
assert position is not None
|
||||||
|
assert position.block_index == 0
|
||||||
|
|
||||||
|
def test_get_chapter_position_case_insensitive(self, sample_blocks_with_headings):
|
||||||
|
"""Test case-insensitive chapter title matching."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
position = navigator.get_chapter_position("chapter one")
|
||||||
|
assert position is not None
|
||||||
|
|
||||||
|
position2 = navigator.get_chapter_position("CHAPTER ONE")
|
||||||
|
assert position2 is not None
|
||||||
|
assert position == position2
|
||||||
|
|
||||||
|
def test_get_chapter_position_not_found(self, sample_blocks_with_headings):
|
||||||
|
"""Test getting position for non-existent chapter."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
position = navigator.get_chapter_position("Nonexistent Chapter")
|
||||||
|
assert position is None
|
||||||
|
|
||||||
|
def test_get_current_chapter_at_start(self, sample_blocks_with_headings):
|
||||||
|
"""Test getting current chapter at document start."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
position = RenderingPosition(chapter_index=0, block_index=0)
|
||||||
|
current = navigator.get_current_chapter(position)
|
||||||
|
|
||||||
|
assert current is not None
|
||||||
|
assert current.title == "Chapter One"
|
||||||
|
|
||||||
|
def test_get_current_chapter_in_middle(self, sample_blocks_with_headings):
|
||||||
|
"""Test getting current chapter in middle of document."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
# Position at block 3 should be in Chapter One
|
||||||
|
position = RenderingPosition(chapter_index=0, block_index=3)
|
||||||
|
current = navigator.get_current_chapter(position)
|
||||||
|
|
||||||
|
assert current is not None
|
||||||
|
assert "Chapter One" in current.title or "Section 1.1" in current.title
|
||||||
|
|
||||||
|
def test_get_current_chapter_at_end(self, sample_blocks_with_headings):
|
||||||
|
"""Test getting current chapter at document end."""
|
||||||
|
navigator = ChapterNavigator(sample_blocks_with_headings)
|
||||||
|
|
||||||
|
position = RenderingPosition(chapter_index=1, block_index=4)
|
||||||
|
current = navigator.get_current_chapter(position)
|
||||||
|
|
||||||
|
assert current is not None
|
||||||
|
assert current.title == "Chapter Two"
|
||||||
|
|
||||||
|
def test_empty_document_no_chapters(self, sample_font):
|
||||||
|
"""Test navigator with document containing no headings."""
|
||||||
|
# Document with only paragraphs
|
||||||
|
blocks = [
|
||||||
|
Paragraph(sample_font),
|
||||||
|
Paragraph(sample_font)
|
||||||
|
]
|
||||||
|
|
||||||
|
navigator = ChapterNavigator(blocks)
|
||||||
|
|
||||||
|
assert len(navigator.chapters) == 0
|
||||||
|
assert navigator.get_table_of_contents() == []
|
||||||
|
|
||||||
|
position = RenderingPosition()
|
||||||
|
assert navigator.get_current_chapter(position) is None
|
||||||
|
|
||||||
|
def test_multiple_heading_levels(self, sample_font):
|
||||||
|
"""Test navigator with multiple heading levels H1-H6."""
|
||||||
|
blocks = []
|
||||||
|
|
||||||
|
# Create headings of each level
|
||||||
|
for level in [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||||
|
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]:
|
||||||
|
heading = Heading(level, sample_font)
|
||||||
|
heading.add_word(Word(f"Heading {level.value}", sample_font))
|
||||||
|
blocks.append(heading)
|
||||||
|
|
||||||
|
navigator = ChapterNavigator(blocks)
|
||||||
|
|
||||||
|
# Should find all 6 headings
|
||||||
|
assert len(navigator.chapters) == 6
|
||||||
|
|
||||||
|
# Note: Due to implementation, H1 increments chapter_index AFTER being added
|
||||||
|
# So: H1=0 (inc to 1), H2=1, H3=1, H4=1, H5=1, H6=1
|
||||||
|
chapter_indices = [ch.position.chapter_index for ch in navigator.chapters]
|
||||||
|
assert chapter_indices[0] == 0 # H1 gets 0, then increments
|
||||||
|
assert all(idx == 1 for idx in chapter_indices[1:]) # H2-H6 all get 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FontScaler Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestFontScaler:
|
||||||
|
"""Tests for the FontScaler utility class."""
|
||||||
|
|
||||||
|
def test_scale_font_no_change(self, sample_font):
|
||||||
|
"""Test scaling font with factor 1.0 returns same font."""
|
||||||
|
scaled = FontScaler.scale_font(sample_font, 1.0)
|
||||||
|
|
||||||
|
# Should return the original font when scale is 1.0
|
||||||
|
assert scaled == sample_font
|
||||||
|
|
||||||
|
def test_scale_font_double_size(self, sample_font):
|
||||||
|
"""Test scaling font to double size."""
|
||||||
|
original_size = sample_font.font_size
|
||||||
|
scaled = FontScaler.scale_font(sample_font, 2.0)
|
||||||
|
|
||||||
|
assert scaled.font_size == original_size * 2
|
||||||
|
|
||||||
|
def test_scale_font_half_size(self, sample_font):
|
||||||
|
"""Test scaling font to half size."""
|
||||||
|
original_size = sample_font.font_size
|
||||||
|
scaled = FontScaler.scale_font(sample_font, 0.5)
|
||||||
|
|
||||||
|
assert scaled.font_size == int(original_size * 0.5)
|
||||||
|
|
||||||
|
def test_scale_font_preserves_color(self, sample_font):
|
||||||
|
"""Test that font scaling preserves color."""
|
||||||
|
scaled = FontScaler.scale_font(sample_font, 1.5)
|
||||||
|
|
||||||
|
assert scaled.colour == sample_font.colour
|
||||||
|
|
||||||
|
def test_scale_font_preserves_properties(self):
|
||||||
|
"""Test that font scaling preserves all font properties."""
|
||||||
|
font = Font(
|
||||||
|
font_size=14,
|
||||||
|
colour=(255, 0, 0),
|
||||||
|
weight="bold",
|
||||||
|
style="italic"
|
||||||
|
)
|
||||||
|
|
||||||
|
scaled = FontScaler.scale_font(font, 1.5)
|
||||||
|
|
||||||
|
assert scaled.colour == font.colour
|
||||||
|
assert scaled.weight == font.weight
|
||||||
|
assert scaled.style == font.style
|
||||||
|
|
||||||
|
def test_scale_font_minimum_size(self):
|
||||||
|
"""Test that font scaling maintains minimum size of 1."""
|
||||||
|
font = Font(font_size=2)
|
||||||
|
|
||||||
|
# Scale to very small
|
||||||
|
scaled = FontScaler.scale_font(font, 0.1)
|
||||||
|
|
||||||
|
# Should be at least 1
|
||||||
|
assert scaled.font_size >= 1
|
||||||
|
|
||||||
|
def test_scale_word_spacing_no_change(self):
|
||||||
|
"""Test scaling word spacing with factor 1.0."""
|
||||||
|
spacing = (5, 10)
|
||||||
|
scaled = FontScaler.scale_word_spacing(spacing, 1.0)
|
||||||
|
|
||||||
|
assert scaled == spacing
|
||||||
|
|
||||||
|
def test_scale_word_spacing_double(self):
|
||||||
|
"""Test scaling word spacing to double."""
|
||||||
|
spacing = (5, 10)
|
||||||
|
scaled = FontScaler.scale_word_spacing(spacing, 2.0)
|
||||||
|
|
||||||
|
assert scaled == (10, 20)
|
||||||
|
|
||||||
|
def test_scale_word_spacing_maintains_minimum(self):
|
||||||
|
"""Test that word spacing maintains minimum values."""
|
||||||
|
spacing = (2, 4)
|
||||||
|
scaled = FontScaler.scale_word_spacing(spacing, 0.1)
|
||||||
|
|
||||||
|
# Min spacing should be at least 1, max at least 2
|
||||||
|
assert scaled[0] >= 1
|
||||||
|
assert scaled[1] >= 2
|
||||||
|
|
||||||
|
def test_scale_font_with_large_factor(self):
|
||||||
|
"""Test scaling with very large factor."""
|
||||||
|
font = Font(font_size=12)
|
||||||
|
scaled = FontScaler.scale_font(font, 10.0)
|
||||||
|
|
||||||
|
assert scaled.font_size == 120
|
||||||
|
|
||||||
|
def test_scale_font_with_small_factor(self):
|
||||||
|
"""Test scaling with very small factor."""
|
||||||
|
font = Font(font_size=12)
|
||||||
|
scaled = FontScaler.scale_font(font, 0.05)
|
||||||
|
|
||||||
|
# Should still be at least 1
|
||||||
|
assert scaled.font_size >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BidirectionalLayouter Tests (Basic)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class TestBidirectionalLayouter:
|
||||||
|
"""Tests for the BidirectionalLayouter class."""
|
||||||
|
|
||||||
|
def test_initialization(self, sample_blocks_with_headings, sample_page_style):
|
||||||
|
"""Test BidirectionalLayouter initialization."""
|
||||||
|
layouter = BidirectionalLayouter(
|
||||||
|
sample_blocks_with_headings,
|
||||||
|
sample_page_style,
|
||||||
|
page_size=(800, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert layouter.blocks == sample_blocks_with_headings
|
||||||
|
assert layouter.page_style == sample_page_style
|
||||||
|
assert layouter.page_size == (800, 600)
|
||||||
|
assert isinstance(layouter.chapter_navigator, ChapterNavigator)
|
||||||
|
|
||||||
|
def test_position_compare_equal(self):
|
||||||
|
"""Test position comparison for equal positions."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=2, word_index=3)
|
||||||
|
|
||||||
|
assert layouter._position_compare(pos1, pos2) == 0
|
||||||
|
|
||||||
|
def test_position_compare_chapter_difference(self):
|
||||||
|
"""Test position comparison with different chapters."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3)
|
||||||
|
pos2 = RenderingPosition(chapter_index=2, block_index=2, word_index=3)
|
||||||
|
|
||||||
|
assert layouter._position_compare(pos1, pos2) == -1
|
||||||
|
assert layouter._position_compare(pos2, pos1) == 1
|
||||||
|
|
||||||
|
def test_position_compare_block_difference(self):
|
||||||
|
"""Test position comparison with different blocks."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=5, word_index=3)
|
||||||
|
|
||||||
|
assert layouter._position_compare(pos1, pos2) == -1
|
||||||
|
assert layouter._position_compare(pos2, pos1) == 1
|
||||||
|
|
||||||
|
def test_position_compare_word_difference(self):
|
||||||
|
"""Test position comparison with different words."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
pos1 = RenderingPosition(chapter_index=1, block_index=2, word_index=3)
|
||||||
|
pos2 = RenderingPosition(chapter_index=1, block_index=2, word_index=10)
|
||||||
|
|
||||||
|
assert layouter._position_compare(pos1, pos2) == -1
|
||||||
|
assert layouter._position_compare(pos2, pos1) == 1
|
||||||
|
|
||||||
|
def test_scale_block_fonts_no_scaling(self, sample_font):
|
||||||
|
"""Test block font scaling with factor 1.0."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
paragraph = Paragraph(sample_font)
|
||||||
|
paragraph.add_word(Word("test", sample_font))
|
||||||
|
|
||||||
|
scaled = layouter._scale_block_fonts(paragraph, 1.0)
|
||||||
|
|
||||||
|
# Should return same block
|
||||||
|
assert scaled == paragraph
|
||||||
|
|
||||||
|
def test_estimate_page_start(self):
|
||||||
|
"""Test estimation of page start position."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
end_pos = RenderingPosition(chapter_index=0, block_index=20, word_index=0)
|
||||||
|
|
||||||
|
estimated = layouter._estimate_page_start(end_pos, 1.0)
|
||||||
|
|
||||||
|
# Should estimate some blocks before the end position
|
||||||
|
assert estimated.block_index < end_pos.block_index
|
||||||
|
assert estimated.block_index >= 0
|
||||||
|
|
||||||
|
def test_estimate_page_start_with_font_scale(self):
|
||||||
|
"""Test that font scale affects page start estimation."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
end_pos = RenderingPosition(chapter_index=0, block_index=20, word_index=0)
|
||||||
|
|
||||||
|
est_normal = layouter._estimate_page_start(end_pos, 1.0)
|
||||||
|
est_large = layouter._estimate_page_start(end_pos, 2.0)
|
||||||
|
|
||||||
|
# Larger font should estimate fewer blocks
|
||||||
|
assert est_large.block_index >= est_normal.block_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_scale_block_fonts_paragraph(self, sample_font):
|
||||||
|
"""Test scaling fonts in a paragraph block."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
paragraph = Paragraph(sample_font)
|
||||||
|
paragraph.add_word(Word("Hello", sample_font))
|
||||||
|
paragraph.add_word(Word("World", sample_font))
|
||||||
|
|
||||||
|
scaled = layouter._scale_block_fonts(paragraph, 2.0)
|
||||||
|
|
||||||
|
# Should be a new paragraph
|
||||||
|
assert isinstance(scaled, Paragraph)
|
||||||
|
assert scaled != paragraph
|
||||||
|
|
||||||
|
# Check that words were scaled (words is a list, not a method)
|
||||||
|
words = scaled.words if hasattr(scaled, 'words') and isinstance(scaled.words, list) else list(scaled.words_iter())
|
||||||
|
assert len(words) >= 2
|
||||||
|
|
||||||
|
def test_scale_block_fonts_heading(self, sample_font):
|
||||||
|
"""Test scaling fonts in a heading block."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
heading = Heading(HeadingLevel.H1, sample_font)
|
||||||
|
heading.add_word(Word("Title", sample_font))
|
||||||
|
|
||||||
|
scaled = layouter._scale_block_fonts(heading, 1.5)
|
||||||
|
|
||||||
|
# Should be a new heading
|
||||||
|
assert isinstance(scaled, Heading)
|
||||||
|
assert scaled.level == HeadingLevel.H1
|
||||||
|
|
||||||
|
def test_layout_block_on_page_unknown_type(self, sample_font):
|
||||||
|
"""Test layout of unknown block type skips gracefully."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
# Create a mock block that's not a known type
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
from pyWebLayout.abstract.block import Block, BlockType
|
||||||
|
page = Page(size=(800, 600), style=PageStyle())
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
# Use a simple block (not Paragraph, Heading, Table, or HList)
|
||||||
|
unknown_block = Block(BlockType.HORIZONTAL_RULE)
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_block_on_page(unknown_block, page, position, 1.0)
|
||||||
|
|
||||||
|
# Should skip and move to next block
|
||||||
|
assert success is True
|
||||||
|
assert new_pos.block_index == 1
|
||||||
|
|
||||||
|
def test_layout_table_on_page(self, sample_font):
|
||||||
|
"""Test table layout on page (currently skips)."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
from pyWebLayout.abstract.block import Table
|
||||||
|
|
||||||
|
page = Page(size=(800, 600), style=PageStyle())
|
||||||
|
table = Table()
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_table_on_page(table, page, position, 1.0)
|
||||||
|
|
||||||
|
# Currently skips tables
|
||||||
|
assert success is True
|
||||||
|
assert new_pos.block_index == 1
|
||||||
|
assert new_pos.table_row == 0
|
||||||
|
assert new_pos.table_col == 0
|
||||||
|
|
||||||
|
def test_layout_list_on_page(self, sample_font):
|
||||||
|
"""Test list layout on page (currently skips)."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
from pyWebLayout.abstract.block import HList
|
||||||
|
|
||||||
|
page = Page(size=(800, 600), style=PageStyle())
|
||||||
|
hlist = HList()
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_list_on_page(hlist, page, position, 1.0)
|
||||||
|
|
||||||
|
# Currently skips lists
|
||||||
|
assert success is True
|
||||||
|
assert new_pos.block_index == 1
|
||||||
|
assert new_pos.list_item_index == 0
|
||||||
|
|
||||||
|
def test_render_page_forward_simple(self, sample_blocks_with_headings, sample_page_style):
|
||||||
|
"""Test forward page rendering with simple blocks."""
|
||||||
|
layouter = BidirectionalLayouter(
|
||||||
|
sample_blocks_with_headings,
|
||||||
|
sample_page_style,
|
||||||
|
page_size=(800, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
position = RenderingPosition()
|
||||||
|
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Should render a page
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
assert isinstance(page, Page)
|
||||||
|
|
||||||
|
# Position should advance
|
||||||
|
assert next_pos.block_index >= position.block_index
|
||||||
|
|
||||||
|
def test_render_page_forward_with_font_scale(self, sample_blocks_with_headings, sample_page_style):
|
||||||
|
"""Test forward rendering with font scaling."""
|
||||||
|
layouter = BidirectionalLayouter(
|
||||||
|
sample_blocks_with_headings,
|
||||||
|
sample_page_style,
|
||||||
|
page_size=(800, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
# Render with normal font
|
||||||
|
page1, next_pos1 = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Render with larger font (should fit less content)
|
||||||
|
page2, next_pos2 = layouter.render_page_forward(position, font_scale=2.0)
|
||||||
|
|
||||||
|
# Both should produce pages
|
||||||
|
assert page1 is not None
|
||||||
|
assert page2 is not None
|
||||||
|
|
||||||
|
def test_render_page_forward_at_end(self, sample_blocks_with_headings, sample_page_style):
|
||||||
|
"""Test forward rendering at end of document."""
|
||||||
|
layouter = BidirectionalLayouter(
|
||||||
|
sample_blocks_with_headings,
|
||||||
|
sample_page_style,
|
||||||
|
page_size=(800, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Position at last block
|
||||||
|
position = RenderingPosition(block_index=len(sample_blocks_with_headings) - 1)
|
||||||
|
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Should still render a page
|
||||||
|
assert page is not None
|
||||||
|
|
||||||
|
def test_render_page_forward_beyond_end(self, sample_blocks_with_headings, sample_page_style):
|
||||||
|
"""Test forward rendering beyond document end."""
|
||||||
|
layouter = BidirectionalLayouter(
|
||||||
|
sample_blocks_with_headings,
|
||||||
|
sample_page_style,
|
||||||
|
page_size=(800, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Position beyond last block
|
||||||
|
position = RenderingPosition(block_index=len(sample_blocks_with_headings) + 10)
|
||||||
|
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Should handle gracefully
|
||||||
|
assert page is not None
|
||||||
|
|
||||||
|
def test_render_page_backward_simple(self, sample_blocks_with_headings, sample_page_style):
|
||||||
|
"""Test backward page rendering."""
|
||||||
|
layouter = BidirectionalLayouter(
|
||||||
|
sample_blocks_with_headings,
|
||||||
|
sample_page_style,
|
||||||
|
page_size=(800, 600)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start from middle of document
|
||||||
|
end_position = RenderingPosition(block_index=3)
|
||||||
|
page, start_pos = layouter.render_page_backward(end_position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Should render a page
|
||||||
|
assert page is not None
|
||||||
|
|
||||||
|
# Start position should be before or at end position
|
||||||
|
assert start_pos.block_index <= end_position.block_index
|
||||||
|
|
||||||
|
def test_adjust_start_estimate_overshot(self):
|
||||||
|
"""Test adjustment when forward render overshoots target."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
current_start = RenderingPosition(block_index=5)
|
||||||
|
target_end = RenderingPosition(block_index=10)
|
||||||
|
actual_end = RenderingPosition(block_index=12) # Overshot
|
||||||
|
|
||||||
|
adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end)
|
||||||
|
|
||||||
|
# Should move start forward (increase block_index)
|
||||||
|
assert adjusted.block_index > current_start.block_index
|
||||||
|
|
||||||
|
def test_adjust_start_estimate_undershot(self):
|
||||||
|
"""Test adjustment when forward render undershoots target."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
current_start = RenderingPosition(block_index=5)
|
||||||
|
target_end = RenderingPosition(block_index=10)
|
||||||
|
actual_end = RenderingPosition(block_index=8) # Undershot
|
||||||
|
|
||||||
|
adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end)
|
||||||
|
|
||||||
|
# Should move start backward (decrease block_index)
|
||||||
|
assert adjusted.block_index <= current_start.block_index
|
||||||
|
|
||||||
|
def test_adjust_start_estimate_exact(self):
|
||||||
|
"""Test adjustment when forward render hits target exactly."""
|
||||||
|
layouter = BidirectionalLayouter([], PageStyle())
|
||||||
|
|
||||||
|
current_start = RenderingPosition(block_index=5)
|
||||||
|
target_end = RenderingPosition(block_index=10)
|
||||||
|
actual_end = RenderingPosition(block_index=10) # Exact
|
||||||
|
|
||||||
|
adjusted = layouter._adjust_start_estimate(current_start, target_end, actual_end)
|
||||||
|
|
||||||
|
# Should return same or similar position
|
||||||
|
assert adjusted.block_index >= 0
|
||||||
|
|
||||||
|
def test_layout_paragraph_on_page_with_pretext(self, sample_font, sample_page_style):
|
||||||
|
"""Test paragraph layout with pretext (hyphenated word continuation)."""
|
||||||
|
layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600))
|
||||||
|
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
|
||||||
|
paragraph = Paragraph(sample_font)
|
||||||
|
paragraph.add_word(Word("continuation", sample_font))
|
||||||
|
|
||||||
|
page = Page(size=(800, 600), style=sample_page_style)
|
||||||
|
position = RenderingPosition(remaining_pretext="pre-")
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_paragraph_on_page(paragraph, page, position, 1.0)
|
||||||
|
|
||||||
|
# Should attempt to layout
|
||||||
|
assert isinstance(success, bool)
|
||||||
|
assert isinstance(new_pos, RenderingPosition)
|
||||||
|
|
||||||
|
def test_layout_paragraph_success(self, sample_font, sample_page_style):
|
||||||
|
"""Test successful paragraph layout."""
|
||||||
|
layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600))
|
||||||
|
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
|
||||||
|
paragraph = Paragraph(sample_font)
|
||||||
|
paragraph.add_word(Word("Short", sample_font))
|
||||||
|
paragraph.add_word(Word("text", sample_font))
|
||||||
|
|
||||||
|
page = Page(size=(800, 600), style=sample_page_style)
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_paragraph_on_page(paragraph, page, position, 1.0)
|
||||||
|
|
||||||
|
# Should complete successfully
|
||||||
|
assert isinstance(success, bool)
|
||||||
|
|
||||||
|
def test_layout_heading_on_page(self, sample_font, sample_page_style):
|
||||||
|
"""Test heading layout delegates to paragraph layout."""
|
||||||
|
layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600))
|
||||||
|
|
||||||
|
from pyWebLayout.concrete.page import Page
|
||||||
|
|
||||||
|
heading = Heading(HeadingLevel.H1, sample_font)
|
||||||
|
heading.add_word(Word("Heading", sample_font))
|
||||||
|
heading.add_word(Word("Text", sample_font))
|
||||||
|
|
||||||
|
page = Page(size=(800, 600), style=sample_page_style)
|
||||||
|
position = RenderingPosition()
|
||||||
|
|
||||||
|
success, new_pos = layouter._layout_heading_on_page(heading, page, position, 1.0)
|
||||||
|
|
||||||
|
# Should attempt to layout like a paragraph
|
||||||
|
assert isinstance(success, bool)
|
||||||
|
assert isinstance(new_pos, RenderingPosition)
|
||||||
|
|
||||||
|
def test_empty_blocks_list(self, sample_page_style):
|
||||||
|
"""Test rendering with empty blocks list."""
|
||||||
|
layouter = BidirectionalLayouter([], sample_page_style, page_size=(800, 600))
|
||||||
|
|
||||||
|
position = RenderingPosition()
|
||||||
|
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||||
|
|
||||||
|
# Should handle empty document
|
||||||
|
assert page is not None
|
||||||
|
assert next_pos == position # No progress possible
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
Loading…
x
Reference in New Issue
Block a user