Files
animaltrack/tests/test_selection_parser.py
Petru Paler 6e9fd17327 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>
2025-12-29 15:19:11 +00:00

234 lines
8.6 KiB
Python

# 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"])])