Compare commits
2 Commits
5d3e7fae7b
...
18be4306bf
| Author | SHA1 | Date | |
|---|---|---|---|
| 18be4306bf | |||
| 7518bcf835 |
@ -825,6 +825,85 @@ class EbookReader:
|
||||
self.close_overlay()
|
||||
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
|
||||
self.close_overlay()
|
||||
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
|
||||
@ -1127,6 +1206,64 @@ class EbookReader:
|
||||
|
||||
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]:
|
||||
"""
|
||||
Close the current overlay and return to reading view.
|
||||
|
||||
@ -125,5 +125,7 @@ class ActionType:
|
||||
OVERLAY_OPENED = "overlay_opened"
|
||||
OVERLAY_CLOSED = "overlay_closed"
|
||||
CHAPTER_SELECTED = "chapter_selected"
|
||||
BOOKMARK_SELECTED = "bookmark_selected"
|
||||
TAB_SWITCHED = "tab_switched"
|
||||
SETTING_CHANGED = "setting_changed"
|
||||
BACK_TO_LIBRARY = "back_to_library"
|
||||
|
||||
@ -502,3 +502,136 @@ def generate_bookmarks_overlay(bookmarks: List[Dict]) -> str:
|
||||
</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
|
||||
|
||||
@ -14,7 +14,8 @@ from .state import OverlayState
|
||||
from .html_generator import (
|
||||
generate_toc_overlay,
|
||||
generate_settings_overlay,
|
||||
generate_bookmarks_overlay
|
||||
generate_bookmarks_overlay,
|
||||
generate_navigation_overlay
|
||||
)
|
||||
|
||||
|
||||
@ -370,6 +371,115 @@ class OverlayManager:
|
||||
# Composite and return
|
||||
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]:
|
||||
"""
|
||||
Close the current overlay and return to base page.
|
||||
|
||||
@ -27,9 +27,10 @@ class EreaderMode(Enum):
|
||||
class OverlayState(Enum):
|
||||
"""Overlay states within READING mode"""
|
||||
NONE = "none"
|
||||
TOC = "toc"
|
||||
TOC = "toc" # Deprecated: use NAVIGATION instead
|
||||
SETTINGS = "settings"
|
||||
BOOKMARKS = "bookmarks"
|
||||
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead
|
||||
NAVIGATION = "navigation" # Unified overlay for TOC and Bookmarks
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
196
examples/navigation_overlay_example.py
Normal file
196
examples/navigation_overlay_example.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""
|
||||
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()
|
||||
589
tests/test_boot_recovery.py
Normal file
589
tests/test_boot_recovery.py
Normal file
@ -0,0 +1,589 @@
|
||||
"""
|
||||
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()
|
||||
196
tests/test_navigation_overlay.py
Normal file
196
tests/test_navigation_overlay.py
Normal file
@ -0,0 +1,196 @@
|
||||
"""
|
||||
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"])
|
||||
Loading…
x
Reference in New Issue
Block a user