Compare commits

..

No commits in common. "18be4306bfb57a6383e5a13ca123afbabf82f7d0" and "5d3e7fae7b217f19bfde28b20ec134fbe551a937" have entirely different histories.

8 changed files with 3 additions and 1367 deletions

View File

@ -825,85 +825,6 @@ class EbookReader:
self.close_overlay() self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {}) return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# For navigation overlay, handle tab switching, chapter/bookmark selection, and close
elif self.current_overlay_state == OverlayState.NAVIGATION:
# Query the overlay to see what was tapped
query_result = self.overlay_manager.query_overlay_pixel(x, y)
# If query failed (tap outside overlay), close it
if not query_result:
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a link
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
# Parse "tab:tabname" format for tab switching
if link_target.startswith("tab:"):
tab_name = link_target.split(":", 1)[1]
# Switch to the selected tab
self.switch_navigation_tab(tab_name)
return GestureResponse(ActionType.TAB_SWITCHED, {
"tab": tab_name
})
# Parse "chapter:N" format for chapter navigation
elif link_target.startswith("chapter:"):
try:
chapter_idx = int(link_target.split(":")[1])
# Get chapter title for response
chapters = self.get_chapters()
chapter_title = None
for title, idx in chapters:
if idx == chapter_idx:
chapter_title = title
break
# Jump to selected chapter
self.jump_to_chapter(chapter_idx)
# Close overlay
self.close_overlay()
return GestureResponse(ActionType.CHAPTER_SELECTED, {
"chapter_index": chapter_idx,
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
})
except (ValueError, IndexError):
pass
# Parse "bookmark:name" format for bookmark navigation
elif link_target.startswith("bookmark:"):
bookmark_name = link_target.split(":", 1)[1]
# Load the bookmark position
page = self.load_position(bookmark_name)
if page:
# Close overlay
self.close_overlay()
return GestureResponse(ActionType.BOOKMARK_SELECTED, {
"bookmark_name": bookmark_name
})
else:
# Failed to load bookmark
return GestureResponse(ActionType.ERROR, {
"message": f"Failed to load bookmark: {bookmark_name}"
})
# Parse "action:close" format for close button
elif link_target.startswith("action:"):
action = link_target.split(":", 1)[1]
if action == "close":
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Not an interactive element, close overlay
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# For other overlays, just close on any tap for now # For other overlays, just close on any tap for now
self.close_overlay() self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {}) return GestureResponse(ActionType.OVERLAY_CLOSED, {})
@ -1206,64 +1127,6 @@ class EbookReader:
return result return result
def open_navigation_overlay(self, active_tab: str = "contents") -> Optional[Image.Image]:
"""
Open the unified navigation overlay with Contents and Bookmarks tabs.
This is the new unified overlay that replaces separate TOC and Bookmarks overlays.
It provides a tabbed interface for switching between table of contents and bookmarks.
Args:
active_tab: Which tab to show initially ("contents" or "bookmarks")
Returns:
Composited image with navigation overlay on top of current page, or None if no book loaded
"""
if not self.is_loaded():
return None
# Get current page as base
base_page = self.get_current_page(include_highlights=False)
if not base_page:
return None
# Get chapters for Contents tab
chapters = self.get_chapters()
# Get bookmarks for Bookmarks tab
bookmark_names = self.list_saved_positions()
bookmarks = [
{"name": name, "position": f"Saved position"}
for name in bookmark_names
]
# Open overlay and get composited image
result = self.overlay_manager.open_navigation_overlay(
chapters=chapters,
bookmarks=bookmarks,
base_page=base_page,
active_tab=active_tab
)
self.current_overlay_state = OverlayState.NAVIGATION
return result
def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]:
"""
Switch between tabs in the navigation overlay.
Args:
new_tab: Tab to switch to ("contents" or "bookmarks")
Returns:
Updated image with new tab active, or None if navigation overlay is not open
"""
if self.current_overlay_state != OverlayState.NAVIGATION:
return None
result = self.overlay_manager.switch_navigation_tab(new_tab)
return result if result else self.get_current_page()
def close_overlay(self) -> Optional[Image.Image]: def close_overlay(self) -> Optional[Image.Image]:
""" """
Close the current overlay and return to reading view. Close the current overlay and return to reading view.

View File

@ -125,7 +125,5 @@ class ActionType:
OVERLAY_OPENED = "overlay_opened" OVERLAY_OPENED = "overlay_opened"
OVERLAY_CLOSED = "overlay_closed" OVERLAY_CLOSED = "overlay_closed"
CHAPTER_SELECTED = "chapter_selected" CHAPTER_SELECTED = "chapter_selected"
BOOKMARK_SELECTED = "bookmark_selected"
TAB_SWITCHED = "tab_switched"
SETTING_CHANGED = "setting_changed" SETTING_CHANGED = "setting_changed"
BACK_TO_LIBRARY = "back_to_library" BACK_TO_LIBRARY = "back_to_library"

View File

@ -502,136 +502,3 @@ def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str:
</html> </html>
''' '''
return html return html
def generate_navigation_overlay(
chapters: List[Dict],
bookmarks: List[Dict],
active_tab: str = "contents",
page_size: tuple = (800, 1200)
) -> str:
"""
Generate HTML for the unified navigation overlay with Contents and Bookmarks tabs.
This combines TOC and Bookmarks into a single overlay with tab switching.
Tabs are clickable links that switch between contents (tab:contents) and bookmarks (tab:bookmarks).
Args:
chapters: List of chapter dictionaries with keys:
- index: Chapter index
- title: Chapter title
bookmarks: List of bookmark dictionaries with keys:
- name: Bookmark name
- position: Position info (optional)
active_tab: Which tab to show ("contents" or "bookmarks")
page_size: Page dimensions (width, height) for sizing the overlay
Returns:
HTML string for navigation overlay with tab switching
"""
# Build chapter list items with clickable links
chapter_items = []
for i, chapter in enumerate(chapters):
title = chapter["title"]
link_text = f'{i+1}. {title}'
if len(title) <= 2:
link_text = f'{i+1}. {title} ' # Extra spaces for padding
chapter_items.append(
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
f'border-left: 3px solid #000;">'
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
f'{link_text}</a></p>'
)
# Build bookmark list items with clickable links
bookmark_items = []
for bookmark in bookmarks:
name = bookmark['name']
position_text = bookmark.get('position', 'Saved position')
bookmark_items.append(
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
f'border-left: 3px solid #000;">'
f'<a href="bookmark:{name}" style="text-decoration: none; color: #000; display: block;">'
f'<span style="font-weight: bold; display: block;">{name}</span>'
f'<span style="font-size: 11px; color: #666;">{position_text}</span>'
f'</a></p>'
)
# Determine which content to show
contents_display = "block" if active_tab == "contents" else "none"
bookmarks_display = "block" if active_tab == "bookmarks" else "none"
# Style active tab
contents_tab_style = "background-color: #000; color: #fff;" if active_tab == "contents" else "background-color: #f0f0f0; color: #000;"
bookmarks_tab_style = "background-color: #000; color: #fff;" if active_tab == "bookmarks" else "background-color: #f0f0f0; color: #000;"
chapters_html = ''.join(chapter_items) if chapter_items else '<p style="padding: 20px; text-align: center; color: #999;">No chapters available</p>'
bookmarks_html = ''.join(bookmark_items) if bookmark_items else '<p style="padding: 20px; text-align: center; color: #999;">No bookmarks yet</p>'
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Navigation</title>
</head>
<body style="background-color: white; margin: 0; padding: 0; font-family: Arial, sans-serif;">
<!-- Tab Bar -->
<div style="display: flex; border-bottom: 2px solid #ccc; background-color: #f8f8f8;">
<a href="tab:contents"
style="flex: 1; padding: 15px; text-align: center; font-weight: bold;
text-decoration: none; border-right: 1px solid #ccc; {contents_tab_style}">
Contents
</a>
<a href="tab:bookmarks"
style="flex: 1; padding: 15px; text-align: center; font-weight: bold;
text-decoration: none; {bookmarks_tab_style}">
Bookmarks
</a>
</div>
<!-- Contents Tab Content -->
<div id="contents-tab" style="padding: 25px; display: {contents_display};">
<h2 style="color: #000; margin: 0 0 15px 0; font-size: 20px; text-align: center;">
Table of Contents
</h2>
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
border-bottom: 2px solid #ccc; font-size: 13px;">
{len(chapters)} chapters
</p>
<div style="overflow-y: auto; max-height: calc(100vh - 200px);">
{chapters_html}
</div>
</div>
<!-- Bookmarks Tab Content -->
<div id="bookmarks-tab" style="padding: 25px; display: {bookmarks_display};">
<h2 style="color: #000; margin: 0 0 15px 0; font-size: 20px; text-align: center;">
Bookmarks
</h2>
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
border-bottom: 2px solid #ccc; font-size: 13px;">
{len(bookmarks)} saved
</p>
<div style="overflow-y: auto; max-height: calc(100vh - 200px);">
{bookmarks_html}
</div>
</div>
<!-- Close Button (bottom right) -->
<div style="position: fixed; bottom: 20px; right: 20px;">
<a href="action:close"
style="display: inline-block; padding: 12px 24px; background-color: #dc3545;
color: white; text-decoration: none; border-radius: 4px; font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);">
Close
</a>
</div>
</body>
</html>
'''
return html

View File

@ -14,8 +14,7 @@ from .state import OverlayState
from .html_generator import ( from .html_generator import (
generate_toc_overlay, generate_toc_overlay,
generate_settings_overlay, generate_settings_overlay,
generate_bookmarks_overlay, generate_bookmarks_overlay
generate_navigation_overlay
) )
@ -371,115 +370,6 @@ class OverlayManager:
# Composite and return # Composite and return
return self.composite_overlay(base_page, overlay_image) return self.composite_overlay(base_page, overlay_image)
def open_navigation_overlay(
self,
chapters: List[Tuple[str, int]],
bookmarks: List[Dict],
base_page: Image.Image,
active_tab: str = "contents"
) -> Image.Image:
"""
Open the unified navigation overlay with Contents and Bookmarks tabs.
This replaces the separate TOC and Bookmarks overlays with a single
overlay that has tabs for switching between contents and bookmarks.
Args:
chapters: List of (chapter_title, chapter_index) tuples
bookmarks: List of bookmark dictionaries with 'name' and optional 'position'
base_page: Current reading page to show underneath
active_tab: Which tab to show ("contents" or "bookmarks")
Returns:
Composited image with navigation overlay on top
"""
# Import here to avoid circular dependency
from .application import EbookReader
# Calculate panel size (60% of screen width, 70% height)
panel_width = int(self.page_size[0] * 0.6)
panel_height = int(self.page_size[1] * 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in chapters
]
# Generate navigation HTML with tabs
html = generate_navigation_overlay(
chapters=chapter_data,
bookmarks=bookmarks,
active_tab=active_tab,
page_size=(panel_width, panel_height)
)
# Create reader for overlay and keep it alive for querying
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=(panel_width, panel_height),
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = self._overlay_reader.load_html(
html_string=html,
title="Navigation",
author="",
document_id="navigation_overlay"
)
if not success:
raise ValueError("Failed to load navigation overlay HTML")
# Get the rendered page
overlay_panel = self._overlay_reader.get_current_page()
# Calculate and store panel position for coordinate translation
panel_x = int((self.page_size[0] - panel_width) / 2)
panel_y = int((self.page_size[1] - panel_height) / 2)
self._overlay_panel_offset = (panel_x, panel_y)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
self.current_overlay = OverlayState.NAVIGATION
# Store active tab for tab switching
self._active_nav_tab = active_tab
self._cached_chapters = chapters
self._cached_bookmarks = bookmarks
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def switch_navigation_tab(self, new_tab: str) -> Optional[Image.Image]:
"""
Switch between tabs in the navigation overlay.
Args:
new_tab: Tab to switch to ("contents" or "bookmarks")
Returns:
Updated composited image with new tab active, or None if not in navigation overlay
"""
if self.current_overlay != OverlayState.NAVIGATION:
return None
# Re-open navigation overlay with new active tab
if hasattr(self, '_cached_chapters') and hasattr(self, '_cached_bookmarks'):
return self.open_navigation_overlay(
chapters=self._cached_chapters,
bookmarks=self._cached_bookmarks,
base_page=self._cached_base_page,
active_tab=new_tab
)
return None
def close_overlay(self) -> Optional[Image.Image]: def close_overlay(self) -> Optional[Image.Image]:
""" """
Close the current overlay and return to base page. Close the current overlay and return to base page.

View File

@ -27,10 +27,9 @@ class EreaderMode(Enum):
class OverlayState(Enum): class OverlayState(Enum):
"""Overlay states within READING mode""" """Overlay states within READING mode"""
NONE = "none" NONE = "none"
TOC = "toc" # Deprecated: use NAVIGATION instead TOC = "toc"
SETTINGS = "settings" SETTINGS = "settings"
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead BOOKMARKS = "bookmarks"
NAVIGATION = "navigation" # Unified overlay for TOC and Bookmarks
@dataclass @dataclass

View File

@ -1,196 +0,0 @@
"""
Example demonstrating the unified navigation overlay feature.
This example shows how to:
1. Open the navigation overlay with Contents and Bookmarks tabs
2. Switch between tabs
3. Navigate to chapters and bookmarks
4. Handle user interactions with the overlay
The navigation overlay replaces the separate TOC and Bookmarks overlays
with a single, unified interface that provides both features in a tabbed view.
"""
from pathlib import Path
from dreader.application import EbookReader
from dreader.state import OverlayState
def main():
# Create reader instance
reader = EbookReader(page_size=(800, 1200), margin=20)
# Load a sample book (adjust path as needed)
book_path = Path(__file__).parent / "books" / "hamlet.epub"
if not book_path.exists():
print(f"Book not found at {book_path}")
print("Creating a simple HTML book for demo...")
# Create a simple multi-chapter book
html = """
<html>
<head><title>Demo Book</title></head>
<body>
<h1>Chapter 1: Introduction</h1>
<p>This is the first chapter with some introductory content.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
<h1>Chapter 2: Main Content</h1>
<p>This is the second chapter with main content.</p>
<p>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
<h1>Chapter 3: Conclusion</h1>
<p>This is the final chapter with concluding remarks.</p>
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco.</p>
</body>
</html>
"""
reader.load_html(
html_string=html,
title="Demo Book",
author="Example Author",
document_id="demo_navigation"
)
else:
print(f"Loading book: {book_path}")
reader.load_epub(str(book_path))
print("\n=== Navigation Overlay Demo ===\n")
# Display current page
position_info = reader.get_position_info()
print(f"Current position: {position_info}")
print(f"Reading progress: {reader.get_reading_progress():.1%}")
# Get chapters
chapters = reader.get_chapters()
print(f"\nAvailable chapters: {len(chapters)}")
for i, (title, idx) in enumerate(chapters[:5]): # Show first 5
print(f" {i+1}. {title}")
# Save some bookmarks for demonstration
print("\n--- Saving bookmarks ---")
reader.save_position("Start of Book")
print("Saved bookmark: 'Start of Book'")
reader.next_page()
reader.next_page()
reader.save_position("Chapter 1 Progress")
print("Saved bookmark: 'Chapter 1 Progress'")
# List saved bookmarks
bookmarks = reader.list_saved_positions()
print(f"\nTotal bookmarks: {len(bookmarks)}")
for name in bookmarks:
print(f" - {name}")
# === Demo 1: Open navigation overlay with Contents tab ===
print("\n\n--- Demo 1: Opening Navigation Overlay (Contents Tab) ---")
image = reader.open_navigation_overlay(active_tab="contents")
if image:
print(f"✓ Navigation overlay opened successfully")
print(f" Overlay state: {reader.get_overlay_state()}")
print(f" Is overlay open: {reader.is_overlay_open()}")
print(f" Image size: {image.size}")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_contents.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# === Demo 2: Switch to Bookmarks tab ===
print("\n\n--- Demo 2: Switching to Bookmarks Tab ---")
image = reader.switch_navigation_tab("bookmarks")
if image:
print(f"✓ Switched to Bookmarks tab")
print(f" Overlay state: {reader.get_overlay_state()}")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_bookmarks.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# === Demo 3: Switch back to Contents tab ===
print("\n\n--- Demo 3: Switching back to Contents Tab ---")
image = reader.switch_navigation_tab("contents")
if image:
print(f"✓ Switched back to Contents tab")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_contents_2.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# === Demo 4: Close overlay ===
print("\n\n--- Demo 4: Closing Navigation Overlay ---")
image = reader.close_overlay()
if image:
print(f"✓ Overlay closed successfully")
print(f" Overlay state: {reader.get_overlay_state()}")
print(f" Is overlay open: {reader.is_overlay_open()}")
# === Demo 5: Open with Bookmarks tab directly ===
print("\n\n--- Demo 5: Opening directly to Bookmarks Tab ---")
image = reader.open_navigation_overlay(active_tab="bookmarks")
if image:
print(f"✓ Navigation overlay opened with Bookmarks tab")
# Save the rendered overlay for inspection
output_path = Path("/tmp/navigation_overlay_bookmarks_direct.png")
image.save(output_path)
print(f" Saved to: {output_path}")
# Close overlay
reader.close_overlay()
# === Demo 6: Simulate user interaction flow ===
print("\n\n--- Demo 6: Simulated User Interaction Flow ---")
print("Simulating: User opens overlay, switches tabs, selects bookmark")
# 1. User opens navigation overlay
print("\n 1. User taps navigation button -> Opens overlay with Contents tab")
reader.open_navigation_overlay(active_tab="contents")
print(f" State: {reader.get_overlay_state()}")
# 2. User switches to Bookmarks tab
print("\n 2. User taps 'Bookmarks' tab")
reader.switch_navigation_tab("bookmarks")
print(f" State: {reader.get_overlay_state()}")
# 3. User selects a bookmark
print("\n 3. User taps on bookmark 'Start of Book'")
page = reader.load_position("Start of Book")
if page:
print(f" ✓ Loaded bookmark successfully")
print(f" Position: {reader.get_position_info()}")
# 4. Close overlay
print("\n 4. System closes overlay after selection")
reader.close_overlay()
print(f" State: {reader.get_overlay_state()}")
# === Summary ===
print("\n\n=== Demo Complete ===")
print(f"\nGenerated overlay images in /tmp:")
print(f" - navigation_overlay_contents.png")
print(f" - navigation_overlay_bookmarks.png")
print(f" - navigation_overlay_contents_2.png")
print(f" - navigation_overlay_bookmarks_direct.png")
print("\n✓ Navigation overlay provides unified interface for:")
print(" • Table of Contents (chapter navigation)")
print(" • Bookmarks (saved positions)")
print(" • Tab switching between Contents and Bookmarks")
print(" • Consistent interaction patterns")
# Cleanup
reader.close()
print("\nReader closed.")
if __name__ == "__main__":
main()

View File

@ -1,589 +0,0 @@
"""
Comprehensive tests for boot recovery and resume functionality.
Tests cover:
- Saving state when closing reader
- Resuming from saved state with a new reader instance
- Restoring reading position (page/chapter)
- Restoring settings (font size, spacing, etc.)
- Restoring bookmarks
- Handling state across multiple books
- Error recovery (corrupt state, missing books)
- Bookmark-based position restoration
"""
import unittest
import tempfile
import shutil
import json
import asyncio
from pathlib import Path
from typing import Dict, Any
from dreader.application import EbookReader
from dreader.state import StateManager, AppState, BookState, Settings, EreaderMode, OverlayState
class TestBootRecovery(unittest.TestCase):
"""Test application state persistence and recovery across reader instances"""
def setUp(self):
"""Set up test environment with temporary directories"""
self.temp_dir = tempfile.mkdtemp()
self.bookmarks_dir = Path(self.temp_dir) / "bookmarks"
self.highlights_dir = Path(self.temp_dir) / "highlights"
self.state_file = Path(self.temp_dir) / "state.json"
self.bookmarks_dir.mkdir(exist_ok=True)
self.highlights_dir.mkdir(exist_ok=True)
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_save_and_restore_reading_position(self):
"""Test saving current position and restoring it in a new reader"""
# Create first reader instance
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
# Load book and navigate to middle
reader1.load_epub(self.epub_path)
# Navigate forward several pages
for _ in range(5):
reader1.next_page()
# Get position before saving
original_position = reader1.get_position_info()
original_progress = reader1.get_reading_progress()
# Save position using special auto-resume bookmark
reader1.save_position("__auto_resume__")
# Close reader
reader1.close()
# Create new reader instance
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
# Load same book
reader2.load_epub(self.epub_path)
# Restore position
success = reader2.load_position("__auto_resume__")
self.assertTrue(success, "Failed to load auto-resume position")
# Verify position matches
restored_position = reader2.get_position_info()
restored_progress = reader2.get_reading_progress()
# Compare positions using the position dict
self.assertEqual(original_position.get('position'), restored_position.get('position'),
f"Position mismatch: {original_position} vs {restored_position}")
self.assertAlmostEqual(original_progress, restored_progress,
places=2, msg="Progress percentage mismatch")
reader2.close()
def test_save_and_restore_settings(self):
"""Test saving settings and restoring them in a new reader"""
# Create first reader
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Change settings
reader1.increase_font_size()
reader1.increase_font_size()
reader1.set_line_spacing(10)
reader1.set_inter_block_spacing(25)
# Get settings
original_font_scale = reader1.base_font_scale
original_line_spacing = reader1.page_style.line_spacing
original_inter_block = reader1.page_style.inter_block_spacing
# Create state manager and save settings
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.update_settings({
'font_scale': original_font_scale,
'line_spacing': original_line_spacing,
'inter_block_spacing': original_inter_block
})
state_manager.save_state(force=True)
reader1.close()
# Create new reader
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Load state and apply settings
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
state_manager2.load_state()
settings_dict = state_manager2.get_settings().to_dict()
reader2.apply_settings(settings_dict)
# Verify settings match
self.assertAlmostEqual(original_font_scale, reader2.base_font_scale, places=2,
msg="Font scale mismatch")
self.assertEqual(original_line_spacing, reader2.page_style.line_spacing,
"Line spacing mismatch")
self.assertEqual(original_inter_block, reader2.page_style.inter_block_spacing,
"Inter-block spacing mismatch")
reader2.close()
def test_save_and_restore_bookmarks(self):
"""Test that bookmarks persist across reader instances"""
# Create first reader
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Navigate and create bookmarks
reader1.next_page()
reader1.next_page()
reader1.save_position("bookmark1")
reader1.next_page()
reader1.next_page()
reader1.next_page()
reader1.save_position("bookmark2")
# Get bookmark list
original_bookmarks = reader1.list_saved_positions()
self.assertGreater(len(original_bookmarks), 0, "No bookmarks saved")
reader1.close()
# Create new reader
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Check bookmarks exist
restored_bookmarks = reader2.list_saved_positions()
self.assertIn("bookmark1", restored_bookmarks, "bookmark1 not found")
self.assertIn("bookmark2", restored_bookmarks, "bookmark2 not found")
# Test loading each bookmark
success1 = reader2.load_position("bookmark1")
self.assertTrue(success1, "Failed to load bookmark1")
success2 = reader2.load_position("bookmark2")
self.assertTrue(success2, "Failed to load bookmark2")
reader2.close()
def test_full_state_persistence_workflow(self):
"""Test complete workflow: read, change settings, save, close, restore"""
# Session 1: Initial reading session
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Simulate reading session
for _ in range(3):
reader1.next_page()
reader1.increase_font_size()
reader1.set_line_spacing(8)
# Save everything
reader1.save_position("__auto_resume__")
reader1.save_position("my_bookmark")
session1_position = reader1.get_position_info()
session1_progress = reader1.get_reading_progress()
session1_font = reader1.base_font_scale
session1_spacing = reader1.page_style.line_spacing
# Save state
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.set_current_book(BookState(
path=self.epub_path,
title=reader1.book_title or "Test Book",
author=reader1.book_author or "Test Author"
))
state_manager.update_settings({
'font_scale': session1_font,
'line_spacing': session1_spacing
})
state_manager.save_state(force=True)
reader1.close()
# Session 2: Resume reading
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
loaded_state = state_manager2.load_state()
# Verify state loaded
self.assertIsNotNone(loaded_state.current_book, "No current book in state")
self.assertEqual(loaded_state.current_book.path, self.epub_path,
"Book path mismatch")
# Create new reader and restore
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(loaded_state.current_book.path)
reader2.apply_settings(loaded_state.settings.to_dict())
reader2.load_position("__auto_resume__")
# Verify restoration
session2_position = reader2.get_position_info()
session2_progress = reader2.get_reading_progress()
self.assertEqual(session1_position.get('position'), session2_position.get('position'),
"Position not restored correctly")
self.assertAlmostEqual(session1_progress, session2_progress, places=2,
msg="Progress not restored correctly")
self.assertAlmostEqual(session1_font, reader2.base_font_scale, places=2,
msg="Font scale not restored correctly")
self.assertEqual(session1_spacing, reader2.page_style.line_spacing,
"Line spacing not restored correctly")
# Verify bookmark exists
bookmarks = reader2.list_saved_positions()
self.assertIn("my_bookmark", bookmarks, "Bookmark lost after restart")
reader2.close()
def test_multiple_books_separate_state(self):
"""Test that different books maintain separate positions and bookmarks"""
epub_path = self.epub_path
# Book 1 - First session
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(epub_path)
for _ in range(3):
reader1.next_page()
reader1.save_position("__auto_resume__")
book1_position = reader1.get_position_info()
book1_progress = reader1.get_reading_progress()
book1_doc_id = reader1.document_id
reader1.close()
# Book 1 - Second session (simulate reopening)
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(epub_path)
reader2.load_position("__auto_resume__")
# Verify we're at the same position
book1_position_restored = reader2.get_position_info()
book1_progress_restored = reader2.get_reading_progress()
self.assertEqual(book1_position.get('position'), book1_position_restored.get('position'),
"Book position not preserved across sessions")
self.assertAlmostEqual(book1_progress, book1_progress_restored, places=2,
msg="Book progress not preserved")
# Now navigate further and save again
for _ in range(2):
reader2.next_page()
reader2.save_position("__auto_resume__")
book1_position_updated = reader2.get_position_info()
book1_progress_updated = reader2.get_reading_progress()
reader2.close()
# Book 1 - Third session, verify updated position
reader3 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader3.load_epub(epub_path)
reader3.load_position("__auto_resume__")
book1_position_final = reader3.get_position_info()
book1_progress_final = reader3.get_reading_progress()
self.assertEqual(book1_position_updated.get('position'), book1_position_final.get('position'),
"Updated position not preserved")
self.assertAlmostEqual(book1_progress_updated, book1_progress_final, places=2,
msg="Updated progress not preserved")
reader3.close()
def test_corrupt_state_file_recovery(self):
"""Test graceful handling of corrupt state file"""
# Create corrupt state file
with open(self.state_file, 'w') as f:
f.write("{ corrupt json content ][[ }")
# Try to load state
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager.load_state()
# Should return default state, not crash
self.assertIsNotNone(state)
self.assertEqual(state.mode, EreaderMode.LIBRARY)
self.assertIsNone(state.current_book)
# Verify backup was created
backup_file = self.state_file.with_suffix('.json.backup')
self.assertTrue(backup_file.exists(), "Backup file not created for corrupt state")
def test_missing_book_in_state(self):
"""Test handling when saved state references a missing book"""
# Create valid state pointing to non-existent book
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state_manager.set_current_book(BookState(
path="/nonexistent/book.epub",
title="Missing Book",
author="Ghost Author"
))
state_manager.save_state(force=True)
# Load state
state_manager2 = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager2.load_state()
# State loads successfully
self.assertIsNotNone(state.current_book)
self.assertEqual(state.current_book.path, "/nonexistent/book.epub")
# But trying to load the book should fail gracefully
reader = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
success = reader.load_epub(state.current_book.path)
self.assertFalse(success, "Should fail to load non-existent book")
self.assertFalse(reader.is_loaded(), "Reader should not be in loaded state")
reader.close()
def test_no_state_file_cold_start(self):
"""Test first boot with no existing state file"""
# Ensure no state file exists
if self.state_file.exists():
self.state_file.unlink()
# Create state manager
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
state = state_manager.load_state()
# Should get default state
self.assertEqual(state.mode, EreaderMode.LIBRARY)
self.assertIsNone(state.current_book)
self.assertEqual(state.overlay, OverlayState.NONE)
self.assertEqual(state.settings.font_scale, 1.0)
# Should be able to save new state
success = state_manager.save_state(force=True)
self.assertTrue(success, "Failed to save initial state")
self.assertTrue(self.state_file.exists(), "State file not created")
def test_position_survives_settings_change(self):
"""Test that position is preserved when settings change"""
# Create reader and navigate
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Navigate to specific position
for _ in range(4):
reader1.next_page()
reader1.save_position("__auto_resume__")
position1_info = reader1.get_position_info()
# Change font size (which re-paginates)
reader1.increase_font_size()
reader1.increase_font_size()
# Position might change due to repagination, but logical position is preserved
# Save again
reader1.save_position("__auto_resume__")
position_after_resize_info = reader1.get_position_info()
position_after_resize_progress = reader1.get_reading_progress()
reader1.close()
# Create new reader with same settings
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
# Apply same font size
reader2.increase_font_size()
reader2.increase_font_size()
# Load position
reader2.load_position("__auto_resume__")
position2_info = reader2.get_position_info()
position2_progress = reader2.get_reading_progress()
# Should match the position after resize, not the original
self.assertEqual(position_after_resize_info.get('position'), position2_info.get('position'),
"Position not preserved after font size change")
self.assertAlmostEqual(position_after_resize_progress, position2_progress, places=2,
msg="Progress not preserved after font size change")
reader2.close()
def test_chapter_position_restoration(self):
"""Test that chapter context is preserved across sessions"""
# Create reader and jump to specific chapter
reader1 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader1.load_epub(self.epub_path)
# Get chapters
chapters = reader1.get_chapters()
if len(chapters) < 2:
self.skipTest("Test EPUB needs at least 2 chapters")
# Jump to second chapter
_, chapter_idx = chapters[1]
reader1.jump_to_chapter(chapter_idx)
# Navigate a bit within the chapter
reader1.next_page()
# Save position
reader1.save_position("__auto_resume__")
chapter1_position = reader1.get_position_info()
chapter1_progress = reader1.get_reading_progress()
reader1.close()
# Create new reader and restore
reader2 = EbookReader(
bookmarks_dir=str(self.bookmarks_dir),
highlights_dir=str(self.highlights_dir)
)
reader2.load_epub(self.epub_path)
reader2.load_position("__auto_resume__")
# Verify we're at the right position
chapter2_position = reader2.get_position_info()
chapter2_progress = reader2.get_reading_progress()
self.assertEqual(chapter1_position.get('position'), chapter2_position.get('position'),
"Chapter position not restored correctly")
self.assertAlmostEqual(chapter1_progress, chapter2_progress, places=2,
msg="Chapter progress not restored correctly")
reader2.close()
class TestStateManagerAsync(unittest.TestCase):
"""Test StateManager async functionality"""
def setUp(self):
"""Set up test environment"""
self.temp_dir = tempfile.mkdtemp()
self.state_file = Path(self.temp_dir) / "state.json"
def tearDown(self):
"""Clean up test environment"""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_async_auto_save(self):
"""Test that async auto-save works"""
async def test_auto_save():
# Create state manager with short interval
state_manager = StateManager(str(self.state_file), auto_save_interval=1)
# Start auto-save
state_manager.start_auto_save()
# Make a change
state_manager.set_mode(EreaderMode.READING)
# Wait for auto-save to trigger
await asyncio.sleep(1.5)
# Stop auto-save
await state_manager.stop_auto_save(save_final=True)
# Verify file was saved
self.assertTrue(self.state_file.exists(), "State file not created")
# Load and verify
with open(self.state_file) as f:
data = json.load(f)
self.assertEqual(data['mode'], 'reading')
# Run async test
asyncio.run(test_auto_save())
def test_async_save_with_lock(self):
"""Test that async saves are thread-safe"""
async def test_concurrent_saves():
state_manager = StateManager(str(self.state_file), auto_save_interval=999)
# Make multiple concurrent saves
tasks = []
for i in range(10):
state_manager.update_setting('brightness', i)
tasks.append(state_manager.save_state_async(force=True))
# Wait for all saves
results = await asyncio.gather(*tasks)
# All should succeed
self.assertTrue(all(results), "Some saves failed")
# File should exist and be valid
self.assertTrue(self.state_file.exists())
# Load and verify (should have last value)
with open(self.state_file) as f:
data = json.load(f)
# Brightness should be set (exact value depends on race, but should be 0-9)
self.assertIn(data['settings']['brightness'], range(10))
asyncio.run(test_concurrent_saves())
if __name__ == '__main__':
unittest.main()

View File

@ -1,196 +0,0 @@
"""
Tests for the unified navigation overlay (TOC + Bookmarks tabs)
"""
import pytest
from pathlib import Path
from PIL import Image
from dreader.application import EbookReader
from dreader.state import OverlayState
from dreader.gesture import TouchEvent, GestureType, ActionType
@pytest.fixture
def reader_with_book():
"""Create a reader with a test book loaded"""
reader = EbookReader(page_size=(400, 600), margin=10)
# Load a simple test book
test_book = Path(__file__).parent.parent / "examples" / "books" / "hamlet.epub"
if test_book.exists():
reader.load_epub(str(test_book))
else:
# Fallback: create simple HTML for testing
html = """
<html>
<body>
<h1>Chapter 1</h1>
<p>This is chapter 1 content</p>
<h1>Chapter 2</h1>
<p>This is chapter 2 content</p>
</body>
</html>
"""
reader.load_html(html, title="Test Book", author="Test Author", document_id="test")
yield reader
reader.close()
def test_open_navigation_overlay_contents_tab(reader_with_book):
"""Test opening navigation overlay with Contents tab active"""
reader = reader_with_book
# Open navigation overlay with contents tab
image = reader.open_navigation_overlay(active_tab="contents")
assert image is not None
assert isinstance(image, Image.Image)
assert reader.get_overlay_state() == OverlayState.NAVIGATION
assert reader.is_overlay_open()
def test_open_navigation_overlay_bookmarks_tab(reader_with_book):
"""Test opening navigation overlay with Bookmarks tab active"""
reader = reader_with_book
# Save a bookmark first
reader.save_position("Test Bookmark")
# Open navigation overlay with bookmarks tab
image = reader.open_navigation_overlay(active_tab="bookmarks")
assert image is not None
assert isinstance(image, Image.Image)
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_switch_navigation_tabs(reader_with_book):
"""Test switching between Contents and Bookmarks tabs"""
reader = reader_with_book
# Open with contents tab
reader.open_navigation_overlay(active_tab="contents")
# Switch to bookmarks
image = reader.switch_navigation_tab("bookmarks")
assert image is not None
assert reader.get_overlay_state() == OverlayState.NAVIGATION
# Switch back to contents
image = reader.switch_navigation_tab("contents")
assert image is not None
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_close_navigation_overlay(reader_with_book):
"""Test closing navigation overlay"""
reader = reader_with_book
# Open overlay
reader.open_navigation_overlay()
assert reader.is_overlay_open()
# Close overlay
image = reader.close_overlay()
assert image is not None
assert not reader.is_overlay_open()
assert reader.get_overlay_state() == OverlayState.NONE
def test_navigation_overlay_tab_switching_gesture(reader_with_book):
"""Test tab switching via gesture/touch handling"""
reader = reader_with_book
# Open navigation overlay
reader.open_navigation_overlay(active_tab="contents")
# Query the overlay to find the bookmarks tab button
# This would normally be done by finding the coordinates of the "Bookmarks" tab
# For now, we test that the switch method works
result = reader.switch_navigation_tab("bookmarks")
assert result is not None
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_navigation_overlay_with_no_bookmarks(reader_with_book):
"""Test navigation overlay when there are no bookmarks"""
reader = reader_with_book
# Open bookmarks tab (should show "No bookmarks yet")
image = reader.open_navigation_overlay(active_tab="bookmarks")
assert image is not None
# The overlay should still open successfully
assert reader.get_overlay_state() == OverlayState.NAVIGATION
def test_navigation_overlay_preserves_page_position(reader_with_book):
"""Test that opening/closing navigation overlay preserves reading position"""
reader = reader_with_book
# Go to page 2
reader.next_page()
initial_position = reader.get_position_info()
# Open and close navigation overlay
reader.open_navigation_overlay()
reader.close_overlay()
# Verify position hasn't changed
final_position = reader.get_position_info()
assert initial_position == final_position
def test_navigation_overlay_chapter_selection(reader_with_book):
"""Test selecting a chapter from the navigation overlay"""
reader = reader_with_book
# Get chapters
chapters = reader.get_chapters()
if len(chapters) < 2:
pytest.skip("Test book doesn't have enough chapters")
# Open navigation overlay
reader.open_navigation_overlay(active_tab="contents")
# Get initial position
initial_position = reader.get_position_info()
# Jump to chapter via the reader method (simulating a tap on chapter)
reader.jump_to_chapter(chapters[1][1]) # chapters[1] = (title, index)
reader.close_overlay()
# Verify position changed
new_position = reader.get_position_info()
assert new_position != initial_position
def test_navigation_overlay_bookmark_selection(reader_with_book):
"""Test selecting a bookmark from the navigation overlay"""
reader = reader_with_book
# Save a bookmark at page 1
reader.save_position("Bookmark 1")
# Move to a different page
reader.next_page()
position_before = reader.get_position_info()
# Open navigation overlay with bookmarks tab
reader.open_navigation_overlay(active_tab="bookmarks")
# Load the bookmark (simulating a tap on bookmark)
page = reader.load_position("Bookmark 1")
assert page is not None
reader.close_overlay()
# Verify position changed back to bookmark
position_after = reader.get_position_info()
assert position_after != position_before
if __name__ == "__main__":
pytest.main([__file__, "-v"])