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