Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2026-08-08 20:35:15 +00:00
commit 735face593
312 changed files with 91102 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
"""
Tests for the page caching layer.
Covers PageBuffer's LRU behaviour and BufferedPageRenderer's cache hits, plus
regressions for S12/R1/R2: the module must not start worker processes and must
not do blocking work in a finaliser.
"""
import subprocess
import sys
import textwrap
import pytest
from pyWebLayout.layout.page_buffer import PageBuffer, BufferedPageRenderer
from pyWebLayout.layout.ereader_layout import RenderingPosition
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def sample_blocks():
"""A document long enough to paginate over several pages."""
font = Font()
blocks = []
for p in range(6):
para = Paragraph(style=font)
for w in range(120):
para.add_word(Word(f"p{p}w{w}", font))
blocks.append(para)
return blocks
@pytest.fixture
def renderer(sample_blocks):
return BufferedPageRenderer(sample_blocks, PageStyle(), buffer_size=3, page_size=(800, 600))
# ============================================================================
# PageBuffer
# ============================================================================
class TestPageBuffer:
def test_get_page_misses_when_empty(self):
buf = PageBuffer(buffer_size=3)
assert buf.get_page(RenderingPosition()) is None
def test_cache_page_round_trips(self):
buf = PageBuffer(buffer_size=3)
pos, nxt = RenderingPosition(block_index=0), RenderingPosition(block_index=1)
sentinel = object()
buf.cache_page(pos, sentinel, nxt)
assert buf.get_page(pos) is sentinel
assert buf.position_map[pos] == nxt
def test_lru_evicts_oldest_and_cleans_position_map(self):
buf = PageBuffer(buffer_size=2)
positions = [RenderingPosition(block_index=i) for i in range(4)]
for i, pos in enumerate(positions):
buf.cache_page(pos, object(), RenderingPosition(block_index=i + 1))
assert buf.get_page(positions[0]) is None, "oldest should have been evicted"
assert positions[0] not in buf.position_map, "position map must not leak evicted entries"
assert buf.get_page(positions[-1]) is not None
def test_get_page_refreshes_lru_order(self):
buf = PageBuffer(buffer_size=2)
a, b, c = (RenderingPosition(block_index=i) for i in range(3))
buf.cache_page(a, object())
buf.cache_page(b, object())
buf.get_page(a) # a becomes most recently used
buf.cache_page(c, object())
assert buf.get_page(a) is not None, "recently used entry should survive"
assert buf.get_page(b) is None, "least recently used entry should be evicted"
def test_backward_pages_land_in_the_backward_buffer(self):
buf = PageBuffer(buffer_size=3)
start, end = RenderingPosition(block_index=1), RenderingPosition(block_index=2)
buf.cache_page(start, object(), end, is_backward=True)
assert start in buf.backward_buffer
assert start not in buf.forward_buffer
assert buf.reverse_position_map[end] == start
def test_font_scale_change_invalidates(self):
buf = PageBuffer(buffer_size=3)
pos = RenderingPosition()
buf.cache_page(pos, object(), RenderingPosition(block_index=1))
buf.set_font_scale(1.5)
assert buf.get_page(pos) is None
def test_same_font_scale_keeps_cache(self):
buf = PageBuffer(buffer_size=3)
pos = RenderingPosition()
buf.cache_page(pos, object(), RenderingPosition(block_index=1))
buf.set_font_scale(1.0)
assert buf.get_page(pos) is not None
def test_shutdown_is_idempotent(self):
buf = PageBuffer(buffer_size=3)
buf.cache_page(RenderingPosition(), object())
buf.shutdown()
buf.shutdown()
assert buf.get_cache_stats()['forward_buffer_size'] == 0
# ============================================================================
# BufferedPageRenderer
# ============================================================================
class TestBufferedPageRenderer:
def test_render_page_returns_a_page_and_advances(self, renderer):
page, next_pos = renderer.render_page(RenderingPosition(), 1.0)
assert page is not None
assert next_pos != RenderingPosition()
def test_second_render_of_same_position_is_served_from_cache(self, renderer):
pos = RenderingPosition()
first, first_next = renderer.render_page(pos, 1.0)
second, second_next = renderer.render_page(pos, 1.0)
assert second is first, "identical page object means it came from the cache"
assert second_next == first_next
def test_font_scale_change_forces_a_re_render(self, renderer):
pos = RenderingPosition()
first, _ = renderer.render_page(pos, 1.0)
scaled, _ = renderer.render_page(pos, 1.5)
assert scaled is not first
def test_backward_render_round_trips_to_the_original_position(self, renderer):
start = RenderingPosition()
_, second_page_pos = renderer.render_page(start, 1.0)
_, back_to = renderer.render_page_backward(second_page_pos, 1.0)
assert back_to == start
def test_shutdown_clears_the_cache(self, renderer):
renderer.render_page(RenderingPosition(), 1.0)
renderer.shutdown()
assert renderer.get_cache_stats()['forward_buffer_size'] == 0
# ============================================================================
# S12 / R1 / R2 regressions
# ============================================================================
class TestNoBackgroundProcesses:
"""
The process pool that used to live here never produced a usable page (a Page
holds a live PIL canvas and cannot be pickled), and on Python 3.14's
forkserver default it raised when driven from module-level code.
"""
def test_module_declares_no_process_pool(self):
import pyWebLayout.layout.page_buffer as page_buffer
source = page_buffer.__file__
assert not hasattr(page_buffer, '_render_page_worker')
assert not hasattr(PageBuffer(), 'executor')
with open(source, encoding='utf-8') as fh:
body = fh.read().split('"""', 2)[-1] # skip the module docstring
assert 'ProcessPoolExecutor' not in body
assert 'pickle' not in body
def test_page_buffer_has_no_finaliser(self):
"""
PageBuffer.__del__ called executor.shutdown(wait=True), which deadlocked
the interpreter at exit. Cleanup must be explicit.
"""
assert '__del__' not in vars(PageBuffer)
def test_navigation_works_without_a_main_guard(self, tmp_path):
"""
R1: EreaderLayoutManager raised RuntimeError when used from module-level
script code, because submitting to a ProcessPoolExecutor under a
non-fork start method requires an `if __name__ == "__main__"` guard.
"""
script = textwrap.dedent(f"""
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
blocks = parse_html_string("<p>" + " ".join(f"w{{i}}" for i in range(2000)) + "</p>")
m = EreaderLayoutManager(blocks, page_size=(800, 600),
bookmarks_dir={str(tmp_path)!r})
m.get_current_page()
m.next_page()
m.previous_page()
m.shutdown()
print("OK")
""")
result = subprocess.run([sys.executable, "-c", script],
capture_output=True, text=True, timeout=120)
assert result.returncode == 0, result.stderr
assert "OK" in result.stdout
def test_interpreter_exits_without_explicit_shutdown(self, tmp_path):
"""
R2: a manager left to be finalised at exit must not hang. The timeout is
the assertion.
"""
script = textwrap.dedent(f"""
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
blocks = parse_html_string("<p>" + " ".join(f"w{{i}}" for i in range(500)) + "</p>")
m = EreaderLayoutManager(blocks, page_size=(800, 600),
bookmarks_dir={str(tmp_path)!r})
m.get_current_page()
# deliberately no shutdown() - rely on interpreter teardown
""")
result = subprocess.run([sys.executable, "-c", script],
capture_output=True, text=True, timeout=60)
assert result.returncode == 0, result.stderr