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