Update coverage badges [skip ci]
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tests for core pyWebLayout functionality."""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user