1098 lines
42 KiB
Python
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()
|