Initial commit

This commit is contained in:
ItsDrike 2025-07-27 12:51:35 +02:00
commit 9fba4b3d34
Signed by: ItsDrike
GPG key ID: FA2745890B7048C0
17 changed files with 2939 additions and 0 deletions

0
tests/__init__.py Normal file
View file

0
tests/config.py Normal file
View file

70
tests/hypot.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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