Update coverage badges [skip ci]
This commit is contained in:
@@ -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"
|
||||
Reference in New Issue
Block a user