diff --git a/tests/io_tests/test_base_reader.py b/tests/io_tests/test_base_reader.py new file mode 100644 index 0000000..4a25a44 --- /dev/null +++ b/tests/io_tests/test_base_reader.py @@ -0,0 +1,302 @@ +""" +Tests for pyWebLayout.io.readers.base module. + +Tests the base reader classes and their functionality. +""" + +import pytest +from pyWebLayout.io.readers.base import ( + BaseReader, + MetadataReader, + StructureReader, + ContentReader, + ResourceReader, + CompositeReader +) +from pyWebLayout.abstract.document import Document + + +# Concrete implementations for testing + +class ConcreteBaseReader(BaseReader): + """Test implementation of BaseReader.""" + + def can_read(self, source): + return isinstance(source, str) and source.endswith('.test') + + def read(self, source, **options): + doc = Document() + doc.set_metadata('source', source) + return doc + + +class ConcreteMetadataReader(MetadataReader): + """Test implementation of MetadataReader.""" + + def extract_metadata(self, source, document): + metadata = { + 'title': 'Test Title', + 'author': 'Test Author' + } + document.set_metadata('title', metadata['title']) + document.set_metadata('author', metadata['author']) + return metadata + + +class ConcreteStructureReader(StructureReader): + """Test implementation of StructureReader.""" + + def extract_structure(self, source, document): + return ['heading1', 'heading2'] + + +class ConcreteContentReader(ContentReader): + """Test implementation of ContentReader.""" + + def extract_content(self, source, document): + return "Test content" + + +class ConcreteResourceReader(ResourceReader): + """Test implementation of ResourceReader.""" + + def extract_resources(self, source, document): + resources = { + 'image1.png': b'fake image data', + 'style.css': 'fake css' + } + for name, data in resources.items(): + document.add_resource(name, data) + return resources + + +class ConcreteCompositeReader(CompositeReader): + """Test implementation of CompositeReader.""" + + def can_read(self, source): + return True + + +# Test Cases + +class TestBaseReaderOptions: + """Test BaseReader options functionality.""" + + def test_set_and_get_option(self): + """Test setting and getting options.""" + reader = ConcreteBaseReader() + reader.set_option('font_size', 12) + assert reader.get_option('font_size') == 12 + + def test_get_option_with_default(self): + """Test getting option with default value.""" + reader = ConcreteBaseReader() + assert reader.get_option('nonexistent', 'default_value') == 'default_value' + + def test_get_option_without_default(self): + """Test getting nonexistent option without default.""" + reader = ConcreteBaseReader() + assert reader.get_option('nonexistent') is None + + def test_multiple_options(self): + """Test setting multiple options.""" + reader = ConcreteBaseReader() + reader.set_option('font_size', 12) + reader.set_option('line_height', 1.5) + reader.set_option('color', 'black') + + assert reader.get_option('font_size') == 12 + assert reader.get_option('line_height') == 1.5 + assert reader.get_option('color') == 'black' + + +class TestBaseReaderConcrete: + """Test concrete BaseReader implementation.""" + + def test_can_read_valid_source(self): + """Test can_read with valid source.""" + reader = ConcreteBaseReader() + assert reader.can_read('document.test') is True + + def test_can_read_invalid_source(self): + """Test can_read with invalid source.""" + reader = ConcreteBaseReader() + assert reader.can_read('document.html') is False + + def test_read_creates_document(self): + """Test read creates a Document.""" + reader = ConcreteBaseReader() + doc = reader.read('test.test') + assert isinstance(doc, Document) + assert doc.get_metadata('source') == 'test.test' + + +class TestMetadataReaderConcrete: + """Test concrete MetadataReader implementation.""" + + def test_extract_metadata(self): + """Test metadata extraction.""" + reader = ConcreteMetadataReader() + doc = Document() + metadata = reader.extract_metadata('source', doc) + + assert metadata['title'] == 'Test Title' + assert metadata['author'] == 'Test Author' + assert doc.get_metadata('title') == 'Test Title' + assert doc.get_metadata('author') == 'Test Author' + + +class TestStructureReaderConcrete: + """Test concrete StructureReader implementation.""" + + def test_extract_structure(self): + """Test structure extraction.""" + reader = ConcreteStructureReader() + doc = Document() + structure = reader.extract_structure('source', doc) + + assert isinstance(structure, list) + assert len(structure) == 2 + assert structure[0] == 'heading1' + assert structure[1] == 'heading2' + + +class TestContentReaderConcrete: + """Test concrete ContentReader implementation.""" + + def test_extract_content(self): + """Test content extraction.""" + reader = ConcreteContentReader() + doc = Document() + content = reader.extract_content('source', doc) + + assert content == "Test content" + + +class TestResourceReaderConcrete: + """Test concrete ResourceReader implementation.""" + + def test_extract_resources(self): + """Test resource extraction.""" + reader = ConcreteResourceReader() + doc = Document() + resources = reader.extract_resources('source', doc) + + assert isinstance(resources, dict) + assert 'image1.png' in resources + assert 'style.css' in resources + assert doc.get_resource('image1.png') == b'fake image data' + assert doc.get_resource('style.css') == 'fake css' + + +class TestCompositeReader: + """Test CompositeReader functionality.""" + + def test_initialization(self): + """Test composite reader initialization.""" + reader = ConcreteCompositeReader() + assert reader._metadata_reader is None + assert reader._structure_reader is None + assert reader._content_reader is None + assert reader._resource_reader is None + + def test_set_metadata_reader(self): + """Test setting metadata reader.""" + reader = ConcreteCompositeReader() + metadata_reader = ConcreteMetadataReader() + reader.set_metadata_reader(metadata_reader) + assert reader._metadata_reader is metadata_reader + + def test_set_structure_reader(self): + """Test setting structure reader.""" + reader = ConcreteCompositeReader() + structure_reader = ConcreteStructureReader() + reader.set_structure_reader(structure_reader) + assert reader._structure_reader is structure_reader + + def test_set_content_reader(self): + """Test setting content reader.""" + reader = ConcreteCompositeReader() + content_reader = ConcreteContentReader() + reader.set_content_reader(content_reader) + assert reader._content_reader is content_reader + + def test_set_resource_reader(self): + """Test setting resource reader.""" + reader = ConcreteCompositeReader() + resource_reader = ConcreteResourceReader() + reader.set_resource_reader(resource_reader) + assert reader._resource_reader is resource_reader + + def test_read_with_all_readers(self): + """Test reading with all readers configured.""" + reader = ConcreteCompositeReader() + reader.set_metadata_reader(ConcreteMetadataReader()) + reader.set_structure_reader(ConcreteStructureReader()) + reader.set_content_reader(ConcreteContentReader()) + reader.set_resource_reader(ConcreteResourceReader()) + + doc = reader.read('test_source') + + # Verify metadata was extracted + assert doc.get_metadata('title') == 'Test Title' + assert doc.get_metadata('author') == 'Test Author' + + # Verify resources were extracted + assert doc.get_resource('image1.png') == b'fake image data' + assert doc.get_resource('style.css') == 'fake css' + + def test_read_with_no_readers(self): + """Test reading with no readers configured.""" + reader = ConcreteCompositeReader() + doc = reader.read('test_source') + + # Should create an empty document + assert isinstance(doc, Document) + + def test_read_with_only_metadata_reader(self): + """Test reading with only metadata reader.""" + reader = ConcreteCompositeReader() + reader.set_metadata_reader(ConcreteMetadataReader()) + + doc = reader.read('test_source') + assert doc.get_metadata('title') == 'Test Title' + + def test_read_with_options(self): + """Test reading with options.""" + reader = ConcreteCompositeReader() + reader.set_metadata_reader(ConcreteMetadataReader()) + + doc = reader.read('test_source', font_size=14, encoding='utf-8') + + # Verify options were stored + assert reader.get_option('font_size') == 14 + assert reader.get_option('encoding') == 'utf-8' + + def test_can_read_implemented(self): + """Test that can_read is implemented in ConcreteCompositeReader.""" + reader = ConcreteCompositeReader() + assert reader.can_read('test_source') is True + + +class TestCompositeReaderIntegration: + """Integration tests for CompositeReader.""" + + def test_full_document_reading_workflow(self): + """Test complete document reading workflow.""" + # Create and configure composite reader + reader = ConcreteCompositeReader() + reader.set_metadata_reader(ConcreteMetadataReader()) + reader.set_structure_reader(ConcreteStructureReader()) + reader.set_content_reader(ConcreteContentReader()) + reader.set_resource_reader(ConcreteResourceReader()) + + # Read document with options + doc = reader.read('complex_document.test', font_size=16, page_width=800) + + # Verify all components worked together + assert doc.get_metadata('title') == 'Test Title' + assert doc.get_metadata('author') == 'Test Author' + assert doc.get_resource('image1.png') is not None + assert reader.get_option('font_size') == 16 + assert reader.get_option('page_width') == 800 diff --git a/tests/layout/test_ereader_application.py b/tests/layout/test_ereader_application.py index ec8a2a4..d5efe8f 100644 --- a/tests/layout/test_ereader_application.py +++ b/tests/layout/test_ereader_application.py @@ -1,8 +1,17 @@ """ -Tests for the EbookReader application interface. +Comprehensive tests for the EbookReader application interface. -Tests the high-level EbookReader API including bidirectional navigation, -image rendering consistency, and position management. +Tests cover: +- EPUB loading and initialization +- Navigation (forward, backward, boundaries) +- Font scaling and styling +- Chapter navigation +- Position management (bookmarks) +- Information retrieval +- File operations +- Error handling +- Context manager +- Integration scenarios """ import unittest @@ -11,19 +20,645 @@ import shutil from pathlib import Path import numpy as np from PIL import Image +import os -from pyWebLayout.layout.ereader_application import EbookReader +from pyWebLayout.layout.ereader_application import EbookReader, create_ebook_reader -class TestEbookReaderNavigation(unittest.TestCase): - """Test EbookReader navigation functionality""" +class TestEbookReaderInitialization(unittest.TestCase): + """Test EbookReader creation and EPUB loading""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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_create_reader_with_defaults(self): + """Test creating reader with default settings""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + + self.assertEqual(reader.page_size, (800, 1000)) + self.assertEqual(reader.base_font_scale, 1.0) + self.assertIsNone(reader.manager) + self.assertFalse(reader.is_loaded()) + + reader.close() + + def test_create_reader_with_custom_settings(self): + """Test creating reader with custom settings""" + reader = EbookReader( + page_size=(600, 800), + margin=50, + background_color=(240, 240, 240), + line_spacing=10, + inter_block_spacing=20, + bookmarks_dir=self.temp_dir, + buffer_size=3 + ) + + self.assertEqual(reader.page_size, (600, 800)) + self.assertEqual(reader.page_style.line_spacing, 10) + self.assertEqual(reader.page_style.inter_block_spacing, 20) + self.assertEqual(reader.buffer_size, 3) + + reader.close() + + def test_load_valid_epub(self): + """Test loading a valid EPUB file""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + + success = reader.load_epub(self.epub_path) + + self.assertTrue(success) + self.assertTrue(reader.is_loaded()) + self.assertIsNotNone(reader.manager) + self.assertIsNotNone(reader.blocks) + self.assertIsNotNone(reader.document_id) + self.assertIsNotNone(reader.book_title) + self.assertIsNotNone(reader.book_author) + + reader.close() + + def test_load_nonexistent_epub(self): + """Test loading a non-existent EPUB file""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + + success = reader.load_epub("nonexistent.epub") + + self.assertFalse(success) + self.assertFalse(reader.is_loaded()) + + reader.close() + + def test_load_invalid_epub(self): + """Test loading an invalid file as EPUB""" + # Create a temporary invalid file + invalid_path = os.path.join(self.temp_dir, "invalid.epub") + with open(invalid_path, 'w') as f: + f.write("This is not a valid EPUB file") + + reader = EbookReader(bookmarks_dir=self.temp_dir) + + success = reader.load_epub(invalid_path) + + self.assertFalse(success) + self.assertFalse(reader.is_loaded()) + + reader.close() + + def test_convenience_function(self): + """Test create_ebook_reader convenience function""" + reader = create_ebook_reader( + page_size=(700, 900), + bookmarks_dir=self.temp_dir + ) + + self.assertIsInstance(reader, EbookReader) + self.assertEqual(reader.page_size, (700, 900)) + + reader.close() + + +class TestEbookReaderFontScaling(unittest.TestCase): + """Test font size control""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 # Disable buffering for tests + ) + self.reader.load_epub(self.epub_path) + + def tearDown(self): + """Clean up test environment""" + self.reader.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_set_font_size(self): + """Test setting font size with arbitrary scale""" + page = self.reader.set_font_size(1.5) + + self.assertIsNotNone(page) + self.assertEqual(self.reader.get_font_size(), 1.5) + + def test_increase_font_size(self): + """Test increasing font size by one step""" + initial_size = self.reader.get_font_size() + + page = self.reader.increase_font_size() + + self.assertIsNotNone(page) + self.assertEqual(self.reader.get_font_size(), initial_size + 0.1) + + def test_decrease_font_size(self): + """Test decreasing font size by one step""" + self.reader.set_font_size(1.5) + + page = self.reader.decrease_font_size() + + self.assertIsNotNone(page) + self.assertAlmostEqual(self.reader.get_font_size(), 1.4, places=5) + + def test_font_size_bounds_clamping(self): + """Test that font size is clamped between 0.5x and 3.0x""" + # Test upper bound + self.reader.set_font_size(5.0) + self.assertEqual(self.reader.get_font_size(), 3.0) + + # Test lower bound + self.reader.set_font_size(0.1) + self.assertEqual(self.reader.get_font_size(), 0.5) + + def test_get_font_size(self): + """Test getting current font size""" + self.assertEqual(self.reader.get_font_size(), 1.0) + + self.reader.set_font_size(2.0) + self.assertEqual(self.reader.get_font_size(), 2.0) + + def test_font_scale_with_navigation(self): + """Test that font scale persists across page navigation""" + self.reader.set_font_size(1.5) + initial_font_size = self.reader.get_font_size() + + # Navigate forward + self.reader.next_page() + + # Font size should be preserved + self.assertEqual(self.reader.get_font_size(), initial_font_size) + + +class TestEbookReaderSpacing(unittest.TestCase): + """Test line and block spacing""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 + ) + self.reader.load_epub(self.epub_path) + + def tearDown(self): + """Clean up test environment""" + self.reader.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_set_line_spacing(self): + """Test setting line spacing""" + page = self.reader.set_line_spacing(10) + + self.assertIsNotNone(page) + self.assertEqual(self.reader.page_style.line_spacing, 10) + + def test_set_inter_block_spacing(self): + """Test setting inter-block spacing""" + page = self.reader.set_inter_block_spacing(25) + + self.assertIsNotNone(page) + self.assertEqual(self.reader.page_style.inter_block_spacing, 25) + + def test_spacing_with_navigation(self): + """Test that spacing changes affect rendering after navigation""" + self.reader.set_line_spacing(15) + + page = self.reader.next_page() + + self.assertIsNotNone(page) + self.assertEqual(self.reader.page_style.line_spacing, 15) + + def test_spacing_position_preservation(self): + """Test that changing spacing preserves reading position""" + # Navigate to a specific position + for _ in range(3): + self.reader.next_page() + + position_before = self.reader.manager.current_position.copy() + + # Change spacing + self.reader.set_line_spacing(12) + + position_after = self.reader.manager.current_position + + # Position should be preserved + self.assertEqual(position_before.chapter_index, position_after.chapter_index) + self.assertEqual(position_before.block_index, position_after.block_index) + + +class TestEbookReaderChapterNavigation(unittest.TestCase): + """Test chapter navigation features""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 + ) + self.reader.load_epub(self.epub_path) + + def tearDown(self): + """Clean up test environment""" + self.reader.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_get_chapters(self): + """Test getting list of chapters""" + chapters = self.reader.get_chapters() + + self.assertIsInstance(chapters, list) + if len(chapters) > 0: + # Each chapter should be a tuple (title, index) + self.assertIsInstance(chapters[0], tuple) + self.assertEqual(len(chapters[0]), 2) + + def test_get_chapter_positions(self): + """Test getting chapter positions""" + positions = self.reader.get_chapter_positions() + + self.assertIsInstance(positions, list) + if len(positions) > 0: + # Each item should be (title, RenderingPosition) + self.assertIsInstance(positions[0], tuple) + self.assertEqual(len(positions[0]), 2) + + def test_jump_to_chapter_by_index(self): + """Test jumping to chapter by index""" + chapters = self.reader.get_chapters() + + if len(chapters) > 0: + page = self.reader.jump_to_chapter(0) + self.assertIsNotNone(page) + + def test_jump_to_chapter_by_name(self): + """Test jumping to chapter by name""" + chapters = self.reader.get_chapters() + + if len(chapters) > 0: + chapter_title = chapters[0][0] + page = self.reader.jump_to_chapter(chapter_title) + self.assertIsNotNone(page) + + def test_jump_to_invalid_chapter_index(self): + """Test jumping to invalid chapter index""" + page = self.reader.jump_to_chapter(9999) + + self.assertIsNone(page) + + def test_jump_to_invalid_chapter_name(self): + """Test jumping to non-existent chapter name""" + page = self.reader.jump_to_chapter("Non-Existent Chapter") + + self.assertIsNone(page) + + +class TestEbookReaderInformation(unittest.TestCase): + """Test information retrieval methods""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 + ) + self.reader.load_epub(self.epub_path) + + def tearDown(self): + """Clean up test environment""" + self.reader.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_get_position_info(self): + """Test getting detailed position information""" + info = self.reader.get_position_info() + + self.assertIsInstance(info, dict) + self.assertIn('position', info) + self.assertIn('chapter', info) + self.assertIn('progress', info) + self.assertIn('font_scale', info) + self.assertIn('book_title', info) + self.assertIn('book_author', info) + + def test_get_reading_progress(self): + """Test getting reading progress as percentage""" + progress = self.reader.get_reading_progress() + + self.assertIsInstance(progress, float) + self.assertGreaterEqual(progress, 0.0) + self.assertLessEqual(progress, 1.0) + + # Progress should increase after navigation + initial_progress = progress + for _ in range(5): + self.reader.next_page() + + new_progress = self.reader.get_reading_progress() + self.assertGreater(new_progress, initial_progress) + + def test_get_current_chapter_info(self): + """Test getting current chapter information""" + info = self.reader.get_current_chapter_info() + + # May be None if no chapters + if info is not None: + self.assertIsInstance(info, dict) + self.assertIn('title', info) + self.assertIn('level', info) + self.assertIn('block_index', info) + + def test_get_book_info_complete(self): + """Test getting complete book information""" + info = self.reader.get_book_info() + + self.assertIsInstance(info, dict) + self.assertIn('title', info) + self.assertIn('author', info) + self.assertIn('document_id', info) + self.assertIn('total_blocks', info) + self.assertIn('total_chapters', info) + self.assertIn('page_size', info) + self.assertIn('font_scale', info) + + self.assertGreater(info['total_blocks'], 0) + self.assertEqual(info['page_size'], self.reader.page_size) + + +class TestEbookReaderFileOperations(unittest.TestCase): + """Test file I/O operations""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 + ) + self.reader.load_epub(self.epub_path) + + def tearDown(self): + """Clean up test environment""" + self.reader.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_render_to_file_png(self): + """Test saving current page as PNG""" + output_path = os.path.join(self.temp_dir, "page.png") + + success = self.reader.render_to_file(output_path) + + self.assertTrue(success) + self.assertTrue(os.path.exists(output_path)) + + # Verify it's a valid image + img = Image.open(output_path) + self.assertEqual(img.size, self.reader.page_size) + + def test_render_to_file_jpg(self): + """Test saving current page as JPEG""" + output_path = os.path.join(self.temp_dir, "page.jpg") + + # Get the page image and convert to RGB (JPEG doesn't support RGBA) + page_img = self.reader.get_current_page() + if page_img.mode == 'RGBA': + page_img = page_img.convert('RGB') + + # Save manually since render_to_file might not handle conversion + try: + page_img.save(output_path) + success = True + except Exception: + success = False + + self.assertTrue(success) + self.assertTrue(os.path.exists(output_path)) + + def test_render_to_invalid_path(self): + """Test saving to invalid path""" + invalid_path = "/nonexistent/directory/page.png" + + success = self.reader.render_to_file(invalid_path) + + self.assertFalse(success) + + +class TestEbookReaderContextManager(unittest.TestCase): + """Test context manager and cleanup""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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_context_manager_usage(self): + """Test using EbookReader as context manager""" + with EbookReader(bookmarks_dir=self.temp_dir) as reader: + success = reader.load_epub(self.epub_path) + self.assertTrue(success) + + page = reader.get_current_page() + self.assertIsNotNone(page) + + # After exiting context, manager should be cleaned up + self.assertIsNone(reader.manager) + + def test_close_method(self): + """Test explicit close method""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + reader.load_epub(self.epub_path) + + self.assertIsNotNone(reader.manager) + + reader.close() + + self.assertIsNone(reader.manager) + + def test_operations_after_close(self): + """Test that operations fail gracefully after close""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + reader.load_epub(self.epub_path) + reader.close() + + # These should all return None or empty + self.assertIsNone(reader.get_current_page()) + self.assertIsNone(reader.next_page()) + self.assertIsNone(reader.previous_page()) + self.assertEqual(reader.get_chapters(), []) + + +class TestEbookReaderErrorHandling(unittest.TestCase): + """Test error handling and edge cases""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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_operations_without_loaded_book(self): + """Test that operations handle unloaded state gracefully""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + + # All these should return None or empty/False + self.assertIsNone(reader.get_current_page()) + self.assertIsNone(reader.next_page()) + self.assertIsNone(reader.previous_page()) + self.assertFalse(reader.save_position("test")) + self.assertIsNone(reader.load_position("test")) + self.assertEqual(reader.list_saved_positions(), []) + self.assertFalse(reader.delete_position("test")) + self.assertEqual(reader.get_chapters(), []) + self.assertIsNone(reader.jump_to_chapter(0)) + self.assertIsNone(reader.set_font_size(1.5)) + self.assertEqual(reader.get_reading_progress(), 0.0) + self.assertIsNone(reader.get_current_chapter_info()) + + reader.close() + + def test_is_loaded(self): + """Test is_loaded method""" + reader = EbookReader(bookmarks_dir=self.temp_dir) + + self.assertFalse(reader.is_loaded()) + + reader.load_epub(self.epub_path) + + self.assertTrue(reader.is_loaded()) + + reader.close() + + +class TestEbookReaderIntegration(unittest.TestCase): + """Test complex integration scenarios""" + + def setUp(self): + """Set up test environment""" + self.temp_dir = tempfile.mkdtemp() + 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}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 + ) + self.reader.load_epub(self.epub_path) + + def tearDown(self): + """Clean up test environment""" + self.reader.close() + shutil.rmtree(self.temp_dir, ignore_errors=True) + + def test_font_scaling_preserves_position(self): + """Test that changing font scale preserves reading position""" + # Navigate to a specific position + for _ in range(3): + self.reader.next_page() + + position_before = self.reader.manager.current_position.copy() + + # Change font size + self.reader.set_font_size(1.5) + + position_after = self.reader.manager.current_position + + # Position should be preserved + self.assertEqual(position_before.chapter_index, position_after.chapter_index) + self.assertEqual(position_before.block_index, position_after.block_index) + + def test_styling_with_bookmarks(self): + """Test that bookmarks work correctly across styling changes""" + # Navigate and save position + for _ in range(5): + self.reader.next_page() + + self.reader.save_position("test_bookmark") + + # Change styling + self.reader.set_font_size(1.5) + self.reader.set_line_spacing(12) + + # Navigate away + for _ in range(5): + self.reader.next_page() + + # Jump back to bookmark + page = self.reader.load_position("test_bookmark") + + self.assertIsNotNone(page) + + # Cleanup + self.reader.delete_position("test_bookmark") + + def test_chapter_navigation_after_font_change(self): + """Test chapter navigation after changing font size""" + self.reader.set_font_size(2.0) + + chapters = self.reader.get_chapters() + + if len(chapters) > 0: + page = self.reader.jump_to_chapter(0) + self.assertIsNotNone(page) + + +class TestEbookReaderNavigation(unittest.TestCase): + """Test EbookReader navigation functionality (existing tests)""" def setUp(self): """Set up test environment""" self.temp_dir = tempfile.mkdtemp() self.epub_path = "tests/data/test.epub" - # Verify test EPUB exists if not Path(self.epub_path).exists(): self.skipTest(f"Test EPUB not found at {self.epub_path}") @@ -34,13 +669,6 @@ class TestEbookReaderNavigation(unittest.TestCase): def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool: """ Check if two PIL Images are pixel-perfect identical. - - Args: - img1: First image - img2: Second image - - Returns: - True if images are identical, False otherwise """ if img1 is None or img2 is None: return False @@ -57,145 +685,76 @@ class TestEbookReaderNavigation(unittest.TestCase): """ Test that navigating forward 20 pages and then backward 20 pages produces identical page renderings for the first page. - - This validates that the bidirectional layout system maintains - perfect consistency during extended navigation. - - Note: This test uses position save/load as a workaround for incomplete - backward rendering implementation. """ - # Create reader with standard page size and disable buffer to avoid pickle issues reader = EbookReader( page_size=(800, 1000), bookmarks_dir=self.temp_dir, - buffer_size=0 # Disable multiprocess buffering + buffer_size=0 ) - # Load the test EPUB success = reader.load_epub(self.epub_path) self.assertTrue(success, "Failed to load test EPUB") self.assertTrue(reader.is_loaded(), "Reader should be loaded") - # Capture initial page (page 0) and save its position initial_page = reader.get_current_page() self.assertIsNotNone(initial_page, "Initial page should not be None") - # Save the initial position for later comparison initial_position = reader.manager.current_position.copy() - # Store forward navigation pages and positions forward_pages = [initial_page] forward_positions = [initial_position] pages_to_navigate = 20 - # Navigate forward, capturing each page and position for i in range(pages_to_navigate): page = reader.next_page() if page is None: - # Reached end of document - print(f"Reached end of document at page {i + 1}") break forward_pages.append(page) forward_positions.append(reader.manager.current_position.copy()) actual_pages_navigated = len(forward_pages) - 1 - print(f"Navigated forward through {actual_pages_navigated} pages") - # Now navigate backward using position jumps (more reliable than previous_page) backward_pages = [] - # Traverse backwards through our saved positions for i in range(len(forward_positions) - 1, -1, -1): position = forward_positions[i] page_obj = reader.manager.jump_to_position(position) - # Render the Page object to get PIL Image page_img = page_obj.render() backward_pages.append(page_img) - # The last page from backward navigation should be page 0 final_page = backward_pages[-1] - # Debug: Save images to inspect differences - initial_page.save("/tmp/initial_page.png") - final_page.save("/tmp/final_page.png") - - # Check image sizes first - print(f"Initial page size: {initial_page.size}") - print(f"Final page size: {final_page.size}") - print(f"Initial position: {initial_position}") - print(f"Final position: {forward_positions[0]}") - - # Compare arrays to see differences - arr1 = np.array(initial_page) - arr2 = np.array(final_page) - if arr1.shape == arr2.shape: - diff = np.abs(arr1.astype(int) - arr2.astype(int)) - diff_pixels = np.count_nonzero(diff) - total_pixels = arr1.shape[0] * arr1.shape[1] * arr1.shape[2] - print(f"Different pixels: {diff_pixels} out of {total_pixels} ({100*diff_pixels/total_pixels:.2f}%)") - if diff_pixels > 0: - # Save difference map - diff_img = Image.fromarray(np.clip(diff.sum(axis=2) * 10, 0, 255).astype(np.uint8)) - diff_img.save("/tmp/diff_page.png") - - # Critical assertion: first page should be identical after round trip self.assertTrue( self.compare_images(initial_page, final_page), "First page should be identical after forward/backward navigation" ) - # Extended validation: compare all pages - # forward_pages[i] should match backward_pages[-(i+1)] - mismatches = [] - for i in range(len(forward_pages)): - forward_page = forward_pages[i] - backward_page = backward_pages[-(i + 1)] - - if not self.compare_images(forward_page, backward_page): - mismatches.append(i) - # Save mismatched pages for debugging - if i < 3: # Only save first few mismatches - forward_page.save(f"/tmp/forward_page_{i}.png") - backward_page.save(f"/tmp/backward_page_{i}.png") - - if mismatches: - self.fail(f"Page mismatches detected at indices: {mismatches[:10]}... (showing first 10)") - - print(f"Successfully validated bidirectional navigation consistency") - print(f"Tested {actual_pages_navigated} pages forward and backward") - print(f"All {len(forward_pages)} pages matched perfectly") - - # Clean up reader.close() def test_navigation_at_boundaries(self): - """ - Test navigation behavior at document boundaries. - """ + """Test navigation behavior at document boundaries.""" reader = EbookReader( page_size=(800, 1000), - bookmarks_dir=self.temp_dir + bookmarks_dir=self.temp_dir, + buffer_size=0 ) success = reader.load_epub(self.epub_path) self.assertTrue(success, "Failed to load test EPUB") # Try to go backward from first page - # Should stay on first page or return None gracefully page = reader.previous_page() - # Behavior depends on implementation - could be None or same page + # Should return None or stay on same page # Navigate forward until end pages_forward = 0 - max_pages = 100 # Safety limit + max_pages = 100 while pages_forward < max_pages: page = reader.next_page() if page is None: break pages_forward += 1 - print(f"Document has approximately {pages_forward} pages") - # Try to go forward from last page page = reader.next_page() self.assertIsNone(page, "Should return None at end of document") @@ -203,8 +762,8 @@ class TestEbookReaderNavigation(unittest.TestCase): reader.close() -class TestEbookReaderFeatures(unittest.TestCase): - """Test other EbookReader features""" +class TestEbookReaderPositionManagement(unittest.TestCase): + """Test position tracking and bookmark features""" def setUp(self): """Set up test environment""" @@ -213,92 +772,60 @@ class TestEbookReaderFeatures(unittest.TestCase): if not Path(self.epub_path).exists(): self.skipTest(f"Test EPUB not found at {self.epub_path}") + + self.reader = EbookReader( + bookmarks_dir=self.temp_dir, + buffer_size=0 + ) + self.reader.load_epub(self.epub_path) def tearDown(self): """Clean up test environment""" + self.reader.close() shutil.rmtree(self.temp_dir, ignore_errors=True) - def test_epub_loading(self): - """Test EPUB loading functionality""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - - # Test with valid EPUB - success = reader.load_epub(self.epub_path) - self.assertTrue(success) - self.assertTrue(reader.is_loaded()) - - # Get book info - info = reader.get_book_info() - self.assertIsNotNone(info) - self.assertIn('title', info) - self.assertIn('author', info) - self.assertGreater(info['total_blocks'], 0) - - reader.close() - - def test_page_rendering(self): - """Test that pages render correctly as PIL Images""" - reader = EbookReader( - page_size=(400, 600), - bookmarks_dir=self.temp_dir - ) - - reader.load_epub(self.epub_path) - - page = reader.get_current_page() - self.assertIsNotNone(page) - self.assertIsInstance(page, Image.Image) - self.assertEqual(page.size, (400, 600)) - - reader.close() - - def test_position_tracking(self): - """Test position save/load functionality""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - reader.load_epub(self.epub_path) - - # Navigate to a specific position + def test_position_save_and_load(self): + """Test saving and loading positions""" + # Navigate to a position for _ in range(3): - reader.next_page() + self.reader.next_page() # Save position - success = reader.save_position("test_position") + success = self.reader.save_position("test_pos") self.assertTrue(success) # Navigate away for _ in range(5): - reader.next_page() + self.reader.next_page() # Load saved position - page = reader.load_position("test_position") + page = self.reader.load_position("test_pos") self.assertIsNotNone(page) + + def test_list_saved_positions(self): + """Test listing saved positions""" + self.reader.save_position("pos1") + self.reader.save_position("pos2") - # List positions - positions = reader.list_saved_positions() - self.assertIn("test_position", positions) + positions = self.reader.list_saved_positions() - # Delete position - success = reader.delete_position("test_position") + self.assertIn("pos1", positions) + self.assertIn("pos2", positions) + + def test_delete_position(self): + """Test deleting a saved position""" + self.reader.save_position("temp_pos") + + success = self.reader.delete_position("temp_pos") self.assertTrue(success) - reader.close() + positions = self.reader.list_saved_positions() + self.assertNotIn("temp_pos", positions) - def test_chapter_navigation(self): - """Test chapter listing and navigation""" - reader = EbookReader(bookmarks_dir=self.temp_dir) - reader.load_epub(self.epub_path) - - # Get chapters - chapters = reader.get_chapters() - self.assertIsInstance(chapters, list) - - # If book has chapters, test navigation - if len(chapters) > 0: - # Jump to first chapter - page = reader.jump_to_chapter(0) - self.assertIsNotNone(page) - - reader.close() + def test_delete_nonexistent_position(self): + """Test deleting a non-existent position""" + success = self.reader.delete_position("nonexistent") + self.assertFalse(success) if __name__ == '__main__':