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:
2025-12-30 14:59:13 +00:00
parent 254466827c
commit 8e155080e4
12 changed files with 1630 additions and 28 deletions

View File

@@ -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."""