425 lines
14 KiB
Python
425 lines
14 KiB
Python
"""
|
|
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()
|