Files
pyWebLayout/tests/layout/test_backward_navigation.py
T

172 lines
6.3 KiB
Python

"""
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"