All checks were successful
Deploy / deploy (push) Successful in 1m50s
- Create reusable dsl_facets.py component with clickable pills that compose DSL filter expressions by appending field:value to the filter input - Add /api/facets endpoint for dynamic facet count refresh via HTMX - Fix select dropdown dark mode styling with color-scheme: dark in SelectStyles - Integrate facet pills into all DSL filter screens: registry, move, and all action forms (tag-add, tag-end, attrs, outcome, status-correct) - Update routes to fetch and pass facet counts, locations, and species - Add comprehensive unit tests for component and API endpoint - Add E2E tests for facet pill click behavior and dark mode select visibility This enables tap-based filter composition on mobile without requiring typing. Facet counts update dynamically as filters are applied via HTMX. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
196 lines
6.8 KiB
Python
196 lines
6.8 KiB
Python
# ABOUTME: Unit tests for /api/facets endpoint.
|
|
# ABOUTME: Tests dynamic facet count retrieval based on filter.
|
|
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
|
from animaltrack.events.store import EventStore
|
|
from animaltrack.projections import ProjectionRegistry
|
|
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
|
from animaltrack.projections.intervals import IntervalProjection
|
|
from animaltrack.services.animal import AnimalService
|
|
|
|
|
|
def make_test_settings(
|
|
csrf_secret: str = "test-secret",
|
|
trusted_proxy_ips: str = "127.0.0.1",
|
|
dev_mode: bool = True,
|
|
):
|
|
"""Create Settings for testing by setting env vars temporarily."""
|
|
from animaltrack.config import Settings
|
|
|
|
old_env = os.environ.copy()
|
|
try:
|
|
os.environ["CSRF_SECRET"] = csrf_secret
|
|
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
|
|
os.environ["DEV_MODE"] = str(dev_mode).lower()
|
|
return Settings()
|
|
finally:
|
|
os.environ.clear()
|
|
os.environ.update(old_env)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(seeded_db):
|
|
"""Create a test client for the app."""
|
|
from animaltrack.web.app import create_app
|
|
|
|
settings = make_test_settings(trusted_proxy_ips="testclient")
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
return TestClient(app, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def projection_registry(seeded_db):
|
|
"""Create a ProjectionRegistry with animal projections registered."""
|
|
registry = ProjectionRegistry()
|
|
registry.register(AnimalRegistryProjection(seeded_db))
|
|
registry.register(EventAnimalsProjection(seeded_db))
|
|
registry.register(IntervalProjection(seeded_db))
|
|
return registry
|
|
|
|
|
|
@pytest.fixture
|
|
def animal_service(seeded_db, projection_registry):
|
|
"""Create an AnimalService for testing."""
|
|
event_store = EventStore(seeded_db)
|
|
return AnimalService(seeded_db, event_store, projection_registry)
|
|
|
|
|
|
@pytest.fixture
|
|
def location_strip1_id(seeded_db):
|
|
"""Get Strip 1 location ID from seeded data."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
return row[0]
|
|
|
|
|
|
@pytest.fixture
|
|
def location_strip2_id(seeded_db):
|
|
"""Get Strip 2 location ID from seeded data."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()
|
|
return row[0]
|
|
|
|
|
|
@pytest.fixture
|
|
def ducks_at_strip1(seeded_db, animal_service, location_strip1_id):
|
|
"""Create 5 female ducks at Strip 1."""
|
|
payload = AnimalCohortCreatedPayload(
|
|
species="duck",
|
|
count=5,
|
|
life_stage="adult",
|
|
sex="female",
|
|
location_id=location_strip1_id,
|
|
origin="purchased",
|
|
)
|
|
ts_utc = int(time.time() * 1000)
|
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
|
return event.entity_refs["animal_ids"]
|
|
|
|
|
|
@pytest.fixture
|
|
def geese_at_strip2(seeded_db, animal_service, location_strip2_id):
|
|
"""Create 3 male geese at Strip 2."""
|
|
payload = AnimalCohortCreatedPayload(
|
|
species="goose",
|
|
count=3,
|
|
life_stage="adult",
|
|
sex="male",
|
|
location_id=location_strip2_id,
|
|
origin="purchased",
|
|
)
|
|
ts_utc = int(time.time() * 1000)
|
|
event = animal_service.create_cohort(payload, ts_utc, "test_user")
|
|
return event.entity_refs["animal_ids"]
|
|
|
|
|
|
class TestApiFacetsEndpoint:
|
|
"""Test GET /api/facets endpoint."""
|
|
|
|
def test_facets_endpoint_exists(self, client, ducks_at_strip1):
|
|
"""Verify the facets endpoint responds."""
|
|
response = client.get("/api/facets")
|
|
assert response.status_code == 200
|
|
|
|
def test_facets_returns_html_partial(self, client, ducks_at_strip1):
|
|
"""Facets endpoint returns HTML partial for HTMX swap."""
|
|
response = client.get("/api/facets")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
|
|
# Should be HTML with facet pills structure
|
|
assert 'id="dsl-facet-pills"' in content
|
|
assert "Species" in content
|
|
|
|
def test_facets_respects_filter(self, client, ducks_at_strip1, geese_at_strip2):
|
|
"""Facets endpoint applies filter and shows filtered counts."""
|
|
# Get facets filtered to ducks only
|
|
response = client.get("/api/facets?filter=species:duck")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
|
|
# Should show sex facets for ducks (5 female)
|
|
assert "female" in content.lower()
|
|
# Should not show goose sex (male) since we filtered to ducks
|
|
# (actually it might show male=0 or not at all)
|
|
|
|
def test_facets_shows_count_for_alive_animals(self, client, ducks_at_strip1):
|
|
"""Facets show counts for alive animals by default."""
|
|
response = client.get("/api/facets")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
|
|
# Should show species with counts
|
|
assert "duck" in content.lower() or "Duck" in content
|
|
# Count 5 should appear
|
|
assert "5" in content
|
|
|
|
def test_facets_with_empty_filter(self, client, ducks_at_strip1, geese_at_strip2):
|
|
"""Empty filter returns all alive animals' facets."""
|
|
response = client.get("/api/facets?filter=")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
|
|
# Should have facet pills
|
|
assert 'id="dsl-facet-pills"' in content
|
|
|
|
def test_facets_with_location_filter(self, client, ducks_at_strip1, geese_at_strip2):
|
|
"""Location filter shows facets for that location only."""
|
|
response = client.get('/api/facets?filter=location:"Strip 1"')
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
|
|
# Should show ducks (at Strip 1)
|
|
assert "duck" in content.lower() or "Duck" in content
|
|
|
|
def test_facets_includes_htmx_swap_attributes(self, client, ducks_at_strip1):
|
|
"""Returned HTML has proper ID for HTMX swap targeting."""
|
|
response = client.get("/api/facets")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
|
|
# Must have same ID for outerHTML swap to work
|
|
assert 'id="dsl-facet-pills"' in content
|
|
|
|
|
|
class TestApiFacetsWithSelectionPreview:
|
|
"""Test facets endpoint integrates with selection preview workflow."""
|
|
|
|
def test_facets_and_preview_use_same_filter(self, client, ducks_at_strip1, geese_at_strip2):
|
|
"""Both endpoints interpret the same filter consistently."""
|
|
filter_str = "species:duck"
|
|
|
|
# Get facets
|
|
facets_resp = client.get(f"/api/facets?filter={filter_str}")
|
|
assert facets_resp.status_code == 200
|
|
|
|
# Get selection preview
|
|
preview_resp = client.get(f"/api/selection-preview?filter={filter_str}")
|
|
assert preview_resp.status_code == 200
|
|
|
|
# Both should work with the same filter
|