feat: add selection filter DSL parser
Implement parser for filter strings like "species:duck sex:female -tag:old". Supports AND (space), OR (|), negation (-), and quoted values. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
233
tests/test_selection_parser.py
Normal file
233
tests/test_selection_parser.py
Normal file
@@ -0,0 +1,233 @@
|
||||
# ABOUTME: Tests for the selection filter DSL parser.
|
||||
# ABOUTME: Covers all syntax variations: field:value, OR, negation, quotes.
|
||||
|
||||
import pytest
|
||||
|
||||
from animaltrack.selection.ast import FieldFilter, FilterAST
|
||||
from animaltrack.selection.parser import ParseError, parse_filter
|
||||
|
||||
|
||||
class TestSimpleFilters:
|
||||
"""Test basic field:value syntax."""
|
||||
|
||||
def test_single_field(self) -> None:
|
||||
"""species:duck -> single field filter."""
|
||||
result = parse_filter("species:duck")
|
||||
assert result == FilterAST([FieldFilter("species", ["duck"])])
|
||||
|
||||
def test_multiple_fields_and(self) -> None:
|
||||
"""species:duck sex:female -> AND of two filters."""
|
||||
result = parse_filter("species:duck sex:female")
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("species", ["duck"]),
|
||||
FieldFilter("sex", ["female"]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_all_supported_fields(self) -> None:
|
||||
"""All supported fields should parse."""
|
||||
result = parse_filter("location:strip1 species:duck sex:male life_stage:adult tag:healthy")
|
||||
assert len(result.filters) == 5
|
||||
assert result.filters[0].field == "location"
|
||||
assert result.filters[1].field == "species"
|
||||
assert result.filters[2].field == "sex"
|
||||
assert result.filters[3].field == "life_stage"
|
||||
assert result.filters[4].field == "tag"
|
||||
|
||||
|
||||
class TestOrSyntax:
|
||||
"""Test OR with pipe character."""
|
||||
|
||||
def test_or_values(self) -> None:
|
||||
"""species:duck|goose -> single filter with two values."""
|
||||
result = parse_filter("species:duck|goose")
|
||||
assert result == FilterAST([FieldFilter("species", ["duck", "goose"])])
|
||||
|
||||
def test_multiple_or_values(self) -> None:
|
||||
"""species:duck|goose|chicken -> three values."""
|
||||
result = parse_filter("species:duck|goose|chicken")
|
||||
assert result == FilterAST([FieldFilter("species", ["duck", "goose", "chicken"])])
|
||||
|
||||
def test_or_combined_with_and(self) -> None:
|
||||
"""species:duck|goose sex:female -> OR within field, AND between fields."""
|
||||
result = parse_filter("species:duck|goose sex:female")
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("species", ["duck", "goose"]),
|
||||
FieldFilter("sex", ["female"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestNegation:
|
||||
"""Test negation with - prefix."""
|
||||
|
||||
def test_negated_field(self) -> None:
|
||||
"""-sex:male -> negated filter."""
|
||||
result = parse_filter("-sex:male")
|
||||
assert result == FilterAST([FieldFilter("sex", ["male"], negated=True)])
|
||||
|
||||
def test_negated_with_or(self) -> None:
|
||||
"""-species:duck|goose -> negated with OR values."""
|
||||
result = parse_filter("-species:duck|goose")
|
||||
assert result == FilterAST([FieldFilter("species", ["duck", "goose"], negated=True)])
|
||||
|
||||
def test_mixed_negated_and_positive(self) -> None:
|
||||
"""species:duck -tag:sick -> mix of positive and negated."""
|
||||
result = parse_filter("species:duck -tag:sick")
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("species", ["duck"]),
|
||||
FieldFilter("tag", ["sick"], negated=True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestQuotedValues:
|
||||
"""Test quoted strings for values with spaces."""
|
||||
|
||||
def test_quoted_value(self) -> None:
|
||||
"""location:"Strip 1" -> value with space."""
|
||||
result = parse_filter('location:"Strip 1"')
|
||||
assert result == FilterAST([FieldFilter("location", ["Strip 1"])])
|
||||
|
||||
def test_quoted_with_other_fields(self) -> None:
|
||||
"""location:"Strip 1" species:duck -> quoted and unquoted."""
|
||||
result = parse_filter('location:"Strip 1" species:duck')
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("location", ["Strip 1"]),
|
||||
FieldFilter("species", ["duck"]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_quoted_negated(self) -> None:
|
||||
"""-location:"Strip 1" -> negated quoted value."""
|
||||
result = parse_filter('-location:"Strip 1"')
|
||||
assert result == FilterAST([FieldFilter("location", ["Strip 1"], negated=True)])
|
||||
|
||||
def test_single_quoted_value(self) -> None:
|
||||
"""location:'Strip 1' -> single quotes also work."""
|
||||
result = parse_filter("location:'Strip 1'")
|
||||
assert result == FilterAST([FieldFilter("location", ["Strip 1"])])
|
||||
|
||||
|
||||
class TestIdentifiedField:
|
||||
"""Test the identified field with flag syntax."""
|
||||
|
||||
def test_identified_with_value(self) -> None:
|
||||
"""identified:1 -> explicit value."""
|
||||
result = parse_filter("identified:1")
|
||||
assert result == FilterAST([FieldFilter("identified", ["1"])])
|
||||
|
||||
def test_identified_zero(self) -> None:
|
||||
"""identified:0 -> explicit false."""
|
||||
result = parse_filter("identified:0")
|
||||
assert result == FilterAST([FieldFilter("identified", ["0"])])
|
||||
|
||||
def test_identified_flag(self) -> None:
|
||||
"""identified -> shorthand for identified:1."""
|
||||
result = parse_filter("identified")
|
||||
assert result == FilterAST([FieldFilter("identified", ["1"])])
|
||||
|
||||
def test_negated_identified_flag(self) -> None:
|
||||
"""-identified -> shorthand for -identified:1."""
|
||||
result = parse_filter("-identified")
|
||||
assert result == FilterAST([FieldFilter("identified", ["1"], negated=True)])
|
||||
|
||||
|
||||
class TestEmptyAndMatchAll:
|
||||
"""Test empty filter string."""
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
"""Empty string -> match all."""
|
||||
result = parse_filter("")
|
||||
assert result == FilterAST([])
|
||||
assert result.is_match_all()
|
||||
|
||||
def test_whitespace_only(self) -> None:
|
||||
"""Whitespace only -> match all."""
|
||||
result = parse_filter(" ")
|
||||
assert result == FilterAST([])
|
||||
assert result.is_match_all()
|
||||
|
||||
|
||||
class TestComplexFilters:
|
||||
"""Test complex combinations."""
|
||||
|
||||
def test_complex_filter(self) -> None:
|
||||
"""Complex filter with all features."""
|
||||
result = parse_filter('species:duck|goose sex:female -tag:old location:"Strip 1"')
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("species", ["duck", "goose"]),
|
||||
FieldFilter("sex", ["female"]),
|
||||
FieldFilter("tag", ["old"], negated=True),
|
||||
FieldFilter("location", ["Strip 1"]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_multiple_negations(self) -> None:
|
||||
"""Multiple negated filters."""
|
||||
result = parse_filter("-tag:sick -tag:old species:duck")
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("tag", ["sick"], negated=True),
|
||||
FieldFilter("tag", ["old"], negated=True),
|
||||
FieldFilter("species", ["duck"]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestParseErrors:
|
||||
"""Test error cases."""
|
||||
|
||||
def test_unknown_field(self) -> None:
|
||||
"""Unknown field raises ParseError."""
|
||||
with pytest.raises(ParseError) as exc_info:
|
||||
parse_filter("unknown:value")
|
||||
assert "unknown field" in str(exc_info.value).lower()
|
||||
|
||||
def test_missing_colon(self) -> None:
|
||||
"""Missing colon raises ParseError for non-flag fields."""
|
||||
with pytest.raises(ParseError) as exc_info:
|
||||
parse_filter("species")
|
||||
assert "missing" in str(exc_info.value).lower() or "invalid" in str(exc_info.value).lower()
|
||||
|
||||
def test_empty_value(self) -> None:
|
||||
"""Empty value after colon raises ParseError."""
|
||||
with pytest.raises(ParseError) as exc_info:
|
||||
parse_filter("species:")
|
||||
assert "empty" in str(exc_info.value).lower() or "value" in str(exc_info.value).lower()
|
||||
|
||||
def test_unclosed_quote(self) -> None:
|
||||
"""Unclosed quote raises ParseError."""
|
||||
with pytest.raises(ParseError) as exc_info:
|
||||
parse_filter('location:"Strip 1')
|
||||
assert "quote" in str(exc_info.value).lower() or "unclosed" in str(exc_info.value).lower()
|
||||
|
||||
def test_empty_or_value(self) -> None:
|
||||
"""Empty value in OR raises ParseError."""
|
||||
with pytest.raises(ParseError) as exc_info:
|
||||
parse_filter("species:duck|")
|
||||
assert "empty" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestWhitespaceHandling:
|
||||
"""Test whitespace handling."""
|
||||
|
||||
def test_extra_spaces(self) -> None:
|
||||
"""Extra spaces between terms are ignored."""
|
||||
result = parse_filter("species:duck sex:female")
|
||||
assert result == FilterAST(
|
||||
[
|
||||
FieldFilter("species", ["duck"]),
|
||||
FieldFilter("sex", ["female"]),
|
||||
]
|
||||
)
|
||||
|
||||
def test_leading_trailing_spaces(self) -> None:
|
||||
"""Leading and trailing spaces are trimmed."""
|
||||
result = parse_filter(" species:duck ")
|
||||
assert result == FilterAST([FieldFilter("species", ["duck"])])
|
||||
Reference in New Issue
Block a user