mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
- Added a comprehensive CSS parser with support for tag, class, and ID selectors. - Implemented property declaration parsing and inline style handling. - Introduced a Selector class for specificity calculation and matching against HTML elements. - Created a CSSRule class to represent individual CSS rules. - Developed a StyleResolver class to compute final styles for elements, considering cascade and inheritance. - Added integration tests for CSS parsing and style application in HTML documents. - Updated HTML parser to retain <style> tags for CSS extraction. - Enhanced tests for CSS parsing, inline styles, and computed styles.
479 lines
15 KiB
Python
479 lines
15 KiB
Python
"""Tests for CSS parsing and style computation."""
|
|
|
|
import pytest
|
|
from src.parser.css import (
|
|
Selector, CSSRule, CSSParser, parse, parse_inline_style
|
|
)
|
|
from src.parser.html import Element, Text
|
|
from src.parser.style import (
|
|
ComputedStyle, StyleResolver, DEFAULT_STYLES, INHERITED_PROPERTIES
|
|
)
|
|
|
|
|
|
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"
|
|
|