Update coverage badges [skip ci]

This commit is contained in:
Gitea Action
2026-08-08 20:35:15 +00:00
commit 735face593
312 changed files with 91102 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for core pyWebLayout functionality."""
+244
View File
@@ -0,0 +1,244 @@
"""
Unit tests for the bounded usage-ranked caches.
Covers the guarantees the text rendering path depends on: that the bounds are never
exceeded, that eviction prefers the least-used entries, that aging lets a new
working set displace an old one, and that document-frequency seeding survives a
scan of unfamiliar keys.
"""
import unittest
from pyWebLayout.core.cache import (
UsageCache,
SizedUsageCache,
DEFAULT_AGING_INTERVAL,
)
class TestUsageCache(unittest.TestCase):
"""Entry-count-bounded cache."""
def test_rejects_invalid_bounds(self):
for bad in (0, -1):
with self.assertRaises(ValueError):
UsageCache(bad)
with self.assertRaises(ValueError):
UsageCache(4, aging_interval=0)
with self.assertRaises(ValueError):
UsageCache(4, eviction_sample=0)
def test_stores_and_returns_values(self):
cache = UsageCache(4)
cache.put('a', 1)
self.assertEqual(cache.get('a'), 1)
self.assertIsNone(cache.get('missing'))
self.assertIn('a', cache)
self.assertEqual(len(cache), 1)
def test_never_exceeds_max_entries(self):
cache = UsageCache(10)
for i in range(500):
cache.put(i, i)
self.assertLessEqual(len(cache), 10)
self.assertEqual(cache.stats()['entries'], 10)
def test_evicts_least_used(self):
# One hot key among many cold ones must survive a long cold scan. The
# sample is smaller than the cache, so this is probabilistic in principle;
# a hot key's count is far enough above the rest to make it reliable.
cache = UsageCache(20, eviction_sample=8)
cache.put('hot', 'value')
for _ in range(200):
cache.get('hot')
for i in range(400):
cache.put(f'cold{i}', i)
cache.get('hot')
self.assertEqual(cache.get('hot'), 'value')
def test_repeated_put_does_not_duplicate(self):
cache = UsageCache(10)
for _ in range(50):
cache.put('a', 1)
self.assertEqual(len(cache), 1)
def test_put_updates_existing_value(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.put('a', 2)
self.assertEqual(cache.get('a'), 2)
def test_seeded_count_outranks_fresh_entries(self):
"""A document-frequency seed must survive a scan of unseen keys."""
cache = UsageCache(20, eviction_sample=8)
cache.put('frequent', 'value', count=5000)
for i in range(400):
cache.put(f'new{i}', i)
self.assertEqual(cache.get('frequent'), 'value')
def test_aging_lets_a_new_working_set_take_over(self):
"""Without aging, stale high counts lock the cache permanently."""
cache = UsageCache(20, aging_interval=50, eviction_sample=8)
for i in range(20):
cache.put(f'old{i}', i, count=10000)
# A completely different working set, each key used a few times.
for round_ in range(60):
for i in range(10):
key = f'new{i}'
if cache.get(key) is None:
cache.put(key, i)
survivors = sum(1 for i in range(10) if f'new{i}' in cache)
self.assertGreater(survivors, 0,
"aging should let the new working set displace the old")
self.assertGreater(cache.stats()['agings'], 0)
def test_aging_can_be_disabled(self):
cache = UsageCache(10, aging_interval=None)
for i in range(100):
cache.put(i, i)
self.assertEqual(cache.stats()['agings'], 0)
def test_resize_evicts_immediately(self):
cache = UsageCache(100)
for i in range(100):
cache.put(i, i)
cache.resize(10)
self.assertEqual(len(cache), 10)
with self.assertRaises(ValueError):
cache.resize(0)
def test_clear_empties_but_keeps_counters(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.get('a')
cache.clear()
self.assertEqual(len(cache), 0)
self.assertNotIn('a', cache)
self.assertEqual(cache.stats()['hits'], 1)
def test_stats_track_hits_and_misses(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.get('a')
cache.get('a')
cache.get('b')
stats = cache.stats()
self.assertEqual(stats['hits'], 2)
self.assertEqual(stats['misses'], 1)
self.assertAlmostEqual(stats['hit_rate'], 2 / 3)
self.assertEqual(stats['max_entries'], 10)
def test_internal_slot_list_stays_consistent(self):
"""Eviction swaps the tail into the freed slot; indices must stay valid."""
cache = UsageCache(8)
for i in range(300):
cache.put(i, i)
for key in list(cache._entries):
self.assertEqual(cache._slots[cache._entries[key][2]], key)
self.assertEqual(len(cache._slots), len(cache._entries))
class TestSizedUsageCache(unittest.TestCase):
"""Byte-bounded cache, as used for glyph bitmaps."""
@staticmethod
def sizer(value):
return value
def test_rejects_invalid_bounds(self):
for bad in (0, -1):
with self.assertRaises(ValueError):
SizedUsageCache(bad, self.sizer)
def test_never_exceeds_max_bytes(self):
cache = SizedUsageCache(1000, self.sizer)
for i in range(500):
cache.put(i, 100)
self.assertLessEqual(cache.total_bytes, 1000)
def test_tracks_total_bytes(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.put('b', 250)
self.assertEqual(cache.total_bytes, 350)
def test_oversized_value_is_not_retained(self):
"""One huge entry must not flush everything else out."""
cache = SizedUsageCache(1000, self.sizer)
cache.put('small', 100)
cache.put('huge', 5000)
self.assertNotIn('huge', cache)
self.assertIn('small', cache)
self.assertEqual(cache.total_bytes, 100)
def test_replacing_a_value_remeasures_it(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.put('a', 300)
self.assertEqual(cache.total_bytes, 300)
self.assertEqual(len(cache), 1)
def test_evicts_least_used(self):
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
cache.put('hot', 100)
for _ in range(200):
cache.get('hot')
for i in range(400):
cache.put(f'cold{i}', 100)
cache.get('hot')
self.assertIn('hot', cache)
def test_seeded_count_outranks_fresh_entries(self):
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
cache.put('frequent', 100, count=5000)
for i in range(400):
cache.put(f'new{i}', 100)
self.assertIn('frequent', cache)
def test_resize_evicts_immediately(self):
cache = SizedUsageCache(10000, self.sizer)
for i in range(100):
cache.put(i, 100)
cache.resize(500)
self.assertLessEqual(cache.total_bytes, 500)
def test_clear_resets_byte_accounting(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.clear()
self.assertEqual(cache.total_bytes, 0)
self.assertEqual(len(cache), 0)
def test_stats_report_bounds(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
stats = cache.stats()
self.assertEqual(stats['total_bytes'], 100)
self.assertEqual(stats['max_bytes'], 1000)
self.assertEqual(stats['entries'], 1)
def test_bookkeeping_stays_consistent_under_churn(self):
"""Byte total and slot list must not drift over many evictions."""
cache = SizedUsageCache(2000, self.sizer, aging_interval=97)
for i in range(2000):
cache.put(i, (i % 7 + 1) * 50)
if i % 3 == 0:
cache.get(i)
self.assertEqual(cache.total_bytes,
sum(cache._sizes[k] for k in cache._entries))
self.assertEqual(len(cache._slots), len(cache._entries))
self.assertLessEqual(cache.total_bytes, cache.max_bytes)
class TestDefaults(unittest.TestCase):
def test_aging_is_enabled_by_default(self):
self.assertIsNotNone(DEFAULT_AGING_INTERVAL)
self.assertGreater(DEFAULT_AGING_INTERVAL, 0)
self.assertIsNotNone(UsageCache(4)._aging_interval)
if __name__ == '__main__':
unittest.main()
+352
View File
@@ -0,0 +1,352 @@
"""
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
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()
+433
View File
@@ -0,0 +1,433 @@
"""
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
# Origin is at baseline (100, 100), so test a point slightly above (at ascent/2)
# and to the right
self.assertTrue(text.in_object(np.array([110, 100])))
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]
# Origin is at baseline, so query at baseline position (Y = origin[1])
# with X offset into the text
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
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]
# Origin is at baseline, query at baseline Y position
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
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]
# Origin is at baseline, query at baseline Y position
point = (int(text_obj._origin[0] + 5), int(text_obj._origin[1]))
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]
# Origin is at baseline, query at baseline Y position
point1 = (int(text_obj1._origin[0] + 5), int(text_obj1._origin[1]))
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]
# Origin is at baseline, query at baseline Y position
point2 = (int(text_obj2._origin[0] + 5), int(text_obj2._origin[1]))
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]
# Origin is at baseline, query at baseline Y position
start_point = (
int(start_text._origin[0] + 5), int(start_text._origin[1]))
end_point = (int(end_text._origin[0] + 5), int(end_text._origin[1]))
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()