- Add status field to filter DSL parser and resolver - Create AnimalRepository with list_animals and get_facet_counts - Implement registry templates with table, facet sidebar, infinite scroll - Create registry route handler with HTMX partial support - Default shows only alive animals; status filter overrides 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
273 lines
10 KiB
Python
273 lines
10 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 status:alive"
|
|
)
|
|
assert len(result.filters) == 6
|
|
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"
|
|
assert result.filters[5].field == "status"
|
|
|
|
|
|
class TestStatusField:
|
|
"""Test the status field for filtering by animal status."""
|
|
|
|
def test_status_alive(self) -> None:
|
|
"""status:alive -> filter by alive status."""
|
|
result = parse_filter("status:alive")
|
|
assert result == FilterAST([FieldFilter("status", ["alive"])])
|
|
|
|
def test_status_multiple_values(self) -> None:
|
|
"""status:alive|dead -> OR of statuses."""
|
|
result = parse_filter("status:alive|dead")
|
|
assert result == FilterAST([FieldFilter("status", ["alive", "dead"])])
|
|
|
|
def test_status_all_values(self) -> None:
|
|
"""All status values should parse."""
|
|
result = parse_filter("status:alive|dead|harvested|sold|merged_into")
|
|
assert result == FilterAST(
|
|
[FieldFilter("status", ["alive", "dead", "harvested", "sold", "merged_into"])]
|
|
)
|
|
|
|
def test_status_negated(self) -> None:
|
|
"""-status:dead -> exclude dead animals."""
|
|
result = parse_filter("-status:dead")
|
|
assert result == FilterAST([FieldFilter("status", ["dead"], negated=True)])
|
|
|
|
def test_status_combined_with_other_fields(self) -> None:
|
|
"""status:alive species:duck -> combine status with other filters."""
|
|
result = parse_filter("status:alive species:duck")
|
|
assert result == FilterAST(
|
|
[
|
|
FieldFilter("status", ["alive"]),
|
|
FieldFilter("species", ["duck"]),
|
|
]
|
|
)
|
|
|
|
|
|
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"])])
|