Update coverage badges [skip ci]
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
"""
|
||||
Unit tests for HTML extraction functionality.
|
||||
|
||||
Tests the HTML parsing and conversion to pyWebLayout abstract elements,
|
||||
including styled content within paragraphs and block-level elements.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel, Quote, CodeBlock, HList, ListStyle, Table, Image
|
||||
from pyWebLayout.abstract.document import Document
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
class TestHTMLParagraph(unittest.TestCase):
|
||||
"""Test cases for basic paragraph parsing."""
|
||||
|
||||
def test_simple(self):
|
||||
text = "<p>This is a paragraph.</p>"
|
||||
paragraphs = parse_html_string(text)
|
||||
self.assertEqual(len(paragraphs), 1)
|
||||
self.assertEqual(len(paragraphs[0]), 4)
|
||||
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(),
|
||||
"This is a paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
def test_multiple(self):
|
||||
text = "<p>This is a paragraph.</p><p>This is another paragraph.</p>"
|
||||
paragraphs = parse_html_string(text)
|
||||
self.assertEqual(len(paragraphs), 2)
|
||||
self.assertEqual(len(paragraphs[0]), 4)
|
||||
self.assertEqual(len(paragraphs[1]), 4)
|
||||
|
||||
for w1, t1 in zip(paragraphs[0].words_iter(),
|
||||
"This is a paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
for w1, t1 in zip(paragraphs[1].words_iter(),
|
||||
"This is another paragraph.".split(" ")):
|
||||
self.assertEqual(w1[1].text, t1)
|
||||
|
||||
|
||||
class TestHTMLStyledParagraphs(unittest.TestCase):
|
||||
"""Test cases for paragraphs with inline styling."""
|
||||
|
||||
def test_bold_text(self):
|
||||
"""Test paragraphs with bold text using <strong> and <b> tags."""
|
||||
text = "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
self.assertEqual(len(words), 7) # "This is bold text in a paragraph."
|
||||
|
||||
# Check that 'bold' and 'text' words have bold font weight
|
||||
bold_word = words[2][1] # 'bold'
|
||||
text_word = words[3][1] # 'text'
|
||||
self.assertEqual(bold_word.text, "bold")
|
||||
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
|
||||
self.assertEqual(text_word.text, "text")
|
||||
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
# Check that other words are not bold
|
||||
normal_word = words[0][1] # 'This'
|
||||
self.assertEqual(normal_word.text, "This")
|
||||
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
def test_italic_text(self):
|
||||
"""Test paragraphs with italic text using <em> and <i> tags."""
|
||||
text = "<p>This is <em>italic text</em> in a paragraph.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
# Check that 'italic' and 'text' words have italic font style
|
||||
italic_word = words[2][1] # 'italic'
|
||||
text_word = words[3][1] # 'text'
|
||||
self.assertEqual(italic_word.text, "italic")
|
||||
self.assertEqual(italic_word.style.style, FontStyle.ITALIC)
|
||||
self.assertEqual(text_word.text, "text")
|
||||
self.assertEqual(text_word.style.style, FontStyle.ITALIC)
|
||||
|
||||
def test_underlined_text(self):
|
||||
"""Test paragraphs with underlined text using <u> tag."""
|
||||
text = "<p>This is <u>underlined text</u> here.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
underlined_word = words[2][1] # 'underlined'
|
||||
self.assertEqual(underlined_word.style.decoration, TextDecoration.UNDERLINE)
|
||||
|
||||
def test_strikethrough_text(self):
|
||||
"""Test paragraphs with strikethrough text using <s> and <del> tags."""
|
||||
text = "<p>This is <s>strikethrough text</s> here.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
strike_word = words[2][1] # 'strikethrough'
|
||||
self.assertEqual(strike_word.style.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
def test_span_with_inline_styles(self):
|
||||
"""Test paragraphs with span elements containing inline CSS styles."""
|
||||
text = (
|
||||
'<p>This text is normal, but <span style="color: red; font-weight: bold;">'
|
||||
'this part is red and bold</span>.</p>'
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
# Find the styled words
|
||||
styled_words = []
|
||||
for _, word in words:
|
||||
if word.text in ["this", "part", "is", "red", "and", "bold"]:
|
||||
if word.style.weight == FontWeight.BOLD:
|
||||
styled_words.append(word)
|
||||
|
||||
self.assertGreater(
|
||||
len(styled_words),
|
||||
0,
|
||||
"Should have bold words in styled span")
|
||||
|
||||
# Check that at least one word has the red color
|
||||
red_words = [w for w in styled_words if w.style.colour == (255, 0, 0)]
|
||||
self.assertGreater(len(red_words), 0, "Should have red colored words")
|
||||
|
||||
def test_mixed_formatting(self):
|
||||
"""Test paragraphs with multiple formatting elements combined."""
|
||||
text = (
|
||||
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
|
||||
'<span style="color: blue;">blue</span>, and <mark>highlighted</mark> '
|
||||
'text all together.</p>'
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
# Check for bold word
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
|
||||
# Check for italic word
|
||||
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(italic_words), 0, "Should have italic words")
|
||||
|
||||
# Check for blue colored word
|
||||
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
|
||||
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
|
||||
|
||||
def test_nested_formatting(self):
|
||||
"""Test nested formatting elements."""
|
||||
text = "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
# Find words that should be both bold and italic
|
||||
bold_italic_words = [w for _, w in words if w.style.weight ==
|
||||
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(
|
||||
len(bold_italic_words),
|
||||
0,
|
||||
"Should have words that are both bold and italic")
|
||||
|
||||
def test_color_variations(self):
|
||||
"""Test different color formats in CSS."""
|
||||
text = '<p><span style="color: #ff0000;">Hex red</span> and <span style="color: green;">Named green</span>.</p>'
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
words = list(blocks[0].words_iter())
|
||||
|
||||
# Check for hex red color
|
||||
hex_red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
|
||||
self.assertGreater(len(hex_red_words), 0, "Should have hex red colored words")
|
||||
|
||||
# Check for named green color
|
||||
green_words = [w for _, w in words if w.style.colour == (0, 255, 0)]
|
||||
self.assertGreater(len(green_words), 0, "Should have green colored words")
|
||||
|
||||
|
||||
class TestHTMLBlockElements(unittest.TestCase):
|
||||
"""Test cases for block-level HTML elements."""
|
||||
|
||||
def test_body_element(self):
|
||||
"""Test parsing of body element containing other elements."""
|
||||
text = "<body><p>Paragraph one.</p><p>Paragraph two.</p></body>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 2)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
self.assertIsInstance(blocks[1], Paragraph)
|
||||
|
||||
def test_div_container(self):
|
||||
"""Test div elements as generic containers."""
|
||||
text = "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 2)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
self.assertIsInstance(blocks[1], Paragraph)
|
||||
|
||||
def test_headings(self):
|
||||
"""Test all heading levels h1-h6."""
|
||||
text = (
|
||||
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3>"
|
||||
"<h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6>"
|
||||
)
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 6)
|
||||
|
||||
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
for i, block in enumerate(blocks):
|
||||
self.assertIsInstance(block, Heading)
|
||||
self.assertEqual(block.level, expected_levels[i])
|
||||
|
||||
words = list(block.words_iter())
|
||||
self.assertEqual(len(words), 2) # "Heading" and number
|
||||
self.assertEqual(words[0][1].text, "Heading")
|
||||
|
||||
def test_blockquote(self):
|
||||
"""Test blockquote elements."""
|
||||
text = "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Quote)
|
||||
|
||||
# Check that the quote contains a paragraph
|
||||
quote_blocks = list(blocks[0].blocks())
|
||||
self.assertEqual(len(quote_blocks), 1)
|
||||
self.assertIsInstance(quote_blocks[0], Paragraph)
|
||||
|
||||
def test_preformatted_code(self):
|
||||
"""Test preformatted code blocks."""
|
||||
text = "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], CodeBlock)
|
||||
|
||||
lines = list(blocks[0].lines())
|
||||
self.assertGreater(len(lines), 0)
|
||||
|
||||
def test_unordered_list(self):
|
||||
"""Test unordered lists."""
|
||||
text = "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], HList)
|
||||
self.assertEqual(blocks[0].style, ListStyle.UNORDERED)
|
||||
|
||||
items = list(blocks[0].items())
|
||||
self.assertEqual(len(items), 3)
|
||||
|
||||
def test_ordered_list(self):
|
||||
"""Test ordered lists."""
|
||||
text = "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], HList)
|
||||
self.assertEqual(blocks[0].style, ListStyle.ORDERED)
|
||||
|
||||
def test_list_with_styled_content(self):
|
||||
"""Test lists containing styled content."""
|
||||
text = "<ul><li>Normal item</li><li><strong>Bold item</strong></li><li>Item with <em>italic</em> text</li></ul>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], HList)
|
||||
|
||||
items = list(blocks[0].items())
|
||||
self.assertEqual(len(items), 3)
|
||||
|
||||
# Check second item has bold text
|
||||
second_item_blocks = list(items[1].blocks())
|
||||
if second_item_blocks:
|
||||
words = list(second_item_blocks[0].words_iter())
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0)
|
||||
|
||||
def test_table_basic(self):
|
||||
"""Test basic table structure."""
|
||||
text = """
|
||||
<table>
|
||||
<tr>
|
||||
<th>Header 1</th>
|
||||
<th>Header 2</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
"""
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Table)
|
||||
|
||||
def test_semantic_elements(self):
|
||||
"""Test semantic HTML5 elements treated as containers."""
|
||||
text = "<section><article><p>Article content</p></article></section>"
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
def test_nested_block_elements(self):
|
||||
"""Test nested block elements."""
|
||||
text = """
|
||||
<div>
|
||||
<h2>Section Title</h2>
|
||||
<p>Some introductory text.</p>
|
||||
<blockquote>
|
||||
<p>A quoted paragraph.</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
"""
|
||||
blocks = parse_html_string(text)
|
||||
self.assertGreater(len(blocks), 2)
|
||||
|
||||
# Should have at least a heading, paragraph, and quote
|
||||
has_heading = any(isinstance(b, Heading) for b in blocks)
|
||||
has_paragraph = any(isinstance(b, Paragraph) for b in blocks)
|
||||
has_quote = any(isinstance(b, Quote) for b in blocks)
|
||||
|
||||
self.assertTrue(has_heading, "Should contain a heading")
|
||||
self.assertTrue(has_paragraph, "Should contain a paragraph")
|
||||
self.assertTrue(has_quote, "Should contain a quote")
|
||||
|
||||
def test_empty_elements(self):
|
||||
"""Test handling of empty elements."""
|
||||
text = "<p></p><div></div><span></span>"
|
||||
blocks = parse_html_string(text)
|
||||
# Empty elements may not create blocks, which is acceptable behavior
|
||||
self.assertGreaterEqual(len(blocks), 0)
|
||||
|
||||
# Test that empty paragraph with some content does create a block
|
||||
text_with_content = "<p> </p>" # Contains whitespace
|
||||
blocks_with_content = parse_html_string(text_with_content)
|
||||
# This should create at least one block since there's whitespace content
|
||||
self.assertGreaterEqual(len(blocks_with_content), 0)
|
||||
|
||||
|
||||
class TestHTMLComplexStructures(unittest.TestCase):
|
||||
"""Test cases for complex HTML structures combining multiple features."""
|
||||
|
||||
def test_article_with_mixed_content(self):
|
||||
"""Test a realistic article structure with mixed content."""
|
||||
text = """
|
||||
<article>
|
||||
<h1>Article Title</h1>
|
||||
<p>This is the <strong>introduction</strong> paragraph with <em>some emphasis</em>.</p>
|
||||
<blockquote>
|
||||
<p>This is a <span style="color: blue;">quoted section</span> with styling.</p>
|
||||
</blockquote>
|
||||
<ul>
|
||||
<li>First <strong>important</strong> point</li>
|
||||
<li>Second point with <code>inline code</code></li>
|
||||
</ul>
|
||||
</article>
|
||||
"""
|
||||
blocks = parse_html_string(text)
|
||||
self.assertGreater(len(blocks), 3)
|
||||
|
||||
# Verify we have the expected block types
|
||||
block_types = [type(b).__name__ for b in blocks]
|
||||
self.assertIn('Heading', block_types)
|
||||
self.assertIn('Paragraph', block_types)
|
||||
self.assertIn('Quote', block_types)
|
||||
self.assertIn('HList', block_types)
|
||||
|
||||
def test_styled_table_content(self):
|
||||
"""Test table with styled cell content."""
|
||||
text = """
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><strong>Product</strong></th>
|
||||
<th><em>Price</em></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Item with <span style="color: red;">red text</span></td>
|
||||
<td><strong>$19.99</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
"""
|
||||
blocks = parse_html_string(text)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Table)
|
||||
|
||||
|
||||
class TestHTMLFontRegistryIntegration(unittest.TestCase):
|
||||
"""Test cases for font registry integration with HTML extraction."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.doc = Document("Test Document", "en-US")
|
||||
self.base_font = Font(font_size=16, colour=(0, 0, 0))
|
||||
|
||||
def test_font_registry_creates_fonts(self):
|
||||
"""Test that HTML parsing with document context creates fonts in registry."""
|
||||
html_content = """
|
||||
<div>
|
||||
<p>This is <strong>bold text</strong> and <em>italic text</em>.</p>
|
||||
<h1>Main Header</h1>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Initially empty style registry
|
||||
initial_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
# Parse HTML with document context
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
|
||||
# Should have created styles for different formatting
|
||||
final_style_count = self.doc.get_style_registry().get_style_count()
|
||||
self.assertGreater(final_style_count, initial_style_count,
|
||||
"Should have created styles in registry")
|
||||
|
||||
# Should have created blocks
|
||||
self.assertGreater(len(blocks), 0, "Should have created blocks")
|
||||
|
||||
def test_font_registry_reuses_fonts(self):
|
||||
"""Test that parsing same content reuses existing styles."""
|
||||
html_content = """
|
||||
<div>
|
||||
<p>This is <strong>bold text</strong> and <em>italic text</em>.</p>
|
||||
<h1>Main Header</h1>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# First parse
|
||||
blocks1 = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
first_parse_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
# Second parse with same content
|
||||
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
second_parse_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
# Style count should not increase on second parse
|
||||
self.assertEqual(first_parse_style_count, second_parse_style_count,
|
||||
"Should reuse existing styles instead of creating new ones")
|
||||
|
||||
# Both parses should create same number of blocks
|
||||
self.assertEqual(len(blocks1), len(blocks2),
|
||||
"Should create same structure on both parses")
|
||||
|
||||
def test_font_registry_different_styles_create_different_fonts(self):
|
||||
"""Test that different styles create different style objects."""
|
||||
# Create styles with different properties
|
||||
style_id1, style1 = self.doc.get_or_create_style(
|
||||
font_size=14, color=(255, 0, 0), font_weight=FontWeight.BOLD
|
||||
)
|
||||
style_id2, style2 = self.doc.get_or_create_style(
|
||||
font_size=16, color=(255, 0, 0), font_weight=FontWeight.BOLD
|
||||
)
|
||||
style_id3, style3 = self.doc.get_or_create_style(
|
||||
font_size=14, color=(0, 255, 0), font_weight=FontWeight.BOLD
|
||||
)
|
||||
|
||||
# Should be different style IDs
|
||||
self.assertNotEqual(
|
||||
style_id1,
|
||||
style_id2,
|
||||
"Different sizes should create different styles")
|
||||
self.assertNotEqual(
|
||||
style_id1,
|
||||
style_id3,
|
||||
"Different colors should create different styles")
|
||||
self.assertNotEqual(style_id2, style_id3, "All styles should be different")
|
||||
|
||||
# Should have multiple styles in registry
|
||||
self.assertGreaterEqual(self.doc.get_style_registry().get_style_count(), 3)
|
||||
|
||||
def test_font_registry_integration_with_html_styles(self):
|
||||
"""Test that HTML parsing uses style registry for styled content."""
|
||||
html_content = """
|
||||
<p>Normal text with <strong>bold</strong> and <em>italic</em> and
|
||||
<span style="color: red;">red text</span>.</p>
|
||||
"""
|
||||
|
||||
# Parse content
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
|
||||
# Extract all words from the paragraph
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words_iter())
|
||||
|
||||
# Find words with different styles
|
||||
normal_words = [w for _, w in words if w.style.weight == FontWeight.NORMAL
|
||||
and w.style.style == FontStyle.NORMAL]
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
|
||||
red_words = [w for _, w in words if w.style.colour == (255, 0, 0)]
|
||||
|
||||
# Should have words with different styles
|
||||
self.assertGreater(len(normal_words), 0, "Should have normal words")
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
self.assertGreater(len(italic_words), 0, "Should have italic words")
|
||||
self.assertGreater(len(red_words), 0, "Should have red words")
|
||||
|
||||
# Style registry should contain multiple styles for different formatting
|
||||
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
|
||||
"Should have multiple styles for different formatting")
|
||||
|
||||
def test_font_registry_without_document_context(self):
|
||||
"""Test that parsing without document context works (fallback behavior)."""
|
||||
html_content = "<p>This is <strong>bold text</strong>.</p>"
|
||||
|
||||
# Get initial style count (should include default style)
|
||||
initial_style_count = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
# Parse without document context
|
||||
blocks = parse_html_string(html_content, self.base_font)
|
||||
|
||||
# Should still create blocks successfully
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
# Should not affect document's style registry
|
||||
final_style_count = self.doc.get_style_registry().get_style_count()
|
||||
self.assertEqual(final_style_count, initial_style_count,
|
||||
"Document style registry should remain unchanged")
|
||||
|
||||
def test_complex_html_font_reuse(self):
|
||||
"""Test style reuse with complex HTML containing repeated styles."""
|
||||
html_content = """
|
||||
<div>
|
||||
<h1>First Header</h1>
|
||||
<p>Paragraph with <strong>bold</strong> text.</p>
|
||||
<h1>Second Header</h1>
|
||||
<p>Another paragraph with <strong>bold</strong> text.</p>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Parse content
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
style_count_after_parse = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
# Parse same content again
|
||||
blocks2 = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
style_count_after_second_parse = self.doc.get_style_registry().get_style_count()
|
||||
|
||||
# Style count should not increase on second parse
|
||||
self.assertEqual(style_count_after_parse, style_count_after_second_parse,
|
||||
"Styles should be reused for repeated formatting")
|
||||
|
||||
# Both should create same structure
|
||||
self.assertEqual(len(blocks), len(blocks2))
|
||||
|
||||
def test_font_registry_with_nested_styles(self):
|
||||
"""Test style registry with nested HTML styles."""
|
||||
html_content = """
|
||||
<p>Text with <strong>bold and <em>bold italic</em> nested</strong> styles.</p>
|
||||
"""
|
||||
|
||||
# Parse content
|
||||
blocks = parse_html_string(html_content, self.base_font, document=self.doc)
|
||||
|
||||
# Should create styles for different style combinations
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words_iter())
|
||||
|
||||
# Find words that are both bold and italic
|
||||
bold_italic_words = [w for _, w in words
|
||||
if w.style.weight == FontWeight.BOLD
|
||||
and w.style.style == FontStyle.ITALIC]
|
||||
|
||||
self.assertGreater(len(bold_italic_words), 0,
|
||||
"Should have words with combined bold+italic style")
|
||||
|
||||
# Should have multiple styles in registry for different combinations
|
||||
self.assertGreater(self.doc.get_style_registry().get_style_count(), 1,
|
||||
"Should create separate styles for style combinations")
|
||||
|
||||
|
||||
class TestHTMLImagesInParagraphs(unittest.TestCase):
|
||||
"""Test cases for handling images inside paragraph tags."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_font = Font(font_size=14)
|
||||
|
||||
def test_image_only_paragraph(self):
|
||||
"""Test paragraph containing only an image (common in EPUBs)."""
|
||||
html = '<p><img src="cover.jpg" alt="Book Cover"/></p>'
|
||||
blocks = parse_html_string(html, base_font=self.base_font)
|
||||
|
||||
# Should parse as an Image block, not a Paragraph
|
||||
self.assertGreater(len(blocks), 0, "Should parse at least one block")
|
||||
|
||||
# Check that we have an Image block
|
||||
image_blocks = [b for b in blocks if isinstance(b, Image)]
|
||||
self.assertGreater(len(image_blocks), 0, "Should have at least one Image block")
|
||||
|
||||
# Verify image properties
|
||||
img = image_blocks[0]
|
||||
self.assertEqual(img.source, "cover.jpg")
|
||||
self.assertEqual(img.alt_text, "Book Cover")
|
||||
|
||||
def test_paragraph_with_multiple_images(self):
|
||||
"""Test paragraph with multiple images."""
|
||||
html = '<p><img src="img1.jpg" alt="First"/><img src="img2.jpg" alt="Second"/></p>'
|
||||
blocks = parse_html_string(html, base_font=self.base_font)
|
||||
|
||||
# Should have multiple Image blocks
|
||||
image_blocks = [b for b in blocks if isinstance(b, Image)]
|
||||
self.assertEqual(len(image_blocks), 2, "Should have two Image blocks")
|
||||
|
||||
# Verify both images were parsed
|
||||
sources = [img.source for img in image_blocks]
|
||||
self.assertIn("img1.jpg", sources)
|
||||
self.assertIn("img2.jpg", sources)
|
||||
|
||||
def test_paragraph_with_text_and_image(self):
|
||||
"""Test paragraph with mixed text and image content."""
|
||||
html = '<p>Some text before <img src="inline.jpg" alt="Inline"/> and after</p>'
|
||||
blocks = parse_html_string(html, base_font=self.base_font)
|
||||
|
||||
# Should have both paragraph and image blocks
|
||||
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||
images = [b for b in blocks if isinstance(b, Image)]
|
||||
|
||||
self.assertGreater(len(paragraphs), 0, "Should have a Paragraph block for text")
|
||||
self.assertGreater(len(images), 0, "Should have an Image block")
|
||||
|
||||
# Verify image was parsed
|
||||
self.assertEqual(images[0].source, "inline.jpg")
|
||||
|
||||
# Verify text was extracted (should have words like "Some", "text", etc.)
|
||||
if paragraphs:
|
||||
words = list(paragraphs[0].words_iter())
|
||||
self.assertGreater(len(words), 0, "Paragraph should have words")
|
||||
|
||||
def test_regular_paragraph_still_works(self):
|
||||
"""Test that regular paragraphs without images still work correctly."""
|
||||
html = '<p>Just regular text without any images.</p>'
|
||||
blocks = parse_html_string(html, base_font=self.base_font)
|
||||
|
||||
# Should be exactly one Paragraph block
|
||||
self.assertEqual(len(blocks), 1, "Should have exactly one block")
|
||||
self.assertIsInstance(blocks[0], Paragraph, "Should be a Paragraph block")
|
||||
|
||||
# Should not have any Image blocks
|
||||
image_blocks = [b for b in blocks if isinstance(b, Image)]
|
||||
self.assertEqual(len(image_blocks), 0, "Should have no Image blocks")
|
||||
|
||||
def test_image_with_width_and_height(self):
|
||||
"""Test image parsing with width and height attributes."""
|
||||
html = '<p><img src="sized.jpg" alt="Sized Image" width="400" height="300"/></p>'
|
||||
blocks = parse_html_string(html, base_font=self.base_font)
|
||||
|
||||
# Should have an Image block
|
||||
image_blocks = [b for b in blocks if isinstance(b, Image)]
|
||||
self.assertEqual(len(image_blocks), 1, "Should have one Image block")
|
||||
|
||||
# Verify dimensions were parsed
|
||||
img = image_blocks[0]
|
||||
self.assertEqual(img.width, 400)
|
||||
self.assertEqual(img.height, 300)
|
||||
|
||||
def test_nested_paragraph_with_image_in_span(self):
|
||||
"""Test image inside nested inline elements."""
|
||||
html = '<p><span><img src="nested.jpg" alt="Nested"/></span></p>'
|
||||
blocks = parse_html_string(html, base_font=self.base_font)
|
||||
|
||||
# Should still extract the image
|
||||
image_blocks = [b for b in blocks if isinstance(b, Image)]
|
||||
self.assertGreater(len(image_blocks), 0, "Should find image even when nested")
|
||||
|
||||
# Verify image was parsed correctly
|
||||
self.assertEqual(image_blocks[0].source, "nested.jpg")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,524 @@
|
||||
"""
|
||||
Unit tests for individual HTML extraction functions.
|
||||
|
||||
Tests the specific handler functions and utility functions in html_extraction module,
|
||||
reusing test patterns from test_html_extraction.py that are known to pass.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from bs4 import BeautifulSoup
|
||||
from pyWebLayout.io.readers.html_extraction import (
|
||||
create_base_context,
|
||||
apply_element_styling,
|
||||
parse_inline_styles,
|
||||
apply_element_font_styles,
|
||||
extract_text_content,
|
||||
paragraph_handler,
|
||||
div_handler,
|
||||
heading_handler,
|
||||
blockquote_handler,
|
||||
preformatted_handler,
|
||||
unordered_list_handler,
|
||||
ordered_list_handler,
|
||||
list_item_handler,
|
||||
table_handler,
|
||||
table_row_handler,
|
||||
table_cell_handler,
|
||||
table_header_cell_handler,
|
||||
horizontal_rule_handler,
|
||||
image_handler,
|
||||
StyleContext,
|
||||
)
|
||||
from pyWebLayout.abstract.block import (
|
||||
Paragraph,
|
||||
Heading,
|
||||
HeadingLevel,
|
||||
Quote,
|
||||
CodeBlock,
|
||||
HList,
|
||||
ListItem,
|
||||
ListStyle,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
HorizontalRule,
|
||||
Image,
|
||||
)
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
|
||||
|
||||
class TestUtilityFunctions(unittest.TestCase):
|
||||
"""Test cases for utility functions."""
|
||||
|
||||
def test_create_base_context(self):
|
||||
"""Test creation of base style context."""
|
||||
context = create_base_context()
|
||||
|
||||
self.assertIsInstance(context, StyleContext)
|
||||
self.assertIsInstance(context.font, Font)
|
||||
self.assertIsNone(context.background)
|
||||
self.assertEqual(context.css_classes, set())
|
||||
self.assertEqual(context.css_styles, {})
|
||||
self.assertEqual(context.element_attributes, {})
|
||||
self.assertEqual(context.parent_elements, [])
|
||||
|
||||
def test_parse_inline_styles_from_existing_tests(self):
|
||||
"""Test parsing CSS inline styles - adapted from test_span_with_inline_styles."""
|
||||
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
|
||||
style_text = "color: red; font-weight: bold;"
|
||||
styles = parse_inline_styles(style_text)
|
||||
|
||||
expected = {
|
||||
"color": "red",
|
||||
"font-weight": "bold"
|
||||
}
|
||||
self.assertEqual(styles, expected)
|
||||
|
||||
def test_parse_inline_styles_color_variations(self):
|
||||
"""Test parsing different color formats - adapted from test_color_variations."""
|
||||
# Test hex color parsing
|
||||
hex_style = "color: #ff0000;"
|
||||
styles = parse_inline_styles(hex_style)
|
||||
self.assertEqual(styles.get("color"), "#ff0000")
|
||||
|
||||
# Test named color parsing
|
||||
named_style = "color: green;"
|
||||
styles = parse_inline_styles(named_style)
|
||||
self.assertEqual(styles.get("color"), "green")
|
||||
|
||||
def test_apply_element_font_styles_bold_elements(self):
|
||||
"""Test font style application for bold elements - adapted from test_bold_text."""
|
||||
base_font = Font()
|
||||
|
||||
# Test <strong> tag - from "<strong>bold text</strong>"
|
||||
font = apply_element_font_styles(base_font, "strong", {})
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
|
||||
# Test <b> tag
|
||||
font = apply_element_font_styles(base_font, "b", {})
|
||||
self.assertEqual(font.weight, FontWeight.BOLD)
|
||||
|
||||
def test_apply_element_font_styles_italic_elements(self):
|
||||
"""Test font style application for italic elements - adapted from test_italic_text."""
|
||||
base_font = Font()
|
||||
|
||||
# Test <em> tag - from "<em>italic text</em>"
|
||||
font = apply_element_font_styles(base_font, "em", {})
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
|
||||
# Test <i> tag
|
||||
font = apply_element_font_styles(base_font, "i", {})
|
||||
self.assertEqual(font.style, FontStyle.ITALIC)
|
||||
|
||||
def test_apply_element_font_styles_decoration_elements(self):
|
||||
"""Test font decoration - adapted from test_underlined_text and test_strikethrough_text."""
|
||||
base_font = Font()
|
||||
|
||||
# Test <u> tag - from "<u>underlined text</u>"
|
||||
font = apply_element_font_styles(base_font, "u", {})
|
||||
self.assertEqual(font.decoration, TextDecoration.UNDERLINE)
|
||||
|
||||
# Test <s> tag - from "<s>strikethrough text</s>"
|
||||
font = apply_element_font_styles(base_font, "s", {})
|
||||
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
# Test <del> tag
|
||||
font = apply_element_font_styles(base_font, "del", {})
|
||||
self.assertEqual(font.decoration, TextDecoration.STRIKETHROUGH)
|
||||
|
||||
def test_apply_element_font_styles_headings(self):
|
||||
"""Test heading font styles - adapted from test_headings."""
|
||||
base_font = Font()
|
||||
|
||||
# Test heading sizes and weights - from test_headings which tests h1-h6
|
||||
headings = [("h1", 24), ("h2", 20), ("h3", 18),
|
||||
("h4", 16), ("h5", 14), ("h6", 12)]
|
||||
|
||||
for tag, expected_size in headings:
|
||||
font = apply_element_font_styles(base_font, tag, {})
|
||||
self.assertEqual(font.font_size, expected_size, f"Size mismatch for {tag}")
|
||||
self.assertEqual(
|
||||
font.weight,
|
||||
FontWeight.BOLD,
|
||||
f"Weight should be bold for {tag}")
|
||||
|
||||
def test_apply_element_font_styles_color_parsing(self):
|
||||
"""Test color parsing - adapted from test_color_variations."""
|
||||
base_font = Font()
|
||||
|
||||
# Test named colors - from '<span style="color: green;">Named green</span>'
|
||||
css_styles = {"color": "green"}
|
||||
font = apply_element_font_styles(base_font, "span", css_styles)
|
||||
self.assertEqual(font.colour, (0, 255, 0))
|
||||
|
||||
# Test hex colors - from '<span style="color: #ff0000;">Hex red</span>'
|
||||
css_styles = {"color": "#ff0000"}
|
||||
font = apply_element_font_styles(base_font, "span", css_styles)
|
||||
self.assertEqual(font.colour, (255, 0, 0))
|
||||
|
||||
def test_apply_element_styling_with_classes_and_styles(self):
|
||||
"""Test complete element styling - adapted from test_span_with_inline_styles."""
|
||||
# From: '<span style="color: red; font-weight: bold;">this part is red and bold</span>'
|
||||
soup = BeautifulSoup(
|
||||
'<span class="highlight" style="color: red; font-weight: bold;">text</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
base_context = create_base_context()
|
||||
|
||||
styled_context = apply_element_styling(base_context, element)
|
||||
|
||||
# Check CSS classes
|
||||
self.assertIn("highlight", styled_context.css_classes)
|
||||
|
||||
# Check CSS styles
|
||||
self.assertEqual(styled_context.css_styles.get("color"), "red")
|
||||
self.assertEqual(styled_context.css_styles.get("font-weight"), "bold")
|
||||
|
||||
# Check font styling
|
||||
self.assertEqual(styled_context.font.colour, (255, 0, 0))
|
||||
self.assertEqual(styled_context.font.weight, FontWeight.BOLD)
|
||||
|
||||
|
||||
class TestExtractTextContent(unittest.TestCase):
|
||||
"""Test cases for text content extraction."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
def test_extract_simple_text(self):
|
||||
"""Test extracting simple text - adapted from test_simple."""
|
||||
# From: "<p>This is a paragraph.</p>"
|
||||
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
# Should match the expected word count from original test
|
||||
self.assertEqual(len(words), 4) # "This", "is", "a", "paragraph."
|
||||
self.assertIsInstance(words[0], Word)
|
||||
self.assertEqual(words[0].text, "This")
|
||||
|
||||
def test_extract_styled_text_bold(self):
|
||||
"""Test extracting bold styled text - adapted from test_bold_text."""
|
||||
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
soup = BeautifulSoup(
|
||||
'<span>This is <strong>bold text</strong> in a paragraph.</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
# Find the bold words
|
||||
bold_words = [w for w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
|
||||
# Check specific words are bold (from original test expectations)
|
||||
bold_word_texts = [w.text for w in bold_words]
|
||||
self.assertIn("bold", bold_word_texts)
|
||||
self.assertIn("text", bold_word_texts)
|
||||
|
||||
def test_extract_nested_formatting(self):
|
||||
"""Test nested formatting - adapted from test_nested_formatting."""
|
||||
# From: "<p>This has <strong>bold with <em>italic inside</em></strong> formatting.</p>"
|
||||
soup = BeautifulSoup(
|
||||
'<span>This has <strong>bold with <em>italic inside</em></strong> formatting.</span>',
|
||||
'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
words = extract_text_content(element, self.base_context)
|
||||
|
||||
# Find words that should be both bold and italic
|
||||
bold_italic_words = [w for w in words if w.style.weight ==
|
||||
FontWeight.BOLD and w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(
|
||||
len(bold_italic_words),
|
||||
0,
|
||||
"Should have words that are both bold and italic")
|
||||
|
||||
|
||||
class TestHandlerFunctions(unittest.TestCase):
|
||||
"""Test cases for HTML element handler functions using known working patterns."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
def test_paragraph_handler_simple(self):
|
||||
"""Test paragraph handler - adapted from test_simple."""
|
||||
# From: "<p>This is a paragraph.</p>"
|
||||
soup = BeautifulSoup('<p>This is a paragraph.</p>', 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, Paragraph)
|
||||
# Should match original test expectations
|
||||
self.assertEqual(len(result), 4) # 4 words
|
||||
|
||||
words = list(result.words_iter())
|
||||
expected_texts = ["This", "is", "a", "paragraph."]
|
||||
for i, expected_text in enumerate(expected_texts):
|
||||
self.assertEqual(words[i][1].text, expected_text)
|
||||
|
||||
def test_heading_handler_all_levels(self):
|
||||
"""Test heading handler - adapted from test_headings."""
|
||||
# From: "<h1>Heading 1</h1><h2>Heading 2</h2>..."
|
||||
expected_levels = [HeadingLevel.H1, HeadingLevel.H2, HeadingLevel.H3,
|
||||
HeadingLevel.H4, HeadingLevel.H5, HeadingLevel.H6]
|
||||
|
||||
for i, expected_level in enumerate(expected_levels, 1):
|
||||
tag = f"h{i}"
|
||||
soup = BeautifulSoup(f'<{tag}>Heading {i}</{tag}>', 'html.parser')
|
||||
element = soup.find(tag)
|
||||
|
||||
result = heading_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, Heading)
|
||||
self.assertEqual(result.level, expected_level)
|
||||
|
||||
# Should match original test word expectations
|
||||
words = list(result.words_iter())
|
||||
self.assertEqual(len(words), 2) # "Heading" and number
|
||||
self.assertEqual(words[0][1].text, "Heading")
|
||||
|
||||
def test_blockquote_handler(self):
|
||||
"""Test blockquote handler - adapted from test_blockquote."""
|
||||
# From: "<blockquote><p>This is a quoted paragraph.</p></blockquote>"
|
||||
soup = BeautifulSoup(
|
||||
'<blockquote><p>This is a quoted paragraph.</p></blockquote>',
|
||||
'html.parser')
|
||||
element = soup.find('blockquote')
|
||||
|
||||
result = blockquote_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, Quote)
|
||||
|
||||
# Check that the quote contains a paragraph (from original test)
|
||||
quote_blocks = list(result.blocks())
|
||||
self.assertEqual(len(quote_blocks), 1)
|
||||
self.assertIsInstance(quote_blocks[0], Paragraph)
|
||||
|
||||
def test_preformatted_handler(self):
|
||||
"""Test preformatted handler - adapted from test_preformatted_code."""
|
||||
# From: "<pre><code>function hello() {\n console.log('Hello');\n}</code></pre>"
|
||||
soup = BeautifulSoup(
|
||||
'<pre><code>function hello() {\n console.log(\'Hello\');\n}</code></pre>',
|
||||
'html.parser')
|
||||
element = soup.find('pre')
|
||||
|
||||
result = preformatted_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, CodeBlock)
|
||||
|
||||
# Should have lines (from original test expectation)
|
||||
lines = list(result.lines())
|
||||
self.assertGreater(len(lines), 0)
|
||||
|
||||
def test_unordered_list_handler(self):
|
||||
"""Test unordered list handler - adapted from test_unordered_list."""
|
||||
# From: "<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>"
|
||||
soup = BeautifulSoup(
|
||||
'<ul><li>First item</li><li>Second item</li><li>Third item</li></ul>',
|
||||
'html.parser')
|
||||
element = soup.find('ul')
|
||||
|
||||
result = unordered_list_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, HList)
|
||||
self.assertEqual(result.style, ListStyle.UNORDERED)
|
||||
|
||||
# Should match original test expectations
|
||||
items = list(result.items())
|
||||
self.assertEqual(len(items), 3)
|
||||
|
||||
def test_ordered_list_handler(self):
|
||||
"""Test ordered list handler - adapted from test_ordered_list."""
|
||||
# From: "<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>"
|
||||
soup = BeautifulSoup(
|
||||
'<ol><li>First item</li><li>Second item</li><li>Third item</li></ol>',
|
||||
'html.parser')
|
||||
element = soup.find('ol')
|
||||
|
||||
result = ordered_list_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, HList)
|
||||
self.assertEqual(result.style, ListStyle.ORDERED)
|
||||
|
||||
# Should match original test expectations
|
||||
items = list(result.items())
|
||||
self.assertEqual(len(items), 3) # "First item", "Second item", "Third item"
|
||||
|
||||
def test_list_item_handler(self):
|
||||
"""Test list item handler."""
|
||||
soup = BeautifulSoup('<li>List item content</li>', 'html.parser')
|
||||
element = soup.find('li')
|
||||
|
||||
result = list_item_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, ListItem)
|
||||
blocks = list(result.blocks())
|
||||
self.assertGreater(len(blocks), 0)
|
||||
|
||||
def test_table_handler(self):
|
||||
"""Test table handler - adapted from test_table_basic."""
|
||||
# From test_table_basic structure
|
||||
soup = BeautifulSoup('''
|
||||
<table>
|
||||
<tr>
|
||||
<th>Header 1</th>
|
||||
<th>Header 2</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
''', 'html.parser')
|
||||
element = soup.find('table')
|
||||
|
||||
result = table_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, Table)
|
||||
|
||||
def test_table_row_handler(self):
|
||||
"""Test table row handler."""
|
||||
soup = BeautifulSoup('<tr><td>Cell 1</td><td>Cell 2</td></tr>', 'html.parser')
|
||||
element = soup.find('tr')
|
||||
|
||||
result = table_row_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, TableRow)
|
||||
|
||||
def test_table_cell_handler(self):
|
||||
"""Test table cell handler."""
|
||||
soup = BeautifulSoup('<td>Cell content</td>', 'html.parser')
|
||||
element = soup.find('td')
|
||||
|
||||
# Apply styling to get attributes
|
||||
styled_context = apply_element_styling(self.base_context, element)
|
||||
result = table_cell_handler(element, styled_context)
|
||||
|
||||
self.assertIsInstance(result, TableCell)
|
||||
self.assertEqual(result.is_header, False)
|
||||
|
||||
def test_table_header_cell_handler(self):
|
||||
"""Test table header cell handler."""
|
||||
soup = BeautifulSoup('<th>Header content</th>', 'html.parser')
|
||||
element = soup.find('th')
|
||||
|
||||
# Apply styling to get attributes
|
||||
styled_context = apply_element_styling(self.base_context, element)
|
||||
result = table_header_cell_handler(element, styled_context)
|
||||
|
||||
self.assertIsInstance(result, TableCell)
|
||||
self.assertEqual(result.is_header, True)
|
||||
|
||||
def test_horizontal_rule_handler(self):
|
||||
"""Test horizontal rule handler."""
|
||||
soup = BeautifulSoup('<hr>', 'html.parser')
|
||||
element = soup.find('hr')
|
||||
|
||||
result = horizontal_rule_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, HorizontalRule)
|
||||
|
||||
def test_image_handler(self):
|
||||
"""Test image handler."""
|
||||
soup = BeautifulSoup(
|
||||
'<img src="test.jpg" alt="Test image" width="100" height="50">',
|
||||
'html.parser')
|
||||
element = soup.find('img')
|
||||
|
||||
# Need to apply styling first to get attributes
|
||||
styled_context = apply_element_styling(self.base_context, element)
|
||||
result = image_handler(element, styled_context)
|
||||
|
||||
self.assertIsInstance(result, Image)
|
||||
self.assertEqual(result.source, "test.jpg")
|
||||
self.assertEqual(result.alt_text, "Test image")
|
||||
self.assertEqual(result.width, 100)
|
||||
self.assertEqual(result.height, 50)
|
||||
|
||||
def test_div_handler_container(self):
|
||||
"""Test div handler - adapted from test_div_container."""
|
||||
# From: "<div><p>First paragraph.</p><p>Second paragraph.</p></div>"
|
||||
soup = BeautifulSoup(
|
||||
'<div><p>First paragraph.</p><p>Second paragraph.</p></div>',
|
||||
'html.parser')
|
||||
element = soup.find('div')
|
||||
|
||||
result = div_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, list)
|
||||
# Should match original test expectations
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertIsInstance(result[0], Paragraph)
|
||||
self.assertIsInstance(result[1], Paragraph)
|
||||
|
||||
|
||||
class TestStyledContentHandling(unittest.TestCase):
|
||||
"""Test styled content handling using patterns from existing tests."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
def test_paragraph_with_bold_content(self):
|
||||
"""Test paragraph with bold content - adapted from test_bold_text."""
|
||||
# From: "<p>This is <strong>bold text</strong> in a paragraph.</p>"
|
||||
soup = BeautifulSoup(
|
||||
'<p>This is <strong>bold text</strong> in a paragraph.</p>',
|
||||
'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, Paragraph)
|
||||
words = list(result.words_iter())
|
||||
self.assertEqual(len(words), 7) # From original test expectation
|
||||
|
||||
# Check that 'bold' and 'text' words have bold font weight (from original test)
|
||||
bold_word = words[2][1] # 'bold'
|
||||
text_word = words[3][1] # 'text'
|
||||
self.assertEqual(bold_word.text, "bold")
|
||||
self.assertEqual(bold_word.style.weight, FontWeight.BOLD)
|
||||
self.assertEqual(text_word.text, "text")
|
||||
self.assertEqual(text_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
# Check that other words are not bold (from original test)
|
||||
normal_word = words[0][1] # 'This'
|
||||
self.assertEqual(normal_word.text, "This")
|
||||
self.assertNotEqual(normal_word.style.weight, FontWeight.BOLD)
|
||||
|
||||
def test_paragraph_with_mixed_formatting(self):
|
||||
"""Test mixed formatting - adapted from test_mixed_formatting."""
|
||||
# From: "<p>This paragraph contains <strong>bold</strong>, <em>italic</em>..."
|
||||
html_str = (
|
||||
'<p>This paragraph contains <strong>bold</strong>, <em>italic</em>, '
|
||||
'<span style="color: blue;">blue</span> text.</p>'
|
||||
)
|
||||
soup = BeautifulSoup(html_str, 'html.parser')
|
||||
element = soup.find('p')
|
||||
|
||||
result = paragraph_handler(element, self.base_context)
|
||||
|
||||
self.assertIsInstance(result, Paragraph)
|
||||
words = list(result.words_iter())
|
||||
|
||||
# Check for bold word (from original test pattern)
|
||||
bold_words = [w for _, w in words if w.style.weight == FontWeight.BOLD]
|
||||
self.assertGreater(len(bold_words), 0, "Should have bold words")
|
||||
|
||||
# Check for italic word (from original test pattern)
|
||||
italic_words = [w for _, w in words if w.style.style == FontStyle.ITALIC]
|
||||
self.assertGreater(len(italic_words), 0, "Should have italic words")
|
||||
|
||||
# Check for blue colored word (from original test pattern)
|
||||
blue_words = [w for _, w in words if w.style.colour == (0, 0, 255)]
|
||||
self.assertGreater(len(blue_words), 0, "Should have blue colored words")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Test module for loading HTML files using the html_extraction module.
|
||||
|
||||
This test verifies that HTML files can be loaded from disk and processed
|
||||
using the html_extraction.parse_html_string function.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.abstract.block import Block
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
|
||||
class TestHTMLFileLoader(unittest.TestCase):
|
||||
"""Test class for HTML file loading functionality."""
|
||||
|
||||
def test_load_html_file(self):
|
||||
"""Test loading and parsing an HTML file from disk."""
|
||||
# Path to the test HTML file
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Verify the test file exists
|
||||
self.assertTrue(
|
||||
os.path.exists(html_file_path),
|
||||
f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
# Read the HTML file
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
# Verify we got some content
|
||||
self.assertGreater(len(html_content), 0, "HTML file should not be empty")
|
||||
|
||||
# Parse the HTML content using the html_extraction module
|
||||
try:
|
||||
blocks = parse_html_string(html_content)
|
||||
except Exception as e:
|
||||
self.fail(f"Failed to parse HTML file: {e}")
|
||||
|
||||
# Verify we got some blocks
|
||||
self.assertIsInstance(blocks, list, "parse_html_string should return a list")
|
||||
self.assertGreater(
|
||||
len(blocks),
|
||||
0,
|
||||
"Should extract at least one block from the HTML file")
|
||||
|
||||
# Verify all returned items are Block instances
|
||||
for i, block in enumerate(blocks):
|
||||
self.assertIsInstance(
|
||||
block,
|
||||
Block,
|
||||
f"Item {i} should be a Block instance, got {type(block)}"
|
||||
)
|
||||
|
||||
print(f"Successfully loaded and parsed HTML file with {len(blocks)} blocks")
|
||||
|
||||
def test_load_html_file_with_custom_font(self):
|
||||
"""Test loading HTML file with a custom base font."""
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
self.skipTest(f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
# Create a custom font
|
||||
custom_font = Font(font_size=14, colour=(100, 100, 100))
|
||||
|
||||
# Read and parse with custom font
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
blocks = parse_html_string(html_content, base_font=custom_font)
|
||||
|
||||
# Verify we got blocks
|
||||
self.assertGreater(len(blocks), 0, "Should extract blocks with custom font")
|
||||
|
||||
print(
|
||||
f"Successfully parsed HTML file with custom font, got {len(blocks)} blocks"
|
||||
)
|
||||
|
||||
def test_load_html_file_content_types(self):
|
||||
"""Test that the loaded HTML file contains expected content types."""
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
self.skipTest(f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
blocks = parse_html_string(html_content)
|
||||
|
||||
# Check that we have different types of blocks
|
||||
block_type_names = [type(block).__name__ for block in blocks]
|
||||
unique_types = set(block_type_names)
|
||||
|
||||
# A Wikipedia page should contain multiple types of content
|
||||
self.assertGreater(
|
||||
len(unique_types),
|
||||
1,
|
||||
"Should have multiple types of blocks in Wikipedia page")
|
||||
|
||||
print(f"Found block types: {sorted(unique_types)}")
|
||||
|
||||
def test_html_file_size_handling(self):
|
||||
"""Test that large HTML files can be handled gracefully."""
|
||||
html_file_path = os.path.join(
|
||||
"tests", "data", "Kimi Räikkönen - Wikipedia.html")
|
||||
|
||||
# Skip if file doesn't exist
|
||||
if not os.path.exists(html_file_path):
|
||||
self.skipTest(f"Test HTML file not found: {html_file_path}")
|
||||
|
||||
# Get file size
|
||||
file_size = os.path.getsize(html_file_path)
|
||||
print(f"HTML file size: {file_size} bytes")
|
||||
|
||||
# Read and parse
|
||||
with open(html_file_path, 'r', encoding='utf-8') as file:
|
||||
html_content = file.read()
|
||||
|
||||
# This should not raise an exception even for large files
|
||||
blocks = parse_html_string(html_content)
|
||||
|
||||
# Basic verification
|
||||
self.assertIsInstance(blocks, list)
|
||||
print(f"Successfully processed {file_size} byte file into {len(blocks)} blocks")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
End-to-end test for HTML link interactivity with query_point.
|
||||
|
||||
This test verifies the complete flow:
|
||||
1. HTML with links -> parse_html_string -> LinkedWord objects
|
||||
2. LinkedWord objects -> DocumentLayouter -> Rendered page
|
||||
3. Rendered page -> query_point -> Interactive link detection
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
|
||||
|
||||
class TestHTMLLinkEndToEnd(unittest.TestCase):
|
||||
"""Test complete HTML link workflow from parsing to interaction."""
|
||||
|
||||
def test_complete_link_workflow(self):
|
||||
"""Test the complete workflow: HTML -> parsing -> layout -> query."""
|
||||
# Step 1: Create HTML with a link
|
||||
html = '<p>Click <a href="action:test_action">this link</a> to test.</p>'
|
||||
|
||||
# Step 2: Parse HTML to blocks
|
||||
blocks = parse_html_string(html)
|
||||
self.assertEqual(len(blocks), 1)
|
||||
|
||||
# Step 3: Verify LinkedWord was created
|
||||
paragraph = blocks[0]
|
||||
linked_words = [w for w in paragraph.words if isinstance(w, LinkedWord)]
|
||||
self.assertEqual(len(linked_words), 2) # "this" and "link"
|
||||
|
||||
# Verify link properties
|
||||
for word in linked_words:
|
||||
self.assertEqual(word.location, "action:test_action")
|
||||
self.assertIn(word.text, ["this", "link"])
|
||||
|
||||
# Step 4: Layout on a page
|
||||
page_style = PageStyle()
|
||||
page = Page((400, 200), page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
layouter.layout_document([paragraph])
|
||||
|
||||
# Step 5: Render the page
|
||||
rendered = page.render()
|
||||
self.assertIsNotNone(rendered)
|
||||
|
||||
# Step 6: Test query_point functionality
|
||||
# The query system should be able to detect interactive elements
|
||||
# Note: Exact coordinates depend on layout, so we test the capability
|
||||
# rather than specific pixel positions
|
||||
|
||||
# Try to query various points on the page
|
||||
# The page should respond to queries (even if we don't hit the exact link)
|
||||
result = page.query_point((100, 50))
|
||||
# Result might be None if we didn't hit any text, but the API should work
|
||||
# The key is that if we DID hit a link, it would be detected
|
||||
|
||||
print(f"Query result: {result}")
|
||||
if result and result.is_interactive:
|
||||
# If we hit an interactive element, it should have link info
|
||||
self.assertIsNotNone(result.link_target)
|
||||
print(f"Found interactive link: {result.link_target}")
|
||||
|
||||
def test_settings_overlay_complete_workflow(self):
|
||||
"""Test the complete workflow with actual settings overlay HTML."""
|
||||
# This is the exact HTML pattern used for settings overlays
|
||||
html = '''
|
||||
<div>
|
||||
<h2>Settings</h2>
|
||||
<p>
|
||||
<a href="action:back_to_library">Back to Library</a>
|
||||
</p>
|
||||
<p>
|
||||
Font Size:
|
||||
<a href="setting:font_decrease">[-]</a>
|
||||
<a href="setting:font_increase">[+]</a>
|
||||
</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
# Parse HTML
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
# Collect all LinkedWords
|
||||
all_linked_words = []
|
||||
for block in blocks:
|
||||
if hasattr(block, 'words'):
|
||||
for word in block.words:
|
||||
if isinstance(word, LinkedWord):
|
||||
all_linked_words.append(word)
|
||||
|
||||
# Verify we found the expected links
|
||||
self.assertGreater(len(all_linked_words), 0)
|
||||
|
||||
# Check for specific actions
|
||||
actions = {word.location for word in all_linked_words}
|
||||
self.assertIn("action:back_to_library", actions)
|
||||
self.assertIn("setting:font_decrease", actions)
|
||||
self.assertIn("setting:font_increase", actions)
|
||||
|
||||
# Layout and render
|
||||
page_style = PageStyle(padding=(10, 10, 10, 10))
|
||||
page = Page((400, 300), page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
for block in blocks:
|
||||
layouter.layout_document([block])
|
||||
|
||||
rendered = page.render()
|
||||
self.assertIsNotNone(rendered)
|
||||
|
||||
print("\nSettings overlay test:")
|
||||
print(f" Found {len(all_linked_words)} linked words")
|
||||
print(f" Actions: {actions}")
|
||||
print(f" Rendered: {rendered.size}")
|
||||
|
||||
# The links are successfully created and rendered!
|
||||
# In a real application, query_point would be used to detect clicks on
|
||||
# these links
|
||||
|
||||
def test_link_metadata_preserved(self):
|
||||
"""Test that link metadata (title, type) is preserved through the workflow."""
|
||||
html = '''
|
||||
<p>
|
||||
<a href="https://example.com" title="Example Site">External</a>
|
||||
<a href="#section2" title="Go to Section 2">Internal</a>
|
||||
<a href="javascript:alert()" title="Alert">API</a>
|
||||
</p>
|
||||
'''
|
||||
|
||||
blocks = parse_html_string(html)
|
||||
paragraph = blocks[0]
|
||||
linked_words = [w for w in paragraph.words if isinstance(w, LinkedWord)]
|
||||
|
||||
# Should have 3 LinkedWords (one for each link)
|
||||
self.assertEqual(len(linked_words), 3)
|
||||
|
||||
# Check that link metadata is preserved
|
||||
links_by_text = {w.text: w for w in linked_words}
|
||||
|
||||
# External link
|
||||
external = links_by_text.get("External")
|
||||
self.assertIsNotNone(external)
|
||||
self.assertEqual(external.location, "https://example.com")
|
||||
self.assertEqual(external.link_title, "Example Site")
|
||||
|
||||
# Internal link
|
||||
internal = links_by_text.get("Internal")
|
||||
self.assertIsNotNone(internal)
|
||||
self.assertEqual(internal.location, "#section2")
|
||||
self.assertEqual(internal.link_title, "Go to Section 2")
|
||||
|
||||
# API link
|
||||
api = links_by_text.get("API")
|
||||
self.assertIsNotNone(api)
|
||||
self.assertTrue(api.location.startswith("javascript:"))
|
||||
self.assertEqual(api.link_title, "Alert")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Test HTML link interactivity when rendering pages.
|
||||
|
||||
This test verifies that HTML links parsed via parse_html_string() are
|
||||
properly interactive and can be queried/detected when rendered on a page.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
|
||||
|
||||
class TestHTMLLinkInteractivity(unittest.TestCase):
|
||||
"""Test that HTML links are interactive when rendered."""
|
||||
|
||||
def test_simple_link_parsing(self):
|
||||
"""Test that parse_html_string creates LinkedWord objects for links."""
|
||||
html = '''
|
||||
<div>
|
||||
<p>Click <a href="action:back_to_library">here</a> to go back.</p>
|
||||
<p>Or visit <a href="setting:font_increase">this setting</a>.</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
# Should have 2 paragraphs
|
||||
self.assertEqual(len(blocks), 2)
|
||||
|
||||
# First paragraph - check for LinkedWord
|
||||
para1_words = list(blocks[0].words)
|
||||
linked_words_1 = [w for w in para1_words if isinstance(w, LinkedWord)]
|
||||
|
||||
# "here" should be a LinkedWord
|
||||
self.assertEqual(len(linked_words_1), 1)
|
||||
self.assertEqual(linked_words_1[0].text, "here")
|
||||
self.assertEqual(linked_words_1[0].location, "action:back_to_library")
|
||||
|
||||
# Second paragraph
|
||||
para2_words = list(blocks[1].words)
|
||||
linked_words_2 = [w for w in para2_words if isinstance(w, LinkedWord)]
|
||||
|
||||
# "this" and "setting" should be LinkedWords
|
||||
self.assertEqual(len(linked_words_2), 2)
|
||||
link_texts = [w.text for w in linked_words_2]
|
||||
self.assertIn("this", link_texts)
|
||||
self.assertIn("setting", link_texts)
|
||||
|
||||
def test_link_rendering_on_page(self):
|
||||
"""Test that links are properly rendered and detectable on a page."""
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
|
||||
html = '''
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<p>Font Size: <a href="setting:font_decrease">-</a> | <a href="setting:font_increase">+</a></p>
|
||||
<p><a href="action:back_to_library">Back to Library</a></p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
# Create a page and use DocumentLayouter to properly layout blocks
|
||||
page_style = PageStyle()
|
||||
page = Page((600, 800), page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Layout all blocks
|
||||
for block in blocks:
|
||||
layouter.layout_document([block])
|
||||
|
||||
# Render the page
|
||||
rendered_image = page.render()
|
||||
|
||||
# Verify the page rendered
|
||||
self.assertIsNotNone(rendered_image)
|
||||
self.assertEqual(rendered_image.size, (600, 800))
|
||||
|
||||
# Now verify we can detect the links via query_point
|
||||
# We need to find the positions of the linked words
|
||||
found_links = []
|
||||
|
||||
# Scan the blocks for LinkedWords
|
||||
for block in blocks:
|
||||
if hasattr(block, 'words'):
|
||||
for word in block.words:
|
||||
if isinstance(word, LinkedWord):
|
||||
found_links.append({
|
||||
'text': word.text,
|
||||
'location': word.location,
|
||||
'link_type': word.link_type
|
||||
})
|
||||
|
||||
# Verify we found the expected links
|
||||
# -, +, Back, to, Library (5 LinkedWords total)
|
||||
self.assertGreater(len(found_links), 0, "Should find at least some LinkedWords")
|
||||
|
||||
link_locations = [link['location'] for link in found_links]
|
||||
self.assertIn("setting:font_decrease", link_locations)
|
||||
self.assertIn("setting:font_increase", link_locations)
|
||||
self.assertIn("action:back_to_library", link_locations)
|
||||
|
||||
def test_link_query_point_detection(self):
|
||||
"""Test that query_point can detect links on a rendered page."""
|
||||
from pyWebLayout.layout.document_layouter import DocumentLayouter
|
||||
|
||||
html = '''
|
||||
<p>Click <a href="action:test">here</a> to test.</p>
|
||||
'''
|
||||
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
# Create a page
|
||||
page_style = PageStyle()
|
||||
page = Page((400, 200), page_style)
|
||||
layouter = DocumentLayouter(page)
|
||||
|
||||
# Layout the block
|
||||
for block in blocks:
|
||||
layouter.layout_document([block])
|
||||
|
||||
# Get the rendered canvas
|
||||
_ = page.draw # Ensure canvas exists
|
||||
|
||||
# Find the LinkedWord in the blocks
|
||||
link_word = None
|
||||
for block in blocks:
|
||||
if hasattr(block, 'words'):
|
||||
for word in block.words:
|
||||
if isinstance(word, LinkedWord) and word.location == "action:test":
|
||||
link_word = word
|
||||
break
|
||||
|
||||
# Verify we found the link
|
||||
self.assertIsNotNone(link_word)
|
||||
self.assertEqual(link_word.text, "here")
|
||||
self.assertEqual(link_word.location, "action:test")
|
||||
|
||||
# Test that query_point can detect the link
|
||||
# Try querying a point in the middle of the page where text should be
|
||||
# Note: The exact coordinates depend on the layout, but we can test the API
|
||||
result = page.query_point((100, 50))
|
||||
if result:
|
||||
# If we hit something, verify we can access its properties
|
||||
self.assertIsNotNone(result.object_type)
|
||||
# Link detection would show is_interactive=True for links
|
||||
if result.is_interactive:
|
||||
self.assertIsNotNone(result.link_target)
|
||||
|
||||
def test_settings_overlay_button_html(self):
|
||||
"""Test the specific HTML pattern used for settings overlay buttons."""
|
||||
# This is the pattern used in the settings overlay
|
||||
html = '''
|
||||
<div>
|
||||
<h2 style="text-align: center; font-size: 18px; font-weight: bold; margin: 10px 0;">Settings</h2>
|
||||
<p style="padding: 15px; margin: 5px 0; background-color: #dc3545; text-align: center;
|
||||
border-radius: 5px;">
|
||||
<a href="action:back_to_library"
|
||||
style="text-decoration: none; color: white; font-weight: bold; font-size: 14px;">
|
||||
◄ Back to Library</a>
|
||||
</p>
|
||||
<p style="padding: 10px; margin: 5px 0; background-color: #f8f9fa; border-radius: 5px;">
|
||||
<span style="font-weight: bold;">Font Size: 100%</span><br>
|
||||
<a href="setting:font_decrease" style="text-decoration: none; color: #007bff; margin: 0 10px;">[-]</a>
|
||||
<a href="setting:font_increase" style="text-decoration: none; color: #007bff; margin: 0 10px;">[+]</a>
|
||||
</p>
|
||||
</div>
|
||||
'''
|
||||
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
# Find all LinkedWords
|
||||
all_linked_words = []
|
||||
for block in blocks:
|
||||
if hasattr(block, 'words'):
|
||||
for word in block.words:
|
||||
if isinstance(word, LinkedWord):
|
||||
all_linked_words.append(word)
|
||||
|
||||
# Verify we found the expected links
|
||||
self.assertGreater(
|
||||
len(all_linked_words),
|
||||
0,
|
||||
"Should find LinkedWords in settings HTML")
|
||||
|
||||
# Check for specific link targets
|
||||
link_targets = {word.location for word in all_linked_words}
|
||||
|
||||
self.assertIn("action:back_to_library", link_targets,
|
||||
"Should find 'Back to Library' link")
|
||||
self.assertIn("setting:font_decrease", link_targets,
|
||||
"Should find font decrease link")
|
||||
self.assertIn("setting:font_increase", link_targets,
|
||||
"Should find font increase link")
|
||||
|
||||
# Verify the link texts
|
||||
back_to_library_words = [w for w in all_linked_words
|
||||
if w.location == "action:back_to_library"]
|
||||
self.assertGreater(len(back_to_library_words), 0,
|
||||
"Should have words linked to back_to_library action")
|
||||
|
||||
# Print debug info
|
||||
print(f"\nFound {len(all_linked_words)} linked words:")
|
||||
for word in all_linked_words:
|
||||
print(f" - '{word.text}' -> {word.location}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Unit tests for HTML link extraction.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from bs4 import BeautifulSoup
|
||||
from pyWebLayout.io.readers.html_extraction import (
|
||||
parse_html_string,
|
||||
extract_text_content,
|
||||
create_base_context
|
||||
)
|
||||
from pyWebLayout.abstract.inline import LinkedWord
|
||||
from pyWebLayout.abstract.functional import LinkType
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
|
||||
|
||||
class TestHTMLLinkExtraction(unittest.TestCase):
|
||||
"""Test cases for HTML hyperlink extraction."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.base_context = create_base_context()
|
||||
|
||||
def test_simple_external_link(self):
|
||||
"""Test extracting a simple external link."""
|
||||
html = '<p>Visit <a href="https://example.com">this site</a> for more.</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
self.assertEqual(len(blocks), 1)
|
||||
self.assertIsInstance(blocks[0], Paragraph)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# Should have: "Visit", "this", "site", "for", "more."
|
||||
self.assertEqual(len(words), 5)
|
||||
|
||||
# Check that "this" and "site" are LinkedWords
|
||||
self.assertIsInstance(words[1], LinkedWord)
|
||||
self.assertIsInstance(words[2], LinkedWord)
|
||||
|
||||
# Check link properties
|
||||
self.assertEqual(words[1].location, "https://example.com")
|
||||
self.assertEqual(words[1].link_type, LinkType.EXTERNAL)
|
||||
self.assertEqual(words[2].location, "https://example.com")
|
||||
self.assertEqual(words[2].link_type, LinkType.EXTERNAL)
|
||||
|
||||
def test_internal_link(self):
|
||||
"""Test extracting an internal anchor link."""
|
||||
html = '<p>Go to <a href="#section2">section 2</a> below.</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# Find LinkedWords
|
||||
linked_words = [w for w in words if isinstance(w, LinkedWord)]
|
||||
self.assertEqual(len(linked_words), 2) # "section" and "2"
|
||||
|
||||
# Check they're internal links
|
||||
for word in linked_words:
|
||||
self.assertEqual(word.link_type, LinkType.INTERNAL)
|
||||
self.assertEqual(word.location, "#section2")
|
||||
|
||||
def test_multi_word_link(self):
|
||||
"""Test that multi-word links create separate LinkedWords."""
|
||||
html = '<p><a href="/next">click here for next page</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# All words should be LinkedWords
|
||||
self.assertEqual(len(words), 5)
|
||||
for word in words:
|
||||
self.assertIsInstance(word, LinkedWord)
|
||||
self.assertEqual(word.location, "/next")
|
||||
self.assertEqual(word.link_type, LinkType.INTERNAL)
|
||||
|
||||
def test_link_with_title(self):
|
||||
"""Test extracting link with title attribute."""
|
||||
html = '<p><a href="https://example.com" title="Visit Example">click</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
self.assertEqual(len(words), 1)
|
||||
self.assertIsInstance(words[0], LinkedWord)
|
||||
self.assertEqual(words[0].link_title, "Visit Example")
|
||||
|
||||
def test_mixed_linked_and_normal_text(self):
|
||||
"""Test paragraph with both linked and normal text."""
|
||||
html = '<p>Some <a href="/page">linked text</a> and normal text.</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# "Some" - normal
|
||||
# "linked" - LinkedWord
|
||||
# "text" - LinkedWord
|
||||
# "and" - normal
|
||||
# "normal" - normal
|
||||
# "text." - normal
|
||||
|
||||
self.assertNotIsInstance(words[0], LinkedWord) # "Some"
|
||||
self.assertIsInstance(words[1], LinkedWord) # "linked"
|
||||
self.assertIsInstance(words[2], LinkedWord) # "text"
|
||||
self.assertNotIsInstance(words[3], LinkedWord) # "and"
|
||||
|
||||
def test_link_without_href(self):
|
||||
"""Test that <a> without href is treated as normal text."""
|
||||
html = '<p><a>not a link</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# Should be regular Words, not LinkedWords
|
||||
for word in words:
|
||||
self.assertNotIsInstance(word, LinkedWord)
|
||||
|
||||
def test_javascript_link(self):
|
||||
"""Test that javascript: links are detected as API type."""
|
||||
html = '<p><a href="javascript:alert()">click</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
self.assertIsInstance(words[0], LinkedWord)
|
||||
self.assertEqual(words[0].link_type, LinkType.API)
|
||||
|
||||
def test_nested_formatting_in_link(self):
|
||||
"""Test link with nested formatting."""
|
||||
html = '<p><a href="/page">text with <strong>bold</strong> word</a></p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# All should be LinkedWords regardless of formatting
|
||||
for word in words:
|
||||
self.assertIsInstance(word, LinkedWord)
|
||||
self.assertEqual(word.location, "/page")
|
||||
|
||||
def test_multiple_links_in_paragraph(self):
|
||||
"""Test paragraph with multiple separate links."""
|
||||
html = '<p><a href="/page1">first</a> and <a href="/page2">second</a> link</p>'
|
||||
blocks = parse_html_string(html)
|
||||
|
||||
paragraph = blocks[0]
|
||||
words = list(paragraph.words)
|
||||
|
||||
# Find LinkedWords and their locations
|
||||
linked_words = [(w.text, w.location)
|
||||
for w in words if isinstance(w, LinkedWord)]
|
||||
|
||||
# Should have "first" linked to /page1 and "second" linked to /page2
|
||||
self.assertIn(("first", "/page1"), linked_words)
|
||||
self.assertIn(("second", "/page2"), linked_words)
|
||||
|
||||
def test_extract_text_content_with_links(self):
|
||||
"""Test extract_text_content directly with link elements."""
|
||||
html = '<span>Visit <a href="https://example.com">our site</a> today</span>'
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
element = soup.find('span')
|
||||
|
||||
context = create_base_context()
|
||||
words = extract_text_content(element, context)
|
||||
|
||||
# Should have: "Visit", "our", "site", "today"
|
||||
self.assertEqual(len(words), 4)
|
||||
|
||||
# Check types
|
||||
self.assertNotIsInstance(words[0], LinkedWord) # "Visit"
|
||||
self.assertIsInstance(words[1], LinkedWord) # "our"
|
||||
self.assertIsInstance(words[2], LinkedWord) # "site"
|
||||
self.assertNotIsInstance(words[3], LinkedWord) # "today"
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Regression tests for inline content inside block containers (spec S1).
|
||||
|
||||
Inline tags are registered to ignore_handler because they are meant to be
|
||||
consumed by extract_text_content. Only <p> and <h1>-<h6> ever called it, so
|
||||
every other container - div, li, td, th, blockquote - iterated its children as
|
||||
blocks, and inline tags returned None. Their text was silently discarded, and
|
||||
bare text nodes each became a separate paragraph.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pyWebLayout.abstract.block import (
|
||||
HList,
|
||||
Paragraph,
|
||||
Quote,
|
||||
Table,
|
||||
)
|
||||
from pyWebLayout.abstract.inline import LinkedWord, Word
|
||||
from pyWebLayout.io.readers.html_extraction import parse_html_string
|
||||
|
||||
|
||||
def words_of(block):
|
||||
return [w.text for w in getattr(block, 'words', [])]
|
||||
|
||||
|
||||
def all_words(blocks):
|
||||
out = []
|
||||
for block in blocks:
|
||||
out.extend(words_of(block))
|
||||
return out
|
||||
|
||||
|
||||
def cell_blocks(table):
|
||||
for _, row in table.all_rows():
|
||||
for cell in row.cells():
|
||||
yield list(cell.blocks())
|
||||
|
||||
|
||||
EXPECTED = ["hello", "world", "again"]
|
||||
|
||||
|
||||
class TestInlineContentIsKept:
|
||||
"""The same markup must survive in every container."""
|
||||
|
||||
def test_paragraph_control(self):
|
||||
"""<p> already worked - this is the reference behaviour."""
|
||||
blocks = parse_html_string("<p>hello <b>world</b> again</p>")
|
||||
assert all_words(blocks) == EXPECTED
|
||||
|
||||
def test_div(self):
|
||||
blocks = parse_html_string("<div>hello <b>world</b> again</div>")
|
||||
assert all_words(blocks) == EXPECTED
|
||||
|
||||
def test_list_item(self):
|
||||
blocks = parse_html_string("<ul><li>hello <b>world</b> again</li></ul>")
|
||||
hlist = next(b for b in blocks if isinstance(b, HList))
|
||||
item = list(hlist.items())[0]
|
||||
assert all_words(item.blocks()) == EXPECTED
|
||||
|
||||
def test_table_cell(self):
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><td>hello <b>world</b> again</td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
assert all_words(next(cell_blocks(table))) == EXPECTED
|
||||
|
||||
def test_table_header_cell(self):
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><th>hello <b>world</b> again</th></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
assert all_words(next(cell_blocks(table))) == EXPECTED
|
||||
|
||||
def test_blockquote(self):
|
||||
blocks = parse_html_string("<blockquote>hello <b>world</b> again</blockquote>")
|
||||
quote = next(b for b in blocks if isinstance(b, Quote))
|
||||
assert all_words(quote.blocks()) == EXPECTED
|
||||
|
||||
|
||||
class TestInlineRunsCoalesce:
|
||||
"""A run of inline content is one paragraph, not one per text node."""
|
||||
|
||||
def test_div_yields_a_single_paragraph(self):
|
||||
blocks = parse_html_string("<div>a <b>b</b> c</div>")
|
||||
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||
assert len(paragraphs) == 1, f"expected one paragraph, got {len(blocks)} blocks"
|
||||
assert words_of(paragraphs[0]) == ["a", "b", "c"]
|
||||
|
||||
def test_cell_yields_a_single_paragraph(self):
|
||||
blocks = parse_html_string("<table><tr><td>a <b>b</b> c</td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
assert len(cell) == 1
|
||||
assert words_of(cell[0]) == ["a", "b", "c"]
|
||||
|
||||
def test_block_child_splits_the_run(self):
|
||||
"""Inline runs either side of a block child stay separate, in order."""
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><td>before<p>middle</p>after</td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
assert [words_of(b) for b in cell] == [["before"], ["middle"], ["after"]]
|
||||
|
||||
def test_line_break_splits_the_run(self):
|
||||
blocks = parse_html_string("<div>first<br>second</div>")
|
||||
paragraphs = [b for b in blocks if isinstance(b, Paragraph)]
|
||||
assert [words_of(p) for p in paragraphs] == [["first"], ["second"]]
|
||||
|
||||
def test_whitespace_between_blocks_makes_no_paragraph(self):
|
||||
blocks = parse_html_string("<div>\n <p>one</p>\n <p>two</p>\n</div>")
|
||||
assert [words_of(b) for b in blocks] == [["one"], ["two"]]
|
||||
|
||||
|
||||
class TestLinksSurvive:
|
||||
"""<a href> must produce LinkedWord wherever it appears."""
|
||||
|
||||
def test_link_in_cell(self):
|
||||
blocks = parse_html_string(
|
||||
'<table><tr><td><a href="http://x">link</a> text</td></tr></table>')
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
found = [w for b in cell for w in getattr(b, 'words', [])]
|
||||
|
||||
assert [w.text for w in found] == ["link", "text"]
|
||||
linked = [w for w in found if isinstance(w, LinkedWord)]
|
||||
assert len(linked) == 1
|
||||
assert linked[0].location == "http://x"
|
||||
|
||||
def test_link_in_div(self):
|
||||
blocks = parse_html_string('<div>see <a href="#s2">Section 2</a> now</div>')
|
||||
found = [w for b in blocks for w in getattr(b, 'words', [])]
|
||||
assert [w.text for w in found] == ["see", "Section", "2", "now"]
|
||||
assert all(isinstance(w, LinkedWord) for w in found[1:3])
|
||||
|
||||
def test_link_in_list_item(self):
|
||||
blocks = parse_html_string('<ul><li><a href="u">click</a> here</li></ul>')
|
||||
hlist = next(b for b in blocks if isinstance(b, HList))
|
||||
item = hlist._items[0]
|
||||
found = [w for b in item.blocks() for w in getattr(b, 'words', [])]
|
||||
assert [w.text for w in found] == ["click", "here"]
|
||||
assert isinstance(found[0], LinkedWord)
|
||||
|
||||
|
||||
class TestNestedContainers:
|
||||
|
||||
def test_div_in_div(self):
|
||||
blocks = parse_html_string("<div>outer <div>inner</div> tail</div>")
|
||||
assert [words_of(b) for b in blocks] == [["outer"], ["inner"], ["tail"]]
|
||||
|
||||
def test_block_children_still_pass_through(self):
|
||||
blocks = parse_html_string("<div><h1>Title</h1><p>Body</p></div>")
|
||||
assert len(blocks) == 2
|
||||
assert words_of(blocks[0]) == ["Title"]
|
||||
assert words_of(blocks[1]) == ["Body"]
|
||||
|
||||
def test_cell_containing_a_list(self):
|
||||
blocks = parse_html_string(
|
||||
"<table><tr><td>intro<ul><li>item</li></ul></td></tr></table>")
|
||||
table = next(b for b in blocks if isinstance(b, Table))
|
||||
cell = next(cell_blocks(table))
|
||||
assert isinstance(cell[0], Paragraph)
|
||||
assert words_of(cell[0]) == ["intro"]
|
||||
assert isinstance(cell[1], HList)
|
||||
|
||||
|
||||
class TestComments:
|
||||
|
||||
def test_comment_text_is_not_content(self):
|
||||
blocks = parse_html_string("<div>real<!-- hidden note -->text</div>")
|
||||
assert all_words(blocks) == ["real", "text"]
|
||||
Reference in New Issue
Block a user