""" Comprehensive unit tests for the dual-location system architecture. Tests the abstract/concrete position system, position translation, and position tracking components. """ import unittest import json from unittest.mock import Mock, patch from pyWebLayout.abstract.document import Document, Book, Chapter from pyWebLayout.abstract.block import Paragraph, Heading, BlockType from pyWebLayout.abstract.inline import Word from pyWebLayout.style import Font, Alignment from pyWebLayout.style.abstract_style import AbstractStyle from pyWebLayout.typesetting.abstract_position import ( AbstractPosition, ConcretePosition, ElementType, PositionAnchor ) from pyWebLayout.typesetting.position_translator import ( PositionTranslator, StyleParameters, PositionTracker ) class TestAbstractPosition(unittest.TestCase): """Test cases for AbstractPosition class.""" def setUp(self): """Set up test fixtures.""" self.position = AbstractPosition( document_id="test_doc", chapter_index=1, block_index=5, element_index=2, element_type=ElementType.PARAGRAPH, word_index=10, character_index=3 ) def test_initialization(self): """Test AbstractPosition initialization.""" self.assertEqual(self.position.document_id, "test_doc") self.assertEqual(self.position.chapter_index, 1) self.assertEqual(self.position.block_index, 5) self.assertEqual(self.position.element_index, 2) self.assertEqual(self.position.element_type, ElementType.PARAGRAPH) self.assertEqual(self.position.word_index, 10) self.assertEqual(self.position.character_index, 3) self.assertTrue(self.position.is_clean_boundary) self.assertEqual(self.position.confidence, 1.0) def test_default_initialization(self): """Test AbstractPosition with default values.""" pos = AbstractPosition() self.assertIsNone(pos.document_id) self.assertIsNone(pos.chapter_index) self.assertEqual(pos.block_index, 0) self.assertEqual(pos.element_index, 0) self.assertEqual(pos.element_type, ElementType.PARAGRAPH) self.assertIsNone(pos.word_index) self.assertIsNone(pos.character_index) self.assertTrue(pos.is_clean_boundary) self.assertEqual(pos.confidence, 1.0) def test_to_dict(self): """Test serialization to dictionary.""" data = self.position.to_dict() expected = { 'document_id': 'test_doc', 'chapter_index': 1, 'block_index': 5, 'element_index': 2, 'element_type': 'paragraph', 'word_index': 10, 'character_index': 3, 'row_index': None, 'cell_index': None, 'list_item_index': None, 'is_clean_boundary': True, 'confidence': 1.0 } self.assertEqual(data, expected) def test_from_dict(self): """Test deserialization from dictionary.""" data = { 'document_id': 'test_doc', 'chapter_index': 2, 'block_index': 3, 'element_index': 1, 'element_type': 'heading', 'word_index': 5, 'character_index': 2, 'is_clean_boundary': False, 'confidence': 0.8 } pos = AbstractPosition.from_dict(data) self.assertEqual(pos.document_id, 'test_doc') self.assertEqual(pos.chapter_index, 2) self.assertEqual(pos.block_index, 3) self.assertEqual(pos.element_index, 1) self.assertEqual(pos.element_type, ElementType.HEADING) self.assertEqual(pos.word_index, 5) self.assertEqual(pos.character_index, 2) self.assertFalse(pos.is_clean_boundary) self.assertEqual(pos.confidence, 0.8) def test_bookmark_serialization(self): """Test bookmark string serialization/deserialization.""" bookmark = self.position.to_bookmark() self.assertIsInstance(bookmark, str) # Should be valid JSON data = json.loads(bookmark) self.assertIsInstance(data, dict) # Should round-trip correctly restored = AbstractPosition.from_bookmark(bookmark) self.assertEqual(restored.document_id, self.position.document_id) self.assertEqual(restored.chapter_index, self.position.chapter_index) self.assertEqual(restored.block_index, self.position.block_index) self.assertEqual(restored.word_index, self.position.word_index) def test_copy(self): """Test position copying.""" copy = self.position.copy() self.assertEqual(copy.document_id, self.position.document_id) self.assertEqual(copy.chapter_index, self.position.chapter_index) self.assertEqual(copy.block_index, self.position.block_index) self.assertEqual(copy.word_index, self.position.word_index) # Should be independent objects copy.block_index = 999 self.assertNotEqual(copy.block_index, self.position.block_index) def test_get_hash(self): """Test position hashing.""" hash1 = self.position.get_hash() hash2 = self.position.get_hash() self.assertEqual(hash1, hash2) # Should be consistent # Different positions should have different hashes other = self.position.copy() other.block_index = 999 hash3 = other.get_hash() self.assertNotEqual(hash1, hash3) def test_is_before(self): """Test position comparison.""" pos1 = AbstractPosition(chapter_index=1, block_index=5, element_index=2, word_index=10) pos2 = AbstractPosition(chapter_index=1, block_index=5, element_index=2, word_index=15) pos3 = AbstractPosition(chapter_index=1, block_index=6, element_index=0, word_index=0) pos4 = AbstractPosition(chapter_index=2, block_index=0, element_index=0, word_index=0) # Same block, different words self.assertTrue(pos1.is_before(pos2)) self.assertFalse(pos2.is_before(pos1)) # Different blocks self.assertTrue(pos1.is_before(pos3)) self.assertFalse(pos3.is_before(pos1)) # Different chapters self.assertTrue(pos1.is_before(pos4)) self.assertFalse(pos4.is_before(pos1)) # Same position self.assertFalse(pos1.is_before(pos1)) def test_is_before_with_characters(self): """Test position comparison with character indices.""" pos1 = AbstractPosition(block_index=1, word_index=5, character_index=2) pos2 = AbstractPosition(block_index=1, word_index=5, character_index=8) self.assertTrue(pos1.is_before(pos2)) self.assertFalse(pos2.is_before(pos1)) def test_is_before_with_tables(self): """Test position comparison with table elements.""" pos1 = AbstractPosition(block_index=1, element_type=ElementType.TABLE, row_index=2, cell_index=1) pos2 = AbstractPosition(block_index=1, element_type=ElementType.TABLE, row_index=2, cell_index=3) pos3 = AbstractPosition(block_index=1, element_type=ElementType.TABLE, row_index=3, cell_index=0) # Same row, different cells self.assertTrue(pos1.is_before(pos2)) self.assertFalse(pos2.is_before(pos1)) # Different rows self.assertTrue(pos1.is_before(pos3)) self.assertFalse(pos3.is_before(pos1)) def test_get_progress_simple_document(self): """Test progress calculation for simple document.""" # Create a simple document doc = Document() for i in range(10): paragraph = Paragraph() paragraph.text = f"Paragraph {i}" doc.add_block(paragraph) # Test progress at different positions pos_start = AbstractPosition(block_index=0) pos_middle = AbstractPosition(block_index=5) pos_end = AbstractPosition(block_index=9) self.assertAlmostEqual(pos_start.get_progress(doc), 0.0, places=2) self.assertAlmostEqual(pos_middle.get_progress(doc), 0.5, places=2) self.assertAlmostEqual(pos_end.get_progress(doc), 0.9, places=2) def test_get_progress_book(self): """Test progress calculation for book with chapters.""" # Create a book with chapters book = Book() for i in range(3): chapter = Chapter(f"Chapter {i}") for j in range(5): paragraph = Paragraph() paragraph.text = f"Chapter {i}, Paragraph {j}" chapter.add_block(paragraph) book.add_chapter(chapter) # Test progress at different positions pos_start = AbstractPosition(chapter_index=0, block_index=0) pos_middle = AbstractPosition(chapter_index=1, block_index=2) pos_end = AbstractPosition(chapter_index=2, block_index=4) progress_start = pos_start.get_progress(book) progress_middle = pos_middle.get_progress(book) progress_end = pos_end.get_progress(book) self.assertGreater(progress_middle, progress_start) self.assertGreater(progress_end, progress_middle) self.assertLessEqual(progress_end, 1.0) def test_get_progress_empty_document(self): """Test progress calculation for empty document.""" doc = Document() pos = AbstractPosition(block_index=0) self.assertEqual(pos.get_progress(doc), 0.0) def test_get_progress_invalid_position(self): """Test progress calculation for invalid position.""" doc = Document() paragraph = Paragraph() paragraph.text = "Test paragraph" doc.add_block(paragraph) # Position beyond document end pos = AbstractPosition(block_index=999) progress = pos.get_progress(doc) self.assertGreaterEqual(progress, 0.0) self.assertLessEqual(progress, 1.0) class TestConcretePosition(unittest.TestCase): """Test cases for ConcretePosition class.""" def setUp(self): """Set up test fixtures.""" self.position = ConcretePosition( page_index=2, viewport_x=100, viewport_y=200, line_index=5, layout_hash="abc123", pixel_offset=10 ) def test_initialization(self): """Test ConcretePosition initialization.""" self.assertEqual(self.position.page_index, 2) self.assertEqual(self.position.viewport_x, 100) self.assertEqual(self.position.viewport_y, 200) self.assertEqual(self.position.line_index, 5) self.assertEqual(self.position.layout_hash, "abc123") self.assertTrue(self.position.is_valid) self.assertTrue(self.position.is_exact) self.assertEqual(self.position.pixel_offset, 10) def test_default_initialization(self): """Test ConcretePosition with default values.""" pos = ConcretePosition() self.assertEqual(pos.page_index, 0) self.assertEqual(pos.viewport_x, 0) self.assertEqual(pos.viewport_y, 0) self.assertIsNone(pos.line_index) self.assertIsNone(pos.layout_hash) self.assertTrue(pos.is_valid) self.assertTrue(pos.is_exact) self.assertEqual(pos.pixel_offset, 0) def test_invalidate(self): """Test position invalidation.""" self.assertTrue(self.position.is_valid) self.assertTrue(self.position.is_exact) self.position.invalidate() self.assertFalse(self.position.is_valid) self.assertFalse(self.position.is_exact) def test_update_layout_hash(self): """Test layout hash update.""" self.position.invalidate() self.assertFalse(self.position.is_valid) self.position.update_layout_hash("new_hash") self.assertTrue(self.position.is_valid) self.assertEqual(self.position.layout_hash, "new_hash") def test_to_dict(self): """Test serialization to dictionary.""" data = self.position.to_dict() expected = { 'page_index': 2, 'viewport_x': 100, 'viewport_y': 200, 'line_index': 5, 'layout_hash': 'abc123', 'is_valid': True, 'is_exact': True, 'pixel_offset': 10 } self.assertEqual(data, expected) def test_from_dict(self): """Test deserialization from dictionary.""" data = { 'page_index': 3, 'viewport_x': 150, 'viewport_y': 250, 'line_index': 7, 'layout_hash': 'def456', 'is_valid': False, 'is_exact': False, 'pixel_offset': 5 } pos = ConcretePosition.from_dict(data) self.assertEqual(pos.page_index, 3) self.assertEqual(pos.viewport_x, 150) self.assertEqual(pos.viewport_y, 250) self.assertEqual(pos.line_index, 7) self.assertEqual(pos.layout_hash, 'def456') self.assertFalse(pos.is_valid) self.assertFalse(pos.is_exact) self.assertEqual(pos.pixel_offset, 5) class TestStyleParameters(unittest.TestCase): """Test cases for StyleParameters class.""" def setUp(self): """Set up test fixtures.""" self.font = Font(font_size=16) self.params = StyleParameters( page_size=(800, 600), margins=(20, 15, 25, 10), default_font=self.font, line_spacing=4, paragraph_spacing=12, alignment=Alignment.CENTER ) def test_initialization(self): """Test StyleParameters initialization.""" self.assertEqual(self.params.page_size, (800, 600)) self.assertEqual(self.params.margins, (20, 15, 25, 10)) self.assertEqual(self.params.default_font, self.font) self.assertEqual(self.params.line_spacing, 4) self.assertEqual(self.params.paragraph_spacing, 12) self.assertEqual(self.params.alignment, Alignment.CENTER) def test_default_initialization(self): """Test StyleParameters with default values.""" params = StyleParameters() self.assertEqual(params.page_size, (800, 600)) self.assertEqual(params.margins, (20, 20, 20, 20)) self.assertIsInstance(params.default_font, Font) self.assertEqual(params.line_spacing, 3) self.assertEqual(params.paragraph_spacing, 10) self.assertEqual(params.alignment, Alignment.LEFT) def test_get_hash(self): """Test style parameters hashing.""" hash1 = self.params.get_hash() hash2 = self.params.get_hash() self.assertEqual(hash1, hash2) # Should be consistent # Different parameters should have different hashes other = StyleParameters(page_size=(900, 700)) hash3 = other.get_hash() self.assertNotEqual(hash1, hash3) def test_copy(self): """Test style parameters copying.""" copy = self.params.copy() self.assertEqual(copy.page_size, self.params.page_size) self.assertEqual(copy.margins, self.params.margins) self.assertEqual(copy.line_spacing, self.params.line_spacing) # Should be independent objects copy.line_spacing = 999 self.assertNotEqual(copy.line_spacing, self.params.line_spacing) def test_hash_consistency_with_font_changes(self): """Test that font changes affect hash.""" hash1 = self.params.get_hash() # Change font size self.params.default_font = Font(font_size=20) hash2 = self.params.get_hash() self.assertNotEqual(hash1, hash2) class TestPositionAnchor(unittest.TestCase): """Test cases for PositionAnchor class.""" def setUp(self): """Set up test fixtures.""" self.primary_pos = AbstractPosition( chapter_index=1, block_index=5, element_index=2, word_index=10 ) self.anchor = PositionAnchor(self.primary_pos) def test_initialization(self): """Test PositionAnchor initialization.""" self.assertEqual(self.anchor.primary_position, self.primary_pos) self.assertEqual(len(self.anchor.fallback_positions), 0) self.assertIsNone(self.anchor.context_text) self.assertEqual(self.anchor.document_progress, 0.0) self.assertEqual(self.anchor.paragraph_progress, 0.0) def test_add_fallback(self): """Test adding fallback positions.""" fallback = AbstractPosition(block_index=5, element_index=0) self.anchor.add_fallback(fallback) self.assertEqual(len(self.anchor.fallback_positions), 1) self.assertEqual(self.anchor.fallback_positions[0], fallback) def test_set_context(self): """Test setting context information.""" context = "This is some context text" doc_progress = 0.3 para_progress = 0.7 self.anchor.set_context(context, doc_progress, para_progress) self.assertEqual(self.anchor.context_text, context) self.assertEqual(self.anchor.document_progress, doc_progress) self.assertEqual(self.anchor.paragraph_progress, para_progress) def test_get_best_position_primary_valid(self): """Test getting best position when primary is valid.""" # Create a document with enough content doc = Document() for i in range(10): paragraph = Paragraph() paragraph.text = f"Paragraph {i}" doc.add_block(paragraph) best_pos = self.anchor.get_best_position(doc) self.assertEqual(best_pos, self.primary_pos) def test_get_best_position_fallback(self): """Test getting best position when primary is invalid.""" # Create a small document where primary position is invalid doc = Document() paragraph = Paragraph() paragraph.text = "Single paragraph" doc.add_block(paragraph) # Add a valid fallback fallback = AbstractPosition(block_index=0, element_index=0) self.anchor.add_fallback(fallback) best_pos = self.anchor.get_best_position(doc) self.assertEqual(best_pos, fallback) def test_get_best_position_approximate(self): """Test getting approximate position when all positions are invalid.""" # Create a document doc = Document() for i in range(3): paragraph = Paragraph() paragraph.text = f"Paragraph {i}" doc.add_block(paragraph) # Set progress information self.anchor.set_context("context", 0.5, 0.3) # All positions should be invalid (beyond document bounds) best_pos = self.anchor.get_best_position(doc) # Should get an approximate position self.assertIsInstance(best_pos, AbstractPosition) self.assertLess(best_pos.confidence, 1.0) # Should be marked as approximate def test_serialization(self): """Test PositionAnchor serialization.""" # Set up anchor with fallbacks and context fallback = AbstractPosition(block_index=3) self.anchor.add_fallback(fallback) self.anchor.set_context("test context", 0.4, 0.6) # Serialize data = self.anchor.to_dict() self.assertIn('primary_position', data) self.assertIn('fallback_positions', data) self.assertEqual(data['context_text'], "test context") self.assertEqual(data['document_progress'], 0.4) self.assertEqual(data['paragraph_progress'], 0.6) # Deserialize restored = PositionAnchor.from_dict(data) self.assertEqual(restored.primary_position.block_index, self.primary_pos.block_index) self.assertEqual(len(restored.fallback_positions), 1) self.assertEqual(restored.fallback_positions[0].block_index, 3) self.assertEqual(restored.context_text, "test context") self.assertEqual(restored.document_progress, 0.4) self.assertEqual(restored.paragraph_progress, 0.6) class TestPositionTranslator(unittest.TestCase): """Test cases for PositionTranslator class.""" def setUp(self): """Set up test fixtures.""" # Create a simple document self.doc = Document() for i in range(5): paragraph = Paragraph() paragraph.text = f"This is paragraph {i} with some text content." # Add some words to the paragraph words = paragraph.text.split() for word_text in words: word = Word(word_text, Font()) paragraph.add_word(word) self.doc.add_block(paragraph) self.style_params = StyleParameters( page_size=(800, 600), margins=(20, 20, 20, 20), default_font=Font(font_size=16) ) self.translator = PositionTranslator(self.doc, self.style_params) def test_initialization(self): """Test PositionTranslator initialization.""" self.assertEqual(self.translator.document, self.doc) self.assertEqual(self.translator.style_params, self.style_params) self.assertEqual(len(self.translator._layout_cache), 0) self.assertEqual(len(self.translator._position_cache), 0) def test_update_style_params(self): """Test updating style parameters.""" # Add something to caches self.translator._layout_cache['test'] = 'data' self.translator._position_cache['test'] = ConcretePosition() new_params = StyleParameters(page_size=(900, 700)) self.translator.update_style_params(new_params) self.assertEqual(self.translator.style_params, new_params) self.assertEqual(len(self.translator._layout_cache), 0) # Should be cleared self.assertEqual(len(self.translator._position_cache), 0) # Should be cleared def test_abstract_to_concrete(self): """Test converting abstract position to concrete.""" abstract_pos = AbstractPosition( block_index=2, element_index=0, word_index=3 ) concrete_pos = self.translator.abstract_to_concrete(abstract_pos) self.assertIsInstance(concrete_pos, ConcretePosition) self.assertGreaterEqual(concrete_pos.page_index, 0) self.assertIsNotNone(concrete_pos.layout_hash) self.assertTrue(concrete_pos.is_valid) def test_abstract_to_concrete_caching(self): """Test that position translation is cached.""" abstract_pos = AbstractPosition(block_index=1, word_index=5) # First call concrete_pos1 = self.translator.abstract_to_concrete(abstract_pos) # Second call should return cached result concrete_pos2 = self.translator.abstract_to_concrete(abstract_pos) self.assertEqual(concrete_pos1.page_index, concrete_pos2.page_index) self.assertEqual(concrete_pos1.viewport_x, concrete_pos2.viewport_x) self.assertEqual(concrete_pos1.viewport_y, concrete_pos2.viewport_y) def test_concrete_to_abstract(self): """Test converting concrete position to abstract.""" concrete_pos = ConcretePosition( page_index=1, viewport_x=100, viewport_y=200 ) abstract_pos = self.translator.concrete_to_abstract(concrete_pos) self.assertIsInstance(abstract_pos, AbstractPosition) self.assertGreaterEqual(abstract_pos.block_index, 0) def test_find_clean_boundary(self): """Test finding clean reading boundaries.""" # Position in middle of word pos_mid_word = AbstractPosition( block_index=1, word_index=3, character_index=5 ) clean_pos = self.translator.find_clean_boundary(pos_mid_word) self.assertIsInstance(clean_pos, AbstractPosition) self.assertEqual(clean_pos.block_index, 1) self.assertEqual(clean_pos.word_index, 3) self.assertEqual(clean_pos.character_index, 0) # Should move to word start self.assertTrue(clean_pos.is_clean_boundary) def test_find_clean_boundary_early_word(self): """Test clean boundary for early words in paragraph.""" # Position at second word pos_early = AbstractPosition( block_index=1, element_type=ElementType.PARAGRAPH, word_index=1, character_index=0 ) clean_pos = self.translator.find_clean_boundary(pos_early) # Should move to paragraph start for better reading experience self.assertEqual(clean_pos.word_index, 0) self.assertEqual(clean_pos.character_index, 0) def test_create_position_anchor(self): """Test creating position anchor with fallbacks.""" abstract_pos = AbstractPosition( block_index=2, element_index=0, word_index=5, character_index=2 ) anchor = self.translator.create_position_anchor(abstract_pos) self.assertIsInstance(anchor, PositionAnchor) self.assertEqual(anchor.primary_position, abstract_pos) self.assertGreater(len(anchor.fallback_positions), 0) # Should have fallbacks self.assertIsNotNone(anchor.context_text) # Should have context def test_create_position_anchor_with_context(self): """Test position anchor context extraction.""" abstract_pos = AbstractPosition( block_index=1, element_index=0, word_index=3 ) anchor = self.translator.create_position_anchor(abstract_pos, context_window=10) self.assertIsInstance(anchor.context_text, str) self.assertGreater(len(anchor.context_text), 0) self.assertGreaterEqual(anchor.document_progress, 0.0) self.assertLessEqual(anchor.document_progress, 1.0) class TestPositionTranslatorWithBook(unittest.TestCase): """Test PositionTranslator with Book documents.""" def setUp(self): """Set up test fixtures with a Book.""" self.book = Book() # Create chapters with content for i in range(3): chapter = Chapter(f"Chapter {i}") for j in range(4): paragraph = Paragraph() paragraph.text = f"Chapter {i}, paragraph {j} with content." words = paragraph.text.split() for word_text in words: word = Word(word_text, Font()) paragraph.add_word(word) chapter.add_block(paragraph) self.book.add_chapter(chapter) self.style_params = StyleParameters() self.translator = PositionTranslator(self.book, self.style_params) def test_abstract_to_concrete_with_chapters(self): """Test position translation with chapter structure.""" abstract_pos = AbstractPosition( chapter_index=1, block_index=2, element_index=0, word_index=1 ) concrete_pos = self.translator.abstract_to_concrete(abstract_pos) self.assertIsInstance(concrete_pos, ConcretePosition) self.assertGreaterEqual(concrete_pos.page_index, 0) def test_position_anchor_with_chapters(self): """Test position anchor creation with chapters.""" abstract_pos = AbstractPosition( chapter_index=1, block_index=1, word_index=2 ) anchor = self.translator.create_position_anchor(abstract_pos) # Should have chapter-aware fallbacks fallbacks = anchor.fallback_positions self.assertGreater(len(fallbacks), 0) # Should have correct chapter context for fallback in fallbacks: if fallback.chapter_index is not None: self.assertEqual(fallback.chapter_index, abstract_pos.chapter_index) class TestPositionTracker(unittest.TestCase): """Test cases for PositionTracker class.""" def setUp(self): """Set up test fixtures.""" # Create a document with content self.doc = Document() for i in range(5): paragraph = Paragraph() paragraph.text = f"This is paragraph {i} with some content." words = paragraph.text.split() for word_text in words: word = Word(word_text, Font()) paragraph.add_word(word) self.doc.add_block(paragraph) self.style_params = StyleParameters() self.tracker = PositionTracker(self.doc, self.style_params) def test_initialization(self): """Test PositionTracker initialization.""" self.assertEqual(self.tracker.document, self.doc) self.assertIsInstance(self.tracker.translator, PositionTranslator) self.assertIsNone(self.tracker.current_position) self.assertEqual(len(self.tracker.reading_history), 0) def test_set_get_current_position(self): """Test setting and getting current position.""" pos = AbstractPosition(block_index=2, word_index=5) self.tracker.set_current_position(pos) current = self.tracker.get_current_position() self.assertEqual(current, pos) def test_save_bookmark(self): """Test saving current position as bookmark.""" pos = AbstractPosition(block_index=3, word_index=7, character_index=2) self.tracker.set_current_position(pos) bookmark = self.tracker.save_bookmark() self.assertIsInstance(bookmark, str) self.assertGreater(len(bookmark), 0) # Should be valid JSON data = json.loads(bookmark) self.assertIsInstance(data, dict) self.assertIn('primary_position', data) def test_save_bookmark_no_position(self): """Test saving bookmark when no current position is set.""" bookmark = self.tracker.save_bookmark() self.assertEqual(bookmark, "") def test_load_bookmark(self): """Test loading position from bookmark.""" # Create and save a position original_pos = AbstractPosition(block_index=2, word_index=4, character_index=1) self.tracker.set_current_position(original_pos) bookmark = self.tracker.save_bookmark() # Clear current position self.tracker.set_current_position(None) self.assertIsNone(self.tracker.get_current_position()) # Load from bookmark success = self.tracker.load_bookmark(bookmark) self.assertTrue(success) restored_pos = self.tracker.get_current_position() self.assertIsNotNone(restored_pos) self.assertEqual(restored_pos.block_index, original_pos.block_index) self.assertEqual(restored_pos.word_index, original_pos.word_index) def test_load_invalid_bookmark(self): """Test loading from invalid bookmark.""" success = self.tracker.load_bookmark("invalid json") self.assertFalse(success) success = self.tracker.load_bookmark('{"invalid": "structure"}') self.assertFalse(success) def test_handle_style_change(self): """Test handling style parameter changes.""" # Set initial position pos = AbstractPosition(block_index=1, word_index=3) self.tracker.set_current_position(pos) # Change style parameters new_params = StyleParameters( page_size=(900, 700), margins=(30, 30, 30, 30), default_font=Font(font_size=20) ) self.tracker.handle_style_change(new_params) # Position should still be set (abstract positions survive style changes) current_pos = self.tracker.get_current_position() self.assertIsNotNone(current_pos) self.assertEqual(current_pos.block_index, pos.block_index) self.assertEqual(current_pos.word_index, pos.word_index) # History should be updated self.assertGreater(len(self.tracker.reading_history), 0) def test_get_concrete_position(self): """Test getting concrete position.""" pos = AbstractPosition(block_index=2, word_index=1) self.tracker.set_current_position(pos) concrete_pos = self.tracker.get_concrete_position() self.assertIsInstance(concrete_pos, ConcretePosition) self.assertGreaterEqual(concrete_pos.page_index, 0) def test_get_concrete_position_no_current(self): """Test getting concrete position when no current position is set.""" concrete_pos = self.tracker.get_concrete_position() self.assertIsNone(concrete_pos) def test_set_position_from_concrete(self): """Test setting position from concrete coordinates.""" concrete_pos = ConcretePosition( page_index=1, viewport_x=100, viewport_y=200 ) self.tracker.set_position_from_concrete(concrete_pos) current_pos = self.tracker.get_current_position() self.assertIsNotNone(current_pos) self.assertIsInstance(current_pos, AbstractPosition) self.assertTrue(current_pos.is_clean_boundary) # Should be cleaned def test_get_reading_progress(self): """Test getting reading progress.""" # No position set progress = self.tracker.get_reading_progress() self.assertEqual(progress, 0.0) # Set position in middle of document pos = AbstractPosition(block_index=2) # Middle of 5 blocks self.tracker.set_current_position(pos) progress = self.tracker.get_reading_progress() self.assertGreater(progress, 0.0) self.assertLess(progress, 1.0) # Set position at end pos_end = AbstractPosition(block_index=4) self.tracker.set_current_position(pos_end) progress_end = self.tracker.get_reading_progress() self.assertGreater(progress_end, progress) class TestPositionSystemIntegration(unittest.TestCase): """Integration tests for the complete position system.""" def setUp(self): """Set up test fixtures.""" # Create a more complex document self.book = Book() for i in range(3): chapter = Chapter(f"Chapter {i+1}") # Add a heading heading = Heading(f"Chapter {i+1} Title") chapter.add_block(heading) # Add several paragraphs for j in range(4): paragraph = Paragraph() paragraph.text = f"This is paragraph {j+1} of chapter {i+1}. " \ f"It contains multiple words and sentences. " \ f"This helps test the position system thoroughly." words = paragraph.text.split() for word_text in words: word = Word(word_text, Font()) paragraph.add_word(word) chapter.add_block(paragraph) self.book.add_chapter(chapter) self.style_params = StyleParameters( page_size=(800, 600), margins=(30, 30, 30, 30), default_font=Font(font_size=14) ) self.tracker = PositionTracker(self.book, self.style_params) def test_complete_workflow(self): """Test a complete reading workflow.""" # Start at beginning start_pos = AbstractPosition( chapter_index=0, block_index=1, # Skip heading, start at first paragraph element_index=0, word_index=0 ) self.tracker.set_current_position(start_pos) # Save bookmark bookmark1 = self.tracker.save_bookmark() self.assertGreater(len(bookmark1), 0) # Move to middle of book middle_pos = AbstractPosition( chapter_index=1, block_index=2, word_index=10, character_index=5 ) self.tracker.set_current_position(middle_pos) # Change style (font size increase) new_style = self.style_params.copy() new_style.default_font = Font(font_size=18) self.tracker.handle_style_change(new_style) # Position should survive style change current_pos = self.tracker.get_current_position() self.assertIsNotNone(current_pos) self.assertEqual(current_pos.chapter_index, middle_pos.chapter_index) self.assertEqual(current_pos.block_index, middle_pos.block_index) # Get concrete position concrete_pos = self.tracker.get_concrete_position() self.assertIsInstance(concrete_pos, ConcretePosition) # Test progress calculation progress = self.tracker.get_reading_progress() self.assertGreater(progress, 0.0) self.assertLess(progress, 1.0) # Restore from original bookmark success = self.tracker.load_bookmark(bookmark1) self.assertTrue(success) restored_pos = self.tracker.get_current_position() self.assertEqual(restored_pos.chapter_index, start_pos.chapter_index) self.assertEqual(restored_pos.block_index, start_pos.block_index) def test_position_comparison_across_chapters(self): """Test position comparison across different chapters.""" pos1 = AbstractPosition(chapter_index=0, block_index=1, word_index=5) pos2 = AbstractPosition(chapter_index=1, block_index=0, word_index=0) pos3 = AbstractPosition(chapter_index=2, block_index=3, word_index=10) # Test ordering self.assertTrue(pos1.is_before(pos2)) self.assertTrue(pos2.is_before(pos3)) self.assertTrue(pos1.is_before(pos3)) # Test progress calculation progress1 = pos1.get_progress(self.book) progress2 = pos2.get_progress(self.book) progress3 = pos3.get_progress(self.book) self.assertLess(progress1, progress2) self.assertLess(progress2, progress3) def test_clean_boundary_with_complex_content(self): """Test clean boundary detection with complex content.""" translator = PositionTranslator(self.book, self.style_params) # Position in middle of word messy_pos = AbstractPosition( chapter_index=1, block_index=2, word_index=5, character_index=3 ) clean_pos = translator.find_clean_boundary(messy_pos) self.assertEqual(clean_pos.chapter_index, messy_pos.chapter_index) self.assertEqual(clean_pos.block_index, messy_pos.block_index) self.assertEqual(clean_pos.word_index, messy_pos.word_index) self.assertEqual(clean_pos.character_index, 0) # Should move to word start self.assertTrue(clean_pos.is_clean_boundary) def test_position_anchor_robustness(self): """Test position anchor robustness with document changes.""" translator = PositionTranslator(self.book, self.style_params) # Create position anchor original_pos = AbstractPosition( chapter_index=1, block_index=2, word_index=8 ) anchor = translator.create_position_anchor(original_pos) # Should have multiple fallback positions self.assertGreater(len(anchor.fallback_positions), 0) # Should have context text self.assertIsNotNone(anchor.context_text) self.assertGreater(len(anchor.context_text), 0) # Should have progress information self.assertGreater(anchor.document_progress, 0.0) self.assertLess(anchor.document_progress, 1.0) # Test with modified document (simulate content change) modified_book = Book() chapter = Chapter("Modified Chapter") paragraph = Paragraph() paragraph.text = "This is a modified paragraph." chapter.add_block(paragraph) modified_book.add_chapter(chapter) # Should get approximate position best_pos = anchor.get_best_position(modified_book) self.assertIsInstance(best_pos, AbstractPosition) def test_style_parameter_impact(self): """Test how style parameter changes affect position system.""" translator = PositionTranslator(self.book, self.style_params) abstract_pos = AbstractPosition(chapter_index=1, block_index=2, word_index=3) # Get concrete position with original style concrete_pos1 = translator.abstract_to_concrete(abstract_pos) hash1 = concrete_pos1.layout_hash # Change style parameters new_style = StyleParameters( page_size=(1000, 800), # Larger page margins=(40, 40, 40, 40), # Larger margins default_font=Font(font_size=20) # Larger font ) translator.update_style_params(new_style) # Get concrete position with new style concrete_pos2 = translator.abstract_to_concrete(abstract_pos) hash2 = concrete_pos2.layout_hash # Layout hashes should be different self.assertNotEqual(hash1, hash2) # Concrete positions should be different # (same abstract position, different concrete position due to style change) self.assertNotEqual(concrete_pos1.viewport_y, concrete_pos2.viewport_y) if __name__ == '__main__': unittest.main()