dreader-application/tests/test_boot_recovery.py
2025-11-12 18:52:08 +00:00

590 lines
21 KiB
Python

"""
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()