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