Update coverage badges [skip ci]
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Tests for the layout module.
|
||||
|
||||
This package contains tests for the layout system including:
|
||||
- Document layouter tests
|
||||
- Ereader layout system tests
|
||||
- Page buffer tests
|
||||
- Position tracking tests
|
||||
"""
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Regression tests for backward page navigation (spec S16).
|
||||
|
||||
The previous page of P is the position q for which laying out forward from q ends
|
||||
exactly at P. The old implementation searched for q by guessing a block index and
|
||||
bisecting, with word_index pinned to 0 - so a page starting mid-paragraph was not
|
||||
in the search space at all. It exhausted its ten iterations and fell back to a
|
||||
position that was not the previous page, typically the start of the document.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
PAGE_SIZE = (800, 600)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=16)
|
||||
|
||||
|
||||
def paragraph(font, count, tag):
|
||||
block = Paragraph(font)
|
||||
for i in range(count):
|
||||
block.add_word(Word(f"{tag}{i}", font))
|
||||
return block
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def long_document(font):
|
||||
"""Short paragraphs around one that spans several pages."""
|
||||
return [
|
||||
paragraph(font, 60, "a"),
|
||||
paragraph(font, 80, "b"),
|
||||
paragraph(font, 1200, "long"),
|
||||
paragraph(font, 70, "c"),
|
||||
paragraph(font, 90, "d"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def block_document(font):
|
||||
"""Many small blocks, so every page starts on a block boundary."""
|
||||
return [paragraph(font, 40, f"p{i}") for i in range(40)]
|
||||
|
||||
|
||||
def forward_chain(layouter, limit=30):
|
||||
"""The page start positions a reader would visit going forward."""
|
||||
starts = []
|
||||
pos = RenderingPosition()
|
||||
for _ in range(limit):
|
||||
starts.append(pos)
|
||||
_, nxt = layouter.render_page_forward(pos, 1.0)
|
||||
if nxt.block_index >= len(layouter.blocks):
|
||||
break
|
||||
if (nxt.block_index, nxt.word_index) == (pos.block_index, pos.word_index):
|
||||
pytest.fail("forward pagination made no progress")
|
||||
pos = nxt
|
||||
return starts
|
||||
|
||||
|
||||
def key(position):
|
||||
return (position.chapter_index, position.block_index, position.word_index)
|
||||
|
||||
|
||||
class TestBackwardMatchesForward:
|
||||
"""The defining invariant: forward from the answer lands exactly on P."""
|
||||
|
||||
@pytest.mark.parametrize("document", ["long_document", "block_document"])
|
||||
def test_previous_page_is_the_forward_predecessor(self, document, request):
|
||||
blocks = request.getfixturevalue(document)
|
||||
layouter = BidirectionalLayouter(blocks, PageStyle(), PAGE_SIZE)
|
||||
starts = forward_chain(layouter)
|
||||
assert len(starts) > 2, "need a few pages to test against"
|
||||
|
||||
for i in range(1, len(starts)):
|
||||
_, got = layouter.render_page_backward(starts[i], 1.0)
|
||||
assert key(got) == key(starts[i - 1]), (
|
||||
f"page {i}: expected to land on page {i - 1} "
|
||||
f"{key(starts[i - 1])}, got {key(got)}")
|
||||
|
||||
def test_result_lays_out_to_the_target(self, long_document):
|
||||
"""Independent of the recorded chain: replaying the answer must reach P."""
|
||||
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||
starts = forward_chain(layouter)
|
||||
|
||||
for target in starts[1:]:
|
||||
_, start = layouter.render_page_backward(target, 1.0)
|
||||
_, end = layouter.render_page_forward(start, 1.0)
|
||||
assert key(end) == key(target), (
|
||||
f"a page starting at {key(start)} ends at {key(end)}, "
|
||||
f"not at the requested {key(target)}")
|
||||
|
||||
def test_mid_paragraph_targets_are_reachable(self, long_document):
|
||||
"""The specific regression: starts inside a block, not on its boundary."""
|
||||
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||
starts = forward_chain(layouter)
|
||||
mid = [s for s in starts if s.word_index > 0]
|
||||
assert mid, "this document should paginate mid-paragraph"
|
||||
|
||||
for target in mid:
|
||||
_, got = layouter.render_page_backward(target, 1.0)
|
||||
assert key(got) != (0, 0, 0) or key(target) == key(starts[1]), \
|
||||
"backward navigation fell back to the document start"
|
||||
|
||||
|
||||
class TestRoundTrip:
|
||||
|
||||
def test_forward_then_back_returns_to_the_same_place(self, long_document):
|
||||
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||
pos = RenderingPosition()
|
||||
|
||||
for _ in range(4):
|
||||
_, nxt = layouter.render_page_forward(pos, 1.0)
|
||||
_, back = layouter.render_page_backward(nxt, 1.0)
|
||||
assert key(back) == key(pos), \
|
||||
f"round trip drifted: {key(pos)} -> {key(nxt)} -> {key(back)}"
|
||||
pos = nxt
|
||||
|
||||
|
||||
class TestEdges:
|
||||
|
||||
def test_at_document_start_stays_there(self, long_document):
|
||||
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||
_, got = layouter.render_page_backward(RenderingPosition(), 1.0)
|
||||
assert key(got) == (0, 0, 0)
|
||||
|
||||
def test_second_page_goes_back_to_the_first(self, long_document):
|
||||
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||
_, second = layouter.render_page_forward(RenderingPosition(), 1.0)
|
||||
_, got = layouter.render_page_backward(second, 1.0)
|
||||
assert key(got) == (0, 0, 0)
|
||||
|
||||
def test_empty_document_is_safe(self):
|
||||
layouter = BidirectionalLayouter([], PageStyle(), PAGE_SIZE)
|
||||
page, got = layouter.render_page_backward(RenderingPosition(), 1.0)
|
||||
assert page is not None
|
||||
assert key(got) == (0, 0, 0)
|
||||
|
||||
|
||||
class TestCost:
|
||||
|
||||
def test_backward_is_not_wildly_more_expensive_than_forward(self, long_document):
|
||||
"""The old path burned ten full layouts per call and still got it wrong."""
|
||||
layouter = BidirectionalLayouter(long_document, PageStyle(), PAGE_SIZE)
|
||||
starts = forward_chain(layouter)
|
||||
|
||||
calls = {"n": 0}
|
||||
original = BidirectionalLayouter.render_page_forward
|
||||
|
||||
def counting(self, position, font_scale=1.0):
|
||||
calls["n"] += 1
|
||||
return original(self, position, font_scale)
|
||||
|
||||
BidirectionalLayouter.render_page_forward = counting
|
||||
try:
|
||||
worst = 0
|
||||
for target in starts[1:]:
|
||||
calls["n"] = 0
|
||||
layouter.render_page_backward(target, 1.0)
|
||||
worst = max(worst, calls["n"])
|
||||
finally:
|
||||
BidirectionalLayouter.render_page_forward = original
|
||||
|
||||
assert worst <= 10, f"backward navigation cost {worst} forward layouts"
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Tests for the highlight API on EreaderLayoutManager (R7).
|
||||
|
||||
core/highlight.py was fully implemented and tested but unreachable: the manager
|
||||
had no highlight API, so highlighting could not be used through the library's
|
||||
own interface. These tests cover the wiring, not the dataclass - that is
|
||||
tests/core/test_highlight.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.core.highlight import Highlight, HighlightColor
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path):
|
||||
blocks = parse_html_string(
|
||||
"<p>" + " ".join(f"word{i}" for i in range(300)) + "</p>")
|
||||
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||
document_id="highlights",
|
||||
bookmarks_dir=str(tmp_path))
|
||||
yield manager
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
def text_points(page, limit=None):
|
||||
"""Points on the rendered page that land on a text object."""
|
||||
found = []
|
||||
for y in range(0, 120, 2):
|
||||
for x in range(0, 400, 2):
|
||||
result = page.query_point((x, y))
|
||||
if result is not None and result.object_type == "text" and result.text:
|
||||
found.append((x, y))
|
||||
if limit and len(found) >= limit:
|
||||
return found
|
||||
return found
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def point_on_text(manager):
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
return text_points(page, limit=1)[0]
|
||||
|
||||
|
||||
class TestHighlightPoint:
|
||||
def test_highlighting_a_word_returns_a_stored_highlight(self, manager, point_on_text):
|
||||
highlight = manager.highlight_point(point_on_text)
|
||||
|
||||
assert isinstance(highlight, Highlight)
|
||||
assert highlight.text
|
||||
assert manager.list_highlights() == [highlight]
|
||||
|
||||
def test_colour_note_and_tags_are_kept(self, manager, point_on_text):
|
||||
highlight = manager.highlight_point(
|
||||
point_on_text, color=HighlightColor.GREEN.value,
|
||||
note="a note", tags=["review"])
|
||||
|
||||
assert highlight.color == HighlightColor.GREEN.value
|
||||
assert highlight.note == "a note"
|
||||
assert highlight.tags == ["review"]
|
||||
|
||||
def test_highlighting_empty_space_returns_none(self, manager):
|
||||
manager.get_current_page().render()
|
||||
|
||||
assert manager.highlight_point((399, 599)) is None
|
||||
assert manager.list_highlights() == []
|
||||
|
||||
def test_the_originating_position_is_recorded(self, manager, point_on_text):
|
||||
highlight = manager.highlight_point(point_on_text)
|
||||
|
||||
assert highlight.position == manager.current_position.to_dict()
|
||||
|
||||
|
||||
class TestHighlightRange:
|
||||
def test_a_selection_spans_multiple_words(self, manager):
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
points = text_points(page)
|
||||
|
||||
highlight = manager.highlight_range(points[0], points[-1])
|
||||
|
||||
assert highlight is not None
|
||||
assert len(highlight.text.split()) > 1
|
||||
assert len(highlight.bounds) > 1
|
||||
|
||||
def test_a_selection_hitting_no_text_returns_none(self, manager):
|
||||
manager.get_current_page().render()
|
||||
|
||||
assert manager.highlight_range((398, 596), (399, 599)) is None
|
||||
|
||||
|
||||
class TestHighlightsAreScopedToTheirPage:
|
||||
def test_current_page_highlights_do_not_leak_across_pages(self, manager, point_on_text):
|
||||
manager.highlight_point(point_on_text)
|
||||
assert len(manager.get_highlights_for_current_page()) == 1
|
||||
|
||||
manager.next_page()
|
||||
|
||||
assert manager.get_highlights_for_current_page() == []
|
||||
assert len(manager.list_highlights()) == 1, "still in the document, just not here"
|
||||
|
||||
def test_returning_to_the_page_finds_it_again(self, manager, point_on_text):
|
||||
highlight = manager.highlight_point(point_on_text)
|
||||
manager.next_page()
|
||||
manager.previous_page()
|
||||
|
||||
assert manager.get_highlights_for_current_page() == [highlight]
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_highlights_survive_a_restart(self, manager, point_on_text, tmp_path):
|
||||
highlight = manager.highlight_point(point_on_text, note="kept")
|
||||
manager.shutdown()
|
||||
|
||||
reopened = EreaderLayoutManager(
|
||||
manager.blocks, page_size=(400, 600), document_id="highlights",
|
||||
bookmarks_dir=str(tmp_path))
|
||||
try:
|
||||
restored = reopened.list_highlights()
|
||||
assert len(restored) == 1
|
||||
assert restored[0].id == highlight.id
|
||||
assert restored[0].note == "kept"
|
||||
assert restored[0].position == highlight.position
|
||||
finally:
|
||||
reopened.shutdown()
|
||||
|
||||
def test_highlights_share_the_bookmarks_directory_by_default(self, manager,
|
||||
point_on_text, tmp_path):
|
||||
manager.highlight_point(point_on_text)
|
||||
|
||||
assert (tmp_path / "highlights_highlights.json").exists()
|
||||
|
||||
def test_removing_a_highlight_persists(self, manager, point_on_text, tmp_path):
|
||||
highlight = manager.highlight_point(point_on_text)
|
||||
|
||||
assert manager.remove_highlight(highlight.id) is True
|
||||
assert manager.remove_highlight(highlight.id) is False
|
||||
|
||||
reopened = EreaderLayoutManager(
|
||||
manager.blocks, page_size=(400, 600), document_id="highlights",
|
||||
bookmarks_dir=str(tmp_path))
|
||||
try:
|
||||
assert reopened.list_highlights() == []
|
||||
finally:
|
||||
reopened.shutdown()
|
||||
|
||||
def test_clear_removes_everything(self, manager, point_on_text):
|
||||
manager.highlight_point(point_on_text)
|
||||
|
||||
manager.clear_highlights()
|
||||
|
||||
assert manager.list_highlights() == []
|
||||
|
||||
def test_a_corrupt_store_does_not_stop_the_book_opening(self, tmp_path):
|
||||
(tmp_path / "broken_highlights.json").write_text("{not json")
|
||||
blocks = parse_html_string("<p>hello world</p>")
|
||||
|
||||
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||
document_id="broken",
|
||||
bookmarks_dir=str(tmp_path))
|
||||
try:
|
||||
assert manager.list_highlights() == []
|
||||
assert manager.get_current_page() is not None
|
||||
finally:
|
||||
manager.shutdown()
|
||||
@@ -0,0 +1,545 @@
|
||||
"""
|
||||
Unit tests for Image block rendering in the ereader layout system.
|
||||
|
||||
Tests cover:
|
||||
- Image block layout on pages
|
||||
- Navigation with images (next/previous page)
|
||||
- Images at different positions (start, middle, end)
|
||||
- Cover page detection and handling
|
||||
- Multi-page scenarios with images
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Image
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
class TestImageBlockLayout(unittest.TestCase):
|
||||
"""Test basic Image block layout functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_font = Font(font_size=14)
|
||||
self.page_size = (400, 600)
|
||||
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||
|
||||
def test_layout_image_block_on_page(self):
|
||||
"""Test that Image blocks can be laid out on pages."""
|
||||
# Create a simple document with an image
|
||||
blocks = [
|
||||
Image(source="test.jpg", alt_text="Test Image", width=200, height=300)
|
||||
]
|
||||
|
||||
layouter = BidirectionalLayouter(blocks, self.page_style)
|
||||
position = RenderingPosition()
|
||||
|
||||
# Render page with image
|
||||
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||
|
||||
# Should successfully render the page
|
||||
self.assertIsNotNone(page)
|
||||
self.assertIsInstance(page, Page)
|
||||
|
||||
# Position should advance past the image block
|
||||
self.assertEqual(next_pos.block_index, 1)
|
||||
|
||||
def test_image_block_advances_position(self):
|
||||
"""Test that rendering an image block correctly advances the position."""
|
||||
blocks = [
|
||||
Image(source="img1.jpg", alt_text="Image 1"),
|
||||
Paragraph(self.base_font)
|
||||
]
|
||||
# Add some words to the paragraph
|
||||
blocks[1].add_word(Word("Text after image", self.base_font))
|
||||
|
||||
layouter = BidirectionalLayouter(blocks, self.page_style)
|
||||
position = RenderingPosition(block_index=0)
|
||||
|
||||
# Render page starting at image
|
||||
page, next_pos = layouter.render_page_forward(position, font_scale=1.0)
|
||||
|
||||
# Position should either:
|
||||
# 1. Move to next block if image was successfully laid out, OR
|
||||
# 2. Stay at same position if image couldn't fit/render
|
||||
# In either case, the layouter should handle it gracefully
|
||||
self.assertIsNotNone(page)
|
||||
self.assertGreaterEqual(next_pos.block_index, 0)
|
||||
|
||||
# If the image is at start and can't render, it may skip to next block anyway
|
||||
# The important thing is the system doesn't crash
|
||||
|
||||
|
||||
class TestImageNavigationScenarios(unittest.TestCase):
|
||||
"""Test navigation scenarios with images in different positions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_font = Font(font_size=14)
|
||||
self.page_size = (400, 600)
|
||||
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary files."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def _create_paragraph(self, text: str) -> Paragraph:
|
||||
"""Helper to create a paragraph with text."""
|
||||
para = Paragraph(self.base_font)
|
||||
para.add_word(Word(text, self.base_font))
|
||||
return para
|
||||
|
||||
def test_next_page_with_image_on_second_page(self):
|
||||
"""Test navigating to next page when an image is on the second page."""
|
||||
# Document structure: paragraph → image → paragraph
|
||||
blocks = [
|
||||
self._create_paragraph("First paragraph on page 1."),
|
||||
Image(source="middle.jpg", alt_text="Middle Image"),
|
||||
self._create_paragraph("Third paragraph after image.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_image_nav",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Start at beginning
|
||||
initial_pos = manager.current_position.block_index
|
||||
self.assertEqual(initial_pos, 0)
|
||||
|
||||
# Navigate to next page
|
||||
next_page = manager.next_page()
|
||||
self.assertIsNotNone(next_page)
|
||||
|
||||
# Position should have advanced
|
||||
self.assertGreater(manager.current_position.block_index, initial_pos)
|
||||
|
||||
def test_previous_page_with_image_on_previous_page(self):
|
||||
"""Test navigating back when previous page contains an image."""
|
||||
blocks = [
|
||||
self._create_paragraph("First paragraph."),
|
||||
Image(source="image1.jpg", alt_text="Image 1"),
|
||||
self._create_paragraph("Third paragraph."),
|
||||
self._create_paragraph("Fourth paragraph.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_prev_image",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Navigate forward to get past the image
|
||||
manager.next_page()
|
||||
manager.next_page()
|
||||
|
||||
current_block = manager.current_position.block_index
|
||||
self.assertGreater(current_block, 0)
|
||||
|
||||
# Navigate backward
|
||||
prev_page = manager.previous_page()
|
||||
self.assertIsNotNone(prev_page)
|
||||
|
||||
# Should have moved to an earlier position
|
||||
self.assertLess(manager.current_position.block_index, current_block)
|
||||
|
||||
def test_multiple_images_in_sequence(self):
|
||||
"""Test document with multiple consecutive images."""
|
||||
blocks = [
|
||||
self._create_paragraph("Introduction text."),
|
||||
Image(source="img1.jpg", alt_text="Image 1"),
|
||||
Image(source="img2.jpg", alt_text="Image 2"),
|
||||
Image(source="img3.jpg", alt_text="Image 3"),
|
||||
self._create_paragraph("Text after images.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_multi_images",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Navigate through pages
|
||||
pages_rendered = 0
|
||||
max_pages = 10 # Safety limit
|
||||
|
||||
while pages_rendered < max_pages:
|
||||
current_block = manager.current_position.block_index
|
||||
|
||||
# Try to go to next page
|
||||
next_page = manager.next_page()
|
||||
|
||||
if next_page is None:
|
||||
# Reached end
|
||||
break
|
||||
|
||||
pages_rendered += 1
|
||||
|
||||
# Position should advance
|
||||
self.assertGreaterEqual(
|
||||
manager.current_position.block_index,
|
||||
current_block,
|
||||
f"Position should advance or stay same, page {pages_rendered}"
|
||||
)
|
||||
|
||||
# Should have rendered at least 2 pages
|
||||
self.assertGreaterEqual(pages_rendered, 1)
|
||||
|
||||
def test_image_at_document_start(self):
|
||||
"""Test document starting with an image (not as cover)."""
|
||||
blocks = [
|
||||
Image(source="start.jpg", alt_text="Start Image"),
|
||||
self._create_paragraph("Text after image.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_image_start",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# First image should be detected as cover
|
||||
self.assertTrue(manager.has_cover())
|
||||
self.assertTrue(manager.is_on_cover())
|
||||
|
||||
# Navigate past cover
|
||||
manager.next_page()
|
||||
|
||||
# Should now be at the text
|
||||
self.assertFalse(manager.is_on_cover())
|
||||
# Should have skipped the image block (cover)
|
||||
self.assertEqual(manager.current_position.block_index, 1)
|
||||
|
||||
def test_image_at_document_end(self):
|
||||
"""Test document ending with an image."""
|
||||
blocks = [
|
||||
self._create_paragraph("First paragraph."),
|
||||
self._create_paragraph("Second paragraph."),
|
||||
Image(source="end.jpg", alt_text="End Image")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_image_end",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Navigate to end
|
||||
page_count = 0
|
||||
max_pages = 10
|
||||
|
||||
while page_count < max_pages:
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
page_count += 1
|
||||
|
||||
# Should have successfully navigated through document including final image
|
||||
self.assertGreater(page_count, 0)
|
||||
|
||||
def test_alternating_text_and_images(self):
|
||||
"""Test document with alternating text and images."""
|
||||
blocks = [
|
||||
self._create_paragraph("Paragraph 1"),
|
||||
Image(source="img1.jpg", alt_text="Image 1"),
|
||||
self._create_paragraph("Paragraph 2"),
|
||||
Image(source="img2.jpg", alt_text="Image 2"),
|
||||
self._create_paragraph("Paragraph 3"),
|
||||
Image(source="img3.jpg", alt_text="Image 3"),
|
||||
self._create_paragraph("Paragraph 4")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_alternating",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Track blocks visited
|
||||
blocks_visited = set()
|
||||
max_pages = 15
|
||||
|
||||
for _ in range(max_pages):
|
||||
blocks_visited.add(manager.current_position.block_index)
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
|
||||
# Should have visited multiple different blocks
|
||||
self.assertGreater(len(blocks_visited), 1)
|
||||
|
||||
|
||||
class TestCoverPageWithImages(unittest.TestCase):
|
||||
"""Test cover page detection and handling with images."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_font = Font(font_size=14)
|
||||
self.page_size = (400, 600)
|
||||
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary files."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def _create_paragraph(self, text: str) -> Paragraph:
|
||||
"""Helper to create a paragraph with text."""
|
||||
para = Paragraph(self.base_font)
|
||||
para.add_word(Word(text, self.base_font))
|
||||
return para
|
||||
|
||||
def test_cover_page_detected_from_first_image(self):
|
||||
"""Test that first image is detected as cover."""
|
||||
blocks = [
|
||||
Image(source="cover.jpg", alt_text="Cover"),
|
||||
self._create_paragraph("Chapter text.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_cover_detection",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Should detect cover
|
||||
self.assertTrue(manager.has_cover())
|
||||
self.assertTrue(manager.is_on_cover())
|
||||
|
||||
def test_no_cover_when_first_block_is_text(self):
|
||||
"""Test that cover is not detected when first block is text."""
|
||||
blocks = [
|
||||
self._create_paragraph("First paragraph."),
|
||||
Image(source="image.jpg", alt_text="Not a cover"),
|
||||
self._create_paragraph("Second paragraph.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_no_cover",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Should NOT detect cover
|
||||
self.assertFalse(manager.has_cover())
|
||||
self.assertFalse(manager.is_on_cover())
|
||||
|
||||
def test_navigation_from_cover_skips_image_block(self):
|
||||
"""Test that next_page from cover skips the cover image block."""
|
||||
blocks = [
|
||||
Image(source="cover.jpg", alt_text="Cover"),
|
||||
self._create_paragraph("First content paragraph."),
|
||||
self._create_paragraph("Second content paragraph.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_cover_skip",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Start on cover
|
||||
self.assertTrue(manager.is_on_cover())
|
||||
self.assertEqual(manager.current_position.block_index, 0)
|
||||
|
||||
# Navigate past cover
|
||||
manager.next_page()
|
||||
|
||||
# Should skip cover image block (index 0) and go to first content (index 1)
|
||||
self.assertFalse(manager.is_on_cover())
|
||||
self.assertEqual(manager.current_position.block_index, 1)
|
||||
|
||||
def test_previous_page_returns_to_cover(self):
|
||||
"""Test that previous_page from first content returns to cover."""
|
||||
blocks = [
|
||||
Image(source="cover.jpg", alt_text="Cover"),
|
||||
self._create_paragraph("Content text.")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_back_to_cover",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Navigate past cover
|
||||
manager.next_page()
|
||||
self.assertFalse(manager.is_on_cover())
|
||||
|
||||
# Go back
|
||||
manager.previous_page()
|
||||
|
||||
# Should be back on cover
|
||||
self.assertTrue(manager.is_on_cover())
|
||||
|
||||
def test_jump_to_cover_from_middle(self):
|
||||
"""Test jumping to cover from middle of document."""
|
||||
blocks = [
|
||||
Image(source="cover.jpg", alt_text="Cover"),
|
||||
self._create_paragraph("Paragraph 1"),
|
||||
self._create_paragraph("Paragraph 2"),
|
||||
self._create_paragraph("Paragraph 3")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_jump_cover",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Navigate to middle
|
||||
manager.next_page()
|
||||
manager.next_page()
|
||||
self.assertFalse(manager.is_on_cover())
|
||||
|
||||
# Jump to cover
|
||||
cover_page = manager.jump_to_cover()
|
||||
|
||||
self.assertIsNotNone(cover_page)
|
||||
self.assertTrue(manager.is_on_cover())
|
||||
|
||||
|
||||
class TestImageBlockPositionTracking(unittest.TestCase):
|
||||
"""Test position tracking with Image blocks."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_font = Font(font_size=14)
|
||||
self.page_size = (400, 600)
|
||||
self.page_style = PageStyle(padding=(20, 20, 20, 20))
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary files."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def _create_paragraph(self, text: str) -> Paragraph:
|
||||
"""Helper to create a paragraph with text."""
|
||||
para = Paragraph(self.base_font)
|
||||
para.add_word(Word(text, self.base_font))
|
||||
return para
|
||||
|
||||
def test_position_info_includes_image_blocks(self):
|
||||
"""Test that position info correctly handles image blocks."""
|
||||
blocks = [
|
||||
self._create_paragraph("Text 1"),
|
||||
Image(source="img.jpg", alt_text="Image"),
|
||||
self._create_paragraph("Text 2")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_pos_info",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Get initial position info
|
||||
pos_info = manager.get_position_info()
|
||||
|
||||
self.assertIn('position', pos_info)
|
||||
self.assertIn('block_index', pos_info['position'])
|
||||
self.assertEqual(pos_info['position']['block_index'], 0)
|
||||
|
||||
def test_bookmark_image_position(self):
|
||||
"""Test bookmarking at an image position."""
|
||||
blocks = [
|
||||
self._create_paragraph("Before image"),
|
||||
Image(source="bookmarked.jpg", alt_text="Bookmarked Image"),
|
||||
self._create_paragraph("After image")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_bookmark_image",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Navigate to image position
|
||||
manager.next_page()
|
||||
|
||||
# Add bookmark
|
||||
bookmark_name = "image_location"
|
||||
success = manager.add_bookmark(bookmark_name)
|
||||
self.assertTrue(success)
|
||||
|
||||
# Navigate away
|
||||
manager.next_page()
|
||||
|
||||
# Jump back to bookmark
|
||||
page = manager.jump_to_bookmark(bookmark_name)
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
# Should be at or near the image position
|
||||
# (exact position depends on how much fits on page)
|
||||
self.assertGreater(manager.current_position.block_index, 0)
|
||||
|
||||
def test_reading_progress_with_images(self):
|
||||
"""Test reading progress calculation with images in document."""
|
||||
blocks = [
|
||||
self._create_paragraph("Text 1"),
|
||||
Image(source="img1.jpg", alt_text="Image 1"),
|
||||
self._create_paragraph("Text 2"),
|
||||
Image(source="img2.jpg", alt_text="Image 2"),
|
||||
self._create_paragraph("Text 3")
|
||||
]
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=self.page_size,
|
||||
document_id="test_progress",
|
||||
page_style=self.page_style,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# At start
|
||||
progress_start = manager.get_reading_progress()
|
||||
self.assertEqual(progress_start, 0.0)
|
||||
|
||||
# Navigate through document
|
||||
for _ in range(5):
|
||||
if manager.next_page() is None:
|
||||
break
|
||||
|
||||
# Progress should have increased
|
||||
progress_end = manager.get_reading_progress()
|
||||
self.assertGreater(progress_end, progress_start)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for pointer interaction on EreaderLayoutManager (R7).
|
||||
|
||||
concrete/interaction_handler.py was 310 lines reachable only from
|
||||
examples/07_pressed_state_demo.py - no library code, no tests. These cover the
|
||||
wiring; the press/hover state on the elements themselves lives in
|
||||
tests/concrete/.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path):
|
||||
blocks = parse_html_string(
|
||||
'<p>Tap <a href="action:go">this link</a> please.</p>'
|
||||
'<p>' + " ".join(f"w{i}" for i in range(400)) + '</p>')
|
||||
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||
document_id="interaction",
|
||||
bookmarks_dir=str(tmp_path))
|
||||
yield manager
|
||||
manager.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def link_point(manager):
|
||||
"""A page coordinate that lands on the interactive link."""
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
for y in range(0, 120, 2):
|
||||
for x in range(0, 400, 2):
|
||||
result = page.query_point((x, y))
|
||||
if result is not None and result.is_interactive:
|
||||
return (x, y)
|
||||
pytest.fail("fixture document rendered no interactive element")
|
||||
|
||||
|
||||
EMPTY_POINT = (399, 599)
|
||||
|
||||
|
||||
class TestHover:
|
||||
def test_hovering_an_element_produces_a_frame(self, manager, link_point):
|
||||
assert isinstance(manager.handle_hover(link_point), Image.Image)
|
||||
|
||||
def test_hovering_the_same_element_again_reports_no_change(self, manager, link_point):
|
||||
manager.handle_hover(link_point)
|
||||
|
||||
assert manager.handle_hover(link_point) is None, \
|
||||
"an unchanged hover should not force the caller to redraw"
|
||||
|
||||
def test_moving_off_the_element_clears_the_hover(self, manager, link_point):
|
||||
manager.handle_hover(link_point)
|
||||
|
||||
assert isinstance(manager.handle_hover(EMPTY_POINT), Image.Image)
|
||||
|
||||
|
||||
class TestPress:
|
||||
def test_pressing_an_element_produces_a_frame(self, manager, link_point):
|
||||
assert isinstance(manager.handle_touch_down(link_point), Image.Image)
|
||||
|
||||
def test_pressing_empty_space_does_nothing(self, manager):
|
||||
manager.get_current_page().render()
|
||||
|
||||
assert manager.handle_touch_down(EMPTY_POINT) is None
|
||||
|
||||
def test_release_runs_the_link_action(self, manager, link_point):
|
||||
manager.handle_touch_down(link_point)
|
||||
|
||||
frame, result = manager.handle_touch_up(link_point)
|
||||
|
||||
assert isinstance(frame, Image.Image)
|
||||
assert result == "action:go"
|
||||
|
||||
def test_release_without_a_press_is_a_no_op(self, manager):
|
||||
manager.get_current_page().render()
|
||||
|
||||
assert manager.handle_touch_up(EMPTY_POINT) == (None, None)
|
||||
|
||||
def test_a_full_press_release_cycle_leaves_no_state(self, manager, link_point):
|
||||
manager.handle_touch_down(link_point)
|
||||
manager.handle_touch_up(link_point)
|
||||
|
||||
assert manager.handle_touch_up(link_point) == (None, None)
|
||||
|
||||
|
||||
class TestStateFollowsTheDisplayedPage:
|
||||
def test_navigating_rebinds_the_state_machine(self, manager, link_point):
|
||||
before = manager._interaction_state()
|
||||
|
||||
manager.next_page()
|
||||
|
||||
assert manager._interaction_state() is not before, \
|
||||
"press state belongs to one rendered page"
|
||||
|
||||
def test_state_survives_repeated_access_on_one_page(self, manager, link_point):
|
||||
assert manager._interaction_state() is manager._interaction_state()
|
||||
|
||||
def test_reset_is_safe_before_any_interaction(self, manager):
|
||||
manager.reset_interaction_state() # must not raise
|
||||
|
||||
def test_reset_clears_a_pending_press(self, manager, link_point):
|
||||
manager.handle_touch_down(link_point)
|
||||
|
||||
manager.reset_interaction_state()
|
||||
|
||||
assert manager.handle_touch_up(link_point) == (None, None)
|
||||
|
||||
|
||||
class TestPressedRenderingRegression:
|
||||
"""
|
||||
LinkText.render passed [origin, origin + size] - two numpy arrays - to
|
||||
PIL's draw.rectangle, which needs a flat four-scalar box. Rendering any
|
||||
hovered or pressed link raised TypeError. Nothing caught it because the
|
||||
only caller was an example.
|
||||
"""
|
||||
|
||||
def test_rendering_a_hovered_link_does_not_raise(self, manager, link_point):
|
||||
manager.handle_hover(link_point)
|
||||
|
||||
assert isinstance(manager.get_current_page().render(), Image.Image)
|
||||
|
||||
def test_rendering_a_pressed_link_does_not_raise(self, manager, link_point):
|
||||
manager.handle_touch_down(link_point)
|
||||
|
||||
assert isinstance(manager.get_current_page().render(), Image.Image)
|
||||
@@ -0,0 +1,873 @@
|
||||
"""
|
||||
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
|
||||
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 is not 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_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_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
|
||||
|
||||
|
||||
class TestNoPageMonkeyPatching:
|
||||
"""
|
||||
R5: importing this module used to run _add_page_methods(), which attached
|
||||
can_fit_line/available_width to Page if they were absent. They are not
|
||||
absent, so it never fired - but its can_fit_line took (line_height) and
|
||||
ignored descenders, while Page's takes (baseline_spacing, ascent, descent).
|
||||
Had Page's ever been renamed, the import would have silently reinstated the
|
||||
pre-S2 clipping bug from another package.
|
||||
"""
|
||||
|
||||
def test_module_does_not_patch_page(self):
|
||||
import pyWebLayout.layout.ereader_layout as ereader_layout
|
||||
|
||||
assert not hasattr(ereader_layout, '_add_page_methods')
|
||||
|
||||
def test_page_owns_its_geometry_methods(self):
|
||||
from pyWebLayout.concrete.page import Page
|
||||
|
||||
assert 'can_fit_line' in vars(Page)
|
||||
assert 'available_width' in vars(Page)
|
||||
|
||||
def test_can_fit_line_still_accounts_for_descenders(self, sample_page_style):
|
||||
"""
|
||||
The patched version took a single line_height and had no way to express
|
||||
descent, so a descender hanging past the content box counted as fitting.
|
||||
"""
|
||||
from pyWebLayout.concrete.page import Page
|
||||
|
||||
page = Page(size=(200, 100), style=sample_page_style)
|
||||
content_y, content_h = page.content_rect[1], page.content_rect[3]
|
||||
available = content_y + content_h - page._current_y_offset
|
||||
|
||||
assert page.can_fit_line(0, ascent=available, descent=0)
|
||||
assert not page.can_fit_line(0, ascent=available, descent=1), \
|
||||
"a descender past the content box must not be reported as fitting"
|
||||
assert page.can_fit_line(0, ascent=available - 1, descent=1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,784 @@
|
||||
"""
|
||||
Tests for the ereader manager components.
|
||||
|
||||
This module tests:
|
||||
- BookmarkManager: Bookmark and position persistence
|
||||
- EreaderLayoutManager: High-level ereader interface
|
||||
- create_ereader_manager: Convenience function
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from pyWebLayout.layout.ereader_manager import (
|
||||
BookmarkManager,
|
||||
EreaderLayoutManager,
|
||||
create_ereader_manager
|
||||
)
|
||||
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def temp_bookmarks_dir(tmp_path):
|
||||
"""Create a temporary directory for bookmarks."""
|
||||
bookmarks_dir = tmp_path / "bookmarks"
|
||||
bookmarks_dir.mkdir()
|
||||
return str(bookmarks_dir)
|
||||
|
||||
|
||||
@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(sample_font):
|
||||
"""Create sample document blocks."""
|
||||
blocks = []
|
||||
|
||||
# Heading
|
||||
h1 = Heading(HeadingLevel.H1, sample_font)
|
||||
h1.add_word(Word("Chapter", sample_font))
|
||||
h1.add_word(Word("One", sample_font))
|
||||
blocks.append(h1)
|
||||
|
||||
# Paragraphs
|
||||
for i in range(5):
|
||||
p = Paragraph(sample_font)
|
||||
p.add_word(Word("Paragraph", sample_font))
|
||||
p.add_word(Word(f"{i}", sample_font))
|
||||
blocks.append(p)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_position():
|
||||
"""Create a sample rendering position."""
|
||||
return RenderingPosition(
|
||||
chapter_index=1,
|
||||
block_index=5,
|
||||
word_index=10
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BookmarkManager Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestBookmarkManager:
|
||||
"""Tests for the BookmarkManager class."""
|
||||
|
||||
def test_initialization(self, temp_bookmarks_dir):
|
||||
"""Test BookmarkManager initialization."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
assert manager.document_id == "test_doc"
|
||||
assert manager.bookmarks_dir == Path(temp_bookmarks_dir)
|
||||
assert manager.bookmarks_file.exists() or True # May not exist yet
|
||||
assert isinstance(manager._bookmarks, dict)
|
||||
|
||||
def test_initialization_creates_directory(self, tmp_path):
|
||||
"""Test that initialization creates bookmarks directory if needed."""
|
||||
bookmarks_dir = str(tmp_path / "new_bookmarks")
|
||||
|
||||
BookmarkManager("test_doc", bookmarks_dir)
|
||||
|
||||
assert Path(bookmarks_dir).exists()
|
||||
assert Path(bookmarks_dir).is_dir()
|
||||
|
||||
def test_add_bookmark(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test adding a bookmark."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
manager.add_bookmark("Chapter 1", sample_position)
|
||||
|
||||
# Verify bookmark was added
|
||||
bookmark = manager.get_bookmark("Chapter 1")
|
||||
assert bookmark is not None
|
||||
assert bookmark == sample_position
|
||||
|
||||
def test_add_multiple_bookmarks(self, temp_bookmarks_dir):
|
||||
"""Test adding multiple bookmarks."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
pos1 = RenderingPosition(chapter_index=1, block_index=5)
|
||||
pos2 = RenderingPosition(chapter_index=2, block_index=10)
|
||||
pos3 = RenderingPosition(chapter_index=3, block_index=15)
|
||||
|
||||
manager.add_bookmark("Bookmark 1", pos1)
|
||||
manager.add_bookmark("Bookmark 2", pos2)
|
||||
manager.add_bookmark("Bookmark 3", pos3)
|
||||
|
||||
assert len(manager._bookmarks) == 3
|
||||
assert manager.get_bookmark("Bookmark 1") == pos1
|
||||
assert manager.get_bookmark("Bookmark 2") == pos2
|
||||
assert manager.get_bookmark("Bookmark 3") == pos3
|
||||
|
||||
def test_remove_bookmark(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test removing a bookmark."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
manager.add_bookmark("Test", sample_position)
|
||||
assert manager.get_bookmark("Test") is not None
|
||||
|
||||
result = manager.remove_bookmark("Test")
|
||||
|
||||
assert result is True
|
||||
assert manager.get_bookmark("Test") is None
|
||||
|
||||
def test_remove_nonexistent_bookmark(self, temp_bookmarks_dir):
|
||||
"""Test removing a bookmark that doesn't exist."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
result = manager.remove_bookmark("Nonexistent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_get_bookmark(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test getting a bookmark."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
manager.add_bookmark("Test", sample_position)
|
||||
|
||||
retrieved = manager.get_bookmark("Test")
|
||||
|
||||
assert retrieved == sample_position
|
||||
|
||||
def test_get_nonexistent_bookmark(self, temp_bookmarks_dir):
|
||||
"""Test getting a bookmark that doesn't exist."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
result = manager.get_bookmark("Nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_list_bookmarks(self, temp_bookmarks_dir):
|
||||
"""Test listing all bookmarks."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
pos1 = RenderingPosition(chapter_index=1, block_index=5)
|
||||
pos2 = RenderingPosition(chapter_index=2, block_index=10)
|
||||
|
||||
manager.add_bookmark("First", pos1)
|
||||
manager.add_bookmark("Second", pos2)
|
||||
|
||||
bookmarks = manager.list_bookmarks()
|
||||
|
||||
assert len(bookmarks) == 2
|
||||
assert ("First", pos1) in bookmarks
|
||||
assert ("Second", pos2) in bookmarks
|
||||
|
||||
def test_list_bookmarks_empty(self, temp_bookmarks_dir):
|
||||
"""Test listing bookmarks when none exist."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
bookmarks = manager.list_bookmarks()
|
||||
|
||||
assert bookmarks == []
|
||||
|
||||
def test_save_reading_position(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test saving current reading position."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
manager.save_reading_position(sample_position)
|
||||
|
||||
# Verify file was created
|
||||
assert manager.position_file.exists()
|
||||
|
||||
# Verify content
|
||||
with open(manager.position_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
assert data['chapter_index'] == sample_position.chapter_index
|
||||
assert data['block_index'] == sample_position.block_index
|
||||
|
||||
def test_load_reading_position(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test loading saved reading position."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
manager.save_reading_position(sample_position)
|
||||
|
||||
loaded = manager.load_reading_position()
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded == sample_position
|
||||
|
||||
def test_load_reading_position_nonexistent(self, temp_bookmarks_dir):
|
||||
"""Test loading reading position when file doesn't exist."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
loaded = manager.load_reading_position()
|
||||
|
||||
assert loaded is None
|
||||
|
||||
def test_bookmark_persistence(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test that bookmarks persist across manager instances."""
|
||||
# Create first manager and add bookmark
|
||||
manager1 = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
manager1.add_bookmark("Persistent", sample_position)
|
||||
|
||||
# Create second manager and verify bookmark exists
|
||||
manager2 = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
loaded = manager2.get_bookmark("Persistent")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded == sample_position
|
||||
|
||||
def test_position_persistence(self, temp_bookmarks_dir, sample_position):
|
||||
"""Test that reading position persists across manager instances."""
|
||||
# Save with first manager
|
||||
manager1 = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
manager1.save_reading_position(sample_position)
|
||||
|
||||
# Load with second manager
|
||||
manager2 = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
loaded = manager2.load_reading_position()
|
||||
|
||||
assert loaded == sample_position
|
||||
|
||||
def test_corrupt_bookmarks_file(self, temp_bookmarks_dir):
|
||||
"""Test handling of corrupt bookmarks file."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
# Write corrupt JSON
|
||||
with open(manager.bookmarks_file, 'w') as f:
|
||||
f.write("{ invalid json }}")
|
||||
|
||||
# Should handle gracefully and load empty bookmarks
|
||||
manager2 = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
assert manager2._bookmarks == {}
|
||||
|
||||
def test_corrupt_position_file(self, temp_bookmarks_dir):
|
||||
"""Test handling of corrupt position file."""
|
||||
manager = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
|
||||
# Write corrupt JSON
|
||||
with open(manager.position_file, 'w') as f:
|
||||
f.write("{ invalid json }}")
|
||||
|
||||
# Should handle gracefully
|
||||
loaded = manager.load_reading_position()
|
||||
assert loaded is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EreaderLayoutManager Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestEreaderLayoutManager:
|
||||
"""Tests for the EreaderLayoutManager class."""
|
||||
|
||||
def test_initialization(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test EreaderLayoutManager initialization."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
document_id="test_doc",
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
assert manager.blocks == sample_blocks
|
||||
assert manager.page_size == (800, 600)
|
||||
assert manager.document_id == "test_doc"
|
||||
assert manager.font_scale == 1.0
|
||||
assert isinstance(manager.current_position, RenderingPosition)
|
||||
|
||||
def test_initialization_with_custom_page_style(
|
||||
self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test initialization with custom page style."""
|
||||
custom_style = PageStyle()
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
page_style=custom_style,
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
assert manager.page_style == custom_style
|
||||
|
||||
def test_initialization_loads_saved_position(
|
||||
self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test that initialization loads saved reading position."""
|
||||
# Save a position first
|
||||
bookmark_mgr = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
saved_pos = RenderingPosition(chapter_index=2, block_index=10)
|
||||
bookmark_mgr.save_reading_position(saved_pos)
|
||||
|
||||
# Create manager - should load saved position
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
document_id="test_doc",
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
assert manager.current_position == saved_pos
|
||||
|
||||
def test_get_current_page(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting the current page."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
page = manager.get_current_page()
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert isinstance(page, Page)
|
||||
|
||||
def test_next_page(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test advancing to next page."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
initial_pos = manager.current_position.copy()
|
||||
|
||||
next_page = manager.next_page()
|
||||
|
||||
# Position should have advanced
|
||||
assert manager.current_position != initial_pos or next_page is None
|
||||
|
||||
def test_next_page_at_end(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test next_page when at end of document."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
# Move to end
|
||||
manager.current_position = RenderingPosition(
|
||||
block_index=len(sample_blocks) + 100
|
||||
)
|
||||
|
||||
result = manager.next_page()
|
||||
|
||||
# Should return None at end
|
||||
assert result is None
|
||||
|
||||
def test_previous_page(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test going to previous page."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
# Move forward first
|
||||
manager.current_position = RenderingPosition(block_index=3)
|
||||
|
||||
prev_page = manager.previous_page()
|
||||
|
||||
# Should return a page or None if at beginning
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert prev_page is None or isinstance(prev_page, Page)
|
||||
|
||||
def test_previous_page_at_beginning(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test previous_page when at beginning of document."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
# At beginning
|
||||
manager.current_position = RenderingPosition()
|
||||
|
||||
result = manager.previous_page()
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_jump_to_position(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to a specific position."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
target_pos = RenderingPosition(chapter_index=1, block_index=3)
|
||||
|
||||
page = manager.jump_to_position(target_pos)
|
||||
|
||||
assert manager.current_position == target_pos
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert isinstance(page, Page)
|
||||
|
||||
def test_jump_to_chapter(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to a chapter by title."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
page = manager.jump_to_chapter("Chapter One")
|
||||
|
||||
# May return page or None if chapter not found
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert page is None or isinstance(page, Page)
|
||||
|
||||
def test_jump_to_chapter_not_found(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to non-existent chapter."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
result = manager.jump_to_chapter("Nonexistent Chapter")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_jump_to_chapter_index(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to chapter by index."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
page = manager.jump_to_chapter_index(0)
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert page is None or isinstance(page, Page)
|
||||
|
||||
def test_jump_to_chapter_index_invalid(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to invalid chapter index."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
result = manager.jump_to_chapter_index(999)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_set_font_scale(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test changing font scale."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
page = manager.set_font_scale(1.5)
|
||||
|
||||
assert manager.font_scale == 1.5
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert isinstance(page, Page)
|
||||
|
||||
def test_set_font_scale_same(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test setting font scale to same value."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
manager.set_font_scale(1.0)
|
||||
|
||||
assert manager.font_scale == 1.0
|
||||
|
||||
def test_get_font_scale(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting current font scale."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
scale = manager.get_font_scale()
|
||||
|
||||
assert scale == 1.0
|
||||
|
||||
def test_get_table_of_contents(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting table of contents."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
toc = manager.get_table_of_contents()
|
||||
|
||||
assert isinstance(toc, list)
|
||||
|
||||
def test_get_current_chapter(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting current chapter info."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
chapter = manager.get_current_chapter()
|
||||
|
||||
# May be None or ChapterInfo
|
||||
assert chapter is None or hasattr(chapter, 'title')
|
||||
|
||||
def test_add_bookmark(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test adding a bookmark at current position."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
result = manager.add_bookmark("Test Bookmark")
|
||||
|
||||
assert result is True
|
||||
# Verify bookmark was added
|
||||
bookmark = manager.bookmark_manager.get_bookmark("Test Bookmark")
|
||||
assert bookmark is not None
|
||||
|
||||
def test_remove_bookmark(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test removing a bookmark."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
manager.add_bookmark("Test")
|
||||
result = manager.remove_bookmark("Test")
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_jump_to_bookmark(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to a bookmark."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
# Add bookmark at specific position
|
||||
manager.current_position = RenderingPosition(block_index=3)
|
||||
manager.add_bookmark("Test Position")
|
||||
|
||||
# Move away
|
||||
manager.current_position = RenderingPosition(block_index=0)
|
||||
|
||||
# Jump to bookmark
|
||||
page = manager.jump_to_bookmark("Test Position")
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
assert isinstance(page, Page)
|
||||
assert manager.current_position.block_index == 3
|
||||
|
||||
def test_jump_to_nonexistent_bookmark(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test jumping to non-existent bookmark."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
result = manager.jump_to_bookmark("Nonexistent")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_list_bookmarks(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test listing all bookmarks."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
manager.add_bookmark("Bookmark 1")
|
||||
manager.add_bookmark("Bookmark 2")
|
||||
|
||||
bookmarks = manager.list_bookmarks()
|
||||
|
||||
assert len(bookmarks) == 2
|
||||
|
||||
def test_get_reading_progress(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting reading progress percentage."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
progress = manager.get_reading_progress()
|
||||
|
||||
assert 0.0 <= progress <= 1.0
|
||||
|
||||
def test_get_reading_progress_at_end(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test reading progress at end of document."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
manager.current_position = RenderingPosition(
|
||||
block_index=len(sample_blocks) - 1
|
||||
)
|
||||
|
||||
progress = manager.get_reading_progress()
|
||||
|
||||
assert progress == 1.0
|
||||
|
||||
def test_get_reading_progress_empty_document(self, temp_bookmarks_dir):
|
||||
"""Test reading progress with empty document."""
|
||||
manager = EreaderLayoutManager(
|
||||
[],
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
progress = manager.get_reading_progress()
|
||||
|
||||
assert progress == 0.0
|
||||
|
||||
def test_get_position_info(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting detailed position information."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
info = manager.get_position_info()
|
||||
|
||||
assert isinstance(info, dict)
|
||||
assert 'position' in info
|
||||
assert 'chapter' in info
|
||||
assert 'progress' in info
|
||||
assert 'font_scale' in info
|
||||
assert 'page_size' in info
|
||||
|
||||
def test_get_cache_stats(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test getting cache statistics."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
stats = manager.get_cache_stats()
|
||||
|
||||
assert isinstance(stats, dict)
|
||||
|
||||
def test_position_changed_callback(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test position changed callback."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
callback_called = []
|
||||
|
||||
def callback(position):
|
||||
callback_called.append(position)
|
||||
|
||||
manager.set_position_changed_callback(callback)
|
||||
manager.jump_to_position(RenderingPosition(block_index=3))
|
||||
|
||||
assert len(callback_called) > 0
|
||||
|
||||
def test_chapter_changed_callback(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test chapter changed callback."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
callback_called = []
|
||||
|
||||
def callback(chapter):
|
||||
callback_called.append(chapter)
|
||||
|
||||
manager.set_chapter_changed_callback(callback)
|
||||
manager.jump_to_position(RenderingPosition(block_index=3))
|
||||
|
||||
assert len(callback_called) > 0
|
||||
|
||||
def test_shutdown(self, sample_blocks, temp_bookmarks_dir):
|
||||
"""Test shutdown saves position."""
|
||||
manager = EreaderLayoutManager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
document_id="test_doc",
|
||||
bookmarks_dir=temp_bookmarks_dir
|
||||
)
|
||||
|
||||
test_pos = RenderingPosition(block_index=5)
|
||||
manager.current_position = test_pos
|
||||
|
||||
manager.shutdown()
|
||||
|
||||
# Verify position was saved
|
||||
bookmark_mgr = BookmarkManager("test_doc", temp_bookmarks_dir)
|
||||
loaded = bookmark_mgr.load_reading_position()
|
||||
assert loaded == test_pos
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Convenience Function Tests
|
||||
# ============================================================================
|
||||
|
||||
class TestCreateEreaderManager:
|
||||
"""Tests for the create_ereader_manager convenience function."""
|
||||
|
||||
def test_create_with_defaults(self, sample_blocks):
|
||||
"""Test creating manager with default parameters."""
|
||||
manager = create_ereader_manager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600)
|
||||
)
|
||||
|
||||
assert isinstance(manager, EreaderLayoutManager)
|
||||
assert manager.blocks == sample_blocks
|
||||
assert manager.page_size == (800, 600)
|
||||
assert manager.document_id == "default"
|
||||
|
||||
def test_create_with_custom_document_id(self, sample_blocks):
|
||||
"""Test creating manager with custom document ID."""
|
||||
manager = create_ereader_manager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
document_id="custom_doc"
|
||||
)
|
||||
|
||||
assert manager.document_id == "custom_doc"
|
||||
|
||||
def test_create_with_kwargs(self, sample_blocks, tmp_path):
|
||||
"""Test creating manager with additional kwargs."""
|
||||
bookmarks_dir = str(tmp_path / "custom_bookmarks")
|
||||
|
||||
manager = create_ereader_manager(
|
||||
sample_blocks,
|
||||
page_size=(800, 600),
|
||||
document_id="test",
|
||||
buffer_size=10,
|
||||
bookmarks_dir=bookmarks_dir
|
||||
)
|
||||
|
||||
assert isinstance(manager, EreaderLayoutManager)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Tests for font scaling in the ereader layout path (R3).
|
||||
|
||||
_scale_block_fonts rebuilds a block with scaled fonts. It used to construct a
|
||||
plain Word for every word, which downgraded LinkedWord and silently discarded
|
||||
every hyperlink in the document as soon as the reader changed font size. It
|
||||
also handled only Paragraph and Heading, so quotes, lists and tables kept their
|
||||
original size while the text around them reflowed.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, Quote, HList, Table
|
||||
from pyWebLayout.abstract.inline import LinkedWord, Word
|
||||
from pyWebLayout.concrete.functional import LinkText
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
HTML = """
|
||||
<p>Go to <a href="http://example.com" title="Tooltip">this link</a> now.</p>
|
||||
<blockquote><p>Quoted <a href="http://q.example">qlink</a> text.</p></blockquote>
|
||||
<ul><li>item <a href="http://l.example">llink</a> one</li></ul>
|
||||
<table>
|
||||
<thead><tr><th>head <a href="http://h.example">hlink</a></th></tr></thead>
|
||||
<tbody><tr><td>cell <a href="http://c.example">clink</a></td></tr></tbody>
|
||||
</table>
|
||||
"""
|
||||
|
||||
|
||||
def collect_links(block, out=None):
|
||||
"""Every LinkedWord reachable in a block, at any nesting depth."""
|
||||
out = [] if out is None else out
|
||||
if isinstance(block, Paragraph): # covers Heading
|
||||
for _, word in block.words_iter():
|
||||
if isinstance(word, LinkedWord):
|
||||
out.append(word)
|
||||
elif isinstance(block, Quote):
|
||||
for child in block.blocks():
|
||||
collect_links(child, out)
|
||||
elif isinstance(block, HList):
|
||||
for item in block.items():
|
||||
for child in item.blocks():
|
||||
collect_links(child, out)
|
||||
elif isinstance(block, Table):
|
||||
for rows in (block.header_rows(), block.body_rows(), block.footer_rows()):
|
||||
for row in rows:
|
||||
for cell in row.cells():
|
||||
for child in cell.blocks():
|
||||
collect_links(child, out)
|
||||
return out
|
||||
|
||||
|
||||
def collect_sizes(block, out=None):
|
||||
"""Every font size reachable in a block, at any nesting depth."""
|
||||
out = [] if out is None else out
|
||||
if isinstance(block, Paragraph):
|
||||
for _, word in block.words_iter():
|
||||
out.append(word.style.font_size)
|
||||
elif isinstance(block, Quote):
|
||||
for child in block.blocks():
|
||||
collect_sizes(child, out)
|
||||
elif isinstance(block, HList):
|
||||
for item in block.items():
|
||||
for child in item.blocks():
|
||||
collect_sizes(child, out)
|
||||
elif isinstance(block, Table):
|
||||
for rows in (block.header_rows(), block.body_rows(), block.footer_rows()):
|
||||
for row in rows:
|
||||
for cell in row.cells():
|
||||
for child in cell.blocks():
|
||||
collect_sizes(child, out)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blocks():
|
||||
return parse_html_string(HTML)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layouter(blocks):
|
||||
return BidirectionalLayouter(blocks, PageStyle(), (400, 600))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Word.with_style
|
||||
# ============================================================================
|
||||
|
||||
class TestWithStyle:
|
||||
def test_word_keeps_its_text_and_takes_the_new_font(self):
|
||||
word = Word("hello", Font(font_size=16))
|
||||
|
||||
copy = word.with_style(Font(font_size=24))
|
||||
|
||||
assert copy.text == "hello"
|
||||
assert copy.style.font_size == 24
|
||||
assert word.style.font_size == 16, "the original must not be mutated"
|
||||
|
||||
def test_linked_word_stays_linked(self):
|
||||
word = LinkedWord("hello", Font(font_size=16), "http://example.com",
|
||||
params={"a": "1"}, title="Tooltip")
|
||||
|
||||
copy = word.with_style(Font(font_size=24))
|
||||
|
||||
assert isinstance(copy, LinkedWord)
|
||||
assert copy.location == "http://example.com"
|
||||
assert copy.link_type == word.link_type
|
||||
assert copy.params == {"a": "1"}
|
||||
assert copy.link_title == "Tooltip"
|
||||
assert copy.style.font_size == 24
|
||||
|
||||
def test_linked_word_params_are_copied_not_shared(self):
|
||||
word = LinkedWord("hello", Font(), "http://example.com", params={"a": "1"})
|
||||
|
||||
copy = word.with_style(Font(font_size=24))
|
||||
copy.params["b"] = "2"
|
||||
|
||||
assert "b" not in word.params
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# _scale_block_fonts
|
||||
# ============================================================================
|
||||
|
||||
class TestScaleBlockFonts:
|
||||
def test_links_survive_scaling_in_every_container(self, blocks, layouter):
|
||||
before = sum(len(collect_links(b)) for b in blocks)
|
||||
after = sum(len(collect_links(layouter._scale_block_fonts(b, 1.5)))
|
||||
for b in blocks)
|
||||
|
||||
assert before == 6, "fixture should contain 6 linked words"
|
||||
assert after == before, "scaling must not discard hyperlinks"
|
||||
|
||||
def test_link_targets_are_preserved_exactly(self, blocks, layouter):
|
||||
scaled = [layouter._scale_block_fonts(b, 1.5) for b in blocks]
|
||||
targets = sorted(w.location for b in scaled for w in collect_links(b))
|
||||
|
||||
assert targets == sorted([
|
||||
"http://example.com", "http://example.com",
|
||||
"http://q.example", "http://l.example",
|
||||
"http://h.example", "http://c.example",
|
||||
])
|
||||
|
||||
@pytest.mark.parametrize("index,kind", [(0, "paragraph"), (1, "quote"),
|
||||
(2, "list"), (3, "table")])
|
||||
def test_every_container_type_actually_scales(self, blocks, layouter, index, kind):
|
||||
original = collect_sizes(blocks[index])
|
||||
scaled = collect_sizes(layouter._scale_block_fonts(blocks[index], 2.0))
|
||||
|
||||
assert original, f"fixture {kind} should contain sized words"
|
||||
assert scaled == [s * 2 for s in original], f"{kind} did not scale"
|
||||
|
||||
def test_table_rows_stay_in_their_section(self, blocks, layouter):
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
|
||||
scaled = layouter._scale_block_fonts(table, 1.5)
|
||||
|
||||
assert len(list(scaled.header_rows())) == len(list(table.header_rows()))
|
||||
assert len(list(scaled.body_rows())) == len(list(table.body_rows()))
|
||||
|
||||
def test_unscaled_blocks_are_returned_unchanged(self, blocks, layouter):
|
||||
assert layouter._scale_block_fonts(blocks[0], 1.0) is blocks[0]
|
||||
|
||||
def test_heading_level_is_preserved(self, layouter):
|
||||
heading = parse_html_string("<h3>Title here</h3>")[0]
|
||||
|
||||
scaled = layouter._scale_block_fonts(heading, 1.5)
|
||||
|
||||
assert isinstance(scaled, Heading)
|
||||
assert scaled.level == heading.level
|
||||
|
||||
def test_result_is_memoised(self, blocks, layouter):
|
||||
"""Rebuilding a block per page render allocated on the hot path."""
|
||||
first = layouter._scale_block_fonts(blocks[0], 1.5)
|
||||
second = layouter._scale_block_fonts(blocks[0], 1.5)
|
||||
|
||||
assert first is second
|
||||
|
||||
def test_different_scales_are_cached_separately(self, blocks, layouter):
|
||||
assert (layouter._scale_block_fonts(blocks[0], 1.5)
|
||||
is not layouter._scale_block_fonts(blocks[0], 2.0))
|
||||
|
||||
def test_originals_are_never_mutated(self, blocks, layouter):
|
||||
before = [collect_sizes(b) for b in blocks]
|
||||
|
||||
for b in blocks:
|
||||
layouter._scale_block_fonts(b, 3.0)
|
||||
|
||||
assert [collect_sizes(b) for b in blocks] == before
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# End to end
|
||||
# ============================================================================
|
||||
|
||||
def rendered_link_texts(page):
|
||||
"""Every LinkText on a rendered page. They live inside Line objects."""
|
||||
found = []
|
||||
for child in page._children:
|
||||
for text_obj in getattr(child, '_text_objects', []):
|
||||
if isinstance(text_obj, LinkText):
|
||||
found.append(text_obj)
|
||||
return found
|
||||
|
||||
|
||||
class TestLinksRemainClickableAfterFontChange:
|
||||
"""
|
||||
The user-visible symptom of R3: increase the font size and links stop
|
||||
responding to taps.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def manager(self):
|
||||
blocks = parse_html_string(
|
||||
'<p>Go to <a href="http://example.com">this link</a> now.</p>')
|
||||
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
|
||||
bookmarks_dir=tempfile.mkdtemp())
|
||||
yield manager
|
||||
manager.shutdown()
|
||||
|
||||
def test_links_render_at_default_scale(self, manager):
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
|
||||
assert [t.link.location for t in rendered_link_texts(page)] == \
|
||||
["http://example.com", "http://example.com"]
|
||||
|
||||
@pytest.mark.parametrize("scale", [0.8, 1.5, 2.0])
|
||||
def test_links_survive_a_font_size_change(self, manager, scale):
|
||||
manager.set_font_scale(scale)
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
|
||||
locations = {t.link.location for t in rendered_link_texts(page)}
|
||||
assert locations == {"http://example.com"}
|
||||
|
||||
@pytest.mark.parametrize("scale", [1.0, 1.5])
|
||||
def test_the_link_is_reachable_by_tapping(self, manager, scale):
|
||||
"""
|
||||
Scanned rather than probed at the LinkText's own centre: the hit region
|
||||
query_point reports is offset from LinkText.origin by roughly the
|
||||
ascent. That misalignment predates this fix and is tracked separately
|
||||
as R9 - it reproduces identically at scale 1.0.
|
||||
"""
|
||||
manager.set_font_scale(scale)
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
|
||||
targets = set()
|
||||
for y in range(0, 120, 2):
|
||||
for x in range(0, 400, 2):
|
||||
result = page.query_point((x, y))
|
||||
if result is not None and result.object_type == "link":
|
||||
targets.add(result.link_target)
|
||||
|
||||
assert targets == {"http://example.com"}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
End-to-end test for HTML links in EreaderLayoutManager.
|
||||
|
||||
This test mimics exactly what the dreader application does:
|
||||
1. Load HTML with links via parse_html_string
|
||||
2. Create an EreaderLayoutManager
|
||||
3. Render a page
|
||||
4. Query for interactive elements
|
||||
|
||||
This should reveal if links are actually interactive after full rendering.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.concrete.functional import LinkText
|
||||
|
||||
|
||||
class TestHTMLLinksInEreader(unittest.TestCase):
|
||||
"""Test HTML link interactivity in the full ereader pipeline."""
|
||||
|
||||
def test_settings_overlay_links_are_interactive(self):
|
||||
"""Test that settings overlay HTML creates interactive links."""
|
||||
# This is realistic settings overlay HTML
|
||||
html = '''
|
||||
<div>
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
<a href="action:back_to_library">Back to Library</a>
|
||||
</p>
|
||||
<p>
|
||||
Font Size:
|
||||
<a href="setting:font_decrease">[-]</a>
|
||||
<a href="setting:font_increase">[+]</a>
|
||||
</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Step 1: Parse HTML to blocks
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
# Verify LinkedWords were created
|
||||
all_linked_words = []
|
||||
for block in blocks:
|
||||
if hasattr(block, 'words'):
|
||||
for word in block.words:
|
||||
if isinstance(word, LinkedWord):
|
||||
all_linked_words.append(word)
|
||||
|
||||
self.assertGreater(
|
||||
len(all_linked_words),
|
||||
0,
|
||||
"Should create LinkedWords from HTML")
|
||||
print(f"\n Created {len(all_linked_words)} LinkedWords from HTML")
|
||||
|
||||
# Step 2: Create EreaderLayoutManager (like the dreader app does)
|
||||
page_size = (400, 600)
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=page_size,
|
||||
document_id="test_settings"
|
||||
)
|
||||
|
||||
# Step 3: Get the rendered page
|
||||
page = manager.get_current_page()
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
# Step 4: Render to image
|
||||
rendered_image = page.render()
|
||||
self.assertIsNotNone(rendered_image)
|
||||
print(f" Rendered page: {rendered_image.size}")
|
||||
|
||||
# Step 5: Find all interactive elements by scanning the page
|
||||
# This is the CRITICAL test - are there LinkText objects in the page?
|
||||
interactive_elements = []
|
||||
|
||||
# Scan through all children of the page
|
||||
if hasattr(page, '_children'):
|
||||
for child in page._children:
|
||||
# Check if child is a Line
|
||||
if hasattr(child, '_text_objects'):
|
||||
for text_obj in child._text_objects:
|
||||
if isinstance(text_obj, LinkText):
|
||||
interactive_elements.append({
|
||||
'type': 'LinkText',
|
||||
'text': text_obj._text,
|
||||
'location': text_obj.link.location,
|
||||
'is_interactive': hasattr(text_obj, 'execute')
|
||||
})
|
||||
|
||||
print(f" Found {len(interactive_elements)} LinkText objects in rendered page")
|
||||
for elem in interactive_elements:
|
||||
print(f" - '{elem['text']}' -> {elem['location']}")
|
||||
|
||||
# THIS IS THE KEY ASSERTION
|
||||
self.assertGreater(
|
||||
len(interactive_elements),
|
||||
0,
|
||||
"Settings overlay should have interactive LinkText objects after rendering!")
|
||||
|
||||
# Verify the expected links are present
|
||||
locations = {elem['location'] for elem in interactive_elements}
|
||||
self.assertIn("action:back_to_library", locations,
|
||||
"Should find 'Back to Library' link")
|
||||
self.assertIn("setting:font_decrease", locations,
|
||||
"Should find font decrease link")
|
||||
self.assertIn("setting:font_increase", locations,
|
||||
"Should find font increase link")
|
||||
|
||||
def test_query_point_detects_links(self):
|
||||
"""Test that query_point can detect LinkText objects."""
|
||||
html = '<p><a href="action:test">Click here</a> to test.</p>'
|
||||
|
||||
blocks = parse_html_string(html)
|
||||
manager = EreaderLayoutManager(
|
||||
blocks=blocks,
|
||||
page_size=(400, 200),
|
||||
document_id="test_query"
|
||||
)
|
||||
|
||||
page = manager.get_current_page()
|
||||
page.render()
|
||||
|
||||
# Try to query various points on the page
|
||||
# We don't know exact coordinates, so scan a grid
|
||||
found_link = False
|
||||
for y in range(20, 100, 10):
|
||||
for x in range(20, 380, 20):
|
||||
result = page.query_point((x, y))
|
||||
if result and result.is_interactive:
|
||||
print(f"\n Found interactive element at ({x}, {y})")
|
||||
print(f" Type: {result.object_type}")
|
||||
print(f" Link target: {result.link_target}")
|
||||
print(f" Text: {result.text}")
|
||||
found_link = True
|
||||
self.assertEqual(result.link_target, "action:test")
|
||||
break
|
||||
if found_link:
|
||||
break
|
||||
|
||||
self.assertTrue(
|
||||
found_link,
|
||||
"Should be able to detect link via query_point somewhere on the page")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Tests for navigation consistency in the ereader manager.
|
||||
|
||||
This module tests that forward and backward navigation are consistent
|
||||
and produce the same content when returning to previously visited positions.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.concrete.page import Page
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_font():
|
||||
"""Create a standard font for testing."""
|
||||
return Font(font_size=12, colour=(0, 0, 0))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def large_document(sample_font):
|
||||
"""Create a large document with many paragraphs to ensure multiple pages."""
|
||||
blocks = []
|
||||
|
||||
# Add multiple headings and paragraphs to create a substantial document
|
||||
for section in range(5):
|
||||
# Add a heading
|
||||
heading = Heading(HeadingLevel.H2, sample_font)
|
||||
heading.add_word(Word(f"Section", sample_font))
|
||||
heading.add_word(Word(f"{section + 1}", sample_font))
|
||||
blocks.append(heading)
|
||||
|
||||
# Add multiple paragraphs per section
|
||||
for para_num in range(10):
|
||||
p = Paragraph(sample_font)
|
||||
# Add enough words to make substantial paragraphs
|
||||
for word_num in range(15):
|
||||
p.add_word(Word(f"Word_{section}_{para_num}_{word_num}", sample_font))
|
||||
blocks.append(p)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_text_content(page: Page) -> str:
|
||||
"""
|
||||
Extract text content from a rendered page.
|
||||
|
||||
This provides a way to compare pages semantically rather than pixel-perfect.
|
||||
Pages contain Line objects as children, and Lines contain Text objects.
|
||||
"""
|
||||
text_content = []
|
||||
|
||||
# Get children from the page (these are Line objects)
|
||||
if hasattr(page, 'children'):
|
||||
for child in page.children:
|
||||
# Each Line contains Text objects
|
||||
if hasattr(child, 'text_objects') and child.text_objects:
|
||||
for text_obj in child.text_objects:
|
||||
if hasattr(text_obj, '_text'):
|
||||
text_content.append(text_obj._text)
|
||||
elif hasattr(text_obj, 'text'):
|
||||
text_content.append(text_obj.text)
|
||||
|
||||
# Join all text with spaces
|
||||
return ' '.join(text_content)
|
||||
|
||||
|
||||
def get_page_summary(page: Page) -> dict:
|
||||
"""
|
||||
Get a summary of page content for comparison.
|
||||
|
||||
Returns a dictionary with:
|
||||
- text_content: All text on the page
|
||||
- child_count: Number of child objects (Lines) on the page
|
||||
- approximate_content_size: Rough measure of content amount
|
||||
"""
|
||||
text = extract_text_content(page)
|
||||
|
||||
child_count = 0
|
||||
if hasattr(page, 'children'):
|
||||
child_count = len(page.children)
|
||||
|
||||
return {
|
||||
'text_content': text,
|
||||
'child_count': child_count,
|
||||
'approximate_content_size': len(text),
|
||||
'has_content': len(text) > 0
|
||||
}
|
||||
|
||||
|
||||
class TestNavigationConsistency:
|
||||
"""Tests for forward/backward navigation consistency."""
|
||||
|
||||
def test_forward_backward_consistency(self, large_document, tmp_path):
|
||||
"""
|
||||
Test that navigating forward then backward returns to the same content.
|
||||
"""
|
||||
manager = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id="test_consistency",
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Navigate forward and capture page summaries
|
||||
forward_pages = []
|
||||
positions = [manager.current_position.copy()]
|
||||
|
||||
for i in range(5):
|
||||
page = manager.get_current_page()
|
||||
forward_pages.append(get_page_summary(page))
|
||||
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
positions.append(manager.current_position.copy())
|
||||
|
||||
# Now navigate backward
|
||||
backward_pages = []
|
||||
for i in range(len(positions) - 1):
|
||||
prev_page = manager.previous_page()
|
||||
assert prev_page is not None, f"Backward navigation {i} failed"
|
||||
|
||||
page = manager.get_current_page()
|
||||
backward_pages.append(get_page_summary(page))
|
||||
|
||||
# Reverse backward_pages to align with forward_pages
|
||||
backward_pages.reverse()
|
||||
|
||||
# Compare content (excluding the last page since we didn't go back to it)
|
||||
for i in range(len(backward_pages)):
|
||||
forward_summary = forward_pages[i]
|
||||
backward_summary = backward_pages[i]
|
||||
|
||||
# Check that content is the same
|
||||
assert forward_summary['text_content'] == backward_summary['text_content'], \
|
||||
f"Page {i} content differs between forward and backward navigation"
|
||||
|
||||
# Check that we have content
|
||||
assert forward_summary['has_content'], f"Page {i} has no content"
|
||||
|
||||
def test_forward_backward_forward_consistency(self, large_document, tmp_path):
|
||||
"""
|
||||
Test navigating forward, backward, then forward again produces consistent content.
|
||||
"""
|
||||
manager = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id="test_fbf_consistency",
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Navigate forward 5 pages
|
||||
forward_first = []
|
||||
for i in range(5):
|
||||
page = manager.get_current_page()
|
||||
forward_first.append(get_page_summary(page))
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
|
||||
# Navigate backward 3 pages
|
||||
for i in range(3):
|
||||
prev_page = manager.previous_page()
|
||||
assert prev_page is not None, f"Backward navigation {i} failed"
|
||||
|
||||
# Navigate forward 3 pages again
|
||||
forward_second = []
|
||||
for i in range(3):
|
||||
page = manager.get_current_page()
|
||||
forward_second.append(get_page_summary(page))
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
|
||||
# Compare the content - the last 3 pages of first forward pass
|
||||
# should match the 3 pages of second forward pass
|
||||
for i in range(min(3, len(forward_second))):
|
||||
original_idx = len(forward_first) - 3 + i
|
||||
if original_idx >= 0:
|
||||
assert forward_first[original_idx]['text_content'] == forward_second[i]['text_content'], \
|
||||
f"Page content differs at position {i}"
|
||||
|
||||
def test_position_consistency(self, large_document, tmp_path):
|
||||
"""
|
||||
Test that positions are consistent when navigating forward and backward.
|
||||
"""
|
||||
manager = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id="test_position_consistency",
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Navigate forward and record positions
|
||||
forward_positions = [manager.current_position.copy()]
|
||||
for i in range(5):
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
forward_positions.append(manager.current_position.copy())
|
||||
|
||||
# Navigate backward and record positions
|
||||
backward_positions = [manager.current_position.copy()]
|
||||
for i in range(len(forward_positions) - 1):
|
||||
prev_page = manager.previous_page()
|
||||
assert prev_page is not None, f"Backward navigation {i} failed"
|
||||
backward_positions.append(manager.current_position.copy())
|
||||
|
||||
# Reverse backward positions to align with forward
|
||||
backward_positions.reverse()
|
||||
|
||||
# Verify positions match
|
||||
for i in range(min(len(forward_positions), len(backward_positions))):
|
||||
assert forward_positions[i] == backward_positions[i], \
|
||||
f"Position {i} differs: forward={forward_positions[i]}, backward={backward_positions[i]}"
|
||||
|
||||
def test_navigation_after_reload(self, large_document, tmp_path):
|
||||
"""
|
||||
Test that navigation works correctly after closing and reopening the document.
|
||||
|
||||
This is the critical test for the bug we fixed: backward and forward navigation
|
||||
after reloading should work without cached position mappings.
|
||||
"""
|
||||
document_id = "test_reload_consistency"
|
||||
|
||||
# Session 1: Navigate forward to middle of document
|
||||
manager1 = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id=document_id,
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Navigate forward 3 pages (not too far, so we have room to navigate)
|
||||
for i in range(3):
|
||||
next_page = manager1.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
|
||||
saved_position = manager1.current_position.copy()
|
||||
manager1.shutdown()
|
||||
del manager1
|
||||
|
||||
# Session 2: Reload and test navigation
|
||||
manager2 = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id=document_id,
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Verify position was restored
|
||||
assert manager2.current_position == saved_position, \
|
||||
"Position was not correctly restored after reload"
|
||||
|
||||
# Test 1: Backward navigation works after reload (THE KEY BUG FIX)
|
||||
current_before = manager2.current_position.copy()
|
||||
prev_page = manager2.previous_page()
|
||||
assert prev_page is not None, \
|
||||
"Backward navigation after reload returned None (bug still exists!)"
|
||||
assert manager2.current_position != current_before, \
|
||||
"Backward navigation after reload did not change position"
|
||||
|
||||
# Test 2: Forward navigation works after backward
|
||||
current_before = manager2.current_position.copy()
|
||||
next_page = manager2.next_page()
|
||||
assert next_page is not None, \
|
||||
"Forward navigation after backward returned None"
|
||||
assert manager2.current_position != current_before, \
|
||||
"Forward navigation after backward did not change position"
|
||||
|
||||
# Test 3: Can go backward again
|
||||
current_before = manager2.current_position.copy()
|
||||
prev_page = manager2.previous_page()
|
||||
assert prev_page is not None, \
|
||||
"Second backward navigation returned None"
|
||||
assert manager2.current_position != current_before, \
|
||||
"Second backward navigation did not change position"
|
||||
|
||||
manager2.shutdown()
|
||||
|
||||
def test_multiple_navigation_cycles(self, large_document, tmp_path):
|
||||
"""
|
||||
Test multiple cycles of forward and backward navigation.
|
||||
"""
|
||||
manager = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id="test_cycles",
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Perform multiple cycles
|
||||
for cycle in range(3):
|
||||
start_position = manager.current_position.copy()
|
||||
|
||||
# Go forward 3 pages
|
||||
for i in range(3):
|
||||
next_page = manager.next_page()
|
||||
assert next_page is not None, \
|
||||
f"Cycle {cycle}: Forward navigation {i} failed"
|
||||
|
||||
# Go backward 3 pages
|
||||
for i in range(3):
|
||||
prev_page = manager.previous_page()
|
||||
assert prev_page is not None, \
|
||||
f"Cycle {cycle}: Backward navigation {i} failed"
|
||||
|
||||
# Should be back at start position
|
||||
assert manager.current_position == start_position, \
|
||||
f"Cycle {cycle}: Did not return to start position"
|
||||
|
||||
def test_content_boundaries(self, large_document, tmp_path):
|
||||
"""
|
||||
Test navigation at document boundaries (beginning and near end).
|
||||
"""
|
||||
manager = EreaderLayoutManager(
|
||||
large_document,
|
||||
page_size=(600, 800),
|
||||
document_id="test_boundaries",
|
||||
bookmarks_dir=str(tmp_path)
|
||||
)
|
||||
|
||||
# Test at beginning
|
||||
initial_position = manager.current_position.copy()
|
||||
prev_page = manager.previous_page()
|
||||
assert prev_page is None, "Should not be able to go back from beginning"
|
||||
assert manager.current_position == initial_position, \
|
||||
"Position should not change when at beginning"
|
||||
|
||||
# Navigate forward to near the end
|
||||
page_count = 0
|
||||
max_pages = 100 # Safety limit
|
||||
while page_count < max_pages:
|
||||
next_page = manager.next_page()
|
||||
if next_page is None:
|
||||
break
|
||||
page_count += 1
|
||||
|
||||
# We should have moved from the beginning
|
||||
assert page_count > 0, "Should have moved at least one page forward"
|
||||
|
||||
# Test going back from near the end
|
||||
end_position = manager.current_position.copy()
|
||||
prev_page = manager.previous_page()
|
||||
|
||||
# Should be able to go back
|
||||
if page_count > 0: # If we moved forward, we should be able to go back
|
||||
assert prev_page is not None, "Should be able to go back from end"
|
||||
assert manager.current_position != end_position, \
|
||||
"Position should change when going back from end"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Tests for the page caching layer.
|
||||
|
||||
Covers PageBuffer's LRU behaviour and BufferedPageRenderer's cache hits, plus
|
||||
regressions for S12/R1/R2: the module must not start worker processes and must
|
||||
not do blocking work in a finaliser.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.layout.page_buffer import PageBuffer, BufferedPageRenderer
|
||||
from pyWebLayout.layout.ereader_layout import RenderingPosition
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Fixtures
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def sample_blocks():
|
||||
"""A document long enough to paginate over several pages."""
|
||||
font = Font()
|
||||
blocks = []
|
||||
for p in range(6):
|
||||
para = Paragraph(style=font)
|
||||
for w in range(120):
|
||||
para.add_word(Word(f"p{p}w{w}", font))
|
||||
blocks.append(para)
|
||||
return blocks
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def renderer(sample_blocks):
|
||||
return BufferedPageRenderer(sample_blocks, PageStyle(), buffer_size=3, page_size=(800, 600))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# PageBuffer
|
||||
# ============================================================================
|
||||
|
||||
class TestPageBuffer:
|
||||
def test_get_page_misses_when_empty(self):
|
||||
buf = PageBuffer(buffer_size=3)
|
||||
assert buf.get_page(RenderingPosition()) is None
|
||||
|
||||
def test_cache_page_round_trips(self):
|
||||
buf = PageBuffer(buffer_size=3)
|
||||
pos, nxt = RenderingPosition(block_index=0), RenderingPosition(block_index=1)
|
||||
sentinel = object()
|
||||
|
||||
buf.cache_page(pos, sentinel, nxt)
|
||||
|
||||
assert buf.get_page(pos) is sentinel
|
||||
assert buf.position_map[pos] == nxt
|
||||
|
||||
def test_lru_evicts_oldest_and_cleans_position_map(self):
|
||||
buf = PageBuffer(buffer_size=2)
|
||||
positions = [RenderingPosition(block_index=i) for i in range(4)]
|
||||
for i, pos in enumerate(positions):
|
||||
buf.cache_page(pos, object(), RenderingPosition(block_index=i + 1))
|
||||
|
||||
assert buf.get_page(positions[0]) is None, "oldest should have been evicted"
|
||||
assert positions[0] not in buf.position_map, "position map must not leak evicted entries"
|
||||
assert buf.get_page(positions[-1]) is not None
|
||||
|
||||
def test_get_page_refreshes_lru_order(self):
|
||||
buf = PageBuffer(buffer_size=2)
|
||||
a, b, c = (RenderingPosition(block_index=i) for i in range(3))
|
||||
buf.cache_page(a, object())
|
||||
buf.cache_page(b, object())
|
||||
|
||||
buf.get_page(a) # a becomes most recently used
|
||||
buf.cache_page(c, object())
|
||||
|
||||
assert buf.get_page(a) is not None, "recently used entry should survive"
|
||||
assert buf.get_page(b) is None, "least recently used entry should be evicted"
|
||||
|
||||
def test_backward_pages_land_in_the_backward_buffer(self):
|
||||
buf = PageBuffer(buffer_size=3)
|
||||
start, end = RenderingPosition(block_index=1), RenderingPosition(block_index=2)
|
||||
|
||||
buf.cache_page(start, object(), end, is_backward=True)
|
||||
|
||||
assert start in buf.backward_buffer
|
||||
assert start not in buf.forward_buffer
|
||||
assert buf.reverse_position_map[end] == start
|
||||
|
||||
def test_font_scale_change_invalidates(self):
|
||||
buf = PageBuffer(buffer_size=3)
|
||||
pos = RenderingPosition()
|
||||
buf.cache_page(pos, object(), RenderingPosition(block_index=1))
|
||||
|
||||
buf.set_font_scale(1.5)
|
||||
|
||||
assert buf.get_page(pos) is None
|
||||
|
||||
def test_same_font_scale_keeps_cache(self):
|
||||
buf = PageBuffer(buffer_size=3)
|
||||
pos = RenderingPosition()
|
||||
buf.cache_page(pos, object(), RenderingPosition(block_index=1))
|
||||
|
||||
buf.set_font_scale(1.0)
|
||||
|
||||
assert buf.get_page(pos) is not None
|
||||
|
||||
def test_shutdown_is_idempotent(self):
|
||||
buf = PageBuffer(buffer_size=3)
|
||||
buf.cache_page(RenderingPosition(), object())
|
||||
|
||||
buf.shutdown()
|
||||
buf.shutdown()
|
||||
|
||||
assert buf.get_cache_stats()['forward_buffer_size'] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BufferedPageRenderer
|
||||
# ============================================================================
|
||||
|
||||
class TestBufferedPageRenderer:
|
||||
def test_render_page_returns_a_page_and_advances(self, renderer):
|
||||
page, next_pos = renderer.render_page(RenderingPosition(), 1.0)
|
||||
|
||||
assert page is not None
|
||||
assert next_pos != RenderingPosition()
|
||||
|
||||
def test_second_render_of_same_position_is_served_from_cache(self, renderer):
|
||||
pos = RenderingPosition()
|
||||
first, first_next = renderer.render_page(pos, 1.0)
|
||||
second, second_next = renderer.render_page(pos, 1.0)
|
||||
|
||||
assert second is first, "identical page object means it came from the cache"
|
||||
assert second_next == first_next
|
||||
|
||||
def test_font_scale_change_forces_a_re_render(self, renderer):
|
||||
pos = RenderingPosition()
|
||||
first, _ = renderer.render_page(pos, 1.0)
|
||||
scaled, _ = renderer.render_page(pos, 1.5)
|
||||
|
||||
assert scaled is not first
|
||||
|
||||
def test_backward_render_round_trips_to_the_original_position(self, renderer):
|
||||
start = RenderingPosition()
|
||||
_, second_page_pos = renderer.render_page(start, 1.0)
|
||||
|
||||
_, back_to = renderer.render_page_backward(second_page_pos, 1.0)
|
||||
|
||||
assert back_to == start
|
||||
|
||||
def test_shutdown_clears_the_cache(self, renderer):
|
||||
renderer.render_page(RenderingPosition(), 1.0)
|
||||
renderer.shutdown()
|
||||
|
||||
assert renderer.get_cache_stats()['forward_buffer_size'] == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# S12 / R1 / R2 regressions
|
||||
# ============================================================================
|
||||
|
||||
class TestNoBackgroundProcesses:
|
||||
"""
|
||||
The process pool that used to live here never produced a usable page (a Page
|
||||
holds a live PIL canvas and cannot be pickled), and on Python 3.14's
|
||||
forkserver default it raised when driven from module-level code.
|
||||
"""
|
||||
|
||||
def test_module_declares_no_process_pool(self):
|
||||
import pyWebLayout.layout.page_buffer as page_buffer
|
||||
|
||||
source = page_buffer.__file__
|
||||
assert not hasattr(page_buffer, '_render_page_worker')
|
||||
assert not hasattr(PageBuffer(), 'executor')
|
||||
with open(source, encoding='utf-8') as fh:
|
||||
body = fh.read().split('"""', 2)[-1] # skip the module docstring
|
||||
assert 'ProcessPoolExecutor' not in body
|
||||
assert 'pickle' not in body
|
||||
|
||||
def test_page_buffer_has_no_finaliser(self):
|
||||
"""
|
||||
PageBuffer.__del__ called executor.shutdown(wait=True), which deadlocked
|
||||
the interpreter at exit. Cleanup must be explicit.
|
||||
"""
|
||||
assert '__del__' not in vars(PageBuffer)
|
||||
|
||||
def test_navigation_works_without_a_main_guard(self, tmp_path):
|
||||
"""
|
||||
R1: EreaderLayoutManager raised RuntimeError when used from module-level
|
||||
script code, because submitting to a ProcessPoolExecutor under a
|
||||
non-fork start method requires an `if __name__ == "__main__"` guard.
|
||||
"""
|
||||
script = textwrap.dedent(f"""
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
|
||||
blocks = parse_html_string("<p>" + " ".join(f"w{{i}}" for i in range(2000)) + "</p>")
|
||||
m = EreaderLayoutManager(blocks, page_size=(800, 600),
|
||||
bookmarks_dir={str(tmp_path)!r})
|
||||
m.get_current_page()
|
||||
m.next_page()
|
||||
m.previous_page()
|
||||
m.shutdown()
|
||||
print("OK")
|
||||
""")
|
||||
result = subprocess.run([sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "OK" in result.stdout
|
||||
|
||||
def test_interpreter_exits_without_explicit_shutdown(self, tmp_path):
|
||||
"""
|
||||
R2: a manager left to be finalised at exit must not hang. The timeout is
|
||||
the assertion.
|
||||
"""
|
||||
script = textwrap.dedent(f"""
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
|
||||
|
||||
blocks = parse_html_string("<p>" + " ".join(f"w{{i}}" for i in range(500)) + "</p>")
|
||||
m = EreaderLayoutManager(blocks, page_size=(800, 600),
|
||||
bookmarks_dir={str(tmp_path)!r})
|
||||
m.get_current_page()
|
||||
# deliberately no shutdown() - rely on interpreter teardown
|
||||
""")
|
||||
result = subprocess.run([sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Regression tests for blocks that span more than one page.
|
||||
|
||||
A block larger than a single page is laid out partially, and the layouter reports
|
||||
where it stopped. If that resume point is discarded, the reader is told it made no
|
||||
progress and navigation dead-ends on that block (spec S11).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
|
||||
|
||||
PAGE_SIZE = (800, 600)
|
||||
|
||||
|
||||
def make_paragraph(word_count, font):
|
||||
"""A paragraph of distinct words, so we can verify none are lost or repeated."""
|
||||
paragraph = Paragraph(font)
|
||||
for i in range(word_count):
|
||||
paragraph.add_word(Word(f"word{i}", font))
|
||||
return paragraph
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def font():
|
||||
return Font(font_size=16)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def huge_paragraph(font):
|
||||
"""A paragraph far larger than one page - the shape that dead-ended."""
|
||||
return make_paragraph(2877, font)
|
||||
|
||||
|
||||
class TestPageSpanningParagraph:
|
||||
"""A single paragraph larger than one page must paginate, not dead-end."""
|
||||
|
||||
def test_layouter_reports_where_it_stopped(self, huge_paragraph):
|
||||
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||
page = Page(size=PAGE_SIZE, style=PageStyle())
|
||||
|
||||
success, new_pos = layouter._layout_block_on_page(
|
||||
huge_paragraph, page, RenderingPosition(), 1.0)
|
||||
|
||||
assert not success, "a 2877-word paragraph cannot fit on one page"
|
||||
assert new_pos.word_index > 0, "the resume point must be reported"
|
||||
|
||||
def test_first_page_advances(self, huge_paragraph):
|
||||
"""The regression: next position equalled the start position."""
|
||||
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||
start = RenderingPosition()
|
||||
|
||||
page, next_pos = layouter.render_page_forward(start, 1.0)
|
||||
|
||||
assert len(page.children) > 0, "content was placed on the page"
|
||||
assert (next_pos.block_index, next_pos.word_index) > \
|
||||
(start.block_index, start.word_index), \
|
||||
"a page with content on it must advance the position"
|
||||
|
||||
def test_paginates_to_completion(self, huge_paragraph):
|
||||
"""Every page advances, and the document terminates."""
|
||||
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||
pos = RenderingPosition()
|
||||
positions = [(pos.block_index, pos.word_index)]
|
||||
|
||||
for _ in range(100):
|
||||
page, next_pos = layouter.render_page_forward(pos, 1.0)
|
||||
key = (next_pos.block_index, next_pos.word_index)
|
||||
|
||||
if next_pos.block_index >= 1:
|
||||
break # ran off the end of the (single-block) document
|
||||
|
||||
assert key > positions[-1], f"no progress at page {len(positions)}"
|
||||
positions.append(key)
|
||||
pos = next_pos
|
||||
else:
|
||||
pytest.fail("pagination did not terminate")
|
||||
|
||||
assert len(positions) > 5, "a 2877-word paragraph spans several pages"
|
||||
|
||||
def test_no_words_lost_or_repeated(self, huge_paragraph):
|
||||
"""Word coverage across pages is exactly the paragraph, in order."""
|
||||
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
|
||||
pos = RenderingPosition()
|
||||
boundaries = [0]
|
||||
|
||||
for _ in range(100):
|
||||
_, next_pos = layouter.render_page_forward(pos, 1.0)
|
||||
if next_pos.block_index >= 1:
|
||||
break
|
||||
boundaries.append(next_pos.word_index)
|
||||
pos = next_pos
|
||||
|
||||
assert boundaries == sorted(boundaries), "word indices must not go backward"
|
||||
assert len(boundaries) == len(set(boundaries)), "a page must not be re-rendered"
|
||||
|
||||
|
||||
class TestNonSpanningBlocksUnaffected:
|
||||
"""The fix must not change behaviour for blocks that fit."""
|
||||
|
||||
def test_small_paragraphs_still_advance_by_block(self, font):
|
||||
blocks = [make_paragraph(20, font) for _ in range(3)]
|
||||
layouter = BidirectionalLayouter(blocks, PageStyle(), PAGE_SIZE)
|
||||
|
||||
_, next_pos = layouter.render_page_forward(RenderingPosition(), 1.0)
|
||||
|
||||
assert next_pos.block_index == 3, "all three short paragraphs fit on one page"
|
||||
assert next_pos.word_index == 0
|
||||
|
||||
def test_empty_document_terminates(self):
|
||||
layouter = BidirectionalLayouter([], PageStyle(), PAGE_SIZE)
|
||||
start = RenderingPosition()
|
||||
|
||||
page, next_pos = layouter.render_page_forward(start, 1.0)
|
||||
|
||||
assert next_pos.block_index == start.block_index
|
||||
assert len(page.children) == 0
|
||||
@@ -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'])
|
||||
Reference in New Issue
Block a user