From dd392d2e15cb2829be3716a52279c86039ac6041 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Mon, 10 Nov 2025 14:13:59 +0100 Subject: [PATCH] improved library screen, fixed issues with image rendering and navigation --- dreader/library.py | 285 ++++++++++++++++----- dreader/managers/document.py | 2 +- test_debug_overlay.py | 106 -------- test_main_integration.py | 211 --------------- test_page_navigation.py | 121 --------- test_swipe_detection.py | 124 --------- tests/test_backward_nav_minimal.py | 85 ++++++ tests/test_backward_navigation_detailed.py | 239 +++++++++++++++++ tests/test_backward_navigation_resume.py | 230 +++++++++++++++++ tests/test_epub_images.py | 132 ++++++++++ tests/test_library_interaction.py | 97 ++++++- 11 files changed, 990 insertions(+), 642 deletions(-) delete mode 100644 test_debug_overlay.py delete mode 100644 test_main_integration.py delete mode 100644 test_page_navigation.py delete mode 100644 test_swipe_detection.py create mode 100644 tests/test_backward_nav_minimal.py create mode 100644 tests/test_backward_navigation_detailed.py create mode 100644 tests/test_backward_navigation_resume.py create mode 100644 tests/test_epub_images.py diff --git a/dreader/library.py b/dreader/library.py index 7e82990..5a7c2e1 100644 --- a/dreader/library.py +++ b/dreader/library.py @@ -45,7 +45,8 @@ class LibraryManager: self, library_path: str, cache_dir: Optional[str] = None, - page_size: Tuple[int, int] = (800, 1200) + page_size: Tuple[int, int] = (800, 1200), + books_per_page: int = 10 ): """ Initialize library manager. @@ -54,9 +55,11 @@ class LibraryManager: library_path: Path to directory containing EPUB files cache_dir: Optional cache directory for covers. If None, uses default. page_size: Page size for library view rendering + books_per_page: Number of books to display per page (must be even for 2-column layout) """ self.library_path = Path(library_path) self.page_size = page_size + self.books_per_page = books_per_page if books_per_page % 2 == 0 else books_per_page + 1 # Set cache directory if cache_dir: @@ -75,6 +78,7 @@ class LibraryManager: self.temp_cover_files: List[str] = [] # Track temp files for cleanup self.row_bounds: List[Tuple[int, int, int, int]] = [] # Bounding boxes for rows (x, y, w, h) self.table_renderer: Optional[TableRenderer] = None # Store renderer for bounds info + self.current_page: int = 0 # Current page index for pagination @staticmethod def _get_default_cache_dir() -> Path: @@ -149,12 +153,13 @@ class LibraryManager: print(f"Error caching cover for {book['title']}: {e}") return None - def create_library_table(self, books: Optional[List[Dict]] = None) -> Table: + def create_library_table(self, books: Optional[List[Dict]] = None, page: Optional[int] = None) -> Table: """ - Create interactive library table with book covers and info. + Create interactive library table with book covers and info in 2-column grid. Args: books: List of books to display. If None, uses self.books + page: Page number to display (0-indexed). If None, uses self.current_page Returns: Table object ready for rendering @@ -162,83 +167,133 @@ class LibraryManager: if books is None: books = self.books + if page is None: + page = self.current_page + if not books: print("No books to display in library") books = [] - print(f"Creating library table with {len(books)} books...") + # Calculate pagination + total_pages = (len(books) + self.books_per_page - 1) // self.books_per_page + start_idx = page * self.books_per_page + end_idx = min(start_idx + self.books_per_page, len(books)) + page_books = books[start_idx:end_idx] - # Create table - table = Table(caption="My Library", style=Font(font_size=18, weight="bold")) + print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...") - # Add books as rows - for i, book in enumerate(books): - row = table.create_row("body") + # Create table with caption showing page info + caption_text = f"My Library (Page {page + 1}/{total_pages})" if total_pages > 1 else "My Library" + table = Table(caption=caption_text, style=Font(font_size=18, weight="bold")) - # Cover cell with interactive image - cover_cell = row.create_cell() - cover_path = book.get('cover_path') - book_path = book['path'] + # Add books in 2-column grid (each pair of books gets 2 rows: covers then details) + for i in range(0, len(page_books), 2): + # Row 1: Covers for this pair + cover_row = table.create_row("body") - # Create callback that returns book path - callback = lambda point, path=book_path: path + # Add first book's cover (left column) + self._add_book_cover(cover_row, page_books[i]) - if cover_path and Path(cover_path).exists(): - # Use cached cover with callback - img = InteractiveImage.create_and_add_to( - cover_cell, - source=cover_path, - alt_text=book['title'], - callback=callback - ) - elif book.get('cover_data'): - # Decode base64 and save to temp file for InteractiveImage - try: - img_data = base64.b64decode(book['cover_data']) - img = Image.open(BytesIO(img_data)) - - # Save to temp file - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: - img.save(tmp.name, 'PNG') - temp_path = tmp.name - self.temp_cover_files.append(temp_path) - - img = InteractiveImage.create_and_add_to( - cover_cell, - source=temp_path, - alt_text=book['title'], - callback=callback - ) - except Exception as e: - print(f"Error creating cover image for {book['title']}: {e}") - self._add_no_cover_text(cover_cell) + # Add second book's cover (right column) if it exists + if i + 1 < len(page_books): + self._add_book_cover(cover_row, page_books[i + 1]) else: - # No cover available - self._add_no_cover_text(cover_cell) + # Add empty cell if odd number of books + cover_row.create_cell() - # Book info cell - info_cell = row.create_cell() + # Row 2: Details for this pair + details_row = table.create_row("body") - # Title paragraph - title_para = info_cell.create_paragraph() - for word in book['title'].split(): - title_para.add_word(Word(word, Font(font_size=14, weight="bold"))) + # Add first book's details (left column) + self._add_book_details(details_row, page_books[i]) - # Author paragraph - author_para = info_cell.create_paragraph() - for word in book.get('author', 'Unknown').split(): - author_para.add_word(Word(word, Font(font_size=12))) - - # Filename paragraph (small, gray) - filename_para = info_cell.create_paragraph() - filename_para.add_word(Word( - Path(book['path']).name, - Font(font_size=10, colour=(150, 150, 150)) - )) + # Add second book's details (right column) if it exists + if i + 1 < len(page_books): + self._add_book_details(details_row, page_books[i + 1]) + else: + # Add empty cell if odd number of books + details_row.create_cell() self.library_table = table return table + def _add_book_cover(self, row, book: Dict): + """ + Add a book cover to a table row. + + Args: + row: Table row to add cover to + book: Book dictionary with metadata + """ + cover_cell = row.create_cell() + + cover_path = book.get('cover_path') + book_path = book['path'] + + # Create callback that returns book path + callback = lambda point, path=book_path: path + + # Add cover image + if cover_path and Path(cover_path).exists(): + # Use cached cover with callback + img = InteractiveImage.create_and_add_to( + cover_cell, + source=cover_path, + alt_text=book['title'], + callback=callback + ) + elif book.get('cover_data'): + # Decode base64 and save to temp file for InteractiveImage + try: + img_data = base64.b64decode(book['cover_data']) + img = Image.open(BytesIO(img_data)) + + # Save to temp file + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + img.save(tmp.name, 'PNG') + temp_path = tmp.name + self.temp_cover_files.append(temp_path) + + img = InteractiveImage.create_and_add_to( + cover_cell, + source=temp_path, + alt_text=book['title'], + callback=callback + ) + except Exception as e: + print(f"Error creating cover image for {book['title']}: {e}") + self._add_no_cover_text(cover_cell) + else: + # No cover available + self._add_no_cover_text(cover_cell) + + def _add_book_details(self, row, book: Dict): + """ + Add book details (title, author, filename) to a table row. + + Args: + row: Table row to add details to + book: Book dictionary with metadata + """ + details_cell = row.create_cell() + + # Title paragraph + title_para = details_cell.create_paragraph() + for word in book['title'].split(): + title_para.add_word(Word(word, Font(font_size=14, weight="bold"))) + + # Author paragraph + author_para = details_cell.create_paragraph() + for word in book.get('author', 'Unknown').split(): + author_para.add_word(Word(word, Font(font_size=12))) + + # Filename paragraph (small, gray) + filename_para = details_cell.create_paragraph() + filename_para.add_word(Word( + Path(book['path']).name, + Font(font_size=10, colour=(150, 150, 150)) + )) + def _add_no_cover_text(self, cell): """Add placeholder text when no cover is available""" para = cell.create_paragraph() @@ -306,10 +361,11 @@ class LibraryManager: def handle_library_tap(self, x: int, y: int) -> Optional[str]: """ - Handle tap event on library view. + Handle tap event on library view with 2-column grid. - Checks if the tap is within any row's bounds and returns the corresponding - book path. This makes the entire row interactive, not just the cover image. + The layout has alternating rows: cover rows and detail rows. + Each pair of rows (cover + detail) represents one pair of books (2 books). + Tapping on either the cover row or detail row selects the corresponding book. Args: x: X coordinate of tap @@ -323,6 +379,11 @@ class LibraryManager: return None try: + # Get paginated books for current page + start_idx = self.current_page * self.books_per_page + end_idx = min(start_idx + self.books_per_page, len(self.books)) + page_books = self.books[start_idx:end_idx] + # Build a mapping of row sections in order all_rows = list(self.library_table.all_rows()) @@ -345,11 +406,43 @@ class LibraryManager: # Find which body row this is (0-indexed) body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body") - # Return the corresponding book - if body_row_index < len(self.books): - book_path = self.books[body_row_index]['path'] - print(f"Book selected (row {body_row_index}): {book_path}") - return book_path + # Each pair of books uses 2 rows (cover row + detail row) + # Determine which book pair this row belongs to + book_pair_index = body_row_index // 2 # Which pair of books (0, 1, 2, ...) + is_cover_row = body_row_index % 2 == 0 # Even rows are covers, odd are details + + # Check cell renderers in this row + if hasattr(row_renderer, '_cell_renderers') and len(row_renderer._cell_renderers) >= 1: + # Check left cell (first book in pair) + left_cell = row_renderer._cell_renderers[0] + left_x, left_y = left_cell._origin + left_w, left_h = left_cell._size + + if (left_x <= x <= left_x + left_w and + left_y <= y <= left_y + left_h): + # Left column (first book in pair) + book_index = book_pair_index * 2 + if book_index < len(page_books): + book_path = page_books[book_index]['path'] + row_type = "cover" if is_cover_row else "detail" + print(f"Book selected (pair {book_pair_index}, left {row_type}): {book_path}") + return book_path + + # Check right cell (second book in pair) if it exists + if len(row_renderer._cell_renderers) >= 2: + right_cell = row_renderer._cell_renderers[1] + right_x, right_y = right_cell._origin + right_w, right_h = right_cell._size + + if (right_x <= x <= right_x + right_w and + right_y <= y <= right_y + right_h): + # Right column (second book in pair) + book_index = book_pair_index * 2 + 1 + if book_index < len(page_books): + book_path = page_books[book_index]['path'] + row_type = "cover" if is_cover_row else "detail" + print(f"Book selected (pair {book_pair_index}, right {row_type}): {book_path}") + return book_path print(f"No book tapped at ({x}, {y})") return None @@ -374,6 +467,56 @@ class LibraryManager: return self.books[index] return None + def next_page(self) -> bool: + """ + Navigate to next page of library. + + Returns: + True if page changed, False if already on last page + """ + total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page + if self.current_page < total_pages - 1: + self.current_page += 1 + return True + return False + + def previous_page(self) -> bool: + """ + Navigate to previous page of library. + + Returns: + True if page changed, False if already on first page + """ + if self.current_page > 0: + self.current_page -= 1 + return True + return False + + def set_page(self, page: int) -> bool: + """ + Set current page. + + Args: + page: Page number (0-indexed) + + Returns: + True if page changed, False if invalid page + """ + total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page + if 0 <= page < total_pages: + self.current_page = page + return True + return False + + def get_total_pages(self) -> int: + """ + Get total number of pages. + + Returns: + Total number of pages + """ + return (len(self.books) + self.books_per_page - 1) // self.books_per_page + def get_library_state(self) -> LibraryState: """ Get current library state for persistence. diff --git a/dreader/managers/document.py b/dreader/managers/document.py index 3c75067..7570813 100644 --- a/dreader/managers/document.py +++ b/dreader/managers/document.py @@ -52,7 +52,7 @@ class DocumentManager: # Extract metadata self.title = book.get_title() or "Unknown Title" - self.author = book.get_metadata('AUTHOR') or "Unknown Author" + self.author = book.get_author() or "Unknown Author" # Create document ID from filename self.document_id = Path(epub_path).stem diff --git a/test_debug_overlay.py b/test_debug_overlay.py deleted file mode 100644 index d4f5cae..0000000 --- a/test_debug_overlay.py +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env python3 -""" -Test the debug overlay visualization feature. -This creates an overlay with debug bounding boxes and saves it as an image. -""" - -import sys -import os -from pathlib import Path - -# Enable debug mode -os.environ['DREADER_DEBUG_OVERLAY'] = '1' - -sys.path.insert(0, str(Path(__file__).parent)) - -from dreader.application import EbookReader -from dreader.overlays.settings import SettingsOverlay -from dreader.overlays.navigation import NavigationOverlay - -def test_settings_overlay_debug(): - """Create settings overlay with debug visualization.""" - print("="*70) - print("Creating Settings Overlay with Debug Bounding Boxes") - print("="*70) - - # Create reader - reader = EbookReader(page_size=(800, 1200)) - - # Load a test book - test_book = Path(__file__).parent / "tests" / "data" / "library-epub" / "pg11-images-3.epub" - if not test_book.exists(): - print(f"Test book not found: {test_book}") - return - - reader.load_epub(str(test_book)) - - # Create settings overlay - settings_overlay = SettingsOverlay(reader) - base_page = reader.get_current_page() - - # Open overlay (with debug mode, this will draw bounding boxes) - overlay_image = settings_overlay.open( - base_page, - font_scale=1.0, - line_spacing=5, - inter_block_spacing=15, - word_spacing=0 - ) - - # Save the result - output_path = Path(__file__).parent / "settings_overlay_debug.png" - overlay_image.save(output_path) - print(f"\nSettings overlay with debug boxes saved to: {output_path}") - print("Red boxes show clickable areas") - - reader.close() - - -def test_navigation_overlay_debug(): - """Create navigation overlay with debug visualization.""" - print("\n" + "="*70) - print("Creating Navigation Overlay with Debug Bounding Boxes") - print("="*70) - - # Create reader - reader = EbookReader(page_size=(800, 1200)) - - # Load a test book - test_book = Path(__file__).parent / "tests" / "data" / "library-epub" / "pg11-images-3.epub" - if not test_book.exists(): - print(f"Test book not found: {test_book}") - return - - reader.load_epub(str(test_book)) - - # Create navigation overlay - nav_overlay = NavigationOverlay(reader) - base_page = reader.get_current_page() - - # Get chapters - chapters = reader.get_chapters() - - # Open overlay (with debug mode, this will draw bounding boxes) - overlay_image = nav_overlay.open( - base_page, - chapters=chapters, - bookmarks=[{"name": "Test Bookmark"}], - active_tab="contents" - ) - - # Save the result - output_path = Path(__file__).parent / "navigation_overlay_debug.png" - overlay_image.save(output_path) - print(f"\nNavigation overlay with debug boxes saved to: {output_path}") - print("Red boxes show clickable areas") - - reader.close() - - -if __name__ == "__main__": - test_settings_overlay_debug() - test_navigation_overlay_debug() - print("\n" + "="*70) - print("Debug visualizations created!") - print("Check the PNG files to see clickable areas marked with red boxes.") - print("="*70) diff --git a/test_main_integration.py b/test_main_integration.py deleted file mode 100644 index df8d253..0000000 --- a/test_main_integration.py +++ /dev/null @@ -1,211 +0,0 @@ -#!/usr/bin/env python3 -""" -Quick integration test for the main application controller. - -This test verifies that all components integrate correctly without -requiring a GUI. It uses a mock HAL to simulate display/input. -""" - -import sys -import asyncio -from pathlib import Path -from PIL import Image - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent)) - -from dreader.main import DReaderApplication, AppConfig -from dreader.hal import DisplayHAL -from dreader.gesture import TouchEvent, GestureType - - -class MockDisplayHAL(DisplayHAL): - """Mock HAL for headless testing.""" - - def __init__(self): - self.images_displayed = [] - self.events = [] - - async def show_image(self, image: Image.Image): - """Record displayed images.""" - self.images_displayed.append(image) - print(f" [HAL] Displayed image: {image.size}") - - async def get_touch_event(self): - """Return queued events.""" - if self.events: - return self.events.pop(0) - return None - - async def set_brightness(self, level: int): - """Mock brightness control.""" - print(f" [HAL] Brightness set to {level}") - - def queue_event(self, event: TouchEvent): - """Add event to queue for testing.""" - self.events.append(event) - - -async def test_integration(): - """Test the complete integration.""" - print("=" * 70) - print("DReader Main Application Integration Test") - print("=" * 70) - - # Find test library - library_path = Path(__file__).parent / "tests" / "data" / "library-epub" - - if not library_path.exists(): - print(f"\nError: Test library not found at {library_path}") - print("Please ensure test EPUB files exist in tests/data/library-epub/") - return False - - print(f"\nLibrary path: {library_path}") - - # Create mock HAL - hal = MockDisplayHAL() - - # Create config - config = AppConfig( - display_hal=hal, - library_path=str(library_path), - page_size=(800, 1200), - auto_save_interval=999 # Don't auto-save during test - ) - - # Create application - app = DReaderApplication(config) - - try: - # Test 1: Start application (should show library) - print("\n" + "-" * 70) - print("Test 1: Starting application") - print("-" * 70) - await app.start() - print(f"✓ Application started") - print(f" Current mode: {app.get_current_mode()}") - print(f" Images displayed: {len(hal.images_displayed)}") - - assert len(hal.images_displayed) > 0, "No image displayed after start" - assert app.get_current_mode().value == "library", "Should start in library mode" - print("✓ Test 1 passed") - - # Test 2: Simulate tap on first book - print("\n" + "-" * 70) - print("Test 2: Selecting a book from library") - print("-" * 70) - - # Tap in approximate location of first book - tap_event = TouchEvent(GestureType.TAP, 400, 150) - print(f" Simulating tap at (400, 150)") - - images_before = len(hal.images_displayed) - await app.handle_touch(tap_event) - - print(f" Current mode: {app.get_current_mode()}") - print(f" New images displayed: {len(hal.images_displayed) - images_before}") - - # Should have transitioned to reading mode - if app.get_current_mode().value == "reading": - print("✓ Successfully entered reading mode") - print("✓ Test 2 passed") - else: - print("⚠ Tap may not have hit a book (this is OK for the test)") - print(" Manually entering reading mode for further tests...") - - # Get first book from library and enter reading mode manually - if app.library: - books = app.library.scan_library() - if books: - await app._enter_reading_mode(books[0]['path']) - print(f"✓ Loaded book: {books[0]['title']}") - - # Test 3: Page navigation - print("\n" + "-" * 70) - print("Test 3: Page navigation") - print("-" * 70) - - if app.get_current_mode().value == "reading": - images_before = len(hal.images_displayed) - - # Next page (swipe left) - swipe_event = TouchEvent(GestureType.SWIPE_LEFT, 600, 600) - print(" Simulating swipe left (next page)") - await app.handle_touch(swipe_event) - - print(f" New images displayed: {len(hal.images_displayed) - images_before}") - print("✓ Test 3 passed") - else: - print("⊘ Skipping (not in reading mode)") - - # Test 4: Font size change (pinch gesture) - print("\n" + "-" * 70) - print("Test 4: Font size adjustment") - print("-" * 70) - - if app.get_current_mode().value == "reading": - images_before = len(hal.images_displayed) - - # Increase font size - pinch_event = TouchEvent(GestureType.PINCH_OUT, 400, 600) - print(" Simulating pinch out (increase font)") - await app.handle_touch(pinch_event) - - print(f" New images displayed: {len(hal.images_displayed) - images_before}") - print("✓ Test 4 passed") - else: - print("⊘ Skipping (not in reading mode)") - - # Test 5: State persistence check - print("\n" + "-" * 70) - print("Test 5: State persistence") - print("-" * 70) - - state = app.state - print(f" Current mode: {state.mode}") - print(f" Current book: {state.current_book}") - print(f" Font scale: {state.settings.font_scale}") - - # Save state - success = app.state_manager.save_state(force=True) - if success: - print("✓ State saved successfully") - print("✓ Test 5 passed") - else: - print("✗ State save failed") - return False - - # Test 6: Graceful shutdown - print("\n" + "-" * 70) - print("Test 6: Graceful shutdown") - print("-" * 70) - - await app.shutdown() - print("✓ Application shut down cleanly") - print("✓ Test 6 passed") - - # Summary - print("\n" + "=" * 70) - print("✓ ALL TESTS PASSED") - print("=" * 70) - print(f"\nTotal images displayed: {len(hal.images_displayed)}") - print(f"Final mode: {app.get_current_mode()}") - print("\nIntegration test successful!") - - return True - - except Exception as e: - print(f"\n✗ TEST FAILED: {e}") - import traceback - traceback.print_exc() - return False - - -def main(): - """Main entry point.""" - success = asyncio.run(test_integration()) - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() diff --git a/test_page_navigation.py b/test_page_navigation.py deleted file mode 100644 index 656104d..0000000 --- a/test_page_navigation.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -""" -Test page navigation (forward and backward). -""" - -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) - -from dreader.application import EbookReader - -def test_navigation(): - """Test forward and backward page navigation.""" - - # Find a test book - epub_path = Path("tests/data/library-epub/pg11-images-3.epub") - - if not epub_path.exists(): - print(f"Error: Test EPUB not found at {epub_path}") - return False - - print("=" * 70) - print("Page Navigation Test") - print("=" * 70) - - # Create reader - reader = EbookReader(page_size=(800, 1200)) - - # Load book - print(f"\nLoading: {epub_path}") - if not reader.load_epub(str(epub_path)): - print("Failed to load EPUB") - return False - - print(f"✓ Loaded: {reader.book_title}") - - # Get starting position - start_pos = reader.get_position_info() - print(f"\nStarting position: {start_pos}") - - # Test forward navigation - print("\n" + "-" * 70) - print("Test 1: Forward navigation (next_page)") - print("-" * 70) - - for i in range(3): - page = reader.next_page() - if page: - pos = reader.get_position_info() - print(f" Page {i+1}: position={pos['position']}, progress={reader.get_reading_progress()*100:.1f}%") - else: - print(f" Page {i+1}: Failed to advance") - return False - - forward_pos = reader.get_position_info() - print(f"\nPosition after 3 forward: {forward_pos}") - - # Test backward navigation - print("\n" + "-" * 70) - print("Test 2: Backward navigation (previous_page)") - print("-" * 70) - - for i in range(3): - page = reader.previous_page() - if page: - pos = reader.get_position_info() - print(f" Back {i+1}: position={pos['position']}, progress={reader.get_reading_progress()*100:.1f}%") - else: - print(f" Back {i+1}: Failed to go back (might be at start)") - - final_pos = reader.get_position_info() - print(f"\nPosition after 3 backward: {final_pos}") - - # Verify we're back at start - print("\n" + "-" * 70) - print("Test 3: Verify position") - print("-" * 70) - - if final_pos['position'] == start_pos['position']: - print("✓ Successfully returned to starting position") - print("✓ Backward navigation working correctly") - result = True - else: - print(f"✗ Position mismatch!") - print(f" Expected: {start_pos['position']}") - print(f" Got: {final_pos['position']}") - result = False - - # Test going back from start (should return None or current page) - print("\n" + "-" * 70) - print("Test 4: Try going back from first page") - print("-" * 70) - - page = reader.previous_page() - if page: - pos = reader.get_position_info() - print(f" Still at position: {pos['position']}") - if pos['position'] == 0: - print("✓ Correctly stayed at first page") - else: - print("⚠ Position changed but shouldn't have") - else: - print(" previous_page() returned None (at start)") - print("✓ Correctly indicated at start of book") - - # Cleanup - reader.close() - - print("\n" + "=" * 70) - if result: - print("✓ ALL TESTS PASSED - Page navigation working correctly") - else: - print("✗ TESTS FAILED") - print("=" * 70) - - return result - -if __name__ == "__main__": - success = test_navigation() - sys.exit(0 if success else 1) diff --git a/test_swipe_detection.py b/test_swipe_detection.py deleted file mode 100644 index 780b7d1..0000000 --- a/test_swipe_detection.py +++ /dev/null @@ -1,124 +0,0 @@ -#!/usr/bin/env python3 -""" -Test swipe detection in Pygame HAL. -This will show you how to perform swipes and what gestures are detected. -""" - -import asyncio -import sys -from pathlib import Path - -sys.path.insert(0, str(Path(__file__).parent)) - -from dreader.hal_pygame import PygameDisplayHAL -from dreader.gesture import GestureType -from PIL import Image, ImageDraw, ImageFont - -async def test_swipes(): - """Test swipe detection with visual feedback.""" - - print("=" * 70) - print("Swipe Detection Test") - print("=" * 70) - print("\nInstructions:") - print(" - Click and drag to create swipes") - print(" - Drag at least 30 pixels for swipe detection") - print(" - Short movements are detected as taps") - print(" - Press Q or ESC to quit") - print("\nSwipe directions:") - print(" - Drag LEFT → Next page (SWIPE_LEFT)") - print(" - Drag RIGHT → Previous page (SWIPE_RIGHT)") - print(" - Drag UP → Scroll up (SWIPE_UP)") - print(" - Drag DOWN → Scroll down (SWIPE_DOWN)") - print("\nOR use keyboard shortcuts:") - print(" - Left/Right Arrow or Space/PageUp/PageDown") - print("\n" + "=" * 70) - - # Create HAL - hal = PygameDisplayHAL(width=800, height=600, title="Swipe Detection Test") - - await hal.initialize() - - # Create instruction image - img = Image.new('RGB', (800, 600), color=(240, 240, 240)) - draw = ImageDraw.Draw(img) - - try: - font_large = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32) - font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20) - except: - font_large = ImageFont.load_default() - font_small = ImageFont.load_default() - - # Draw instructions - y = 50 - draw.text((400, y), "Swipe Detection Test", fill=(0, 0, 0), font=font_large, anchor="mt") - - y += 80 - instructions = [ - "Click and DRAG to create swipes:", - "", - "← Drag LEFT = Next Page", - "→ Drag RIGHT = Previous Page", - "↑ Drag UP = Scroll Up", - "↓ Drag DOWN = Scroll Down", - "", - "Minimum drag distance: 30 pixels", - "", - "Or use keyboard:", - "Space/Right Arrow = Next", - "Left Arrow = Previous", - "", - "Press Q or ESC to quit" - ] - - for line in instructions: - draw.text((400, y), line, fill=(0, 0, 0), font=font_small, anchor="mt") - y += 30 - - await hal.show_image(img) - - # Event loop - hal.running = True - gesture_count = 0 - last_gesture = None - - print("\nWaiting for gestures... (window is now open)") - - while hal.running: - event = await hal.get_touch_event() - - if event: - gesture_count += 1 - last_gesture = event.gesture - - print(f"\n[{gesture_count}] Detected: {event.gesture.value}") - print(f" Position: ({event.x}, {event.y})") - - # Visual feedback - feedback_img = img.copy() - feedback_draw = ImageDraw.Draw(feedback_img) - - # Draw gesture type - gesture_text = f"Gesture #{gesture_count}: {event.gesture.value.upper()}" - feedback_draw.rectangle([(0, 550), (800, 600)], fill=(50, 150, 50)) - feedback_draw.text((400, 575), gesture_text, fill=(255, 255, 255), font=font_large, anchor="mm") - - await hal.show_image(feedback_img) - - # Brief pause to show feedback - await asyncio.sleep(0.5) - await hal.show_image(img) - - await asyncio.sleep(0.01) - - await hal.cleanup() - - print("\n" + "=" * 70) - print(f"Test complete! Detected {gesture_count} gestures.") - if last_gesture: - print(f"Last gesture: {last_gesture.value}") - print("=" * 70) - -if __name__ == "__main__": - asyncio.run(test_swipes()) diff --git a/tests/test_backward_nav_minimal.py b/tests/test_backward_nav_minimal.py new file mode 100644 index 0000000..ae799f6 --- /dev/null +++ b/tests/test_backward_nav_minimal.py @@ -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() diff --git a/tests/test_backward_navigation_detailed.py b/tests/test_backward_navigation_detailed.py new file mode 100644 index 0000000..4fe6707 --- /dev/null +++ b/tests/test_backward_navigation_detailed.py @@ -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() diff --git a/tests/test_backward_navigation_resume.py b/tests/test_backward_navigation_resume.py new file mode 100644 index 0000000..1df5ff8 --- /dev/null +++ b/tests/test_backward_navigation_resume.py @@ -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() diff --git a/tests/test_epub_images.py b/tests/test_epub_images.py new file mode 100644 index 0000000..12e0126 --- /dev/null +++ b/tests/test_epub_images.py @@ -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!") diff --git a/tests/test_library_interaction.py b/tests/test_library_interaction.py index ca2f6e6..e7badee 100644 --- a/tests/test_library_interaction.py +++ b/tests/test_library_interaction.py @@ -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()