pyWebLayout/tests/test_position_system.py
Duncan Tourolle d0153c6397
All checks were successful
Python CI / test (push) Successful in 5m17s
Fix tests
2025-06-22 19:26:40 +02:00

1098 lines
42 KiB
Python

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