Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2026-08-08 20:35:15 +00:00
commit 735face593
312 changed files with 91102 additions and 0 deletions
+9
View File
@@ -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
"""
+171
View File
@@ -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"
+168
View File
@@ -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()
+129
View File
@@ -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)
+873
View File
@@ -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"])
+784
View File
@@ -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"])
+262
View File
@@ -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"}
+148
View File
@@ -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()
+359
View File
@@ -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"])
+237
View File
@@ -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
+123
View File
@@ -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
+397
View File
@@ -0,0 +1,397 @@
"""
Unit tests for table column width optimization.
"""
import pytest
from pyWebLayout.layout.table_optimizer import (
optimize_table_layout,
sample_table_rows,
extract_html_column_widths,
parse_html_width,
distribute_column_widths,
get_column_count,
calculate_table_overhead
)
from pyWebLayout.abstract.block import Table
from pyWebLayout.concrete.table import TableStyle
class TestParseHtmlWidth:
"""Test HTML width parsing."""
def test_parse_int(self):
assert parse_html_width(100) == 100
def test_parse_px_string(self):
assert parse_html_width("150px") == 150
def test_parse_plain_number_string(self):
assert parse_html_width("200") == 200
def test_parse_percentage_returns_none(self):
assert parse_html_width("50%") is None
def test_parse_invalid_string(self):
assert parse_html_width("invalid") is None
def test_parse_with_whitespace(self):
assert parse_html_width(" 120px ") == 120
class TestDistributeColumnWidths:
"""Test column width distribution."""
def test_distribute_with_no_fixed_columns(self):
min_widths = [50, 60, 70]
pref_widths = [100, 120, 140]
available = 360
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Should use preferred widths (they fit)
assert result == [100, 120, 140]
def test_distribute_when_preferred_fits(self):
min_widths = [50, 50]
pref_widths = [100, 100]
available = 250
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Preferred widths fit, extra 50px distributed proportionally (25px each)
assert result == [125, 125]
def test_distribute_when_must_use_minimum(self):
min_widths = [100, 100]
pref_widths = [200, 200]
available = 150
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Can't even fit minimum, but force it anyway
assert result == [100, 100]
def test_distribute_proportional(self):
min_widths = [50, 50]
pref_widths = [200, 100]
available = 200 # Between min and pref totals
fixed = {}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Should distribute proportionally
assert len(result) == 2
assert result[0] + result[1] == 200
# First column should get more (higher pref)
assert result[0] > result[1]
def test_distribute_with_fixed_columns(self):
min_widths = [50, 50, 50]
pref_widths = [100, 100, 100]
available = 300
fixed = {1: 80} # Second column fixed at 80
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
# Second column should be 80
assert result[1] == 80
# Other columns share remaining space
assert result[0] + result[2] == 220
def test_distribute_all_fixed(self):
min_widths = [50, 50]
pref_widths = [100, 100]
available = 300
fixed = {0: 120, 1: 150}
result = distribute_column_widths(min_widths, pref_widths, available, fixed)
assert result == [120, 150]
def test_distribute_empty(self):
result = distribute_column_widths([], [], 100, {})
assert result == []
class TestGetColumnCount:
"""Test column counting."""
def test_empty_table(self):
table = Table()
assert get_column_count(table) == 0
def test_table_with_header(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["A", "B", "C"]:
cell = TableCell(is_header=True)
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="header")
assert get_column_count(table) == 3
def test_table_with_body(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph, Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["1", "2"]:
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
assert get_column_count(table) == 2
class TestSampleTableRows:
"""Test row sampling."""
def test_sample_small_table(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
for text in ["1", "2"]:
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
sampled = sample_table_rows(table, sample_size=5)
# Should get all rows (only 2)
assert len(sampled) == 2
def test_sample_large_table(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
for i in range(20):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(str(i), font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
sampled = sample_table_rows(table, sample_size=5)
# Should get only 5 body rows
assert len(sampled) == 5
def test_sample_with_header_body_footer(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
# 3 header rows
for i in range(3):
row = TableRow()
cell = TableCell(is_header=True)
para = Paragraph(font)
para.add_word(Word(f"H{i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="header")
# 10 body rows
for i in range(10):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(f"B{i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
# 2 footer rows
for i in range(2):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(f"F{i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="footer")
sampled = sample_table_rows(table, sample_size=2)
# Should get 2 from each section = 6 total
assert len(sampled) == 6
class TestExtractHtmlColumnWidths:
"""Test HTML width extraction."""
def test_no_widths(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["A", "B"]:
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(text, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
widths = extract_html_column_widths(table)
assert widths == [None, None]
def test_cell_width_attributes(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
cell1 = TableCell()
cell1.width = "100px"
para1 = Paragraph(font)
para1.add_word(Word("A", font))
cell1.add_block(para1)
row.add_cell(cell1)
cell2 = TableCell()
cell2.width = "150"
para2 = Paragraph(font)
para2.add_word(Word("B", font))
cell2.add_block(para2)
row.add_cell(cell2)
table.add_row(row, section="body")
widths = extract_html_column_widths(table)
assert widths == [100, 150]
class TestCalculateTableOverhead:
"""Test table overhead calculation."""
def test_basic_overhead(self):
style = TableStyle(border_width=1, cell_spacing=0)
overhead = calculate_table_overhead(3, style)
# 3 columns = 4 borders (n+1)
assert overhead == 4
def test_with_cell_spacing(self):
style = TableStyle(border_width=1, cell_spacing=5)
overhead = calculate_table_overhead(3, style)
# 4 borders + 2 spacings (n-1)
assert overhead == 4 + 10
def test_thicker_borders(self):
style = TableStyle(border_width=3, cell_spacing=0)
overhead = calculate_table_overhead(2, style)
# 2 columns = 3 borders * 3px
assert overhead == 9
class TestOptimizeTableLayout:
"""Test full table optimization."""
def test_optimize_simple_table(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
row = TableRow()
for text in ["Short", "A bit longer text"]:
cell = TableCell()
para = Paragraph(font)
for word in text.split():
para.add_word(Word(word, font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
style = TableStyle()
widths = optimize_table_layout(table, available_width=400, style=style)
# Should return 2 column widths
assert len(widths) == 2
# Second column should be wider
assert widths[1] > widths[0]
def test_optimize_empty_table(self):
table = Table()
widths = optimize_table_layout(table, available_width=400)
assert widths == []
def test_optimize_respects_sample_size(self):
from pyWebLayout.abstract.block import TableRow, TableCell, Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
table = Table()
font = Font(font_size=12)
# Create 20 rows but only first 5 should be sampled
for i in range(20):
row = TableRow()
cell = TableCell()
para = Paragraph(font)
para.add_word(Word(f"Data {i}", font))
cell.add_block(para)
row.add_cell(cell)
table.add_row(row, section="body")
widths = optimize_table_layout(table, available_width=400, sample_size=5)
# Should return width for 1 column
assert len(widths) == 1
if __name__ == '__main__':
pytest.main([__file__, '-v'])