From 18be4306bfb57a6383e5a13ca123afbabf82f7d0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 8 Nov 2025 23:56:32 +0100 Subject: [PATCH] trests for rebooting --- tests/test_boot_recovery.py | 589 ++++++++++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 tests/test_boot_recovery.py diff --git a/tests/test_boot_recovery.py b/tests/test_boot_recovery.py new file mode 100644 index 0000000..ec4de27 --- /dev/null +++ b/tests/test_boot_recovery.py @@ -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()