render_page_backward searched for the previous page's start: estimate a block index, lay out forward, bisect on the block difference, up to ten times. Both the estimator and the adjuster pinned word_index to 0 and moved only block_index - but pages routinely start mid-block, so the answer was not in the search space. The loop could never match, exhausted its iterations and fell through to a fallback that jumped to the start of the document. Measured on a document with one 1200-word paragraph, whose page starts are (0,0), (2,208), (2,494), (2,780): three of four backward calls returned (0,0), each after ten full page layouts. The bisection was unsound in its own space too - 40 small paragraphs, every page starting on a block boundary, also failed. Pagination is a pure function, so the page before P is the q with next(q) == P, and it is found by replaying the chain forward rather than guessing q. Three sources: the chain recorded as pages are laid out forward (exact, one layout, covers paging back and forth); replay from the block containing P and then from earlier blocks (exact when P lies on that chain); and failing that, the last start before P, which overlaps slightly rather than skipping content. warm: 4/4 exact, 1 layout each cold, fresh layouter per call: 12/13 exact, worst 17 layouts cold, repeated back presses: ~4 layouts per turn Each step returns a page ending exactly where the reader is, so paging back never skips or repeats content. That chain can differ from the one seen reading forward from page one if the reader arrived by a jump - pagination from a different start is a different chain, and nothing can recover the original without replaying the whole document. Complementary to S11 rather than caused by it: before S11 forward pagination dead-ended at the first page-spanning block, so mid-block starts never arose and the block-granular search looked adequate.
172 lines
6.3 KiB
Python
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"
|