Initial commit
This commit is contained in:
commit
9fba4b3d34
17 changed files with 2939 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/config.py
Normal file
0
tests/config.py
Normal file
70
tests/hypot.py
Normal file
70
tests/hypot.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
"""Reusable hypothesis strategies for CSS selectors."""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from hypothesis import strategies as st
|
||||
|
||||
# CSS identifier (used for tag, class, ID names)
|
||||
css_ident = st.from_regex(r"[_a-zA-Z][_a-zA-Z0-9-]*", fullmatch=True)
|
||||
|
||||
css_tag = css_ident
|
||||
css_class = css_ident.map(lambda x: f".{x}")
|
||||
css_class_multi = st.lists(css_class, min_size=1).map("".join)
|
||||
css_id = css_ident.map(lambda x: f"#{x}")
|
||||
|
||||
# Any amount of whitespace, including none (spaces only)
|
||||
_whitespace = st.from_regex(r" *", fullmatch=True)
|
||||
|
||||
|
||||
def simple_selector() -> st.SearchStrategy[str]:
|
||||
"""Returns a strategy for simple seletors: tag, .class, #id (all optional, at least one)."""
|
||||
|
||||
# Optional tag + (one or more classes) + optional id, at least one item present
|
||||
parts = st.tuples(
|
||||
st.none() | css_tag,
|
||||
st.none() | css_class_multi,
|
||||
st.none() | css_id,
|
||||
).filter(lambda parts: any(parts))
|
||||
|
||||
return parts.map(lambda parts: "".join(p for p in parts if p))
|
||||
|
||||
|
||||
# nth-child pseudo-classes
|
||||
nth_child_suffix = st.from_regex(r":nth-child\([1-9][0-9]*\)", fullmatch=True)
|
||||
_specific_child_pseudo_classes = st.sampled_from([":first-child", ":last-child"])
|
||||
any_nth_child_suffix = st.one_of(nth_child_suffix, _specific_child_pseudo_classes)
|
||||
|
||||
|
||||
def not_selector(inner: st.SearchStrategy[str]) -> st.SearchStrategy[str]:
|
||||
return st.tuples(st.just(":not("), _whitespace, inner, _whitespace, st.just(")")).map("".join)
|
||||
|
||||
|
||||
def pseudo_suffixes(inner: st.SearchStrategy[str]) -> st.SearchStrategy[str]:
|
||||
"""Generate a single random pseudo-class suffix."""
|
||||
return st.one_of(
|
||||
not_selector(inner),
|
||||
any_nth_child_suffix,
|
||||
)
|
||||
|
||||
|
||||
combinator = st.tuples(_whitespace, st.sampled_from([">", " ", "+", ",", "~"]), _whitespace).map("".join)
|
||||
|
||||
selector = st.recursive(
|
||||
# Base: simple selector + pseudo-classes
|
||||
base=simple_selector(),
|
||||
extend=lambda s: st.one_of(
|
||||
# Add combinator + selector sequence
|
||||
cast(
|
||||
"st.SearchStrategy[str]",
|
||||
st.builds(
|
||||
lambda left, comb, right: left + comb + right,
|
||||
s,
|
||||
combinator,
|
||||
s,
|
||||
),
|
||||
),
|
||||
# Apply pseudo-suffix
|
||||
st.tuples(s, pseudo_suffixes(s)).map("".join),
|
||||
),
|
||||
max_leaves=10,
|
||||
)
|
443
tests/test_matching.py
Normal file
443
tests/test_matching.py
Normal file
|
@ -0,0 +1,443 @@
|
|||
import pytest
|
||||
|
||||
from src.node import Node
|
||||
from src.node_helpers import ParentAwareNode
|
||||
from src.parser import (
|
||||
DescendantSelector,
|
||||
MultiSelector,
|
||||
NonMultiSelector,
|
||||
NotPseudoClassSelector,
|
||||
NthChildPseudoClassSelector,
|
||||
SiblingSelector,
|
||||
SimpleSelector,
|
||||
)
|
||||
|
||||
# This test tree matches the one from python discord's original test suite
|
||||
#
|
||||
# Structure:
|
||||
# <div id="topDiv">
|
||||
# <div class="container colour-primary" id="innerDiv">
|
||||
# <h1>This is a heading!</h1>
|
||||
# <p class="colour-secondary" id="innerContent">
|
||||
# I have some content within this container also!
|
||||
# </p>
|
||||
# <p class="colour-secondary" id="two">
|
||||
# This is another paragraph.
|
||||
# </p>
|
||||
# <p class="colour-secondary important">
|
||||
# This is a third paragraph.
|
||||
# </p>
|
||||
# <a class="colour-primary button" id="home-link">
|
||||
# This is a button link.
|
||||
# </a>
|
||||
# </div>
|
||||
# <div class="container colour-secondary">
|
||||
# <p class="colour-primary">This is a paragraph in a secondary container.</p>
|
||||
# </div>
|
||||
# </div>
|
||||
TEST_TREE = ParentAwareNode.from_node(
|
||||
Node(
|
||||
tag="div",
|
||||
attributes={"id": "topDiv"},
|
||||
children=[
|
||||
Node(
|
||||
tag="div",
|
||||
attributes={"id": "innerDiv", "class": "container colour-primary"},
|
||||
children=[
|
||||
Node(tag="h1", text="This is a heading!"),
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-secondary", "id": "innerContent"},
|
||||
text="I have some content within this container also!",
|
||||
),
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-secondary", "id": "two"},
|
||||
text="This is another paragraph.",
|
||||
),
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-secondary important"},
|
||||
text="This is a third paragraph.",
|
||||
),
|
||||
Node(
|
||||
tag="a",
|
||||
attributes={"id": "home-link", "class": "colour-primary button"},
|
||||
text="This is a button link.",
|
||||
),
|
||||
],
|
||||
),
|
||||
Node(
|
||||
tag="div",
|
||||
attributes={"class": "container colour-secondary"},
|
||||
children=[
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-primary"},
|
||||
text="This is a paragraph in a secondary container.",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Get references to important nodes
|
||||
TOP_DIV = TEST_TREE
|
||||
INNER_DIV = TOP_DIV.children[0]
|
||||
H1 = INNER_DIV.children[0]
|
||||
P1 = INNER_DIV.children[1]
|
||||
P2 = INNER_DIV.children[2]
|
||||
P3 = INNER_DIV.children[3]
|
||||
A_LINK = INNER_DIV.children[4]
|
||||
SECOND_DIV = TEST_TREE.children[1]
|
||||
SECOND_P = SECOND_DIV.children[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "node", "expected"),
|
||||
[
|
||||
(SimpleSelector(tag="div"), TOP_DIV, True),
|
||||
(SimpleSelector(tag="div"), H1, False),
|
||||
(SimpleSelector(classes=["colour-primary"]), P1, False),
|
||||
(SimpleSelector(classes=["colour-secondary"]), P1, True),
|
||||
(SimpleSelector(ids=["home-link"]), A_LINK, True),
|
||||
(SimpleSelector(tag="a", classes=["button"], ids=["home-link"]), A_LINK, True),
|
||||
(SimpleSelector(tag="p", classes=["important"]), P3, True),
|
||||
(SimpleSelector(tag="p", classes=["nonexistent"]), P3, False),
|
||||
(SimpleSelector(tag="p", ids=["innerContent"]), P1, True),
|
||||
(SimpleSelector(tag="h1", ids=["innerContent"]), P1, False),
|
||||
],
|
||||
)
|
||||
def test_simple_selector(selector: SimpleSelector, node: ParentAwareNode, expected: bool) -> None:
|
||||
assert selector.match_node(node) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "node", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", classes=["container"]),
|
||||
child=SimpleSelector(tag="h1"),
|
||||
direct=True,
|
||||
),
|
||||
H1,
|
||||
True,
|
||||
id="div.container > h1 (on h1)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div"),
|
||||
child=DescendantSelector(
|
||||
parent=SimpleSelector(tag="div"),
|
||||
child=SimpleSelector(tag="p", classes=["important"]),
|
||||
direct=True,
|
||||
),
|
||||
direct=True,
|
||||
),
|
||||
P3,
|
||||
True,
|
||||
id="div > div > p.important (on p3)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", classes=["container"]),
|
||||
child=SimpleSelector(tag="p", classes=["colour-secondary"]),
|
||||
direct=False,
|
||||
),
|
||||
P1,
|
||||
True,
|
||||
id="div.container p.colour-secondary (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", ids=["topDiv"]),
|
||||
child=SimpleSelector(tag="p", classes=["colour-secondary"]),
|
||||
direct=False,
|
||||
),
|
||||
P1,
|
||||
True,
|
||||
id="div#topDiv p.colour-secondary (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", classes=["container"]),
|
||||
child=SimpleSelector(tag="a", classes=["colour-secondary"]),
|
||||
direct=False,
|
||||
),
|
||||
P1,
|
||||
False,
|
||||
id="div.container a.colour-secondary (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", classes=["nonexistent"]),
|
||||
child=SimpleSelector(tag="p", classes=["colour-secondary"]),
|
||||
direct=False,
|
||||
),
|
||||
P1,
|
||||
False,
|
||||
id="div.nonexistent p.colour-secondary (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", ids=["topDiv"]),
|
||||
child=SimpleSelector(tag="div", ids=["topDiv"]),
|
||||
direct=False,
|
||||
),
|
||||
TOP_DIV,
|
||||
False,
|
||||
id="div#topDiv div#topDiv (on top_div)",
|
||||
),
|
||||
pytest.param(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div"),
|
||||
child=SimpleSelector(tag="div", classes=["container"]),
|
||||
direct=False,
|
||||
),
|
||||
A_LINK,
|
||||
True,
|
||||
id="div#topDiv > div.container > a (on a_link)",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_descendant_selector_matching(selector: DescendantSelector, node: ParentAwareNode, expected: bool) -> None:
|
||||
assert selector.match_node(node) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "node", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="p", classes=["important"]),
|
||||
selector=SimpleSelector(tag="a", classes=["button"]),
|
||||
is_adjacent=True,
|
||||
),
|
||||
A_LINK,
|
||||
True,
|
||||
id="p.colour-secondary + a.button (on a_link)",
|
||||
),
|
||||
pytest.param(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="p", classes=["colour-secondary"]),
|
||||
selector=SimpleSelector(tag="a", classes=["button"]),
|
||||
is_adjacent=False,
|
||||
),
|
||||
A_LINK,
|
||||
True,
|
||||
id="p.colour-secondary ~ a.button (on a_link)",
|
||||
),
|
||||
pytest.param(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="p", ids=["innerContent"]),
|
||||
selector=SimpleSelector(tag="p", ids=["two"]),
|
||||
is_adjacent=True,
|
||||
),
|
||||
P2,
|
||||
True,
|
||||
id="p + p (on p2)",
|
||||
),
|
||||
pytest.param(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="h1"),
|
||||
selector=SimpleSelector(tag="p", ids=["innerContent"]),
|
||||
is_adjacent=True,
|
||||
),
|
||||
P1,
|
||||
True,
|
||||
id="h1 + p (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="h1"),
|
||||
selector=SimpleSelector(tag="p", ids=["innerContent"]),
|
||||
is_adjacent=True,
|
||||
),
|
||||
P2,
|
||||
False,
|
||||
id="h1 + p (on p2)",
|
||||
),
|
||||
pytest.param(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="div"),
|
||||
selector=SimpleSelector(tag="h1"),
|
||||
is_adjacent=True,
|
||||
),
|
||||
H1,
|
||||
False,
|
||||
id="div + h1 (on h1)",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sibling_selector_matching(selector: SiblingSelector, node: ParentAwareNode, expected: bool) -> None:
|
||||
assert selector.match_node(node) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("n", "node", "expected"),
|
||||
[
|
||||
(1, H1, True), # first-child
|
||||
(2, P1, True),
|
||||
(3, P2, True),
|
||||
(4, P3, True),
|
||||
(5, A_LINK, True),
|
||||
(6, A_LINK, False),
|
||||
(-1, A_LINK, True), # last-child
|
||||
(-1, P3, False),
|
||||
(1, TOP_DIV, False),
|
||||
(1, SECOND_P, True),
|
||||
(-1, SECOND_P, True),
|
||||
(2, SECOND_P, False),
|
||||
],
|
||||
)
|
||||
def test_nth_child_selector(n: int, node: ParentAwareNode, expected: bool) -> None:
|
||||
selector = NthChildPseudoClassSelector(selector=None, n=n)
|
||||
assert selector.match_node(node) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "node", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("p"),
|
||||
not_selector=SimpleSelector(classes=["important"]),
|
||||
),
|
||||
P1,
|
||||
True,
|
||||
id="p:not(.important) (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("p"),
|
||||
not_selector=SimpleSelector(classes=["important"]),
|
||||
),
|
||||
P3,
|
||||
False,
|
||||
id="p:not(.important) (on p3)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("div"),
|
||||
not_selector=SimpleSelector(classes=["important"]),
|
||||
),
|
||||
P1,
|
||||
False,
|
||||
id="div:not(.important) (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=None,
|
||||
not_selector=SimpleSelector(classes=["important"]),
|
||||
),
|
||||
P1,
|
||||
True,
|
||||
id=":not(.important) (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("div"),
|
||||
not_selector=SimpleSelector(ids=["innerDiv"]),
|
||||
),
|
||||
SECOND_DIV,
|
||||
True,
|
||||
id="div:not(#innerDiv) (on second_div)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("div"),
|
||||
not_selector=SimpleSelector(ids=["innerDiv"]),
|
||||
),
|
||||
INNER_DIV,
|
||||
False,
|
||||
id="div:not(#innerDiv) (on inner_div)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("p"),
|
||||
not_selector=DescendantSelector(
|
||||
SimpleSelector(tag="div", classes=["colour-secondary"]), SimpleSelector(tag="p"), direct=False
|
||||
),
|
||||
),
|
||||
P2,
|
||||
True,
|
||||
id="p:not(div.colour-secondary p) (on p2)",
|
||||
),
|
||||
pytest.param(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector("p"),
|
||||
not_selector=DescendantSelector(
|
||||
SimpleSelector(tag="div", classes=["container"]),
|
||||
SimpleSelector(tag="p"),
|
||||
direct=False,
|
||||
),
|
||||
),
|
||||
SECOND_P,
|
||||
False,
|
||||
id="p:not(div.container p) (on second_p)",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_not_selector(selector: NotPseudoClassSelector, node: ParentAwareNode, expected: bool) -> None:
|
||||
assert selector.match_node(node) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selectors", "node", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
SimpleSelector(tag="a"),
|
||||
SimpleSelector(tag="p", ids=["innerContent"]),
|
||||
],
|
||||
A_LINK,
|
||||
True,
|
||||
id="a, p#innerContent (on a_link)",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
SimpleSelector(tag="a"),
|
||||
SimpleSelector(tag="p", ids=["innerContent"]),
|
||||
],
|
||||
P1,
|
||||
True,
|
||||
id="a, p#innerContent (on p1)",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
SimpleSelector(tag="a"),
|
||||
SimpleSelector(tag="p", ids=["innerContent"]),
|
||||
],
|
||||
P2,
|
||||
False,
|
||||
id="a, p#innerContent (on p2)",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
SimpleSelector(tag="h1"),
|
||||
SimpleSelector(classes=["colour-primary"]),
|
||||
SimpleSelector(ids=["home-link"]),
|
||||
],
|
||||
TOP_DIV,
|
||||
False,
|
||||
id="h1, .colour-primary, #home-link (on top_div)",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
NotPseudoClassSelector(
|
||||
selector=None,
|
||||
not_selector=SimpleSelector(tag="h1"),
|
||||
),
|
||||
SimpleSelector(tag="p"),
|
||||
],
|
||||
TOP_DIV,
|
||||
True,
|
||||
id=":not(h1), p",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_multi_selector_matching(selectors: list[NonMultiSelector], node: ParentAwareNode, expected: bool) -> None:
|
||||
selector = MultiSelector(selectors=selectors)
|
||||
assert selector.match_node(node) is expected
|
474
tests/test_parser.py
Normal file
474
tests/test_parser.py
Normal file
|
@ -0,0 +1,474 @@
|
|||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from src.parser import (
|
||||
DescendantSelector,
|
||||
InvalidSelectorError,
|
||||
MultiSelector,
|
||||
NotPseudoClassSelector,
|
||||
NthChildPseudoClassSelector,
|
||||
PseudoClassSelector,
|
||||
SiblingSelector,
|
||||
SimpleSelector,
|
||||
parse_tokens,
|
||||
)
|
||||
from src.tokenizer import Token, TokenStream, TokenType
|
||||
|
||||
|
||||
def test_parse_simple_tag_class_id() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
Token(TokenType.ID, "#bar"),
|
||||
]
|
||||
)
|
||||
sel = SimpleSelector.parse_tokens(tokens)
|
||||
assert sel.tag == "div"
|
||||
assert sel.classes == ["foo"]
|
||||
assert sel.ids == ["bar"]
|
||||
|
||||
|
||||
def test_parse_simple_selector_id_only() -> None:
|
||||
tokens = TokenStream([Token(TokenType.ID, "#foo")])
|
||||
sel = SimpleSelector.parse_tokens(tokens)
|
||||
assert sel.tag is None
|
||||
assert sel.classes == []
|
||||
assert sel.ids == ["foo"]
|
||||
|
||||
|
||||
def test_parse_simple_selector_class_only() -> None:
|
||||
tokens = TokenStream([Token(TokenType.CLASS, ".foo")])
|
||||
sel = SimpleSelector.parse_tokens(tokens)
|
||||
assert sel.tag is None
|
||||
assert sel.classes == ["foo"]
|
||||
assert sel.ids == []
|
||||
|
||||
|
||||
def test_parse_simple_selector_multi_class_only() -> None:
|
||||
tokens = TokenStream([Token(TokenType.CLASS, ".foo"), Token(TokenType.CLASS, ".bar")])
|
||||
sel = SimpleSelector.parse_tokens(tokens)
|
||||
assert sel.tag is None
|
||||
assert sel.classes == ["foo", "bar"]
|
||||
assert sel.ids == []
|
||||
|
||||
|
||||
def test_parse_simple_selector_multiple_ids_warns() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.ID, "#one"),
|
||||
Token(TokenType.ID, "#two"),
|
||||
]
|
||||
)
|
||||
with pytest.warns(UserWarning, match="multiple IDs"):
|
||||
sel = SimpleSelector.parse_tokens(tokens)
|
||||
assert sel.ids == ["one", "two"]
|
||||
|
||||
|
||||
def test_parse_simple_selector_invalid_double_tag_raises() -> None:
|
||||
# This should be impossible to tokenize anyways, but still, let's
|
||||
# make sure the parser correctly handles it with an exception
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.TAG, "span"),
|
||||
]
|
||||
)
|
||||
with pytest.raises(InvalidSelectorError, match="multiple tag"):
|
||||
_ = SimpleSelector.parse_tokens(tokens)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_tokens",
|
||||
[
|
||||
[Token(TokenType.PSEUDO_CLASS, ":first-child")],
|
||||
[
|
||||
Token(TokenType.DESCENDANT, " "),
|
||||
Token(TokenType.TAG, "p"),
|
||||
],
|
||||
[
|
||||
Token(TokenType.COMMA, ", "),
|
||||
Token(TokenType.TAG, "p"),
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_parse_simple_tag_leaves_extra_tokens(extra_tokens: list[Token]) -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
Token(TokenType.ID, "#bar"),
|
||||
*extra_tokens,
|
||||
]
|
||||
)
|
||||
_ = SimpleSelector.parse_tokens(tokens)
|
||||
|
||||
assert tokens.peek() == extra_tokens[0]
|
||||
|
||||
|
||||
def test_parse_descendant_selector() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.DESCENDANT, " "),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
]
|
||||
)
|
||||
sel = parse_tokens(tokens)
|
||||
assert isinstance(sel, DescendantSelector)
|
||||
assert isinstance(sel.parent, SimpleSelector)
|
||||
assert sel.parent.tag == "div"
|
||||
assert isinstance(sel.child, SimpleSelector)
|
||||
assert sel.child.classes == ["foo"]
|
||||
|
||||
|
||||
def test_parse_direct_child_selector() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.DIRECT_CHILD, ">"),
|
||||
Token(TokenType.CLASS, ".bar"),
|
||||
]
|
||||
)
|
||||
sel = parse_tokens(tokens)
|
||||
assert isinstance(sel, DescendantSelector)
|
||||
assert sel.direct is True
|
||||
assert isinstance(sel.parent, SimpleSelector)
|
||||
assert sel.parent.tag == "div"
|
||||
assert isinstance(sel.child, SimpleSelector)
|
||||
assert sel.child.classes == ["bar"]
|
||||
|
||||
|
||||
def test_parse_sibling_selector_adjacent() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.CLASS, ".a"),
|
||||
Token(TokenType.ADJACENT_SIBLING, "+"),
|
||||
Token(TokenType.CLASS, ".b"),
|
||||
]
|
||||
)
|
||||
sel = parse_tokens(tokens)
|
||||
assert isinstance(sel, SiblingSelector)
|
||||
assert sel.is_adjacent is True
|
||||
assert isinstance(sel.sibling_selector, SimpleSelector)
|
||||
assert sel.sibling_selector.classes == ["a"]
|
||||
assert isinstance(sel.selector, SimpleSelector)
|
||||
assert sel.selector.classes == ["b"]
|
||||
|
||||
|
||||
def test_parse_sibling_selector_subsequent() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.CLASS, ".a"),
|
||||
Token(TokenType.SUBSEQUENT_SIBLING, "~"),
|
||||
Token(TokenType.CLASS, ".b"),
|
||||
]
|
||||
)
|
||||
sel = parse_tokens(tokens)
|
||||
assert isinstance(sel, SiblingSelector)
|
||||
assert sel.is_adjacent is False
|
||||
assert isinstance(sel.sibling_selector, SimpleSelector)
|
||||
assert sel.sibling_selector.classes == ["a"]
|
||||
assert isinstance(sel.selector, SimpleSelector)
|
||||
assert sel.selector.classes == ["b"]
|
||||
|
||||
|
||||
def test_parse_multi_selector() -> None:
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.CLASS, ".a"),
|
||||
Token(TokenType.COMMA, ","),
|
||||
Token(TokenType.CLASS, ".b"),
|
||||
Token(TokenType.COMMA, ","),
|
||||
Token(TokenType.CLASS, ".c"),
|
||||
]
|
||||
)
|
||||
sel = parse_tokens(tokens)
|
||||
assert isinstance(sel, MultiSelector)
|
||||
assert len(sel.selectors) == 3
|
||||
assert all(isinstance(subsel, SimpleSelector) for subsel in sel.selectors)
|
||||
sels = cast("list[SimpleSelector]", sel.selectors)
|
||||
assert sels[0].classes == ["a"]
|
||||
assert sels[1].classes == ["b"]
|
||||
assert sels[2].classes == ["c"]
|
||||
|
||||
|
||||
def test_pseudo_class_without_arguments() -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
tokens = TokenStream([Token(TokenType.PSEUDO_CLASS, ":first-child")])
|
||||
pseudo = PseudoClassSelector.parse_tokens(tokens, base)
|
||||
assert pseudo.pseudo_class == "first-child"
|
||||
assert pseudo.selector == base
|
||||
assert pseudo.argument is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_tokens",
|
||||
[
|
||||
[Token(TokenType.PSEUDO_CLASS, ":first-child")],
|
||||
[
|
||||
Token(TokenType.DESCENDANT, " "),
|
||||
Token(TokenType.TAG, "p"),
|
||||
],
|
||||
[
|
||||
Token(TokenType.COMMA, ", "),
|
||||
Token(TokenType.TAG, "p"),
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_pseudo_class_without_arguments_leaves_extra_tokens(extra_tokens: list[Token]) -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.PSEUDO_CLASS, ":first-child"),
|
||||
*extra_tokens,
|
||||
]
|
||||
)
|
||||
_ = PseudoClassSelector.parse_tokens(tokens, base)
|
||||
assert tokens.peek() == extra_tokens[0]
|
||||
|
||||
|
||||
def test_pseudo_class_with_nested_argument() -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
arg_tokens = [
|
||||
Token(TokenType.TAG, "span"),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
]
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.PSEUDO_CLASS, ":not"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
*arg_tokens,
|
||||
Token(TokenType.RPARENS, ")"),
|
||||
]
|
||||
)
|
||||
pseudo = PseudoClassSelector.parse_tokens(tokens, base)
|
||||
assert pseudo.pseudo_class == "not"
|
||||
assert pseudo.selector == base
|
||||
assert pseudo.argument == arg_tokens
|
||||
|
||||
|
||||
def test_parse_pseudo_class_nested_parens() -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
arg_tokens = [
|
||||
Token(TokenType.PSEUDO_CLASS, ":nth-child"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
Token(TokenType.NUMBER, "2"),
|
||||
Token(TokenType.RPARENS, ")"),
|
||||
]
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.PSEUDO_CLASS, ":not"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
*arg_tokens,
|
||||
Token(TokenType.RPARENS, ")"),
|
||||
]
|
||||
)
|
||||
pseudo = PseudoClassSelector.parse_tokens(tokens, base)
|
||||
assert pseudo.pseudo_class == "not"
|
||||
assert pseudo.argument == arg_tokens
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_tokens",
|
||||
[
|
||||
[Token(TokenType.PSEUDO_CLASS, ":first-child")],
|
||||
[
|
||||
Token(TokenType.DESCENDANT, " "),
|
||||
Token(TokenType.TAG, "p"),
|
||||
],
|
||||
[
|
||||
Token(TokenType.COMMA, ", "),
|
||||
Token(TokenType.TAG, "p"),
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_pseudo_class_with_nested_argument_leaves_extra_tokens(extra_tokens: list[Token]) -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
arg_tokens = [
|
||||
Token(TokenType.TAG, "span"),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
]
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.PSEUDO_CLASS, ":not"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
*arg_tokens,
|
||||
Token(TokenType.RPARENS, ")"),
|
||||
*extra_tokens,
|
||||
]
|
||||
)
|
||||
_ = PseudoClassSelector.parse_tokens(tokens, base)
|
||||
assert tokens.peek() == extra_tokens[0]
|
||||
|
||||
|
||||
def test_pseudo_class_unbalanced_parens() -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.PSEUDO_CLASS, ":not"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
Token(TokenType.TAG, "span"),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
Token(TokenType.PSEUDO_CLASS, ":nth-child"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
Token(TokenType.NUMBER, "2"),
|
||||
Token(TokenType.RPARENS, ")"),
|
||||
]
|
||||
)
|
||||
with pytest.raises(InvalidSelectorError):
|
||||
_ = PseudoClassSelector.parse_tokens(tokens, base)
|
||||
|
||||
|
||||
def test_nth_child_valid() -> None:
|
||||
base = SimpleSelector(tag="li")
|
||||
arg_tokens = [Token(TokenType.NUMBER, "3")]
|
||||
pseudo = PseudoClassSelector("nth-child", base, arg_tokens)
|
||||
|
||||
sel = NthChildPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
assert sel.n == 3
|
||||
assert sel.selector == base
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"argument_tokens",
|
||||
[
|
||||
pytest.param([Token(TokenType.CLASS, ".bad")], id="bad-type"),
|
||||
pytest.param(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.CLASS, ".main"),
|
||||
],
|
||||
id="multi-token",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Token(TokenType.NUMBER, "5"),
|
||||
Token(TokenType.TAG, "div"),
|
||||
],
|
||||
id="multi-token-number-first",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_nth_child_parsing_invalid_argument(argument_tokens: list[Token]) -> None:
|
||||
base = SimpleSelector(tag="li")
|
||||
pseudo = PseudoClassSelector("nth-child", base, argument_tokens)
|
||||
|
||||
with pytest.raises(InvalidSelectorError):
|
||||
_ = NthChildPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
|
||||
|
||||
def test_nth_child_parsing_missing_argument() -> None:
|
||||
base = SimpleSelector(tag="li")
|
||||
pseudo = PseudoClassSelector("nth-child", base, None)
|
||||
|
||||
with pytest.raises(InvalidSelectorError):
|
||||
_ = NthChildPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "n"),
|
||||
[
|
||||
("first-child", 1),
|
||||
("last-child", -1),
|
||||
],
|
||||
)
|
||||
def test_specific_nth_child(selector: str, n: int) -> None:
|
||||
base = SimpleSelector(tag="li")
|
||||
pseudo = PseudoClassSelector(selector, base, None)
|
||||
|
||||
sel = NthChildPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
assert sel.n == n
|
||||
assert sel.selector == base
|
||||
|
||||
|
||||
def test_specific_nth_child_with_argument() -> None:
|
||||
base = SimpleSelector(tag="li")
|
||||
arg_tokens = [Token(TokenType.NUMBER, "4")]
|
||||
pseudo = PseudoClassSelector("first-child", base, arg_tokens)
|
||||
|
||||
with pytest.raises(InvalidSelectorError):
|
||||
_ = NthChildPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
|
||||
|
||||
def test_last_child_parsing() -> None:
|
||||
base = SimpleSelector(tag="li")
|
||||
pseudo = PseudoClassSelector("last-child", base, None)
|
||||
|
||||
sel = NthChildPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
assert sel.n == -1
|
||||
assert sel.selector == base
|
||||
|
||||
|
||||
def test_parse_not_valid() -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
arg_tokens = [
|
||||
Token(TokenType.TAG, "span"),
|
||||
Token(TokenType.CLASS, ".foo"),
|
||||
]
|
||||
pseudo = PseudoClassSelector("not", base, arg_tokens)
|
||||
|
||||
sel = NotPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
assert isinstance(sel.not_selector, SimpleSelector)
|
||||
assert sel.not_selector.tag == "span"
|
||||
assert sel.not_selector.classes == ["foo"]
|
||||
assert sel.selector == base
|
||||
|
||||
|
||||
def test_parse_not_with_missing_argument() -> None:
|
||||
base = SimpleSelector(tag="div")
|
||||
pseudo = PseudoClassSelector("not", base, None)
|
||||
|
||||
with pytest.raises(InvalidSelectorError):
|
||||
_ = NotPseudoClassSelector.from_pseudo_cls(pseudo)
|
||||
|
||||
|
||||
def test_parse_multiple_combinators() -> None:
|
||||
# div .parent > .child + .sibling:not(.bar):first-child
|
||||
tokens = TokenStream(
|
||||
[
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.DESCENDANT, " "),
|
||||
Token(TokenType.CLASS, ".parent"),
|
||||
Token(TokenType.DIRECT_CHILD, ">"),
|
||||
Token(TokenType.CLASS, ".child"),
|
||||
Token(TokenType.ADJACENT_SIBLING, "+"),
|
||||
Token(TokenType.CLASS, ".sibling"),
|
||||
Token(TokenType.PSEUDO_CLASS, ":not"),
|
||||
Token(TokenType.LPARENS, "("),
|
||||
Token(TokenType.CLASS, ".bar"),
|
||||
Token(TokenType.RPARENS, ")"),
|
||||
Token(TokenType.PSEUDO_CLASS, ":first-child"),
|
||||
]
|
||||
)
|
||||
sel = parse_tokens(tokens)
|
||||
assert isinstance(sel, DescendantSelector)
|
||||
|
||||
assert isinstance(sel.parent, SimpleSelector)
|
||||
assert sel.parent.tag == "div"
|
||||
|
||||
assert isinstance(sel.child, DescendantSelector)
|
||||
|
||||
assert isinstance(sel.child.parent, SimpleSelector)
|
||||
assert sel.child.parent.classes == ["parent"]
|
||||
|
||||
sibling = sel.child.child # .child + .sibling:not(.bar):first-child
|
||||
assert isinstance(sibling, SiblingSelector)
|
||||
assert sibling.is_adjacent is True
|
||||
|
||||
assert isinstance(sibling.sibling_selector, SimpleSelector)
|
||||
assert sibling.sibling_selector.classes == ["child"]
|
||||
|
||||
nth_child = sibling.selector # .sibling:not(.bar):first-child
|
||||
assert isinstance(nth_child, NthChildPseudoClassSelector)
|
||||
assert nth_child.n == 1
|
||||
|
||||
not_selector = nth_child.selector # .sibling:not(.bar)
|
||||
assert isinstance(not_selector, NotPseudoClassSelector)
|
||||
|
||||
assert isinstance(not_selector.not_selector, SimpleSelector)
|
||||
assert not_selector.not_selector.classes == ["bar"]
|
||||
|
||||
assert isinstance(not_selector.selector, SimpleSelector)
|
||||
assert not_selector.selector.classes == ["sibling"]
|
335
tests/test_qualifier.py
Normal file
335
tests/test_qualifier.py
Normal file
|
@ -0,0 +1,335 @@
|
|||
"""Test suite which tests the entire logic together (tokenization, parsing, matching).
|
||||
|
||||
This suite doesn't focus on any individual components, only going from selector string -> matching.
|
||||
Some of the test cases in this suite will technically overlap those in the other suites, this
|
||||
is intentional, as this suite is also useful for testing other implementations of the matching
|
||||
(not just my specific one), unlike with the other suites, which work with my specific AST/parser.
|
||||
|
||||
This suite is written with pytest to allow for easier parametrizations and provide better output.
|
||||
Generally, this suite contains the same tests as the original python discord's test suite, with
|
||||
some additional ones to test out various edge cases (and it's rewritten from pure unit-test suite).
|
||||
|
||||
On top of the original simple tests for the basic implementation, this suite also includes tests
|
||||
with the 'bonus' marker, which cover the bonus task. (The basic implementation tests have the 'basic'
|
||||
marker).
|
||||
|
||||
Finally, this suite also contains some extra cases for sibling matching (+ and ~). This is a part of
|
||||
the CSS specification, however, it was not a part of even the bonus task. These tests are marked with
|
||||
the 'extra' marker.
|
||||
|
||||
If you'd like to port this test-suite to your code, you'll want to change the src.qualifier imports to
|
||||
just qualifier as that's what the original implementation expects.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from collections.abc import Iterable
|
||||
|
||||
import pytest
|
||||
|
||||
from src.node import Node
|
||||
from src.qualifier import query_selector_all as solution
|
||||
|
||||
# This test tree matches the one from python discord's original test suite
|
||||
#
|
||||
# Structure:
|
||||
# <div id="topDiv">
|
||||
# <div class="container colour-primary" id="innerDiv">
|
||||
# <h1>This is a heading!</h1>
|
||||
# <p class="colour-secondary" id="innerContent">
|
||||
# I have some content within this container also!
|
||||
# </p>
|
||||
# <p class="colour-secondary" id="two">
|
||||
# This is another paragraph.
|
||||
# </p>
|
||||
# <p class="colour-secondary important">
|
||||
# This is a third paragraph.
|
||||
# </p>
|
||||
# <a class="colour-primary button" id="home-link">
|
||||
# This is a button link.
|
||||
# </a>
|
||||
# </div>
|
||||
# <div class="container colour-secondary">
|
||||
# <p class="colour-primary">This is a paragraph in a secondary container.</p>
|
||||
# </div>
|
||||
# </div>
|
||||
TEST_TREE = Node(
|
||||
tag="div",
|
||||
attributes={"id": "topDiv"},
|
||||
children=[
|
||||
Node(
|
||||
tag="div",
|
||||
attributes={"id": "innerDiv", "class": "container colour-primary"},
|
||||
children=[
|
||||
Node(tag="h1", text="This is a heading!"),
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-secondary", "id": "innerContent"},
|
||||
text="I have some content within this container also!",
|
||||
),
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-secondary", "id": "two"},
|
||||
text="This is another paragraph.",
|
||||
),
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-secondary important"},
|
||||
text="This is a third paragraph.",
|
||||
),
|
||||
Node(
|
||||
tag="a",
|
||||
attributes={"id": "home-link", "class": "colour-primary button"},
|
||||
text="This is a button link.",
|
||||
),
|
||||
],
|
||||
),
|
||||
Node(
|
||||
tag="div",
|
||||
attributes={"class": "container colour-secondary"},
|
||||
children=[
|
||||
Node(
|
||||
tag="p",
|
||||
attributes={"class": "colour-primary"},
|
||||
text="This is a paragraph in a secondary container.",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# Get references to important nodes
|
||||
TOP_DIV = TEST_TREE
|
||||
INNER_DIV = TOP_DIV.children[0]
|
||||
H1 = INNER_DIV.children[0]
|
||||
P1 = INNER_DIV.children[1]
|
||||
P2 = INNER_DIV.children[2]
|
||||
P3 = INNER_DIV.children[3]
|
||||
A_LINK = INNER_DIV.children[4]
|
||||
SECOND_DIV = TEST_TREE.children[1]
|
||||
SECOND_P = SECOND_DIV.children[0]
|
||||
|
||||
|
||||
def assert_count_equal[T](first: Iterable[T], second: Iterable[T]) -> None:
|
||||
case = unittest.TestCase()
|
||||
case.assertCountEqual(first, second) # noqa: PT009
|
||||
|
||||
|
||||
@pytest.mark.basic
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
# matches pydis tests exactly
|
||||
("div", [TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("#innerDiv", [INNER_DIV]),
|
||||
(".colour-primary", [INNER_DIV, A_LINK, SECOND_P]),
|
||||
("p.colour-secondary", [P1, P2, P3]),
|
||||
("div#innerDiv", [INNER_DIV]),
|
||||
("#innerContent.colour-secondary", [P1]),
|
||||
("div#innerDiv.colour-primary", [INNER_DIV]),
|
||||
(".colour-primary.button", [A_LINK]),
|
||||
("#home-link.colour-primary.button", [A_LINK]),
|
||||
("a#home-link.colour-primary.button", [A_LINK]),
|
||||
("i", []),
|
||||
("#badId", []),
|
||||
(".missing-class", []),
|
||||
# some extra tests
|
||||
("#home-link", [A_LINK]),
|
||||
("a.button#home-link", [A_LINK]),
|
||||
("p.important", [P3]),
|
||||
("p.nonexistent", []),
|
||||
("p#innerContent", [P1]),
|
||||
("h1#innerContent", []),
|
||||
],
|
||||
)
|
||||
def test_simple_selector(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.basic
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
# matches pydis tests exactly
|
||||
("#topDiv, h1, .colour-primary", [TOP_DIV, H1, INNER_DIV, A_LINK, SECOND_P]),
|
||||
("h1, a#home-link.colour-primary.button", [H1, A_LINK]),
|
||||
("p#two.colour-secondary, a#home-link.colour-primary.button", [P2, A_LINK]),
|
||||
("i, #badID, .missing-class", []),
|
||||
("h1, #badID, .missing-class", [H1]),
|
||||
("li#random.someclass, a#home-link, div, .colour-primary.badclass", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
],
|
||||
)
|
||||
def test_multi_selector(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.bonus
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("div.container > h1", [H1]),
|
||||
("div > div > p.important", [P3]),
|
||||
("div.container p.colour-secondary", [P1, P2, P3]),
|
||||
("div.container > p.colour-secondary", [P1, P2, P3]),
|
||||
("div#topDiv p.colour-secondary", [P1, P2, P3]),
|
||||
("div#topDiv p", [P1, P2, P3, SECOND_P]),
|
||||
("div#topDiv > p", []),
|
||||
("div#innerDiv p.colour-primary", []),
|
||||
("div.nonexistent p.colour-secondary", []),
|
||||
("div#topDiv div#topDiv", []),
|
||||
# ("div#topDiv > div.container > a", [A_LINK]),
|
||||
],
|
||||
)
|
||||
def test_descendant_selector(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.extra
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("p.important + a.button", [A_LINK]),
|
||||
("p.colour-secondary ~ a.button", [A_LINK]),
|
||||
("p + p", [P2, P3]),
|
||||
("p ~ p", [P2, P3]),
|
||||
("h1 + p", [P1]),
|
||||
("h1 ~ p", [P1, P2, P3]),
|
||||
("div + h1", []),
|
||||
("p.nonexistent + p", []),
|
||||
("p + h1", []),
|
||||
("p ~ h1", []),
|
||||
("div + div", [SECOND_DIV]),
|
||||
("div ~ div", [SECOND_DIV]),
|
||||
("div#innerDiv ~ div", [SECOND_DIV]),
|
||||
("div#topDiv ~ div", []),
|
||||
],
|
||||
)
|
||||
def test_sibling_selector(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.bonus
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(":first-child", [INNER_DIV, H1, SECOND_P]),
|
||||
(":last-child", [SECOND_DIV, A_LINK, SECOND_P]),
|
||||
(":nth-child(1)", [INNER_DIV, H1, SECOND_P]),
|
||||
(":nth-child(2)", [SECOND_DIV, P1]),
|
||||
(":nth-child(3)", [P2]),
|
||||
(":nth-child(4)", [P3]),
|
||||
(":nth-child(5)", [A_LINK]),
|
||||
(":nth-child(6)", []),
|
||||
("p:first-child", [SECOND_P]),
|
||||
("p:last-child", [SECOND_P]),
|
||||
("p:nth-child(1)", [SECOND_P]),
|
||||
("a:first-child", []),
|
||||
("a:last-child", [A_LINK]),
|
||||
("a:nth-child(5)", [A_LINK]),
|
||||
("div:last-child", [SECOND_DIV]),
|
||||
("div.nonexistent:last-child", []),
|
||||
(".colour-primary:first-child", [INNER_DIV, SECOND_P]),
|
||||
],
|
||||
)
|
||||
def test_nth_child_selector(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.bonus
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(":not(p)", [TOP_DIV, INNER_DIV, H1, A_LINK, SECOND_DIV]),
|
||||
("p:not(.important)", [P1, P2, SECOND_P]),
|
||||
("div:not(.important)", [TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
(":not(.important)", [TOP_DIV, INNER_DIV, H1, P1, P2, A_LINK, SECOND_DIV, SECOND_P]),
|
||||
("div:not(#innerDiv)", [TOP_DIV, SECOND_DIV]),
|
||||
("#innerDiv:not(p)", [INNER_DIV]),
|
||||
("#innerDiv:not(div)", []),
|
||||
("p:not(p)", []),
|
||||
(":not(div):not(p):not(a)", [H1]),
|
||||
(":not(div):not(p):not(a):not(h1)", []),
|
||||
("p:not(.colour-primary:not(#two))", [P1, P2, P3]),
|
||||
("p:not(.colour-secondary:not(#two))", [P2, SECOND_P]),
|
||||
(":not(:not(div))", [TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
],
|
||||
)
|
||||
def test_not_selector(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.basic
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("q, b", []),
|
||||
],
|
||||
)
|
||||
def test_combined_basic(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.bonus
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("p:first-child:not(:last-child)", []),
|
||||
("h1:first-child:not(:last-child)", [H1]),
|
||||
(":first-child:not(:nth-child(2))", [INNER_DIV, SECOND_P, H1]),
|
||||
(":first-child:not(:nth-child(2)):not(h1)", [INNER_DIV, SECOND_P]),
|
||||
(":first-child:not(:nth-child(2), h1)", [INNER_DIV, SECOND_P]),
|
||||
("p:not(p:first-child)", [P1, P2, P3]),
|
||||
("p:not(p:nth-child(1))", [P1, P2, P3]),
|
||||
("p:not(.colour-secondary:not(#two)):not(:first-child)", [P2]),
|
||||
("p:not(div.colour-secondary p)", [P1, P2, P3]),
|
||||
(":not(div, p, a)", [H1]),
|
||||
(":not(div, p, a, h1)", []),
|
||||
(":not(div p)", [TOP_DIV, INNER_DIV, H1, A_LINK, SECOND_DIV]),
|
||||
(".colour-primary > p:not(#two)", [P1, P3]),
|
||||
(".colour-primary p:not(#two)", [P1, P3]),
|
||||
("#topDiv p:not(#two)", [P1, P3, SECOND_P]),
|
||||
("#topDiv > p:not(#two)", []),
|
||||
],
|
||||
)
|
||||
def test_combined_bonus(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.basic
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("a,div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("a ,div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("a, div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("a , div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("a , div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("a#home-link , div", [A_LINK, TOP_DIV, INNER_DIV, SECOND_DIV]),
|
||||
("a.button#home-link , div.container", [A_LINK, INNER_DIV, SECOND_DIV]),
|
||||
],
|
||||
)
|
||||
def test_whitespace_basic(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
||||
|
||||
|
||||
@pytest.mark.bonus
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("div.container>h1", [H1]),
|
||||
("div.container> h1", [H1]),
|
||||
("div.container >h1", [H1]),
|
||||
("div.container > h1", [H1]),
|
||||
("div.container > h1", [H1]),
|
||||
# ("div#topDiv >div> a", [A_LINK]),
|
||||
# ("div#topDiv >div a", [A_LINK]),
|
||||
# ("div#topDiv >div:not(.colour-secondary) a", [A_LINK]),
|
||||
("div:not(.colour-secondary)", [TOP_DIV, INNER_DIV]),
|
||||
("div:not( .colour-secondary)", [TOP_DIV, INNER_DIV]),
|
||||
("div:not(.colour-secondary )", [TOP_DIV, INNER_DIV]),
|
||||
("div:not( .colour-secondary )", [TOP_DIV, INNER_DIV]),
|
||||
("div:not( .colour-secondary )", [TOP_DIV, INNER_DIV]),
|
||||
# ("div#topDiv >div:not( .colour-secondary ) a", [A_LINK]),
|
||||
],
|
||||
)
|
||||
def test_whitespace_bonus(selector: str, expected: list[Node]) -> None:
|
||||
assert_count_equal(solution(TEST_TREE, selector), expected)
|
143
tests/test_stringify.py
Normal file
143
tests/test_stringify.py
Normal file
|
@ -0,0 +1,143 @@
|
|||
import pytest
|
||||
|
||||
from src.parser import (
|
||||
DescendantSelector,
|
||||
MultiSelector,
|
||||
NotPseudoClassSelector,
|
||||
NthChildPseudoClassSelector,
|
||||
SiblingSelector,
|
||||
SimpleSelector,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(SimpleSelector(tag="div"), "div"),
|
||||
(SimpleSelector(classes=["main"]), ".main"),
|
||||
(SimpleSelector(tag="span", classes=["big", "blue"]), "span.big.blue"),
|
||||
(SimpleSelector(tag="a", ids=["link"]), "a#link"),
|
||||
(SimpleSelector(classes=["one"], ids=["unique"]), ".one#unique"),
|
||||
(SimpleSelector(tag="p", classes=["x"], ids=["y"]), "p.x#y"),
|
||||
(SimpleSelector(ids=["onlyid"]), "#onlyid"),
|
||||
],
|
||||
)
|
||||
def test_simple_selector_str(selector: SimpleSelector, expected: str) -> None:
|
||||
assert str(selector) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(
|
||||
NotPseudoClassSelector(
|
||||
selector=SimpleSelector(tag="div"),
|
||||
not_selector=SimpleSelector(classes=["main"]),
|
||||
),
|
||||
"div:not(.main)",
|
||||
),
|
||||
(
|
||||
NotPseudoClassSelector(
|
||||
selector=None,
|
||||
not_selector=SimpleSelector(classes=["a", "b"]),
|
||||
),
|
||||
":not(.a.b)",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_not_pseudo_class_selector_str(selector: NotPseudoClassSelector, expected: str) -> None:
|
||||
assert str(selector) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(
|
||||
NthChildPseudoClassSelector(selector=None, n=2),
|
||||
":nth-child(2)",
|
||||
),
|
||||
(
|
||||
NthChildPseudoClassSelector(selector=SimpleSelector(tag="div"), n=2),
|
||||
"div:nth-child(2)",
|
||||
),
|
||||
(
|
||||
NthChildPseudoClassSelector(selector=SimpleSelector(tag="li"), n=1),
|
||||
"li:first-child",
|
||||
),
|
||||
(
|
||||
NthChildPseudoClassSelector(selector=SimpleSelector(tag="li"), n=-1),
|
||||
"li:last-child",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_nth_child_pseudo_class_selector_str(selector: NthChildPseudoClassSelector, expected: str) -> None:
|
||||
assert str(selector) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="div", classes=["main"]),
|
||||
child=SimpleSelector(tag="span"),
|
||||
direct=False,
|
||||
),
|
||||
"div.main span",
|
||||
),
|
||||
(
|
||||
DescendantSelector(
|
||||
parent=SimpleSelector(tag="ul"),
|
||||
child=SimpleSelector(tag="li", classes=["active"]),
|
||||
direct=True,
|
||||
),
|
||||
"ul > li.active",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_descendant_selector_str(selector: DescendantSelector, expected: str) -> None:
|
||||
assert str(selector) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="p"),
|
||||
selector=SimpleSelector(tag="span"),
|
||||
is_adjacent=True,
|
||||
),
|
||||
"p + span",
|
||||
),
|
||||
(
|
||||
SiblingSelector(
|
||||
sibling_selector=SimpleSelector(tag="div", classes=["a"]),
|
||||
selector=SimpleSelector(classes=["b"]),
|
||||
is_adjacent=False,
|
||||
),
|
||||
"div.a ~ .b",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_sibling_selector_str(selector: SiblingSelector, expected: str) -> None:
|
||||
assert str(selector) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
(
|
||||
MultiSelector(
|
||||
[
|
||||
SimpleSelector("div", classes=["main"]),
|
||||
SimpleSelector("span"),
|
||||
SimpleSelector(ids=["x"]),
|
||||
]
|
||||
),
|
||||
"div.main, span, #x",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_multi_selector_str(selector: MultiSelector, expected: str) -> None:
|
||||
assert str(selector) == expected
|
200
tests/test_tokenizer.py
Normal file
200
tests/test_tokenizer.py
Normal file
|
@ -0,0 +1,200 @@
|
|||
import pytest
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from src.tokenizer import Token, TokenStream, TokenType, tokenize_selector
|
||||
from tests.hypot import css_class, css_class_multi, css_id, css_tag, selector
|
||||
|
||||
# region: Tokenization tests
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_str", "expected_type", "expected_value"),
|
||||
[
|
||||
("div", TokenType.TAG, "div"),
|
||||
("#id", TokenType.ID, "#id"),
|
||||
(".class", TokenType.CLASS, ".class"),
|
||||
("123", TokenType.NUMBER, "123"),
|
||||
(",", TokenType.COMMA, ", "),
|
||||
(">", TokenType.DIRECT_CHILD, " > "),
|
||||
(" ", TokenType.DESCENDANT, " "),
|
||||
(":hover", TokenType.PSEUDO_CLASS, ":hover"),
|
||||
("(", TokenType.LPARENS, "("),
|
||||
(")", TokenType.RPARENS, ")"),
|
||||
("::after", TokenType.PSEUDO_ELEMENT, "::after"),
|
||||
("+", TokenType.ADJACENT_SIBLING, " + "),
|
||||
("~", TokenType.SUBSEQUENT_SIBLING, " ~ "),
|
||||
("$", TokenType.UNKNOWN, "$"),
|
||||
],
|
||||
)
|
||||
def test_individual_tokens(input_str: str, expected_type: TokenType, expected_value: str) -> None:
|
||||
"""Test each token type in isolation."""
|
||||
tokens = list(tokenize_selector(input_str))
|
||||
assert len(tokens) == 1
|
||||
token = tokens[0]
|
||||
assert token.type == expected_type
|
||||
assert token.value == expected_value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "expected"),
|
||||
[
|
||||
("div.class", [TokenType.TAG, TokenType.CLASS]),
|
||||
("div > .class", [TokenType.TAG, TokenType.DIRECT_CHILD, TokenType.CLASS]),
|
||||
("div, span", [TokenType.TAG, TokenType.COMMA, TokenType.TAG]),
|
||||
("a:b::c", [TokenType.TAG, TokenType.PSEUDO_CLASS, TokenType.PSEUDO_ELEMENT]),
|
||||
("a + b", [TokenType.TAG, TokenType.ADJACENT_SIBLING, TokenType.TAG]),
|
||||
("a ~ b", [TokenType.TAG, TokenType.SUBSEQUENT_SIBLING, TokenType.TAG]),
|
||||
("div (", [TokenType.TAG, TokenType.DESCENDANT, TokenType.LPARENS]),
|
||||
],
|
||||
)
|
||||
def test_token_combinations(selector: str, expected: list[TokenType]) -> None:
|
||||
"""Test combinations of tokens (not necessarily valid ones)."""
|
||||
tokens = list(tokenize_selector(selector))
|
||||
assert [t.type for t in tokens] == expected
|
||||
|
||||
|
||||
def test_empty_string() -> None:
|
||||
"""Test tokenizing empty string returns no tokens."""
|
||||
tokens = list(tokenize_selector(""))
|
||||
assert len(tokens) == 0
|
||||
|
||||
|
||||
@given(css_tag)
|
||||
def test_valid_tags(tag: str) -> None:
|
||||
"""Test valid tag names."""
|
||||
tokens = list(tokenize_selector(tag))
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0].type == TokenType.TAG
|
||||
assert tokens[0].value == tag
|
||||
|
||||
|
||||
@given(css_id)
|
||||
def test_valid_ids(id_val: str) -> None:
|
||||
"""Test valid ID values."""
|
||||
tokens = list(tokenize_selector(id_val))
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0].type == TokenType.ID
|
||||
assert tokens[0].value == id_val
|
||||
|
||||
|
||||
@given(css_class)
|
||||
def test_valid_class(val: str) -> None:
|
||||
"""Test valid single class values."""
|
||||
tokens = list(tokenize_selector(val))
|
||||
assert len(tokens) == 1
|
||||
assert tokens[0].type == TokenType.CLASS
|
||||
assert tokens[0].value == val
|
||||
|
||||
|
||||
@given(css_class_multi)
|
||||
def test_valid_class_multi(val: str) -> None:
|
||||
"""Test valid multi class values."""
|
||||
tokens = list(tokenize_selector(val))
|
||||
assert all(tok.type == TokenType.CLASS for tok in tokens)
|
||||
|
||||
|
||||
@given(selector)
|
||||
def test_arbitrary_valid_selector(selector: str) -> None:
|
||||
"""Ensure tokenizer can handle any valid selector string."""
|
||||
tokens = list(tokenize_selector(selector))
|
||||
tok_types = {tok.type for tok in tokens}
|
||||
assert TokenType.UNKNOWN not in tok_types
|
||||
|
||||
|
||||
@given(st.text())
|
||||
def test_no_crashes_on_arbitrary_text(s: str) -> None:
|
||||
"""Ensure tokenizer doesn't crash on any input.
|
||||
|
||||
(We should instead handle this with unknown tokens.)
|
||||
"""
|
||||
_ = list(tokenize_selector(s))
|
||||
|
||||
|
||||
# endregion
|
||||
# region: TokenStream tests
|
||||
|
||||
|
||||
def test_peek_and_pop() -> None:
|
||||
tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main")]
|
||||
stream = TokenStream(tokens)
|
||||
|
||||
# Initial peek
|
||||
assert stream.peek() == tokens[0]
|
||||
|
||||
# Pop moves the stream
|
||||
assert stream.pop() == tokens[0]
|
||||
assert stream.peek() == tokens[1]
|
||||
|
||||
|
||||
def test_peek_trusted() -> None:
|
||||
stream = TokenStream([Token(TokenType.TAG, "div")])
|
||||
tok = stream.peek_trusted()
|
||||
assert tok.type == TokenType.TAG
|
||||
|
||||
_ = stream.pop()
|
||||
with pytest.raises(AssertionError):
|
||||
_ = stream.peek_trusted()
|
||||
|
||||
|
||||
def test_has_more() -> None:
|
||||
stream = TokenStream([Token(TokenType.TAG, "div")])
|
||||
assert stream.has_more()
|
||||
_ = stream.pop()
|
||||
assert not stream.has_more()
|
||||
|
||||
|
||||
def test_pop_exhausted_raises() -> None:
|
||||
stream = TokenStream([Token(TokenType.TAG, "div")])
|
||||
_ = stream.pop()
|
||||
with pytest.raises(StopIteration):
|
||||
_ = stream.pop()
|
||||
|
||||
|
||||
def test_consume_while() -> None:
|
||||
tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main"), Token(TokenType.ID, "#id")]
|
||||
stream = TokenStream(tokens)
|
||||
|
||||
# Consume until we see an ID token
|
||||
consumed = list(stream.consume_while(lambda t: t.type != TokenType.ID))
|
||||
|
||||
assert consumed == tokens[:2]
|
||||
assert stream.peek() == tokens[2]
|
||||
|
||||
|
||||
def test_consume_while_all() -> None:
|
||||
tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main"), Token(TokenType.ID, "#id")]
|
||||
stream = TokenStream(tokens)
|
||||
|
||||
# Consume until we see an ID token
|
||||
consumed = list(stream.consume_while(lambda t: t.type != TokenType.LPARENS))
|
||||
|
||||
assert consumed == tokens
|
||||
assert stream.peek() is None
|
||||
|
||||
|
||||
def test_reset() -> None:
|
||||
tokens = [Token(TokenType.TAG, "div"), Token(TokenType.CLASS, ".main")]
|
||||
stream = TokenStream(tokens)
|
||||
|
||||
_ = stream.pop()
|
||||
assert stream.peek() == tokens[1]
|
||||
|
||||
stream.reset()
|
||||
assert stream.peek() == tokens[0]
|
||||
|
||||
|
||||
def test_str_and_raw_str() -> None:
|
||||
tokens = [
|
||||
Token(TokenType.TAG, "div"),
|
||||
Token(TokenType.CLASS, ".main"),
|
||||
Token(TokenType.COMMA, ", "),
|
||||
Token(TokenType.TAG, "a"),
|
||||
]
|
||||
stream = TokenStream(tokens)
|
||||
|
||||
assert str(stream) == "div.main, a"
|
||||
assert stream.raw_str == "div.main, a"
|
||||
|
||||
|
||||
# endregion
|
Loading…
Add table
Add a link
Reference in a new issue