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