mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
- **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.
478 lines
14 KiB
Python
478 lines
14 KiB
Python
"""Tests for CSS parsing and style computation."""
|
|
|
|
from src.parser.css import (
|
|
Selector, parse, parse_inline_style
|
|
)
|
|
from src.parser.html import Element, Text
|
|
from src.parser.style import (
|
|
ComputedStyle, StyleResolver
|
|
)
|
|
|
|
|
|
class TestSelector:
|
|
"""Test CSS selector parsing and matching."""
|
|
|
|
def test_tag_selector(self):
|
|
sel = Selector("p")
|
|
assert sel.tag == "p"
|
|
assert sel.id is None
|
|
assert sel.classes == []
|
|
|
|
def test_class_selector(self):
|
|
sel = Selector(".container")
|
|
assert sel.tag is None
|
|
assert sel.classes == ["container"]
|
|
|
|
def test_id_selector(self):
|
|
sel = Selector("#header")
|
|
assert sel.id == "header"
|
|
assert sel.tag is None
|
|
|
|
def test_compound_selector(self):
|
|
sel = Selector("div.container")
|
|
assert sel.tag == "div"
|
|
assert sel.classes == ["container"]
|
|
|
|
def test_complex_compound_selector(self):
|
|
sel = Selector("div#main.container.active")
|
|
assert sel.tag == "div"
|
|
assert sel.id == "main"
|
|
assert set(sel.classes) == {"container", "active"}
|
|
|
|
def test_specificity_tag_only(self):
|
|
sel = Selector("p")
|
|
assert sel.specificity() == (0, 0, 1)
|
|
|
|
def test_specificity_class_only(self):
|
|
sel = Selector(".container")
|
|
assert sel.specificity() == (0, 1, 0)
|
|
|
|
def test_specificity_id_only(self):
|
|
sel = Selector("#header")
|
|
assert sel.specificity() == (1, 0, 0)
|
|
|
|
def test_specificity_compound(self):
|
|
sel = Selector("div#main.container.active")
|
|
assert sel.specificity() == (1, 2, 1)
|
|
|
|
def test_matches_tag(self):
|
|
sel = Selector("p")
|
|
elem = Element("p")
|
|
assert sel.matches(elem) is True
|
|
|
|
elem2 = Element("div")
|
|
assert sel.matches(elem2) is False
|
|
|
|
def test_matches_class(self):
|
|
sel = Selector(".container")
|
|
elem = Element("div", {"class": "container sidebar"})
|
|
assert sel.matches(elem) is True
|
|
|
|
elem2 = Element("div", {"class": "sidebar"})
|
|
assert sel.matches(elem2) is False
|
|
|
|
def test_matches_id(self):
|
|
sel = Selector("#header")
|
|
elem = Element("div", {"id": "header"})
|
|
assert sel.matches(elem) is True
|
|
|
|
elem2 = Element("div", {"id": "footer"})
|
|
assert sel.matches(elem2) is False
|
|
|
|
def test_matches_compound(self):
|
|
sel = Selector("div.container")
|
|
elem = Element("div", {"class": "container"})
|
|
assert sel.matches(elem) is True
|
|
|
|
# Wrong tag
|
|
elem2 = Element("p", {"class": "container"})
|
|
assert sel.matches(elem2) is False
|
|
|
|
# Wrong class
|
|
elem3 = Element("div", {"class": "sidebar"})
|
|
assert sel.matches(elem3) is False
|
|
|
|
|
|
class TestCSSParser:
|
|
"""Test CSS stylesheet parsing."""
|
|
|
|
def test_empty_stylesheet(self):
|
|
rules = parse("")
|
|
assert rules == []
|
|
|
|
def test_single_rule(self):
|
|
css = "p { color: red; }"
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].selector.tag == "p"
|
|
assert rules[0].declarations == {"color": "red"}
|
|
|
|
def test_multiple_rules(self):
|
|
css = """
|
|
p { color: red; }
|
|
div { background: blue; }
|
|
"""
|
|
rules = parse(css)
|
|
assert len(rules) == 2
|
|
assert rules[0].selector.tag == "p"
|
|
assert rules[1].selector.tag == "div"
|
|
|
|
def test_multiple_declarations(self):
|
|
css = "p { color: red; font-size: 14px; margin: 10px; }"
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].declarations == {
|
|
"color": "red",
|
|
"font-size": "14px",
|
|
"margin": "10px"
|
|
}
|
|
|
|
def test_multiline_declarations(self):
|
|
css = """
|
|
p {
|
|
color: red;
|
|
font-size: 14px;
|
|
margin: 10px;
|
|
}
|
|
"""
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].declarations == {
|
|
"color": "red",
|
|
"font-size": "14px",
|
|
"margin": "10px"
|
|
}
|
|
|
|
def test_no_semicolon_on_last_declaration(self):
|
|
css = "p { color: red; font-size: 14px }"
|
|
rules = parse(css)
|
|
assert rules[0].declarations == {
|
|
"color": "red",
|
|
"font-size": "14px"
|
|
}
|
|
|
|
def test_class_selector_rule(self):
|
|
css = ".container { width: 100%; }"
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].selector.classes == ["container"]
|
|
assert rules[0].declarations == {"width": "100%"}
|
|
|
|
def test_id_selector_rule(self):
|
|
css = "#header { height: 50px; }"
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].selector.id == "header"
|
|
assert rules[0].declarations == {"height": "50px"}
|
|
|
|
def test_compound_selector_rule(self):
|
|
css = "div.container { padding: 20px; }"
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].selector.tag == "div"
|
|
assert rules[0].selector.classes == ["container"]
|
|
|
|
def test_whitespace_handling(self):
|
|
css = " p { color : red ; } "
|
|
rules = parse(css)
|
|
assert len(rules) == 1
|
|
assert rules[0].declarations == {"color": "red"}
|
|
|
|
def test_comments(self):
|
|
css = """
|
|
/* This is a comment */
|
|
p { color: red; }
|
|
/* Another comment */
|
|
div { /* inline comment */ background: blue; }
|
|
"""
|
|
rules = parse(css)
|
|
assert len(rules) == 2
|
|
assert rules[0].selector.tag == "p"
|
|
assert rules[1].selector.tag == "div"
|
|
|
|
def test_property_values_with_spaces(self):
|
|
css = "p { font-family: Arial, sans-serif; }"
|
|
rules = parse(css)
|
|
assert rules[0].declarations == {"font-family": "Arial, sans-serif"}
|
|
|
|
def test_complex_stylesheet(self):
|
|
css = """
|
|
/* Reset */
|
|
* { margin: 0; padding: 0; }
|
|
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
font-size: 16px;
|
|
color: #333;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 32px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.container {
|
|
width: 960px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
#header {
|
|
background: #f0f0f0;
|
|
padding: 10px;
|
|
}
|
|
|
|
div.highlight {
|
|
background: yellow;
|
|
font-weight: bold;
|
|
}
|
|
"""
|
|
rules = parse(css)
|
|
assert len(rules) == 6
|
|
|
|
# Check body rule
|
|
body_rule = next(r for r in rules if r.selector.tag == "body")
|
|
assert "font-family" in body_rule.declarations
|
|
assert "font-size" in body_rule.declarations
|
|
|
|
|
|
class TestInlineStyleParser:
|
|
"""Test inline style attribute parsing."""
|
|
|
|
def test_empty_style(self):
|
|
decls = parse_inline_style("")
|
|
assert decls == {}
|
|
|
|
def test_single_declaration(self):
|
|
decls = parse_inline_style("color: red")
|
|
assert decls == {"color": "red"}
|
|
|
|
def test_multiple_declarations(self):
|
|
decls = parse_inline_style("color: red; font-size: 14px")
|
|
assert decls == {"color": "red", "font-size": "14px"}
|
|
|
|
def test_trailing_semicolon(self):
|
|
decls = parse_inline_style("color: red; font-size: 14px;")
|
|
assert decls == {"color": "red", "font-size": "14px"}
|
|
|
|
def test_whitespace_handling(self):
|
|
decls = parse_inline_style(" color : red ; font-size : 14px ")
|
|
assert decls == {"color": "red", "font-size": "14px"}
|
|
|
|
def test_complex_values(self):
|
|
decls = parse_inline_style("font-family: Arial, sans-serif; margin: 10px 20px")
|
|
assert decls == {
|
|
"font-family": "Arial, sans-serif",
|
|
"margin": "10px 20px"
|
|
}
|
|
|
|
def test_malformed_ignored(self):
|
|
# Missing colon
|
|
decls = parse_inline_style("color red; font-size: 14px")
|
|
assert decls == {"font-size": "14px"}
|
|
|
|
|
|
class TestComputedStyle:
|
|
"""Test computed style value accessors."""
|
|
|
|
def test_empty_style(self):
|
|
style = ComputedStyle()
|
|
assert style.get("color") == ""
|
|
assert style.get("color", "black") == "black"
|
|
|
|
def test_get_set(self):
|
|
style = ComputedStyle()
|
|
style.set("color", "red")
|
|
assert style.get("color") == "red"
|
|
|
|
def test_get_int(self):
|
|
style = ComputedStyle()
|
|
style.set("font-size", "16px")
|
|
assert style.get_int("font-size") == 16
|
|
|
|
def test_get_int_no_unit(self):
|
|
style = ComputedStyle()
|
|
style.set("font-size", "16")
|
|
assert style.get_int("font-size") == 16
|
|
|
|
def test_get_int_default(self):
|
|
style = ComputedStyle()
|
|
assert style.get_int("font-size", 14) == 14
|
|
|
|
def test_get_float(self):
|
|
style = ComputedStyle()
|
|
style.set("margin", "10.5px")
|
|
assert style.get_float("margin") == 10.5
|
|
|
|
def test_get_float_default(self):
|
|
style = ComputedStyle()
|
|
assert style.get_float("margin", 5.5) == 5.5
|
|
|
|
|
|
class TestStyleResolver:
|
|
"""Test style resolution with cascade and inheritance."""
|
|
|
|
def test_default_styles(self):
|
|
resolver = StyleResolver()
|
|
elem = Element("p")
|
|
style = resolver.resolve_style(elem)
|
|
|
|
assert style.get("display") == "block"
|
|
assert style.get("margin-top") == "16px"
|
|
assert style.get("margin-bottom") == "16px"
|
|
|
|
def test_no_default_for_unknown_tag(self):
|
|
resolver = StyleResolver()
|
|
elem = Element("unknown")
|
|
style = resolver.resolve_style(elem)
|
|
|
|
# Should have empty properties (no defaults)
|
|
assert style.get("display") == ""
|
|
|
|
def test_stylesheet_overrides_default(self):
|
|
rules = parse("p { margin-top: 20px; }")
|
|
resolver = StyleResolver(rules)
|
|
elem = Element("p")
|
|
style = resolver.resolve_style(elem)
|
|
|
|
# Stylesheet should override default
|
|
assert style.get("margin-top") == "20px"
|
|
# But default not overridden should remain
|
|
assert style.get("margin-bottom") == "16px"
|
|
|
|
def test_inline_overrides_stylesheet(self):
|
|
rules = parse("p { color: blue; }")
|
|
resolver = StyleResolver(rules)
|
|
elem = Element("p", {"style": "color: red"})
|
|
style = resolver.resolve_style(elem)
|
|
|
|
# Inline should win
|
|
assert style.get("color") == "red"
|
|
|
|
def test_specificity_class_over_tag(self):
|
|
rules = parse("""
|
|
p { color: blue; }
|
|
.highlight { color: red; }
|
|
""")
|
|
resolver = StyleResolver(rules)
|
|
elem = Element("p", {"class": "highlight"})
|
|
style = resolver.resolve_style(elem)
|
|
|
|
# Class selector has higher specificity
|
|
assert style.get("color") == "red"
|
|
|
|
def test_specificity_id_over_class(self):
|
|
rules = parse("""
|
|
p { color: blue; }
|
|
.highlight { color: red; }
|
|
#main { color: green; }
|
|
""")
|
|
resolver = StyleResolver(rules)
|
|
elem = Element("p", {"class": "highlight", "id": "main"})
|
|
style = resolver.resolve_style(elem)
|
|
|
|
# ID selector has highest specificity
|
|
assert style.get("color") == "green"
|
|
|
|
def test_inheritance_from_parent(self):
|
|
rules = parse("body { color: blue; font-size: 16px; }")
|
|
resolver = StyleResolver(rules)
|
|
|
|
parent = Element("body")
|
|
parent_style = resolver.resolve_style(parent)
|
|
|
|
child = Element("div")
|
|
child_style = resolver.resolve_style(child, parent_style)
|
|
|
|
# Should inherit color and font-size
|
|
assert child_style.get("color") == "blue"
|
|
assert child_style.get("font-size") == "16px"
|
|
|
|
def test_non_inherited_properties(self):
|
|
rules = parse("body { margin: 10px; }")
|
|
resolver = StyleResolver(rules)
|
|
|
|
parent = Element("body")
|
|
parent_style = resolver.resolve_style(parent)
|
|
|
|
child = Element("div")
|
|
child_style = resolver.resolve_style(child, parent_style)
|
|
|
|
# Margin should not inherit
|
|
assert child_style.get("margin") == ""
|
|
|
|
def test_child_overrides_inherited(self):
|
|
rules = parse("""
|
|
body { color: blue; }
|
|
p { color: red; }
|
|
""")
|
|
resolver = StyleResolver(rules)
|
|
|
|
parent = Element("body")
|
|
parent_style = resolver.resolve_style(parent)
|
|
|
|
child = Element("p")
|
|
child_style = resolver.resolve_style(child, parent_style)
|
|
|
|
# Child's own style should override inherited
|
|
assert child_style.get("color") == "red"
|
|
|
|
def test_resolve_tree(self):
|
|
css = """
|
|
body { color: blue; font-size: 16px; }
|
|
p { margin: 10px; }
|
|
.highlight { background: yellow; }
|
|
"""
|
|
rules = parse(css)
|
|
resolver = StyleResolver(rules)
|
|
|
|
# Build tree
|
|
root = Element("body")
|
|
p1 = Element("p", parent=root)
|
|
p2 = Element("p", {"class": "highlight"}, parent=root)
|
|
text = Text("Hello", parent=p1)
|
|
root.children = [p1, p2]
|
|
p1.children = [text]
|
|
|
|
# Resolve entire tree
|
|
resolver.resolve_tree(root)
|
|
|
|
# Check root
|
|
assert root.computed_style.get("color") == "blue"
|
|
assert root.computed_style.get("font-size") == "16px"
|
|
|
|
# Check p1 (inherits color)
|
|
assert p1.computed_style.get("color") == "blue"
|
|
assert p1.computed_style.get("margin") == "10px"
|
|
|
|
# Check p2 (inherits + has class)
|
|
assert p2.computed_style.get("color") == "blue"
|
|
assert p2.computed_style.get("background") == "yellow"
|
|
|
|
# Check text (has parent style)
|
|
assert text.computed_style.get("color") == "blue"
|
|
|
|
def test_heading_defaults(self):
|
|
resolver = StyleResolver()
|
|
|
|
h1 = Element("h1")
|
|
h1_style = resolver.resolve_style(h1)
|
|
assert h1_style.get("font-size") == "32px"
|
|
assert h1_style.get("font-weight") == "bold"
|
|
|
|
h2 = Element("h2")
|
|
h2_style = resolver.resolve_style(h2)
|
|
assert h2_style.get("font-size") == "24px"
|
|
|
|
def test_inline_elements(self):
|
|
resolver = StyleResolver()
|
|
|
|
a = Element("a")
|
|
a_style = resolver.resolve_style(a)
|
|
assert a_style.get("display") == "inline"
|
|
assert a_style.get("color") == "blue"
|
|
assert a_style.get("text-decoration") == "underline"
|
|
|
|
span = Element("span")
|
|
span_style = resolver.resolve_style(span)
|
|
assert span_style.get("display") == "inline"
|
|
|