bowser/tests/test_links.py
Benedikt Willi 8c2d360515 This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.

- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.

- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.

- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.

- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.

- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 13:06:20 +01:00

341 lines
11 KiB
Python

"""Tests for link parsing, rendering, and navigation."""
import pytest
from src.parser.html import parse_html, parse_html_with_styles, Element, Text
from src.layout.document import DocumentLayout, LayoutLine
from src.network.url import URL
class TestLinkParsing:
"""Tests for parsing anchor elements from HTML."""
def test_parse_simple_link(self):
"""Test parsing a simple anchor tag."""
html = "<html><body><a href='https://example.com'>Click here</a></body></html>"
root = parse_html(html)
# Find the anchor element
body = root.children[0]
assert body.tag == "body"
anchor = body.children[0]
assert anchor.tag == "a"
assert anchor.attributes.get("href") == "https://example.com"
def test_parse_link_with_text(self):
"""Test that link text content is preserved."""
html = "<html><body><a href='/page'>Link Text</a></body></html>"
root = parse_html(html)
body = root.children[0]
anchor = body.children[0]
assert len(anchor.children) == 1
assert isinstance(anchor.children[0], Text)
assert anchor.children[0].text.strip() == "Link Text"
def test_parse_link_in_paragraph(self):
"""Test parsing a link inside a paragraph."""
html = "<html><body><p>Visit <a href='https://test.com'>our site</a> today!</p></body></html>"
root = parse_html(html)
body = root.children[0]
# The parser may flatten this - check for anchor presence
found_anchor = False
def find_anchor(node):
nonlocal found_anchor
if isinstance(node, Element) and node.tag == "a":
found_anchor = True
assert node.attributes.get("href") == "https://test.com"
if hasattr(node, "children"):
for child in node.children:
find_anchor(child)
find_anchor(body)
assert found_anchor, "Anchor element not found"
def test_parse_link_with_relative_href(self):
"""Test parsing a link with a relative URL."""
html = "<html><body><a href='/about'>About</a></body></html>"
root = parse_html(html)
body = root.children[0]
anchor = body.children[0]
assert anchor.attributes.get("href") == "/about"
def test_parse_link_with_anchor_href(self):
"""Test parsing a link with an anchor reference."""
html = "<html><body><a href='#section'>Jump</a></body></html>"
root = parse_html(html)
body = root.children[0]
anchor = body.children[0]
assert anchor.attributes.get("href") == "#section"
class TestLinkLayout:
"""Tests for link layout and styling."""
def test_link_layout_has_href(self):
"""Test that layout lines for links include href."""
html = "<html><body><a href='https://example.com'>Link</a></body></html>"
root = parse_html_with_styles(html)
layout = DocumentLayout(root)
layout.layout(800)
# Find line with href
link_lines = [line for line in layout.lines if line.href]
assert len(link_lines) > 0, "No link lines found"
assert link_lines[0].href == "https://example.com"
def test_link_layout_has_color(self):
"""Test that layout lines for links have a color."""
html = "<html><body><a href='https://example.com'>Link</a></body></html>"
root = parse_html_with_styles(html)
layout = DocumentLayout(root)
layout.layout(800)
# Find line with color
link_lines = [line for line in layout.lines if line.href]
assert len(link_lines) > 0
# Should have either CSS-specified color or default link color
assert link_lines[0].color is not None
def test_non_link_has_no_href(self):
"""Test that non-link elements don't have href."""
html = "<html><body><p>Regular paragraph</p></body></html>"
root = parse_html_with_styles(html)
layout = DocumentLayout(root)
layout.layout(800)
# All lines should have no href
for line in layout.lines:
assert line.href is None
def test_layout_line_constructor(self):
"""Test LayoutLine constructor with color and href."""
line = LayoutLine(
text="Click me",
x=10,
y=20,
font_size=14,
color="#0066cc",
href="https://example.com"
)
assert line.text == "Click me"
assert line.color == "#0066cc"
assert line.href == "https://example.com"
def test_layout_line_default_values(self):
"""Test LayoutLine defaults for color and href."""
line = LayoutLine(
text="Normal text",
x=10,
y=20,
font_size=14
)
assert line.color is None
assert line.href is None
class TestLinkURLResolution:
"""Tests for URL resolution of links."""
def test_resolve_absolute_url(self):
"""Test that absolute URLs are preserved."""
base = URL("https://example.com/page")
resolved = base.resolve("https://other.com/path")
assert str(resolved) == "https://other.com/path"
def test_resolve_relative_url(self):
"""Test resolving a relative URL."""
base = URL("https://example.com/page")
resolved = base.resolve("/about")
assert str(resolved) == "https://example.com/about"
def test_resolve_relative_path(self):
"""Test resolving a relative path."""
base = URL("https://example.com/dir/page")
resolved = base.resolve("other")
assert str(resolved) == "https://example.com/dir/other"
def test_resolve_parent_relative(self):
"""Test resolving a parent-relative path."""
base = URL("https://example.com/dir/subdir/page")
resolved = base.resolve("../other")
assert str(resolved) == "https://example.com/dir/other"
def test_resolve_anchor_only(self):
"""Test resolving an anchor-only URL."""
base = URL("https://example.com/page")
resolved = base.resolve("#section")
assert str(resolved) == "https://example.com/page#section"
def test_resolve_query_string(self):
"""Test resolving a URL with query string."""
base = URL("https://example.com/page")
resolved = base.resolve("?query=value")
assert str(resolved) == "https://example.com/page?query=value"
class TestRenderPipelineColorParsing:
"""Tests for color parsing in the render pipeline.
Note: These tests only run when skia is NOT mocked (i.e., when run in isolation).
When run after test_render.py, skia becomes a MagicMock and these tests are skipped.
"""
def test_parse_hex_color_6digit(self):
"""Test parsing 6-digit hex colors."""
from src.render.pipeline import RenderPipeline
import skia
# Skip if skia is mocked
if hasattr(skia.Color, '_mock_name'):
pytest.skip("skia is mocked")
pipeline = RenderPipeline()
color = pipeline._parse_color("#0066cc")
# Extract RGB components (Skia color is ARGB)
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
assert r == 0x00
assert g == 0x66
assert b == 0xcc
def test_parse_hex_color_3digit(self):
"""Test parsing 3-digit hex colors."""
from src.render.pipeline import RenderPipeline
import skia
# Skip if skia is mocked
if hasattr(skia.Color, '_mock_name'):
pytest.skip("skia is mocked")
pipeline = RenderPipeline()
color = pipeline._parse_color("#abc")
# #abc should expand to #aabbcc
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
# Each digit is doubled: a->aa, b->bb, c->cc
# But our implementation uses int("a" * 2, 16) which is int("aa", 16) = 170
assert r == 0xaa
assert g == 0xbb
assert b == 0xcc
def test_parse_named_color(self):
"""Test parsing named colors."""
from src.render.pipeline import RenderPipeline
import skia
# Skip if skia is mocked
if hasattr(skia.ColorBLACK, '_mock_name'):
pytest.skip("skia is mocked")
pipeline = RenderPipeline()
# Test that named colors return a valid integer color value
black = pipeline._parse_color("black")
white = pipeline._parse_color("white")
red = pipeline._parse_color("red")
# Black should be 0xFF000000 (opaque black in ARGB)
assert isinstance(black, int)
assert (black & 0xFFFFFF) == 0x000000 # RGB is 0
# White is converted to black because it would be invisible on white bg
assert isinstance(white, int)
assert (white & 0xFFFFFF) == 0x000000 # Converted to black
# Red should have R=255, G=0, B=0
assert isinstance(red, int)
r = (red >> 16) & 0xFF
assert r == 0xFF # Red component should be 255
def test_parse_invalid_color_returns_black(self):
"""Test that invalid colors return black."""
from src.render.pipeline import RenderPipeline
import skia
# Skip if skia is mocked
if hasattr(skia.ColorBLACK, '_mock_name'):
pytest.skip("skia is mocked")
pipeline = RenderPipeline()
invalid = pipeline._parse_color("invalid")
invalid2 = pipeline._parse_color("#xyz")
invalid3 = pipeline._parse_color("")
# All should return black (integer value)
assert isinstance(invalid, int)
assert isinstance(invalid2, int)
assert isinstance(invalid3, int)
# RGB components should be 0 (black)
assert (invalid & 0xFFFFFF) == 0x000000
class TestGetTextLayoutWithHref:
"""Tests for text layout including href information."""
def test_get_text_layout_includes_href(self):
"""Test that get_text_layout includes href for links."""
from src.render.pipeline import RenderPipeline
html = "<html><body><a href='https://example.com'>Click</a></body></html>"
root = parse_html_with_styles(html)
pipeline = RenderPipeline()
pipeline.layout(root, 800)
text_layout = pipeline.get_text_layout()
# Find the link line
link_entries = [entry for entry in text_layout if entry.get("href")]
assert len(link_entries) > 0
assert link_entries[0]["href"] == "https://example.com"
def test_get_text_layout_normal_text_no_href(self):
"""Test that normal text has no href in layout."""
from src.render.pipeline import RenderPipeline
html = "<html><body><p>Normal text</p></body></html>"
root = parse_html_with_styles(html)
pipeline = RenderPipeline()
pipeline.layout(root, 800)
text_layout = pipeline.get_text_layout()
# All entries should have href=None
for entry in text_layout:
assert entry.get("href") is None
class TestLinkDefaultStyling:
"""Tests for default link styling from CSS."""
def test_link_default_color_in_css(self):
"""Test that default.css defines link color."""
from pathlib import Path
css_path = Path(__file__).parent.parent / "assets" / "default.css"
assert css_path.exists(), "default.css should exist"
css_content = css_path.read_text()
# Check that 'a' selector is defined with a color
assert "a {" in css_content or "a{" in css_content.replace(" ", "")
assert "color:" in css_content