improved library screen, fixed issues with image rendering and navigation
Python CI / test (3.12) (push) Successful in 6m50s
Python CI / test (3.13) (push) Has been cancelled

This commit is contained in:
2025-11-10 14:13:59 +01:00
parent 8f6205dcfe
commit dd392d2e15
11 changed files with 990 additions and 642 deletions
+85
View File
@@ -0,0 +1,85 @@
"""
Minimal reproduction test for backward navigation bug.
BUG: Backward navigation cannot reach block_index=0 from block_index=1.
This is a pyWebLayout issue, not a dreader-application issue.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from dreader.application import EbookReader
class TestBackwardNavigationBug(unittest.TestCase):
"""Minimal reproduction of backward navigation bug"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_minimal_backward_navigation_bug(self):
"""
MINIMAL REPRODUCTION:
1. Start at block_index=0
2. Go forward once (to block_index=1)
3. Go backward once
4. BUG: Lands at block_index=1 instead of block_index=0
This proves backward navigation cannot reach the first block.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
# Starting position
pos_start = reader.manager.current_position.copy()
print(f"\n1. Starting at block_index={pos_start.block_index}")
self.assertEqual(pos_start.block_index, 0, "Should start at block 0")
# Go forward
reader.next_page()
pos_forward = reader.manager.current_position.copy()
print(f"2. After next_page(): block_index={pos_forward.block_index}")
self.assertEqual(pos_forward.block_index, 1, "Should be at block 1")
# Go backward
reader.previous_page()
pos_final = reader.manager.current_position.copy()
print(f"3. After previous_page(): block_index={pos_final.block_index}")
# THE BUG: This assertion will fail
print(f"\nEXPECTED: block_index=0")
print(f"ACTUAL: block_index={pos_final.block_index}")
if pos_final.block_index != 0:
print("\n❌ BUG CONFIRMED: Cannot navigate backward to block_index=0")
print(" This is a pyWebLayout bug in the previous_page() method.")
self.assertEqual(
pos_final.block_index,
0,
"BUG: Backward navigation from block 1 should return to block 0"
)
reader.close()
if __name__ == '__main__':
unittest.main()
+239
View File
@@ -0,0 +1,239 @@
"""
Detailed test for backward navigation issues.
This test explores the backward navigation behavior more thoroughly
to understand if the issue is:
1. Complete failure (previous_page returns None)
2. Imprecise positioning (lands on wrong block)
3. Only occurs after resume
4. Occurs during continuous navigation
"""
import unittest
import tempfile
import shutil
from pathlib import Path
from dreader.application import EbookReader
class TestBackwardNavigationDetailed(unittest.TestCase):
"""Detailed backward navigation tests"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_continuous_backward_navigation_no_resume(self):
"""
Test backward navigation without closing/resuming.
This checks if the issue is specific to resume or general.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Continuous backward navigation (no resume) ===")
# Record starting position
pos0 = reader.manager.current_position.copy()
print(f"Starting position: {pos0}")
# Go forward 5 pages, recording positions
forward_positions = [pos0]
for i in range(5):
page = reader.next_page()
if page is None:
print(f"Reached end at page {i}")
break
pos = reader.manager.current_position.copy()
forward_positions.append(pos)
print(f"Forward page {i+1}: block_index={pos.block_index}")
num_forward = len(forward_positions) - 1
print(f"\nNavigated forward {num_forward} pages")
# Now go backward the same number of times
print("\n--- Going backward ---")
backward_positions = []
for i in range(num_forward):
page = reader.previous_page()
if page is None:
print(f"ERROR: previous_page() returned None at step {i+1}")
self.fail(f"Backward navigation failed at step {i+1}")
pos = reader.manager.current_position.copy()
backward_positions.append(pos)
print(f"Backward step {i+1}: block_index={pos.block_index}")
# Check final position
final_pos = reader.manager.current_position.copy()
print(f"\nFinal position: {final_pos}")
print(f"Expected (pos0): {pos0}")
if final_pos != pos0:
print(f"WARNING: Position mismatch!")
print(f" Expected block_index: {pos0.block_index}")
print(f" Actual block_index: {final_pos.block_index}")
print(f" Difference: {final_pos.block_index - pos0.block_index} blocks")
self.assertEqual(
final_pos,
pos0,
f"After {num_forward} forward and {num_forward} backward, should be at start"
)
reader.close()
def test_backward_navigation_at_start(self):
"""
Test that previous_page() behaves correctly when at the start of the book.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Backward navigation at start ===")
pos_start = reader.manager.current_position.copy()
print(f"At start: {pos_start}")
# Try to go back from the very first page
page = reader.previous_page()
print(f"previous_page() returned: {page is not None}")
pos_after = reader.manager.current_position.copy()
print(f"Position after previous_page(): {pos_after}")
# Should either return None or stay at same position
if page is not None:
self.assertEqual(
pos_after,
pos_start,
"If previous_page() returns a page at start, position should not change"
)
reader.close()
def test_alternating_navigation(self):
"""
Test alternating forward/backward navigation.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Alternating forward/backward navigation ===")
pos0 = reader.manager.current_position.copy()
print(f"Start: block_index={pos0.block_index}")
# Go forward, back, forward, back pattern
operations = [
("forward", 1),
("backward", 1),
("forward", 2),
("backward", 1),
("forward", 1),
("backward", 2),
]
for op, count in operations:
for i in range(count):
if op == "forward":
page = reader.next_page()
else:
page = reader.previous_page()
self.assertIsNotNone(
page,
f"{op} navigation failed at iteration {i+1}"
)
pos = reader.manager.current_position.copy()
print(f"After {count}x {op}: block_index={pos.block_index}")
# We should end up at the starting position (net: +5 -4 = +1, then +1 -2 = -1, total = 0)
# Actually: +1 -1 +2 -1 +1 -2 = 0
final_pos = reader.manager.current_position.copy()
print(f"\nFinal: block_index={final_pos.block_index}")
print(f"Expected: block_index={pos0.block_index}")
self.assertEqual(
final_pos,
pos0,
"Alternating navigation should return to start"
)
reader.close()
def test_backward_then_forward(self):
"""
Test that forward navigation works correctly after backward navigation.
"""
reader = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader.load_epub(self.epub_path)
print("\n=== Test: Backward then forward ===")
# Go forward 3 pages
positions = [reader.manager.current_position.copy()]
for i in range(3):
reader.next_page()
positions.append(reader.manager.current_position.copy())
print(f"Forward positions: {[p.block_index for p in positions]}")
# Go back 3 pages
for i in range(3):
reader.previous_page()
pos_after_back = reader.manager.current_position.copy()
print(f"After going back: block_index={pos_after_back.block_index}")
# Now go forward 3 pages again
for i in range(3):
reader.next_page()
final_pos = reader.manager.current_position.copy()
print(f"After going forward again: block_index={final_pos.block_index}")
print(f"Expected: block_index={positions[3].block_index}")
self.assertEqual(
final_pos,
positions[3],
"Forward after backward should reach same position"
)
reader.close()
if __name__ == '__main__':
unittest.main()
+230
View File
@@ -0,0 +1,230 @@
"""
Test backward navigation after resuming from a saved position.
This test specifically checks if backward navigation works correctly
after opening an epub, navigating forward, closing it, then resuming
and attempting to navigate backward.
This may reveal issues with pyWebLayout's backward navigation handling.
"""
import unittest
import tempfile
import shutil
from pathlib import Path
import numpy as np
from PIL import Image
from dreader.application import EbookReader
class TestBackwardNavigationAfterResume(unittest.TestCase):
"""Test backward navigation behavior after resume"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.epub_path = "tests/data/test.epub"
if not Path(self.epub_path).exists():
self.skipTest(f"Test EPUB not found at {self.epub_path}")
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
"""
Check if two PIL Images are pixel-perfect identical.
"""
if img1 is None or img2 is None:
return False
if img1.size != img2.size:
return False
arr1 = np.array(img1)
arr2 = np.array(img2)
return np.array_equal(arr1, arr2)
def test_backward_navigation_after_resume(self):
"""
Test that backward navigation works after closing and resuming.
Steps:
1. Open EPUB
2. Navigate forward 3 pages
3. Save positions and pages
4. Close reader
5. Open new reader (resume)
6. Try to navigate backward
7. Verify we can reach previous pages
"""
# Phase 1: Initial session - navigate forward
reader1 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0 # Disable buffering for consistent testing
)
success = reader1.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB")
# Capture initial page
page0 = reader1.get_current_page()
self.assertIsNotNone(page0, "Initial page should not be None")
pos0 = reader1.manager.current_position.copy()
print(f"\nInitial position: {pos0}")
# Navigate forward 3 pages, capturing each page
pages = [page0]
positions = [pos0]
for i in range(3):
page = reader1.next_page()
self.assertIsNotNone(page, f"Page {i+1} should not be None")
pages.append(page)
positions.append(reader1.manager.current_position.copy())
print(f"Forward page {i+1} position: {positions[-1]}")
# We should now be at page 3 (0-indexed)
self.assertEqual(len(pages), 4, "Should have 4 pages total (0-3)")
# Save the current position before closing
final_position = reader1.manager.current_position.copy()
print(f"Final position before close: {final_position}")
# Close reader (this should save the position)
reader1.close()
# Phase 2: Resume session - navigate backward
reader2 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
success = reader2.load_epub(self.epub_path)
self.assertTrue(success, "Failed to load test EPUB on resume")
# Verify we resumed at the correct position
resumed_position = reader2.manager.current_position.copy()
print(f"Resumed at position: {resumed_position}")
self.assertEqual(
resumed_position,
final_position,
"Should resume at the last saved position"
)
# Get the current page (should match page 3)
resumed_page = reader2.get_current_page()
self.assertIsNotNone(resumed_page, "Resumed page should not be None")
# Now try to navigate backward
print("\nAttempting backward navigation...")
backward_pages = []
backward_positions = []
# Try to go back 3 times
for i in range(3):
prev_page = reader2.previous_page()
print(f"Backward step {i+1}: page={'Not None' if prev_page else 'None'}")
if prev_page is None:
print(f"WARNING: previous_page() returned None at step {i+1}")
# This is the bug we're testing for!
self.fail(f"Backward navigation failed at step {i+1}: previous_page() returned None")
backward_pages.append(prev_page)
backward_positions.append(reader2.manager.current_position.copy())
print(f" Position after backward: {backward_positions[-1]}")
# We should have successfully gone back 3 pages
self.assertEqual(len(backward_pages), 3, "Should have navigated back 3 pages")
# Verify final position matches original position
final_backward_position = reader2.manager.current_position.copy()
print(f"\nFinal position after backward navigation: {final_backward_position}")
print(f"Original position (page 0): {pos0}")
self.assertEqual(
final_backward_position,
pos0,
"After going forward 3 and back 3, should be at initial position"
)
# Verify the page content matches
final_page = reader2.get_current_page()
self.assertTrue(
self.compare_images(page0, final_page),
"Final page should match initial page after forward/backward navigation"
)
reader2.close()
print("\n✓ Test passed: Backward navigation works correctly after resume")
def test_backward_navigation_single_step(self):
"""
Simplified test: Open, go forward 1 page, close, resume, go back 1 page.
This is a minimal reproduction case.
"""
# Session 1: Navigate forward one page
reader1 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader1.load_epub(self.epub_path)
page0 = reader1.get_current_page()
pos0 = reader1.manager.current_position.copy()
page1 = reader1.next_page()
self.assertIsNotNone(page1, "Should be able to navigate forward")
pos1 = reader1.manager.current_position.copy()
reader1.close()
# Session 2: Resume and navigate backward
reader2 = EbookReader(
page_size=(800, 1000),
bookmarks_dir=self.temp_dir,
buffer_size=0
)
reader2.load_epub(self.epub_path)
# Verify we're at page 1
self.assertEqual(
reader2.manager.current_position,
pos1,
"Should resume at page 1"
)
# Try to go back
prev_page = reader2.previous_page()
# This is the critical assertion - if this fails, backward nav is broken
self.assertIsNotNone(
prev_page,
"CRITICAL: previous_page() returned None after resume - this indicates a pyWebLayout bug"
)
# Verify we're back at page 0
final_pos = reader2.manager.current_position.copy()
self.assertEqual(
final_pos,
pos0,
"Should be back at initial position"
)
reader2.close()
if __name__ == '__main__':
unittest.main()
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Test that images render correctly in EPUB files.
This test verifies that:
1. All images in the EPUB are loaded with correct dimensions
2. Images can be navigated to without errors
3. Pages with images render successfully
4. The rendered pages contain actual image content (not blank)
"""
import pytest
from dreader.application import EbookReader
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image
import numpy as np
def test_epub_images():
"""Test that EPUB images render correctly."""
# Create reader
reader = EbookReader(page_size=(800, 1200))
# Load EPUB
epub_path = "tests/data/library-epub/pg11-images-3.epub"
success = reader.load_epub(epub_path)
assert success, "Failed to load EPUB"
assert reader.book_title == "Alice's Adventures in Wonderland"
# Check that images were parsed
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
assert len(images) >= 1, f"Expected at least 1 image, found {len(images)}"
# Check that all images have dimensions set
for img in images:
assert img.width is not None, f"Image {img.source} has no width"
assert img.height is not None, f"Image {img.source} has no height"
assert img.width > 0, f"Image {img.source} has invalid width: {img.width}"
assert img.height > 0, f"Image {img.source} has invalid height: {img.height}"
# Check that image is loaded into memory
assert hasattr(img, '_loaded_image'), f"Image {img.source} not loaded"
assert img._loaded_image is not None, f"Image {img.source} _loaded_image is None"
# Test navigation through first 15 pages (which should include all images)
for page_num in range(15):
page_img = reader.get_current_page()
assert page_img is not None, f"Page {page_num + 1} failed to render"
assert isinstance(page_img, Image.Image), f"Page {page_num + 1} is not a PIL Image"
assert page_img.size == (800, 1200), f"Page {page_num + 1} has wrong size: {page_img.size}"
# Check that page has some non-white content
arr = np.array(page_img.convert('RGB'))
non_white_pixels = np.sum(arr < 255)
assert non_white_pixels > 100, f"Page {page_num + 1} appears to be blank (only {non_white_pixels} non-white pixels)"
# Navigate to next page
if page_num < 14:
next_result = reader.next_page()
if next_result is None:
# It's OK to reach end of book early
break
def test_cover_image():
"""Specifically test that the cover image renders."""
reader = EbookReader(page_size=(800, 1200))
reader.load_epub("tests/data/library-epub/pg11-images-3.epub")
# The first page should have the cover image
page_img = reader.get_current_page()
assert page_img is not None, "Cover page failed to render"
# Save for visual inspection
output_path = "/tmp/epub_cover_test.png"
page_img.save(output_path)
# Check that it has significant content (the cover image)
arr = np.array(page_img.convert('RGB'))
non_white_pixels = np.sum(arr < 255)
# The cover page should have substantial content
assert non_white_pixels > 10000, f"Cover page has too few non-white pixels: {non_white_pixels}"
def test_multiple_epub_images():
"""Test images across multiple EPUB files."""
epub_files = [
("tests/data/library-epub/pg11-images-3.epub", "Alice's Adventures in Wonderland"),
("tests/data/library-epub/pg16328-images-3.epub", "Beowulf: An Anglo-Saxon Epic Poem"),
("tests/data/library-epub/pg5200-images-3.epub", "Metamorphosis"),
]
for epub_path, expected_title in epub_files:
reader = EbookReader(page_size=(800, 1200))
success = reader.load_epub(epub_path)
assert success, f"Failed to load {epub_path}"
assert reader.book_title == expected_title
# Check that at least one image exists
images = [b for b in reader.blocks if isinstance(b, AbstractImage)]
assert len(images) >= 1, f"{epub_path} should have at least 1 image"
# Check first image is valid
img = images[0]
assert img.width > 0 and img.height > 0, f"Invalid dimensions in {epub_path}"
if __name__ == "__main__":
# Run tests directly
print("Testing EPUB images...")
print("\n1. Testing all images load and render...")
test_epub_images()
print("✓ PASSED")
print("\n2. Testing cover image...")
test_cover_image()
print("✓ PASSED")
print("\n3. Testing multiple EPUB images...")
test_multiple_epub_images()
print("✓ PASSED")
print("\n✓ All tests passed!")
+89 -8
View File
@@ -7,6 +7,7 @@ and verify that tap detection works correctly.
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from dreader import LibraryManager
@@ -46,9 +47,12 @@ class TestLibraryInteraction(unittest.TestCase):
# Table should exist
self.assertIsNotNone(table)
# Table should have body rows matching book count
# Table should have body rows for 2-column grid layout
# Each pair of books gets 2 rows (cover row + detail row)
# So N books = ceil(N/2) * 2 rows
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), len(books))
expected_rows = ((len(books) + 1) // 2) * 2 # Round up to nearest even number, then double
self.assertEqual(len(body_rows), expected_rows)
def test_library_rendering(self):
"""Test that library can be rendered to image"""
@@ -136,7 +140,7 @@ class TestLibraryInteraction(unittest.TestCase):
self.assertIsNone(selected_path, "Tap below last book should not select anything")
def test_multiple_taps(self):
"""Test that multiple taps work correctly"""
"""Test that multiple taps work correctly with 2-column grid layout"""
books = self.library.scan_library()
if len(books) < 3:
@@ -145,16 +149,20 @@ class TestLibraryInteraction(unittest.TestCase):
self.library.create_library_table()
self.library.render_library()
# Tap first book (row 0: y=60-180)
# In 2-column layout:
# Books 0 and 1 are in the first pair (rows 0-1: cover and detail)
# Books 2 and 3 are in the second pair (rows 2-3: cover and detail)
# Tap first book (left column, first pair cover row)
path1 = self.library.handle_library_tap(x=100, y=100)
self.assertEqual(path1, books[0]['path'])
# Tap second book (row 1: y=181-301)
path2 = self.library.handle_library_tap(x=400, y=250)
# Tap second book (right column, first pair cover row)
path2 = self.library.handle_library_tap(x=500, y=100)
self.assertEqual(path2, books[1]['path'])
# Tap third book (row 2: y=302-422)
path3 = self.library.handle_library_tap(x=400, y=360)
# Tap third book (left column, second pair cover row)
path3 = self.library.handle_library_tap(x=100, y=360)
self.assertEqual(path3, books[2]['path'])
# All should be different
@@ -162,6 +170,79 @@ class TestLibraryInteraction(unittest.TestCase):
self.assertNotEqual(path2, path3)
self.assertNotEqual(path1, path3)
def test_pagination(self):
"""Test library pagination with fake book data"""
# Create fake books (20 books to ensure multiple pages)
fake_books = []
for i in range(20):
fake_books.append({
'path': f'/fake/path/book_{i}.epub',
'title': f'Book Title {i}',
'author': f'Author {i}',
'filename': f'book_{i}.epub',
'cover_data': None,
'cover_path': None
})
# Create library with 6 books per page
library = LibraryManager(
library_path=str(self.library_path),
page_size=(800, 1200),
books_per_page=6
)
library.books = fake_books
# Test initial state
self.assertEqual(library.current_page, 0)
self.assertEqual(library.get_total_pages(), 4) # 20 books / 6 per page = 4 pages
# Test creating table for first page
table = library.create_library_table()
self.assertIsNotNone(table)
# 6 books = 3 pairs = 6 rows (3 cover rows + 3 detail rows)
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), 6)
# Test navigation to next page
self.assertTrue(library.next_page())
self.assertEqual(library.current_page, 1)
# Create table for second page
table = library.create_library_table()
body_rows = list(table.body_rows())
self.assertEqual(len(body_rows), 6) # Still 6 books on page 2
# Test navigation to last page
library.set_page(3)
self.assertEqual(library.current_page, 3)
table = library.create_library_table()
body_rows = list(table.body_rows())
# Page 4 has 2 books (20 - 18 = 2) = 1 pair = 2 rows
self.assertEqual(len(body_rows), 2)
# Test can't go beyond last page
self.assertFalse(library.next_page())
self.assertEqual(library.current_page, 3)
# Test navigation to previous page
self.assertTrue(library.previous_page())
self.assertEqual(library.current_page, 2)
# Test navigation to first page
library.set_page(0)
self.assertEqual(library.current_page, 0)
# Test can't go before first page
self.assertFalse(library.previous_page())
self.assertEqual(library.current_page, 0)
# Test invalid page number
self.assertFalse(library.set_page(-1))
self.assertFalse(library.set_page(100))
self.assertEqual(library.current_page, 0) # Should stay on current page
library.cleanup()
if __name__ == '__main__':
unittest.main()