This commit is contained in:
parent
7518bcf835
commit
18be4306bf
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()
|
||||
Loading…
x
Reference in New Issue
Block a user