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