feat: implement Animal Registry view with filtering and pagination (Step 8.1)
- 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>
This commit is contained in:
@@ -370,6 +370,179 @@ class TestResolveFilterIdentified:
|
||||
assert result.animal_ids == expected
|
||||
|
||||
|
||||
class TestResolveFilterStatus:
|
||||
"""Tests for status filter."""
|
||||
|
||||
def test_status_alive_is_default(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Empty filter returns only alive animals (status:alive is implicit)."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
payload = make_cohort_payload(valid_location_id, count=3)
|
||||
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||
ids = event.entity_refs["animal_ids"]
|
||||
|
||||
# Kill one animal by updating status in attr_intervals
|
||||
dead_id = ids[0]
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# Default filter excludes dead
|
||||
result = resolve_filter(seeded_db, FilterAST([]), ts_utc + 2)
|
||||
assert dead_id not in result.animal_ids
|
||||
assert len(result.animal_ids) == 2
|
||||
|
||||
def test_status_dead_returns_dead_animals(self, seeded_db, animal_service, valid_location_id):
|
||||
"""status:dead returns only dead animals."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
payload = make_cohort_payload(valid_location_id, count=3)
|
||||
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||
ids = event.entity_refs["animal_ids"]
|
||||
|
||||
# Kill one animal
|
||||
dead_id = ids[0]
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# status:dead filter returns only dead
|
||||
filter_ast = FilterAST([FieldFilter("status", ["dead"])])
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 2)
|
||||
assert result.animal_ids == [dead_id]
|
||||
|
||||
def test_status_alive_or_dead_returns_both(self, seeded_db, animal_service, valid_location_id):
|
||||
"""status:alive|dead returns both alive and dead animals."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
payload = make_cohort_payload(valid_location_id, count=3)
|
||||
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||
ids = event.entity_refs["animal_ids"]
|
||||
|
||||
# Kill one animal
|
||||
dead_id = ids[0]
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# status:alive|dead filter returns all
|
||||
filter_ast = FilterAST([FieldFilter("status", ["alive", "dead"])])
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 2)
|
||||
assert sorted(result.animal_ids) == sorted(ids)
|
||||
|
||||
def test_status_negated_excludes(self, seeded_db, animal_service, valid_location_id):
|
||||
"""-status:dead excludes dead animals (same as default)."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
payload = make_cohort_payload(valid_location_id, count=3)
|
||||
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
||||
ids = event.entity_refs["animal_ids"]
|
||||
|
||||
# Kill one animal
|
||||
dead_id = ids[0]
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 1, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 1),
|
||||
)
|
||||
|
||||
# -status:dead uses alive as base, then excludes dead (effectively same as default)
|
||||
filter_ast = FilterAST([FieldFilter("status", ["dead"], negated=True)])
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 2)
|
||||
assert dead_id not in result.animal_ids
|
||||
|
||||
def test_status_combined_with_species(self, seeded_db, animal_service, valid_location_id):
|
||||
"""status:dead species:duck returns only dead ducks."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Create ducks
|
||||
duck_payload = make_cohort_payload(valid_location_id, count=2, species="duck")
|
||||
duck_event = animal_service.create_cohort(duck_payload, ts_utc, "test_user")
|
||||
duck_ids = duck_event.entity_refs["animal_ids"]
|
||||
|
||||
# Create geese
|
||||
goose_payload = make_cohort_payload(valid_location_id, count=2, species="goose")
|
||||
goose_event = animal_service.create_cohort(goose_payload, ts_utc + 1, "test_user")
|
||||
goose_ids = goose_event.entity_refs["animal_ids"]
|
||||
|
||||
# Kill one duck and one goose
|
||||
dead_duck_id = duck_ids[0]
|
||||
dead_goose_id = goose_ids[0]
|
||||
|
||||
for dead_id in [dead_duck_id, dead_goose_id]:
|
||||
seeded_db.execute(
|
||||
"""
|
||||
UPDATE animal_attr_intervals
|
||||
SET end_utc = ?
|
||||
WHERE animal_id = ? AND attr = 'status' AND value = 'alive'
|
||||
""",
|
||||
(ts_utc + 2, dead_id),
|
||||
)
|
||||
seeded_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_attr_intervals (animal_id, attr, value, start_utc, end_utc)
|
||||
VALUES (?, 'status', 'dead', ?, NULL)
|
||||
""",
|
||||
(dead_id, ts_utc + 2),
|
||||
)
|
||||
|
||||
# status:dead species:duck returns only dead duck
|
||||
filter_ast = FilterAST(
|
||||
[
|
||||
FieldFilter("status", ["dead"]),
|
||||
FieldFilter("species", ["duck"]),
|
||||
]
|
||||
)
|
||||
result = resolve_filter(seeded_db, filter_ast, ts_utc + 3)
|
||||
assert result.animal_ids == [dead_duck_id]
|
||||
|
||||
|
||||
class TestResolveFilterNegation:
|
||||
"""Tests for negated filters."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user