pyWebLayout/tests/core/test_query_system.py

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