This commit is contained in:
@@ -18,11 +18,11 @@ from pyWebLayout.core.base import Renderable, Queriable
|
||||
|
||||
class SimpleTestRenderable(Renderable, Queriable):
|
||||
"""A simple test renderable for testing the page system"""
|
||||
|
||||
|
||||
def __init__(self, text: str, size: tuple = (100, 50)):
|
||||
self._text = text
|
||||
self.size = size
|
||||
self.origin = np.array([0, 0])
|
||||
self._origin = np.array([0, 0])
|
||||
|
||||
def render(self):
|
||||
"""Render returns None - drawing is done via the page's draw object"""
|
||||
@@ -145,25 +145,28 @@ class TestPageImplementation(unittest.TestCase):
|
||||
def test_page_query_point(self):
|
||||
"""Test querying points to find children"""
|
||||
page = Page(size=(400, 300))
|
||||
|
||||
|
||||
# Add children with known positions and sizes
|
||||
child1 = SimpleTestRenderable("Child 1", (100, 50))
|
||||
child2 = SimpleTestRenderable("Child 2", (80, 40))
|
||||
|
||||
|
||||
page.add_child(child1).add_child(child2)
|
||||
|
||||
# Query points
|
||||
# Point within first child
|
||||
found_child = page.query_point((90, 30))
|
||||
self.assertEqual(found_child, child1)
|
||||
|
||||
result = page.query_point((90, 30))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object, child1)
|
||||
|
||||
# Point within second child
|
||||
found_child = page.query_point((30, 30))
|
||||
self.assertEqual(found_child, child2)
|
||||
|
||||
# Point outside any child
|
||||
found_child = page.query_point((300, 250))
|
||||
self.assertIsNone(found_child)
|
||||
result = page.query_point((30, 30))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object, child2)
|
||||
|
||||
# Point outside any child - returns QueryResult with object_type "empty"
|
||||
result = page.query_point((300, 250))
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "empty")
|
||||
|
||||
def test_page_in_object(self):
|
||||
"""Test that page correctly implements in_object"""
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for core pyWebLayout functionality."""
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Unit tests for the highlight system.
|
||||
|
||||
Tests Highlight, HighlightColor, HighlightManager, and integration with query system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from pyWebLayout.core.highlight import (
|
||||
Highlight,
|
||||
HighlightColor,
|
||||
HighlightManager,
|
||||
create_highlight_from_query_result
|
||||
)
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
|
||||
|
||||
class TestHighlightColor(unittest.TestCase):
|
||||
"""Test HighlightColor enum"""
|
||||
|
||||
def test_colors_defined(self):
|
||||
"""Test all expected colors are defined"""
|
||||
expected_colors = ['YELLOW', 'GREEN', 'BLUE', 'PINK', 'ORANGE', 'PURPLE', 'RED']
|
||||
|
||||
for color_name in expected_colors:
|
||||
self.assertTrue(hasattr(HighlightColor, color_name))
|
||||
color = getattr(HighlightColor, color_name)
|
||||
self.assertIsInstance(color.value, tuple)
|
||||
self.assertEqual(len(color.value), 4) # RGBA
|
||||
|
||||
def test_yellow_is_default(self):
|
||||
"""Test yellow highlight color"""
|
||||
yellow = HighlightColor.YELLOW.value
|
||||
self.assertEqual(yellow, (255, 255, 0, 100))
|
||||
|
||||
|
||||
class TestHighlight(unittest.TestCase):
|
||||
"""Test Highlight dataclass"""
|
||||
|
||||
def test_init_basic(self):
|
||||
"""Test basic Highlight creation"""
|
||||
highlight = Highlight(
|
||||
id="test-id",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Hello"
|
||||
)
|
||||
|
||||
self.assertEqual(highlight.id, "test-id")
|
||||
self.assertEqual(len(highlight.bounds), 1)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
|
||||
self.assertEqual(highlight.color, (255, 255, 0, 100))
|
||||
self.assertEqual(highlight.text, "Hello")
|
||||
self.assertIsNone(highlight.note)
|
||||
self.assertEqual(highlight.tags, [])
|
||||
|
||||
def test_init_with_metadata(self):
|
||||
"""Test Highlight with full metadata"""
|
||||
highlight = Highlight(
|
||||
id="test-id",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Hello",
|
||||
note="Important word",
|
||||
tags=["important", "keyword"],
|
||||
timestamp=1234567890.0,
|
||||
start_word_index=5,
|
||||
end_word_index=5
|
||||
)
|
||||
|
||||
self.assertEqual(highlight.note, "Important word")
|
||||
self.assertEqual(highlight.tags, ["important", "keyword"])
|
||||
self.assertEqual(highlight.timestamp, 1234567890.0)
|
||||
self.assertEqual(highlight.start_word_index, 5)
|
||||
self.assertEqual(highlight.end_word_index, 5)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test Highlight serialization"""
|
||||
highlight = Highlight(
|
||||
id="test-id",
|
||||
bounds=[(10, 20, 50, 15), (70, 20, 40, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Hello world",
|
||||
note="Test note",
|
||||
tags=["test"],
|
||||
timestamp=1234567890.0
|
||||
)
|
||||
|
||||
data = highlight.to_dict()
|
||||
|
||||
self.assertEqual(data['id'], "test-id")
|
||||
self.assertEqual(len(data['bounds']), 2)
|
||||
self.assertEqual(data['bounds'][0], (10, 20, 50, 15))
|
||||
self.assertEqual(data['color'], (255, 255, 0, 100))
|
||||
self.assertEqual(data['text'], "Hello world")
|
||||
self.assertEqual(data['note'], "Test note")
|
||||
self.assertEqual(data['tags'], ["test"])
|
||||
self.assertEqual(data['timestamp'], 1234567890.0)
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test Highlight deserialization"""
|
||||
data = {
|
||||
'id': "test-id",
|
||||
'bounds': [[10, 20, 50, 15], [70, 20, 40, 15]],
|
||||
'color': [255, 255, 0, 100],
|
||||
'text': "Hello world",
|
||||
'note': "Test note",
|
||||
'tags': ["test"],
|
||||
'timestamp': 1234567890.0,
|
||||
'start_word_index': 5,
|
||||
'end_word_index': 6
|
||||
}
|
||||
|
||||
highlight = Highlight.from_dict(data)
|
||||
|
||||
self.assertEqual(highlight.id, "test-id")
|
||||
self.assertEqual(len(highlight.bounds), 2)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
|
||||
self.assertEqual(highlight.color, (255, 255, 0, 100))
|
||||
self.assertEqual(highlight.text, "Hello world")
|
||||
self.assertEqual(highlight.note, "Test note")
|
||||
self.assertEqual(highlight.tags, ["test"])
|
||||
self.assertEqual(highlight.start_word_index, 5)
|
||||
self.assertEqual(highlight.end_word_index, 6)
|
||||
|
||||
|
||||
class TestHighlightManager(unittest.TestCase):
|
||||
"""Test HighlightManager class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create temporary directory for highlights"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.manager = HighlightManager(
|
||||
document_id="test-doc",
|
||||
highlights_dir=self.temp_dir
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up temporary directory"""
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_init(self):
|
||||
"""Test HighlightManager initialization"""
|
||||
self.assertEqual(self.manager.document_id, "test-doc")
|
||||
self.assertEqual(self.manager.highlights_dir, Path(self.temp_dir))
|
||||
self.assertEqual(len(self.manager.highlights), 0)
|
||||
|
||||
def test_add_highlight(self):
|
||||
"""Test adding a highlight"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Test"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
|
||||
self.assertEqual(len(self.manager.highlights), 1)
|
||||
self.assertIn("test-1", self.manager.highlights)
|
||||
self.assertEqual(self.manager.highlights["test-1"], highlight)
|
||||
|
||||
def test_remove_highlight(self):
|
||||
"""Test removing a highlight"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Test"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
self.assertEqual(len(self.manager.highlights), 1)
|
||||
|
||||
result = self.manager.remove_highlight("test-1")
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(len(self.manager.highlights), 0)
|
||||
|
||||
def test_remove_nonexistent_highlight(self):
|
||||
"""Test removing a highlight that doesn't exist"""
|
||||
result = self.manager.remove_highlight("nonexistent")
|
||||
self.assertFalse(result)
|
||||
|
||||
def test_get_highlight(self):
|
||||
"""Test getting a highlight by ID"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Test"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
retrieved = self.manager.get_highlight("test-1")
|
||||
|
||||
self.assertIsNotNone(retrieved)
|
||||
self.assertEqual(retrieved.id, "test-1")
|
||||
self.assertEqual(retrieved.text, "Test")
|
||||
|
||||
def test_list_highlights(self):
|
||||
"""Test listing all highlights"""
|
||||
highlight1 = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="First"
|
||||
)
|
||||
highlight2 = Highlight(
|
||||
id="test-2",
|
||||
bounds=[(100, 20, 50, 15)],
|
||||
color=(100, 255, 100, 100),
|
||||
text="Second"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight1)
|
||||
self.manager.add_highlight(highlight2)
|
||||
|
||||
highlights = self.manager.list_highlights()
|
||||
self.assertEqual(len(highlights), 2)
|
||||
self.assertIn(highlight1, highlights)
|
||||
self.assertIn(highlight2, highlights)
|
||||
|
||||
def test_clear_all(self):
|
||||
"""Test clearing all highlights"""
|
||||
highlight1 = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="First"
|
||||
)
|
||||
highlight2 = Highlight(
|
||||
id="test-2",
|
||||
bounds=[(100, 20, 50, 15)],
|
||||
color=(100, 255, 100, 100),
|
||||
text="Second"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight1)
|
||||
self.manager.add_highlight(highlight2)
|
||||
self.assertEqual(len(self.manager.highlights), 2)
|
||||
|
||||
self.manager.clear_all()
|
||||
self.assertEqual(len(self.manager.highlights), 0)
|
||||
|
||||
def test_persistence(self):
|
||||
"""Test that highlights are persisted to disk"""
|
||||
highlight = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(10, 20, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Persisted"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight)
|
||||
|
||||
# Create new manager for same document
|
||||
new_manager = HighlightManager(
|
||||
document_id="test-doc",
|
||||
highlights_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Should load existing highlights
|
||||
self.assertEqual(len(new_manager.highlights), 1)
|
||||
self.assertIn("test-1", new_manager.highlights)
|
||||
self.assertEqual(new_manager.highlights["test-1"].text, "Persisted")
|
||||
|
||||
def test_get_highlights_for_page(self):
|
||||
"""Test filtering highlights by page bounds"""
|
||||
# Highlight on page
|
||||
highlight1 = Highlight(
|
||||
id="test-1",
|
||||
bounds=[(100, 100, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="On page"
|
||||
)
|
||||
|
||||
# Highlight off page
|
||||
highlight2 = Highlight(
|
||||
id="test-2",
|
||||
bounds=[(1000, 1000, 50, 15)],
|
||||
color=(255, 255, 0, 100),
|
||||
text="Off page"
|
||||
)
|
||||
|
||||
self.manager.add_highlight(highlight1)
|
||||
self.manager.add_highlight(highlight2)
|
||||
|
||||
# Page bounds (0, 0, 800, 1000)
|
||||
page_bounds = (0, 0, 800, 1000)
|
||||
page_highlights = self.manager.get_highlights_for_page(page_bounds)
|
||||
|
||||
self.assertEqual(len(page_highlights), 1)
|
||||
self.assertEqual(page_highlights[0].id, "test-1")
|
||||
|
||||
|
||||
class TestCreateHighlightFromQueryResult(unittest.TestCase):
|
||||
"""Test create_highlight_from_query_result function"""
|
||||
|
||||
def test_create_from_single_result(self):
|
||||
"""Test creating highlight from single QueryResult"""
|
||||
result = QueryResult(
|
||||
object=object(),
|
||||
object_type="text",
|
||||
bounds=(10, 20, 50, 15),
|
||||
text="Hello"
|
||||
)
|
||||
|
||||
highlight = create_highlight_from_query_result(
|
||||
result,
|
||||
color=(255, 255, 0, 100),
|
||||
note="Test note",
|
||||
tags=["test"]
|
||||
)
|
||||
|
||||
self.assertIsNotNone(highlight.id)
|
||||
self.assertEqual(len(highlight.bounds), 1)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 50, 15))
|
||||
self.assertEqual(highlight.color, (255, 255, 0, 100))
|
||||
self.assertEqual(highlight.text, "Hello")
|
||||
self.assertEqual(highlight.note, "Test note")
|
||||
self.assertEqual(highlight.tags, ["test"])
|
||||
self.assertIsNotNone(highlight.timestamp)
|
||||
|
||||
def test_create_from_selection_range(self):
|
||||
"""Test creating highlight from SelectionRange"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
|
||||
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((10, 20), (80, 35), results)
|
||||
|
||||
highlight = create_highlight_from_query_result(
|
||||
sel_range,
|
||||
color=(100, 255, 100, 100),
|
||||
note="Multi-word"
|
||||
)
|
||||
|
||||
self.assertIsNotNone(highlight.id)
|
||||
self.assertEqual(len(highlight.bounds), 2)
|
||||
self.assertEqual(highlight.bounds[0], (10, 20, 30, 15))
|
||||
self.assertEqual(highlight.bounds[1], (45, 20, 35, 15))
|
||||
self.assertEqual(highlight.color, (100, 255, 100, 100))
|
||||
self.assertEqual(highlight.text, "Hello world")
|
||||
self.assertEqual(highlight.note, "Multi-word")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
Unit tests for the query system (pixel-to-content mapping).
|
||||
|
||||
Tests the QueryResult, SelectionRange, and query_point functionality
|
||||
across Page, Line, and Text classes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.core.query import QueryResult, SelectionRange
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.concrete.functional import LinkText
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.abstract.functional import Link, LinkType
|
||||
from pyWebLayout.style import Font, Alignment
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from tests.utils.test_fonts import create_default_test_font, ensure_consistent_font_in_tests
|
||||
|
||||
|
||||
class TestQueryResult(unittest.TestCase):
|
||||
"""Test QueryResult dataclass"""
|
||||
|
||||
def test_init_basic(self):
|
||||
"""Test basic QueryResult creation"""
|
||||
obj = object()
|
||||
result = QueryResult(
|
||||
object=obj,
|
||||
object_type="text",
|
||||
bounds=(100, 200, 50, 20)
|
||||
)
|
||||
|
||||
self.assertEqual(result.object, obj)
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertEqual(result.bounds, (100, 200, 50, 20))
|
||||
self.assertIsNone(result.text)
|
||||
self.assertFalse(result.is_interactive)
|
||||
|
||||
def test_init_with_metadata(self):
|
||||
"""Test QueryResult with full metadata"""
|
||||
obj = object()
|
||||
result = QueryResult(
|
||||
object=obj,
|
||||
object_type="link",
|
||||
bounds=(100, 200, 50, 20),
|
||||
text="Click here",
|
||||
is_interactive=True,
|
||||
link_target="chapter2"
|
||||
)
|
||||
|
||||
self.assertEqual(result.text, "Click here")
|
||||
self.assertTrue(result.is_interactive)
|
||||
self.assertEqual(result.link_target, "chapter2")
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test QueryResult serialization"""
|
||||
result = QueryResult(
|
||||
object=object(),
|
||||
object_type="link",
|
||||
bounds=(100, 200, 50, 20),
|
||||
text="Click here",
|
||||
is_interactive=True,
|
||||
link_target="chapter2"
|
||||
)
|
||||
|
||||
d = result.to_dict()
|
||||
self.assertEqual(d['object_type'], "link")
|
||||
self.assertEqual(d['bounds'], (100, 200, 50, 20))
|
||||
self.assertEqual(d['text'], "Click here")
|
||||
self.assertTrue(d['is_interactive'])
|
||||
self.assertEqual(d['link_target'], "chapter2")
|
||||
|
||||
|
||||
class TestSelectionRange(unittest.TestCase):
|
||||
"""Test SelectionRange dataclass"""
|
||||
|
||||
def test_init(self):
|
||||
"""Test SelectionRange creation"""
|
||||
results = []
|
||||
sel_range = SelectionRange(
|
||||
start_point=(10, 20),
|
||||
end_point=(100, 30),
|
||||
results=results
|
||||
)
|
||||
|
||||
self.assertEqual(sel_range.start_point, (10, 20))
|
||||
self.assertEqual(sel_range.end_point, (100, 30))
|
||||
self.assertEqual(sel_range.results, results)
|
||||
|
||||
def test_text_property(self):
|
||||
"""Test concatenated text extraction"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (0, 0, 0, 0), text="Hello"),
|
||||
QueryResult(object(), "text", (0, 0, 0, 0), text="world"),
|
||||
QueryResult(object(), "text", (0, 0, 0, 0), text="test")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((0, 0), (100, 100), results)
|
||||
self.assertEqual(sel_range.text, "Hello world test")
|
||||
|
||||
def test_bounds_list_property(self):
|
||||
"""Test bounds list extraction"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
|
||||
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((0, 0), (100, 100), results)
|
||||
bounds = sel_range.bounds_list
|
||||
|
||||
self.assertEqual(len(bounds), 2)
|
||||
self.assertEqual(bounds[0], (10, 20, 30, 15))
|
||||
self.assertEqual(bounds[1], (45, 20, 35, 15))
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test SelectionRange serialization"""
|
||||
results = [
|
||||
QueryResult(object(), "text", (10, 20, 30, 15), text="Hello"),
|
||||
QueryResult(object(), "text", (45, 20, 35, 15), text="world")
|
||||
]
|
||||
|
||||
sel_range = SelectionRange((10, 20), (80, 35), results)
|
||||
d = sel_range.to_dict()
|
||||
|
||||
self.assertEqual(d['start'], (10, 20))
|
||||
self.assertEqual(d['end'], (80, 35))
|
||||
self.assertEqual(d['text'], "Hello world")
|
||||
self.assertEqual(d['word_count'], 2)
|
||||
self.assertEqual(len(d['bounds']), 2)
|
||||
|
||||
|
||||
class TestTextQueryPoint(unittest.TestCase):
|
||||
"""Test Text class in_object (from Queriable mixin)"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_in_object_hit(self):
|
||||
"""Test in_object returns True for point inside text"""
|
||||
text = Text("Hello", self.font, self.draw)
|
||||
text.set_origin(np.array([100, 100]))
|
||||
|
||||
# Point inside text bounds
|
||||
self.assertTrue(text.in_object(np.array([110, 105])))
|
||||
|
||||
def test_in_object_miss(self):
|
||||
"""Test in_object returns False for point outside text"""
|
||||
text = Text("Hello", self.font, self.draw)
|
||||
text.set_origin(np.array([100, 100]))
|
||||
|
||||
# Point outside text bounds
|
||||
self.assertFalse(text.in_object(np.array([50, 50])))
|
||||
self.assertFalse(text.in_object(np.array([200, 200])))
|
||||
|
||||
|
||||
class TestLineQueryPoint(unittest.TestCase):
|
||||
"""Test Line.query_point method"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_query_point_finds_text(self):
|
||||
"""Test Line.query_point finds a text object"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
# Add text objects
|
||||
word1 = Word("Hello", self.font)
|
||||
word2 = Word("world", self.font)
|
||||
|
||||
line.add_word(word1)
|
||||
line.add_word(word2)
|
||||
line.render()
|
||||
|
||||
# Query a point that should hit first word
|
||||
# (after rendering, text objects have positions set)
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
|
||||
result = line.query_point(point)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertIsNotNone(result.text)
|
||||
|
||||
def test_query_point_miss(self):
|
||||
"""Test Line.query_point returns None for miss"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
word1 = Word("Hello", self.font)
|
||||
line.add_word(word1)
|
||||
line.render()
|
||||
|
||||
# Query far outside line bounds
|
||||
result = line.query_point((10, 10))
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_query_point_finds_link(self):
|
||||
"""Test Line.query_point correctly identifies links"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
# Create a linked word
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
linked_word = LinkedWord("Click", self.font, "chapter2", LinkType.INTERNAL)
|
||||
|
||||
line.add_word(linked_word)
|
||||
line.render()
|
||||
|
||||
# Query the link
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
|
||||
result = line.query_point(point)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "link")
|
||||
self.assertTrue(result.is_interactive)
|
||||
self.assertEqual(result.link_target, "chapter2")
|
||||
|
||||
|
||||
class TestPageQueryPoint(unittest.TestCase):
|
||||
"""Test Page.query_point method"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.page = Page(size=(800, 1000), style=PageStyle())
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_query_point_empty_page(self):
|
||||
"""Test querying empty page returns empty result"""
|
||||
result = self.page.query_point((400, 500))
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "empty")
|
||||
self.assertEqual(result.object, self.page)
|
||||
|
||||
def test_query_point_finds_line(self):
|
||||
"""Test Page.query_point traverses to Line"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
word = Word("Hello", self.font)
|
||||
line.add_word(word)
|
||||
line.render()
|
||||
|
||||
self.page.add_child(line)
|
||||
|
||||
# Query a point inside the line
|
||||
if len(line._text_objects) > 0:
|
||||
text_obj = line._text_objects[0]
|
||||
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1] + 5))
|
||||
|
||||
result = self.page.query_point(point)
|
||||
|
||||
# Should traverse Page → Line → Text
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertEqual(result.parent_page, self.page)
|
||||
|
||||
def test_query_point_multiple_lines(self):
|
||||
"""Test Page.query_point with multiple lines"""
|
||||
# Add two lines at different Y positions
|
||||
line1 = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
line2 = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 150]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
word1 = Word("First", self.font)
|
||||
word2 = Word("Second", self.font)
|
||||
|
||||
line1.add_word(word1)
|
||||
line2.add_word(word2)
|
||||
|
||||
line1.render()
|
||||
line2.render()
|
||||
|
||||
self.page.add_child(line1)
|
||||
self.page.add_child(line2)
|
||||
|
||||
# Query first line
|
||||
if len(line1._text_objects) > 0:
|
||||
text_obj1 = line1._text_objects[0]
|
||||
point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1] + 5))
|
||||
result1 = self.page.query_point(point1)
|
||||
|
||||
self.assertIsNotNone(result1)
|
||||
self.assertEqual(result1.text, "First")
|
||||
|
||||
# Query second line
|
||||
if len(line2._text_objects) > 0:
|
||||
text_obj2 = line2._text_objects[0]
|
||||
point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1] + 5))
|
||||
result2 = self.page.query_point(point2)
|
||||
|
||||
self.assertIsNotNone(result2)
|
||||
self.assertEqual(result2.text, "Second")
|
||||
|
||||
|
||||
class TestPageQueryRange(unittest.TestCase):
|
||||
"""Test Page.query_range method for text selection"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.page = Page(size=(800, 1000), style=PageStyle())
|
||||
self.font = create_default_test_font()
|
||||
|
||||
def test_query_range_single_line(self):
|
||||
"""Test selecting text within a single line"""
|
||||
line = Line(
|
||||
spacing=(5, 10),
|
||||
origin=np.array([50, 100]),
|
||||
size=(700, 30),
|
||||
draw=self.page.draw,
|
||||
font=self.font
|
||||
)
|
||||
|
||||
# Add multiple words
|
||||
words = [Word(text, self.font) for text in ["Hello", "world", "test"]]
|
||||
for word in words:
|
||||
line.add_word(word)
|
||||
|
||||
line.render()
|
||||
self.page.add_child(line)
|
||||
|
||||
if len(line._text_objects) >= 2:
|
||||
# Select from first to second word
|
||||
start_text = line._text_objects[0]
|
||||
end_text = line._text_objects[1]
|
||||
|
||||
start_point = (int(start_text._origin[0] + 5), int(start_text._origin[1] + 5))
|
||||
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1] + 5))
|
||||
|
||||
sel_range = self.page.query_range(start_point, end_point)
|
||||
|
||||
self.assertIsNotNone(sel_range)
|
||||
self.assertGreater(len(sel_range.results), 0)
|
||||
self.assertIn("Hello", sel_range.text)
|
||||
|
||||
def test_query_range_invalid(self):
|
||||
"""Test query_range with invalid points returns empty"""
|
||||
sel_range = self.page.query_range((10, 10), (20, 20))
|
||||
|
||||
self.assertEqual(len(sel_range.results), 0)
|
||||
self.assertEqual(sel_range.text, "")
|
||||
|
||||
|
||||
class TestPageMakeQueryResult(unittest.TestCase):
|
||||
"""Test Page._make_query_result helper"""
|
||||
|
||||
def setUp(self):
|
||||
ensure_consistent_font_in_tests()
|
||||
self.page = Page(size=(800, 1000), style=PageStyle())
|
||||
self.font = create_default_test_font()
|
||||
self.draw = self.page.draw
|
||||
|
||||
def test_make_query_result_text(self):
|
||||
"""Test packaging regular Text object"""
|
||||
text = Text("Hello", self.font, self.draw)
|
||||
text.set_origin(np.array([100, 200]))
|
||||
|
||||
result = self.page._make_query_result(text, (105, 205))
|
||||
|
||||
self.assertEqual(result.object_type, "text")
|
||||
self.assertEqual(result.text, "Hello")
|
||||
self.assertFalse(result.is_interactive)
|
||||
|
||||
def test_make_query_result_link(self):
|
||||
"""Test packaging LinkText object"""
|
||||
link = Link(location="chapter2", link_type=LinkType.INTERNAL, callback=None)
|
||||
link_text = LinkText(link, "Click here", self.font, self.draw)
|
||||
link_text.set_origin(np.array([100, 200]))
|
||||
|
||||
result = self.page._make_query_result(link_text, (105, 205))
|
||||
|
||||
self.assertEqual(result.object_type, "link")
|
||||
self.assertEqual(result.text, "Click here")
|
||||
self.assertTrue(result.is_interactive)
|
||||
self.assertEqual(result.link_target, "chapter2")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for I/O functionality."""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
Unit tests for gesture event system.
|
||||
|
||||
Tests TouchEvent, GestureType, GestureResponse, and HAL integration.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.io.gesture import (
|
||||
GestureType,
|
||||
TouchEvent,
|
||||
GestureResponse,
|
||||
ActionType
|
||||
)
|
||||
|
||||
|
||||
class TestGestureType(unittest.TestCase):
|
||||
"""Test GestureType enum"""
|
||||
|
||||
def test_gesture_types_exist(self):
|
||||
"""Test all gesture types are defined"""
|
||||
self.assertEqual(GestureType.TAP.value, "tap")
|
||||
self.assertEqual(GestureType.LONG_PRESS.value, "long_press")
|
||||
self.assertEqual(GestureType.SWIPE_LEFT.value, "swipe_left")
|
||||
self.assertEqual(GestureType.SWIPE_RIGHT.value, "swipe_right")
|
||||
self.assertEqual(GestureType.SWIPE_UP.value, "swipe_up")
|
||||
self.assertEqual(GestureType.SWIPE_DOWN.value, "swipe_down")
|
||||
self.assertEqual(GestureType.PINCH_IN.value, "pinch_in")
|
||||
self.assertEqual(GestureType.PINCH_OUT.value, "pinch_out")
|
||||
self.assertEqual(GestureType.DRAG_START.value, "drag_start")
|
||||
self.assertEqual(GestureType.DRAG_MOVE.value, "drag_move")
|
||||
self.assertEqual(GestureType.DRAG_END.value, "drag_end")
|
||||
|
||||
|
||||
class TestTouchEvent(unittest.TestCase):
|
||||
"""Test TouchEvent dataclass"""
|
||||
|
||||
def test_init_basic(self):
|
||||
"""Test basic TouchEvent creation"""
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.TAP,
|
||||
x=450,
|
||||
y=320
|
||||
)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.TAP)
|
||||
self.assertEqual(event.x, 450)
|
||||
self.assertEqual(event.y, 320)
|
||||
self.assertIsNone(event.x2)
|
||||
self.assertIsNone(event.y2)
|
||||
self.assertEqual(event.timestamp_ms, 0)
|
||||
|
||||
def test_init_with_secondary_point(self):
|
||||
"""Test TouchEvent with secondary point (pinch/drag)"""
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.PINCH_OUT,
|
||||
x=400,
|
||||
y=300,
|
||||
x2=450,
|
||||
y2=350,
|
||||
timestamp_ms=12345.678
|
||||
)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||
self.assertEqual(event.x, 400)
|
||||
self.assertEqual(event.y, 300)
|
||||
self.assertEqual(event.x2, 450)
|
||||
self.assertEqual(event.y2, 350)
|
||||
self.assertEqual(event.timestamp_ms, 12345.678)
|
||||
|
||||
def test_from_hal_basic(self):
|
||||
"""Test parsing TouchEvent from HAL format"""
|
||||
hal_data = {
|
||||
'gesture': 'tap',
|
||||
'x': 450,
|
||||
'y': 320
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.TAP)
|
||||
self.assertEqual(event.x, 450)
|
||||
self.assertEqual(event.y, 320)
|
||||
|
||||
def test_from_hal_complete(self):
|
||||
"""Test parsing TouchEvent with all fields from HAL"""
|
||||
hal_data = {
|
||||
'gesture': 'pinch_out',
|
||||
'x': 400,
|
||||
'y': 300,
|
||||
'x2': 450,
|
||||
'y2': 350,
|
||||
'timestamp': 12345.678
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||
self.assertEqual(event.x, 400)
|
||||
self.assertEqual(event.y, 300)
|
||||
self.assertEqual(event.x2, 450)
|
||||
self.assertEqual(event.y2, 350)
|
||||
self.assertEqual(event.timestamp_ms, 12345.678)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test TouchEvent serialization"""
|
||||
event = TouchEvent(
|
||||
gesture=GestureType.SWIPE_LEFT,
|
||||
x=600,
|
||||
y=400,
|
||||
timestamp_ms=12345.0
|
||||
)
|
||||
|
||||
d = event.to_dict()
|
||||
|
||||
self.assertEqual(d['gesture'], 'swipe_left')
|
||||
self.assertEqual(d['x'], 600)
|
||||
self.assertEqual(d['y'], 400)
|
||||
self.assertIsNone(d['x2'])
|
||||
self.assertIsNone(d['y2'])
|
||||
self.assertEqual(d['timestamp_ms'], 12345.0)
|
||||
|
||||
|
||||
class TestGestureResponse(unittest.TestCase):
|
||||
"""Test GestureResponse dataclass"""
|
||||
|
||||
def test_init(self):
|
||||
"""Test GestureResponse creation"""
|
||||
response = GestureResponse(
|
||||
action="page_turn",
|
||||
data={"direction": "forward", "progress": 0.42}
|
||||
)
|
||||
|
||||
self.assertEqual(response.action, "page_turn")
|
||||
self.assertEqual(response.data['direction'], "forward")
|
||||
self.assertEqual(response.data['progress'], 0.42)
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test GestureResponse serialization"""
|
||||
response = GestureResponse(
|
||||
action="define",
|
||||
data={"word": "ephemeral", "bounds": (100, 200, 50, 20)}
|
||||
)
|
||||
|
||||
d = response.to_dict()
|
||||
|
||||
self.assertEqual(d['action'], "define")
|
||||
self.assertEqual(d['data']['word'], "ephemeral")
|
||||
self.assertEqual(d['data']['bounds'], (100, 200, 50, 20))
|
||||
|
||||
def test_to_dict_empty_data(self):
|
||||
"""Test GestureResponse with empty data"""
|
||||
response = GestureResponse(action="none", data={})
|
||||
|
||||
d = response.to_dict()
|
||||
|
||||
self.assertEqual(d['action'], "none")
|
||||
self.assertEqual(d['data'], {})
|
||||
|
||||
|
||||
class TestActionType(unittest.TestCase):
|
||||
"""Test ActionType constants"""
|
||||
|
||||
def test_action_types_defined(self):
|
||||
"""Test all action type constants are defined"""
|
||||
self.assertEqual(ActionType.NONE, "none")
|
||||
self.assertEqual(ActionType.PAGE_TURN, "page_turn")
|
||||
self.assertEqual(ActionType.NAVIGATE, "navigate")
|
||||
self.assertEqual(ActionType.DEFINE, "define")
|
||||
self.assertEqual(ActionType.SELECT, "select")
|
||||
self.assertEqual(ActionType.ZOOM, "zoom")
|
||||
self.assertEqual(ActionType.BOOK_LOADED, "book_loaded")
|
||||
self.assertEqual(ActionType.WORD_SELECTED, "word_selected")
|
||||
self.assertEqual(ActionType.SHOW_MENU, "show_menu")
|
||||
self.assertEqual(ActionType.SELECTION_START, "selection_start")
|
||||
self.assertEqual(ActionType.SELECTION_UPDATE, "selection_update")
|
||||
self.assertEqual(ActionType.SELECTION_COMPLETE, "selection_complete")
|
||||
self.assertEqual(ActionType.AT_START, "at_start")
|
||||
self.assertEqual(ActionType.AT_END, "at_end")
|
||||
self.assertEqual(ActionType.ERROR, "error")
|
||||
|
||||
|
||||
class TestHALIntegration(unittest.TestCase):
|
||||
"""Test HAL integration scenarios"""
|
||||
|
||||
def test_hal_tap_flow(self):
|
||||
"""Test complete HAL tap event flow"""
|
||||
# Simulate HAL sending tap event
|
||||
hal_data = {
|
||||
'gesture': 'tap',
|
||||
'x': 450,
|
||||
'y': 320,
|
||||
'timestamp': 1234567890.123
|
||||
}
|
||||
|
||||
# Parse event
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
# Verify event
|
||||
self.assertEqual(event.gesture, GestureType.TAP)
|
||||
self.assertEqual(event.x, 450)
|
||||
self.assertEqual(event.y, 320)
|
||||
|
||||
# Simulate business logic response
|
||||
response = GestureResponse(
|
||||
action=ActionType.WORD_SELECTED,
|
||||
data={"word": "hello", "bounds": (440, 310, 50, 20)}
|
||||
)
|
||||
|
||||
# Serialize for Flask
|
||||
response_dict = response.to_dict()
|
||||
|
||||
self.assertEqual(response_dict['action'], "word_selected")
|
||||
self.assertEqual(response_dict['data']['word'], "hello")
|
||||
|
||||
def test_hal_pinch_flow(self):
|
||||
"""Test complete HAL pinch event flow"""
|
||||
# Simulate HAL sending pinch event with two touch points
|
||||
hal_data = {
|
||||
'gesture': 'pinch_out',
|
||||
'x': 400,
|
||||
'y': 500,
|
||||
'x2': 500,
|
||||
'y2': 500,
|
||||
'timestamp': 1234567891.456
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.PINCH_OUT)
|
||||
self.assertEqual(event.x, 400)
|
||||
self.assertEqual(event.x2, 500)
|
||||
|
||||
def test_hal_swipe_flow(self):
|
||||
"""Test complete HAL swipe event flow"""
|
||||
hal_data = {
|
||||
'gesture': 'swipe_left',
|
||||
'x': 600,
|
||||
'y': 400
|
||||
}
|
||||
|
||||
event = TouchEvent.from_hal(hal_data)
|
||||
|
||||
self.assertEqual(event.gesture, GestureType.SWIPE_LEFT)
|
||||
|
||||
# Expected response
|
||||
response = GestureResponse(
|
||||
action=ActionType.PAGE_TURN,
|
||||
data={"direction": "forward", "progress": 0.25}
|
||||
)
|
||||
|
||||
self.assertEqual(response.action, "page_turn")
|
||||
|
||||
def test_hal_drag_selection_flow(self):
|
||||
"""Test complete drag selection flow"""
|
||||
# Drag start
|
||||
start_data = {
|
||||
'gesture': 'drag_start',
|
||||
'x': 100,
|
||||
'y': 200
|
||||
}
|
||||
|
||||
start_event = TouchEvent.from_hal(start_data)
|
||||
self.assertEqual(start_event.gesture, GestureType.DRAG_START)
|
||||
|
||||
# Drag move
|
||||
move_data = {
|
||||
'gesture': 'drag_move',
|
||||
'x': 300,
|
||||
'y': 250
|
||||
}
|
||||
|
||||
move_event = TouchEvent.from_hal(move_data)
|
||||
self.assertEqual(move_event.gesture, GestureType.DRAG_MOVE)
|
||||
|
||||
# Drag end
|
||||
end_data = {
|
||||
'gesture': 'drag_end',
|
||||
'x': 500,
|
||||
'y': 300
|
||||
}
|
||||
|
||||
end_event = TouchEvent.from_hal(end_data)
|
||||
self.assertEqual(end_event.gesture, GestureType.DRAG_END)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,832 +0,0 @@
|
||||
"""
|
||||
Comprehensive tests for the EbookReader application interface.
|
||||
|
||||
Tests cover:
|
||||
- EPUB loading and initialization
|
||||
- Navigation (forward, backward, boundaries)
|
||||
- Font scaling and styling
|
||||
- Chapter navigation
|
||||
- Position management (bookmarks)
|
||||
- Information retrieval
|
||||
- File operations
|
||||
- Error handling
|
||||
- Context manager
|
||||
- Integration scenarios
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import os
|
||||
|
||||
from pyWebLayout.layout.ereader_application import EbookReader, create_ebook_reader
|
||||
|
||||
|
||||
class TestEbookReaderInitialization(unittest.TestCase):
|
||||
"""Test EbookReader creation and EPUB loading"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_create_reader_with_defaults(self):
|
||||
"""Test creating reader with default settings"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
|
||||
self.assertEqual(reader.page_size, (800, 1000))
|
||||
self.assertEqual(reader.base_font_scale, 1.0)
|
||||
self.assertIsNone(reader.manager)
|
||||
self.assertFalse(reader.is_loaded())
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_create_reader_with_custom_settings(self):
|
||||
"""Test creating reader with custom settings"""
|
||||
reader = EbookReader(
|
||||
page_size=(600, 800),
|
||||
margin=50,
|
||||
background_color=(240, 240, 240),
|
||||
line_spacing=10,
|
||||
inter_block_spacing=20,
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=3
|
||||
)
|
||||
|
||||
self.assertEqual(reader.page_size, (600, 800))
|
||||
self.assertEqual(reader.page_style.line_spacing, 10)
|
||||
self.assertEqual(reader.page_style.inter_block_spacing, 20)
|
||||
self.assertEqual(reader.buffer_size, 3)
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_load_valid_epub(self):
|
||||
"""Test loading a valid EPUB file"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
|
||||
success = reader.load_epub(self.epub_path)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(reader.is_loaded())
|
||||
self.assertIsNotNone(reader.manager)
|
||||
self.assertIsNotNone(reader.blocks)
|
||||
self.assertIsNotNone(reader.document_id)
|
||||
self.assertIsNotNone(reader.book_title)
|
||||
self.assertIsNotNone(reader.book_author)
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_load_nonexistent_epub(self):
|
||||
"""Test loading a non-existent EPUB file"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
|
||||
success = reader.load_epub("nonexistent.epub")
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(reader.is_loaded())
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_load_invalid_epub(self):
|
||||
"""Test loading an invalid file as EPUB"""
|
||||
# Create a temporary invalid file
|
||||
invalid_path = os.path.join(self.temp_dir, "invalid.epub")
|
||||
with open(invalid_path, 'w') as f:
|
||||
f.write("This is not a valid EPUB file")
|
||||
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
|
||||
success = reader.load_epub(invalid_path)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertFalse(reader.is_loaded())
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_convenience_function(self):
|
||||
"""Test create_ebook_reader convenience function"""
|
||||
reader = create_ebook_reader(
|
||||
page_size=(700, 900),
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
self.assertIsInstance(reader, EbookReader)
|
||||
self.assertEqual(reader.page_size, (700, 900))
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
class TestEbookReaderFontScaling(unittest.TestCase):
|
||||
"""Test font size control"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0 # Disable buffering for tests
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_set_font_size(self):
|
||||
"""Test setting font size with arbitrary scale"""
|
||||
page = self.reader.set_font_size(1.5)
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
self.assertEqual(self.reader.get_font_size(), 1.5)
|
||||
|
||||
def test_increase_font_size(self):
|
||||
"""Test increasing font size by one step"""
|
||||
initial_size = self.reader.get_font_size()
|
||||
|
||||
page = self.reader.increase_font_size()
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
self.assertEqual(self.reader.get_font_size(), initial_size + 0.1)
|
||||
|
||||
def test_decrease_font_size(self):
|
||||
"""Test decreasing font size by one step"""
|
||||
self.reader.set_font_size(1.5)
|
||||
|
||||
page = self.reader.decrease_font_size()
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
self.assertAlmostEqual(self.reader.get_font_size(), 1.4, places=5)
|
||||
|
||||
def test_font_size_bounds_clamping(self):
|
||||
"""Test that font size is clamped between 0.5x and 3.0x"""
|
||||
# Test upper bound
|
||||
self.reader.set_font_size(5.0)
|
||||
self.assertEqual(self.reader.get_font_size(), 3.0)
|
||||
|
||||
# Test lower bound
|
||||
self.reader.set_font_size(0.1)
|
||||
self.assertEqual(self.reader.get_font_size(), 0.5)
|
||||
|
||||
def test_get_font_size(self):
|
||||
"""Test getting current font size"""
|
||||
self.assertEqual(self.reader.get_font_size(), 1.0)
|
||||
|
||||
self.reader.set_font_size(2.0)
|
||||
self.assertEqual(self.reader.get_font_size(), 2.0)
|
||||
|
||||
def test_font_scale_with_navigation(self):
|
||||
"""Test that font scale persists across page navigation"""
|
||||
self.reader.set_font_size(1.5)
|
||||
initial_font_size = self.reader.get_font_size()
|
||||
|
||||
# Navigate forward
|
||||
self.reader.next_page()
|
||||
|
||||
# Font size should be preserved
|
||||
self.assertEqual(self.reader.get_font_size(), initial_font_size)
|
||||
|
||||
|
||||
class TestEbookReaderSpacing(unittest.TestCase):
|
||||
"""Test line and block spacing"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_set_line_spacing(self):
|
||||
"""Test setting line spacing"""
|
||||
page = self.reader.set_line_spacing(10)
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
self.assertEqual(self.reader.page_style.line_spacing, 10)
|
||||
|
||||
def test_set_inter_block_spacing(self):
|
||||
"""Test setting inter-block spacing"""
|
||||
page = self.reader.set_inter_block_spacing(25)
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
self.assertEqual(self.reader.page_style.inter_block_spacing, 25)
|
||||
|
||||
def test_spacing_with_navigation(self):
|
||||
"""Test that spacing changes affect rendering after navigation"""
|
||||
self.reader.set_line_spacing(15)
|
||||
|
||||
page = self.reader.next_page()
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
self.assertEqual(self.reader.page_style.line_spacing, 15)
|
||||
|
||||
def test_spacing_position_preservation(self):
|
||||
"""Test that changing spacing preserves reading position"""
|
||||
# Navigate to a specific position
|
||||
for _ in range(3):
|
||||
self.reader.next_page()
|
||||
|
||||
position_before = self.reader.manager.current_position.copy()
|
||||
|
||||
# Change spacing
|
||||
self.reader.set_line_spacing(12)
|
||||
|
||||
position_after = self.reader.manager.current_position
|
||||
|
||||
# Position should be preserved
|
||||
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
|
||||
self.assertEqual(position_before.block_index, position_after.block_index)
|
||||
|
||||
|
||||
class TestEbookReaderChapterNavigation(unittest.TestCase):
|
||||
"""Test chapter navigation features"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_get_chapters(self):
|
||||
"""Test getting list of chapters"""
|
||||
chapters = self.reader.get_chapters()
|
||||
|
||||
self.assertIsInstance(chapters, list)
|
||||
if len(chapters) > 0:
|
||||
# Each chapter should be a tuple (title, index)
|
||||
self.assertIsInstance(chapters[0], tuple)
|
||||
self.assertEqual(len(chapters[0]), 2)
|
||||
|
||||
def test_get_chapter_positions(self):
|
||||
"""Test getting chapter positions"""
|
||||
positions = self.reader.get_chapter_positions()
|
||||
|
||||
self.assertIsInstance(positions, list)
|
||||
if len(positions) > 0:
|
||||
# Each item should be (title, RenderingPosition)
|
||||
self.assertIsInstance(positions[0], tuple)
|
||||
self.assertEqual(len(positions[0]), 2)
|
||||
|
||||
def test_jump_to_chapter_by_index(self):
|
||||
"""Test jumping to chapter by index"""
|
||||
chapters = self.reader.get_chapters()
|
||||
|
||||
if len(chapters) > 0:
|
||||
page = self.reader.jump_to_chapter(0)
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
def test_jump_to_chapter_by_name(self):
|
||||
"""Test jumping to chapter by name"""
|
||||
chapters = self.reader.get_chapters()
|
||||
|
||||
if len(chapters) > 0:
|
||||
chapter_title = chapters[0][0]
|
||||
page = self.reader.jump_to_chapter(chapter_title)
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
def test_jump_to_invalid_chapter_index(self):
|
||||
"""Test jumping to invalid chapter index"""
|
||||
page = self.reader.jump_to_chapter(9999)
|
||||
|
||||
self.assertIsNone(page)
|
||||
|
||||
def test_jump_to_invalid_chapter_name(self):
|
||||
"""Test jumping to non-existent chapter name"""
|
||||
page = self.reader.jump_to_chapter("Non-Existent Chapter")
|
||||
|
||||
self.assertIsNone(page)
|
||||
|
||||
|
||||
class TestEbookReaderInformation(unittest.TestCase):
|
||||
"""Test information retrieval methods"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_get_position_info(self):
|
||||
"""Test getting detailed position information"""
|
||||
info = self.reader.get_position_info()
|
||||
|
||||
self.assertIsInstance(info, dict)
|
||||
self.assertIn('position', info)
|
||||
self.assertIn('chapter', info)
|
||||
self.assertIn('progress', info)
|
||||
self.assertIn('font_scale', info)
|
||||
self.assertIn('book_title', info)
|
||||
self.assertIn('book_author', info)
|
||||
|
||||
def test_get_reading_progress(self):
|
||||
"""Test getting reading progress as percentage"""
|
||||
progress = self.reader.get_reading_progress()
|
||||
|
||||
self.assertIsInstance(progress, float)
|
||||
self.assertGreaterEqual(progress, 0.0)
|
||||
self.assertLessEqual(progress, 1.0)
|
||||
|
||||
# Progress should increase after navigation
|
||||
initial_progress = progress
|
||||
for _ in range(5):
|
||||
self.reader.next_page()
|
||||
|
||||
new_progress = self.reader.get_reading_progress()
|
||||
self.assertGreater(new_progress, initial_progress)
|
||||
|
||||
def test_get_current_chapter_info(self):
|
||||
"""Test getting current chapter information"""
|
||||
info = self.reader.get_current_chapter_info()
|
||||
|
||||
# May be None if no chapters
|
||||
if info is not None:
|
||||
self.assertIsInstance(info, dict)
|
||||
self.assertIn('title', info)
|
||||
self.assertIn('level', info)
|
||||
self.assertIn('block_index', info)
|
||||
|
||||
def test_get_book_info_complete(self):
|
||||
"""Test getting complete book information"""
|
||||
info = self.reader.get_book_info()
|
||||
|
||||
self.assertIsInstance(info, dict)
|
||||
self.assertIn('title', info)
|
||||
self.assertIn('author', info)
|
||||
self.assertIn('document_id', info)
|
||||
self.assertIn('total_blocks', info)
|
||||
self.assertIn('total_chapters', info)
|
||||
self.assertIn('page_size', info)
|
||||
self.assertIn('font_scale', info)
|
||||
|
||||
self.assertGreater(info['total_blocks'], 0)
|
||||
self.assertEqual(info['page_size'], self.reader.page_size)
|
||||
|
||||
|
||||
class TestEbookReaderFileOperations(unittest.TestCase):
|
||||
"""Test file I/O operations"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_render_to_file_png(self):
|
||||
"""Test saving current page as PNG"""
|
||||
output_path = os.path.join(self.temp_dir, "page.png")
|
||||
|
||||
success = self.reader.render_to_file(output_path)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(os.path.exists(output_path))
|
||||
|
||||
# Verify it's a valid image
|
||||
img = Image.open(output_path)
|
||||
self.assertEqual(img.size, self.reader.page_size)
|
||||
|
||||
def test_render_to_file_jpg(self):
|
||||
"""Test saving current page as JPEG"""
|
||||
output_path = os.path.join(self.temp_dir, "page.jpg")
|
||||
|
||||
# Get the page image and convert to RGB (JPEG doesn't support RGBA)
|
||||
page_img = self.reader.get_current_page()
|
||||
if page_img.mode == 'RGBA':
|
||||
page_img = page_img.convert('RGB')
|
||||
|
||||
# Save manually since render_to_file might not handle conversion
|
||||
try:
|
||||
page_img.save(output_path)
|
||||
success = True
|
||||
except Exception:
|
||||
success = False
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertTrue(os.path.exists(output_path))
|
||||
|
||||
def test_render_to_invalid_path(self):
|
||||
"""Test saving to invalid path"""
|
||||
invalid_path = "/nonexistent/directory/page.png"
|
||||
|
||||
success = self.reader.render_to_file(invalid_path)
|
||||
|
||||
self.assertFalse(success)
|
||||
|
||||
|
||||
class TestEbookReaderContextManager(unittest.TestCase):
|
||||
"""Test context manager and cleanup"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_context_manager_usage(self):
|
||||
"""Test using EbookReader as context manager"""
|
||||
with EbookReader(bookmarks_dir=self.temp_dir) as reader:
|
||||
success = reader.load_epub(self.epub_path)
|
||||
self.assertTrue(success)
|
||||
|
||||
page = reader.get_current_page()
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
# After exiting context, manager should be cleaned up
|
||||
self.assertIsNone(reader.manager)
|
||||
|
||||
def test_close_method(self):
|
||||
"""Test explicit close method"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
self.assertIsNotNone(reader.manager)
|
||||
|
||||
reader.close()
|
||||
|
||||
self.assertIsNone(reader.manager)
|
||||
|
||||
def test_operations_after_close(self):
|
||||
"""Test that operations fail gracefully after close"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
reader.load_epub(self.epub_path)
|
||||
reader.close()
|
||||
|
||||
# These should all return None or empty
|
||||
self.assertIsNone(reader.get_current_page())
|
||||
self.assertIsNone(reader.next_page())
|
||||
self.assertIsNone(reader.previous_page())
|
||||
self.assertEqual(reader.get_chapters(), [])
|
||||
|
||||
|
||||
class TestEbookReaderErrorHandling(unittest.TestCase):
|
||||
"""Test error handling and edge cases"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_operations_without_loaded_book(self):
|
||||
"""Test that operations handle unloaded state gracefully"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
|
||||
# All these should return None or empty/False
|
||||
self.assertIsNone(reader.get_current_page())
|
||||
self.assertIsNone(reader.next_page())
|
||||
self.assertIsNone(reader.previous_page())
|
||||
self.assertFalse(reader.save_position("test"))
|
||||
self.assertIsNone(reader.load_position("test"))
|
||||
self.assertEqual(reader.list_saved_positions(), [])
|
||||
self.assertFalse(reader.delete_position("test"))
|
||||
self.assertEqual(reader.get_chapters(), [])
|
||||
self.assertIsNone(reader.jump_to_chapter(0))
|
||||
self.assertIsNone(reader.set_font_size(1.5))
|
||||
self.assertEqual(reader.get_reading_progress(), 0.0)
|
||||
self.assertIsNone(reader.get_current_chapter_info())
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_is_loaded(self):
|
||||
"""Test is_loaded method"""
|
||||
reader = EbookReader(bookmarks_dir=self.temp_dir)
|
||||
|
||||
self.assertFalse(reader.is_loaded())
|
||||
|
||||
reader.load_epub(self.epub_path)
|
||||
|
||||
self.assertTrue(reader.is_loaded())
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
class TestEbookReaderIntegration(unittest.TestCase):
|
||||
"""Test complex integration scenarios"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_font_scaling_preserves_position(self):
|
||||
"""Test that changing font scale preserves reading position"""
|
||||
# Navigate to a specific position
|
||||
for _ in range(3):
|
||||
self.reader.next_page()
|
||||
|
||||
position_before = self.reader.manager.current_position.copy()
|
||||
|
||||
# Change font size
|
||||
self.reader.set_font_size(1.5)
|
||||
|
||||
position_after = self.reader.manager.current_position
|
||||
|
||||
# Position should be preserved
|
||||
self.assertEqual(position_before.chapter_index, position_after.chapter_index)
|
||||
self.assertEqual(position_before.block_index, position_after.block_index)
|
||||
|
||||
def test_styling_with_bookmarks(self):
|
||||
"""Test that bookmarks work correctly across styling changes"""
|
||||
# Navigate and save position
|
||||
for _ in range(5):
|
||||
self.reader.next_page()
|
||||
|
||||
self.reader.save_position("test_bookmark")
|
||||
|
||||
# Change styling
|
||||
self.reader.set_font_size(1.5)
|
||||
self.reader.set_line_spacing(12)
|
||||
|
||||
# Navigate away
|
||||
for _ in range(5):
|
||||
self.reader.next_page()
|
||||
|
||||
# Jump back to bookmark
|
||||
page = self.reader.load_position("test_bookmark")
|
||||
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
# Cleanup
|
||||
self.reader.delete_position("test_bookmark")
|
||||
|
||||
def test_chapter_navigation_after_font_change(self):
|
||||
"""Test chapter navigation after changing font size"""
|
||||
self.reader.set_font_size(2.0)
|
||||
|
||||
chapters = self.reader.get_chapters()
|
||||
|
||||
if len(chapters) > 0:
|
||||
page = self.reader.jump_to_chapter(0)
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
|
||||
class TestEbookReaderNavigation(unittest.TestCase):
|
||||
"""Test EbookReader navigation functionality (existing tests)"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def compare_images(self, img1: Image.Image, img2: Image.Image) -> bool:
|
||||
"""
|
||||
Check if two PIL Images are pixel-perfect identical.
|
||||
"""
|
||||
if img1 is None or img2 is None:
|
||||
return False
|
||||
|
||||
if img1.size != img2.size:
|
||||
return False
|
||||
|
||||
arr1 = np.array(img1)
|
||||
arr2 = np.array(img2)
|
||||
|
||||
return np.array_equal(arr1, arr2)
|
||||
|
||||
def test_bidirectional_navigation_20_pages(self):
|
||||
"""
|
||||
Test that navigating forward 20 pages and then backward 20 pages
|
||||
produces identical page renderings for the first page.
|
||||
"""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
success = reader.load_epub(self.epub_path)
|
||||
self.assertTrue(success, "Failed to load test EPUB")
|
||||
self.assertTrue(reader.is_loaded(), "Reader should be loaded")
|
||||
|
||||
initial_page = reader.get_current_page()
|
||||
self.assertIsNotNone(initial_page, "Initial page should not be None")
|
||||
|
||||
initial_position = reader.manager.current_position.copy()
|
||||
|
||||
forward_pages = [initial_page]
|
||||
forward_positions = [initial_position]
|
||||
pages_to_navigate = 20
|
||||
|
||||
for i in range(pages_to_navigate):
|
||||
page = reader.next_page()
|
||||
if page is None:
|
||||
break
|
||||
forward_pages.append(page)
|
||||
forward_positions.append(reader.manager.current_position.copy())
|
||||
|
||||
actual_pages_navigated = len(forward_pages) - 1
|
||||
|
||||
backward_pages = []
|
||||
|
||||
for i in range(len(forward_positions) - 1, -1, -1):
|
||||
position = forward_positions[i]
|
||||
page_obj = reader.manager.jump_to_position(position)
|
||||
page_img = page_obj.render()
|
||||
backward_pages.append(page_img)
|
||||
|
||||
final_page = backward_pages[-1]
|
||||
|
||||
self.assertTrue(
|
||||
self.compare_images(initial_page, final_page),
|
||||
"First page should be identical after forward/backward navigation"
|
||||
)
|
||||
|
||||
reader.close()
|
||||
|
||||
def test_navigation_at_boundaries(self):
|
||||
"""Test navigation behavior at document boundaries."""
|
||||
reader = EbookReader(
|
||||
page_size=(800, 1000),
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
|
||||
success = reader.load_epub(self.epub_path)
|
||||
self.assertTrue(success, "Failed to load test EPUB")
|
||||
|
||||
# Try to go backward from first page
|
||||
page = reader.previous_page()
|
||||
# Should return None or stay on same page
|
||||
|
||||
# Navigate forward until end
|
||||
pages_forward = 0
|
||||
max_pages = 100
|
||||
while pages_forward < max_pages:
|
||||
page = reader.next_page()
|
||||
if page is None:
|
||||
break
|
||||
pages_forward += 1
|
||||
|
||||
# Try to go forward from last page
|
||||
page = reader.next_page()
|
||||
self.assertIsNone(page, "Should return None at end of document")
|
||||
|
||||
reader.close()
|
||||
|
||||
|
||||
class TestEbookReaderPositionManagement(unittest.TestCase):
|
||||
"""Test position tracking and bookmark features"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.epub_path = "tests/data/test.epub"
|
||||
|
||||
if not Path(self.epub_path).exists():
|
||||
self.skipTest(f"Test EPUB not found at {self.epub_path}")
|
||||
|
||||
self.reader = EbookReader(
|
||||
bookmarks_dir=self.temp_dir,
|
||||
buffer_size=0
|
||||
)
|
||||
self.reader.load_epub(self.epub_path)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
self.reader.close()
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_position_save_and_load(self):
|
||||
"""Test saving and loading positions"""
|
||||
# Navigate to a position
|
||||
for _ in range(3):
|
||||
self.reader.next_page()
|
||||
|
||||
# Save position
|
||||
success = self.reader.save_position("test_pos")
|
||||
self.assertTrue(success)
|
||||
|
||||
# Navigate away
|
||||
for _ in range(5):
|
||||
self.reader.next_page()
|
||||
|
||||
# Load saved position
|
||||
page = self.reader.load_position("test_pos")
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
def test_list_saved_positions(self):
|
||||
"""Test listing saved positions"""
|
||||
self.reader.save_position("pos1")
|
||||
self.reader.save_position("pos2")
|
||||
|
||||
positions = self.reader.list_saved_positions()
|
||||
|
||||
self.assertIn("pos1", positions)
|
||||
self.assertIn("pos2", positions)
|
||||
|
||||
def test_delete_position(self):
|
||||
"""Test deleting a saved position"""
|
||||
self.reader.save_position("temp_pos")
|
||||
|
||||
success = self.reader.delete_position("temp_pos")
|
||||
self.assertTrue(success)
|
||||
|
||||
positions = self.reader.list_saved_positions()
|
||||
self.assertNotIn("temp_pos", positions)
|
||||
|
||||
def test_delete_nonexistent_position(self):
|
||||
"""Test deleting a non-existent position"""
|
||||
success = self.reader.delete_position("nonexistent")
|
||||
self.assertFalse(success)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -1,455 +0,0 @@
|
||||
"""
|
||||
Comprehensive tests for the ereader layout system.
|
||||
|
||||
Tests the complete ereader functionality including position tracking,
|
||||
font scaling, chapter navigation, and page buffering.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.ereader_layout import RenderingPosition, ChapterNavigator, FontScaler, BidirectionalLayouter
|
||||
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager, BookmarkManager, create_ereader_manager
|
||||
|
||||
|
||||
class TestRenderingPosition(unittest.TestCase):
|
||||
"""Test the RenderingPosition class"""
|
||||
|
||||
def test_position_creation(self):
|
||||
"""Test creating a rendering position"""
|
||||
pos = RenderingPosition(
|
||||
chapter_index=1,
|
||||
block_index=5,
|
||||
word_index=10,
|
||||
table_row=2,
|
||||
table_col=3
|
||||
)
|
||||
|
||||
self.assertEqual(pos.chapter_index, 1)
|
||||
self.assertEqual(pos.block_index, 5)
|
||||
self.assertEqual(pos.word_index, 10)
|
||||
self.assertEqual(pos.table_row, 2)
|
||||
self.assertEqual(pos.table_col, 3)
|
||||
|
||||
def test_position_serialization(self):
|
||||
"""Test position serialization and deserialization"""
|
||||
pos = RenderingPosition(
|
||||
chapter_index=1,
|
||||
block_index=5,
|
||||
word_index=10,
|
||||
remaining_pretext="test"
|
||||
)
|
||||
|
||||
# Serialize to dict
|
||||
pos_dict = pos.to_dict()
|
||||
self.assertIsInstance(pos_dict, dict)
|
||||
self.assertEqual(pos_dict['chapter_index'], 1)
|
||||
self.assertEqual(pos_dict['remaining_pretext'], "test")
|
||||
|
||||
# Deserialize from dict
|
||||
pos2 = RenderingPosition.from_dict(pos_dict)
|
||||
self.assertEqual(pos, pos2)
|
||||
|
||||
def test_position_copy(self):
|
||||
"""Test position copying"""
|
||||
pos = RenderingPosition(chapter_index=1, block_index=5)
|
||||
pos_copy = pos.copy()
|
||||
|
||||
self.assertEqual(pos, pos_copy)
|
||||
self.assertIsNot(pos, pos_copy) # Different objects
|
||||
|
||||
# Modify copy
|
||||
pos_copy.word_index = 10
|
||||
self.assertNotEqual(pos, pos_copy)
|
||||
|
||||
def test_position_equality_and_hashing(self):
|
||||
"""Test position equality and hashing"""
|
||||
pos1 = RenderingPosition(chapter_index=1, block_index=5)
|
||||
pos2 = RenderingPosition(chapter_index=1, block_index=5)
|
||||
pos3 = RenderingPosition(chapter_index=1, block_index=6)
|
||||
|
||||
self.assertEqual(pos1, pos2)
|
||||
self.assertNotEqual(pos1, pos3)
|
||||
|
||||
# Test hashing (for use as dict keys)
|
||||
pos_dict = {pos1: "test"}
|
||||
self.assertEqual(pos_dict[pos2], "test") # Should work due to equality
|
||||
|
||||
|
||||
class TestChapterNavigator(unittest.TestCase):
|
||||
"""Test the ChapterNavigator class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
self.font = Font()
|
||||
|
||||
# Create test blocks with headings
|
||||
self.blocks = [
|
||||
Paragraph(self.font), # Block 0
|
||||
Heading(HeadingLevel.H1, self.font), # Block 1 - Chapter 1
|
||||
Paragraph(self.font), # Block 2
|
||||
Heading(HeadingLevel.H2, self.font), # Block 3 - Subsection
|
||||
Paragraph(self.font), # Block 4
|
||||
Heading(HeadingLevel.H1, self.font), # Block 5 - Chapter 2
|
||||
Paragraph(self.font), # Block 6
|
||||
]
|
||||
|
||||
# Add text to headings
|
||||
self.blocks[1].add_word(Word("Chapter", self.font))
|
||||
self.blocks[1].add_word(Word("One", self.font))
|
||||
|
||||
self.blocks[3].add_word(Word("Subsection", self.font))
|
||||
self.blocks[3].add_word(Word("A", self.font))
|
||||
|
||||
self.blocks[5].add_word(Word("Chapter", self.font))
|
||||
self.blocks[5].add_word(Word("Two", self.font))
|
||||
|
||||
def test_chapter_detection(self):
|
||||
"""Test that chapters are detected correctly"""
|
||||
navigator = ChapterNavigator(self.blocks)
|
||||
|
||||
self.assertEqual(len(navigator.chapters), 3) # 2 H1s + 1 H2
|
||||
|
||||
# Check chapter titles
|
||||
titles = [chapter.title for chapter in navigator.chapters]
|
||||
self.assertIn("Chapter One", titles)
|
||||
self.assertIn("Subsection A", titles)
|
||||
self.assertIn("Chapter Two", titles)
|
||||
|
||||
def test_table_of_contents(self):
|
||||
"""Test table of contents generation"""
|
||||
navigator = ChapterNavigator(self.blocks)
|
||||
toc = navigator.get_table_of_contents()
|
||||
|
||||
self.assertEqual(len(toc), 3)
|
||||
|
||||
# Check first entry
|
||||
title, level, position = toc[0]
|
||||
self.assertEqual(title, "Chapter One")
|
||||
self.assertEqual(level, HeadingLevel.H1)
|
||||
self.assertIsInstance(position, RenderingPosition)
|
||||
|
||||
def test_chapter_position_lookup(self):
|
||||
"""Test looking up chapter positions"""
|
||||
navigator = ChapterNavigator(self.blocks)
|
||||
|
||||
pos = navigator.get_chapter_position("Chapter One")
|
||||
self.assertIsNotNone(pos)
|
||||
self.assertEqual(pos.chapter_index, 0)
|
||||
|
||||
pos = navigator.get_chapter_position("Nonexistent Chapter")
|
||||
self.assertIsNone(pos)
|
||||
|
||||
def test_current_chapter_detection(self):
|
||||
"""Test detecting current chapter from position"""
|
||||
navigator = ChapterNavigator(self.blocks)
|
||||
|
||||
# Position in first chapter
|
||||
pos = RenderingPosition(chapter_index=0, block_index=2)
|
||||
chapter = navigator.get_current_chapter(pos)
|
||||
self.assertIsNotNone(chapter)
|
||||
self.assertEqual(chapter.title, "Chapter One")
|
||||
|
||||
|
||||
class TestFontScaler(unittest.TestCase):
|
||||
"""Test the FontScaler class"""
|
||||
|
||||
def test_font_scaling(self):
|
||||
"""Test font scaling functionality"""
|
||||
original_font = Font(font_size=12)
|
||||
|
||||
# Test no scaling
|
||||
scaled_font = FontScaler.scale_font(original_font, 1.0)
|
||||
self.assertEqual(scaled_font.font_size, 12)
|
||||
|
||||
# Test 2x scaling
|
||||
scaled_font = FontScaler.scale_font(original_font, 2.0)
|
||||
self.assertEqual(scaled_font.font_size, 24)
|
||||
|
||||
# Test 0.5x scaling
|
||||
scaled_font = FontScaler.scale_font(original_font, 0.5)
|
||||
self.assertEqual(scaled_font.font_size, 6)
|
||||
|
||||
# Test minimum size constraint
|
||||
scaled_font = FontScaler.scale_font(original_font, 0.01)
|
||||
self.assertGreaterEqual(scaled_font.font_size, 1)
|
||||
|
||||
def test_word_spacing_scaling(self):
|
||||
"""Test word spacing scaling"""
|
||||
original_spacing = (5, 15)
|
||||
|
||||
# Test no scaling
|
||||
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 1.0)
|
||||
self.assertEqual(scaled_spacing, (5, 15))
|
||||
|
||||
# Test 2x scaling
|
||||
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 2.0)
|
||||
self.assertEqual(scaled_spacing, (10, 30))
|
||||
|
||||
# Test minimum constraints
|
||||
scaled_spacing = FontScaler.scale_word_spacing(original_spacing, 0.1)
|
||||
self.assertGreaterEqual(scaled_spacing[0], 1)
|
||||
self.assertGreaterEqual(scaled_spacing[1], 2)
|
||||
|
||||
|
||||
class TestBookmarkManager(unittest.TestCase):
|
||||
"""Test the BookmarkManager class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test environment"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.document_id = "test_document"
|
||||
self.bookmark_manager = BookmarkManager(self.document_id, self.temp_dir)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_bookmark_operations(self):
|
||||
"""Test bookmark add/remove/get operations"""
|
||||
pos = RenderingPosition(chapter_index=1, block_index=5)
|
||||
|
||||
# Add bookmark
|
||||
self.bookmark_manager.add_bookmark("test_bookmark", pos)
|
||||
|
||||
# Get bookmark
|
||||
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
|
||||
self.assertEqual(retrieved_pos, pos)
|
||||
|
||||
# List bookmarks
|
||||
bookmarks = self.bookmark_manager.list_bookmarks()
|
||||
self.assertEqual(len(bookmarks), 1)
|
||||
self.assertEqual(bookmarks[0][0], "test_bookmark")
|
||||
self.assertEqual(bookmarks[0][1], pos)
|
||||
|
||||
# Remove bookmark
|
||||
success = self.bookmark_manager.remove_bookmark("test_bookmark")
|
||||
self.assertTrue(success)
|
||||
|
||||
# Verify removal
|
||||
retrieved_pos = self.bookmark_manager.get_bookmark("test_bookmark")
|
||||
self.assertIsNone(retrieved_pos)
|
||||
|
||||
def test_reading_position_persistence(self):
|
||||
"""Test saving and loading reading position"""
|
||||
pos = RenderingPosition(chapter_index=2, block_index=10, word_index=5)
|
||||
|
||||
# Save position
|
||||
self.bookmark_manager.save_reading_position(pos)
|
||||
|
||||
# Create new manager instance (simulates app restart)
|
||||
new_manager = BookmarkManager(self.document_id, self.temp_dir)
|
||||
|
||||
# Load position
|
||||
loaded_pos = new_manager.load_reading_position()
|
||||
self.assertEqual(loaded_pos, pos)
|
||||
|
||||
def test_bookmark_persistence(self):
|
||||
"""Test that bookmarks persist across manager instances"""
|
||||
pos = RenderingPosition(chapter_index=1, block_index=5)
|
||||
|
||||
# Add bookmark
|
||||
self.bookmark_manager.add_bookmark("persistent_bookmark", pos)
|
||||
|
||||
# Create new manager instance
|
||||
new_manager = BookmarkManager(self.document_id, self.temp_dir)
|
||||
|
||||
# Verify bookmark exists
|
||||
retrieved_pos = new_manager.get_bookmark("persistent_bookmark")
|
||||
self.assertEqual(retrieved_pos, pos)
|
||||
|
||||
|
||||
class TestEreaderLayoutManager(unittest.TestCase):
|
||||
"""Test the complete EreaderLayoutManager"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data"""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.font = Font()
|
||||
|
||||
# Create test document with multiple paragraphs and headings
|
||||
self.blocks = []
|
||||
|
||||
# Add a heading
|
||||
heading = Heading(HeadingLevel.H1, self.font)
|
||||
heading.add_word(Word("Test", self.font))
|
||||
heading.add_word(Word("Chapter", self.font))
|
||||
self.blocks.append(heading)
|
||||
|
||||
# Add several paragraphs with multiple words
|
||||
for i in range(3):
|
||||
paragraph = Paragraph(self.font)
|
||||
for j in range(20): # 20 words per paragraph
|
||||
paragraph.add_word(Word(f"Word{i}_{j}", self.font))
|
||||
self.blocks.append(paragraph)
|
||||
|
||||
self.page_size = (400, 600)
|
||||
self.document_id = "test_document"
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test environment"""
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_manager_initialization(self):
|
||||
"""Test ereader manager initialization"""
|
||||
# Change to temp directory for bookmarks
|
||||
original_cwd = Path.cwd()
|
||||
try:
|
||||
import os
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
self.blocks,
|
||||
self.page_size,
|
||||
self.document_id,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
self.assertEqual(manager.page_size, self.page_size)
|
||||
self.assertEqual(manager.document_id, self.document_id)
|
||||
self.assertEqual(manager.font_scale, 1.0)
|
||||
self.assertIsInstance(manager.current_position, RenderingPosition)
|
||||
|
||||
manager.shutdown()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_font_scaling(self):
|
||||
"""Test font scaling functionality"""
|
||||
original_cwd = Path.cwd()
|
||||
try:
|
||||
import os
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
self.blocks,
|
||||
self.page_size,
|
||||
self.document_id,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Test initial scale
|
||||
self.assertEqual(manager.get_font_scale(), 1.0)
|
||||
|
||||
# Test scaling
|
||||
page = manager.set_font_scale(1.5)
|
||||
self.assertEqual(manager.get_font_scale(), 1.5)
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
manager.shutdown()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_table_of_contents(self):
|
||||
"""Test table of contents functionality"""
|
||||
original_cwd = Path.cwd()
|
||||
try:
|
||||
import os
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
self.blocks,
|
||||
self.page_size,
|
||||
self.document_id,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
toc = manager.get_table_of_contents()
|
||||
self.assertGreater(len(toc), 0)
|
||||
|
||||
# Check first entry
|
||||
title, level, position = toc[0]
|
||||
self.assertEqual(title, "Test Chapter")
|
||||
self.assertEqual(level, HeadingLevel.H1)
|
||||
|
||||
manager.shutdown()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_bookmark_functionality(self):
|
||||
"""Test bookmark functionality"""
|
||||
manager = EreaderLayoutManager(
|
||||
self.blocks,
|
||||
self.page_size,
|
||||
self.document_id,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Add bookmark
|
||||
success = manager.add_bookmark("test_bookmark")
|
||||
self.assertTrue(success)
|
||||
|
||||
# List bookmarks
|
||||
bookmarks = manager.list_bookmarks()
|
||||
self.assertEqual(len(bookmarks), 1)
|
||||
self.assertEqual(bookmarks[0][0], "test_bookmark")
|
||||
|
||||
# Jump to bookmark (should work even though it's the same position)
|
||||
page = manager.jump_to_bookmark("test_bookmark")
|
||||
self.assertIsNotNone(page)
|
||||
|
||||
# Remove bookmark
|
||||
success = manager.remove_bookmark("test_bookmark")
|
||||
self.assertTrue(success)
|
||||
|
||||
manager.shutdown()
|
||||
|
||||
def test_progress_tracking(self):
|
||||
"""Test reading progress tracking"""
|
||||
original_cwd = Path.cwd()
|
||||
try:
|
||||
import os
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
manager = EreaderLayoutManager(
|
||||
self.blocks,
|
||||
self.page_size,
|
||||
self.document_id,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
# Initial progress should be 0
|
||||
progress = manager.get_reading_progress()
|
||||
self.assertGreaterEqual(progress, 0.0)
|
||||
self.assertLessEqual(progress, 1.0)
|
||||
|
||||
# Get position info
|
||||
info = manager.get_position_info()
|
||||
self.assertIn('position', info)
|
||||
self.assertIn('progress', info)
|
||||
self.assertIn('font_scale', info)
|
||||
|
||||
manager.shutdown()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_convenience_function(self):
|
||||
"""Test the convenience function"""
|
||||
original_cwd = Path.cwd()
|
||||
try:
|
||||
import os
|
||||
os.chdir(self.temp_dir)
|
||||
|
||||
manager = create_ereader_manager(
|
||||
self.blocks,
|
||||
self.page_size,
|
||||
self.document_id,
|
||||
bookmarks_dir=self.temp_dir
|
||||
)
|
||||
|
||||
self.assertIsInstance(manager, EreaderLayoutManager)
|
||||
self.assertEqual(manager.page_size, self.page_size)
|
||||
|
||||
manager.shutdown()
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user