diff --git a/ami/main/tests.py b/ami/main/tests.py index c25800e0f..7a8d8ba56 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -518,173 +518,6 @@ def test_thumbnail_delete_removes_storage_blob(self): self.assertFalse(default_storage.exists(path), "pre_delete signal must clean the storage blob") - def test_serializer_emits_storage_url_when_thumb_row_exists(self): - """Warm-path optimization: when a SourceImageThumbnail row exists with the - configured width, the serializer must emit ``default_storage.url(row.path)`` - directly instead of the lazy-regen route URL. This is the whole point of - the direct-URLs change — every cached thumbnail must skip Django on the - warm path. - """ - from django.core.files.storage import default_storage - - # Pre-create a thumbnail row whose width matches THUMBNAILS['SIZES']['small']. - configured_width = settings.THUMBNAILS["SIZES"]["small"]["width"] - self.first_capture.thumbnails.create( - path="thumbnails/cached/abc.jpg", label="small", width=configured_width, height=180, size=42 - ) - - response = self.client.get(f"/api/v2/captures/{self.first_capture.pk}/?project_id={self.project.pk}") - self.assertEqual(response.status_code, 200) - small_url = response.json()["thumbnails"]["small"] - - # ``small`` should be the storage URL, not the route URL. - expected_storage_url = default_storage.url("thumbnails/cached/abc.jpg") - self.assertEqual(small_url, expected_storage_url) - # ``medium`` has no cached row → falls back to the route URL. - self.assertURLEqual( - response.json()["thumbnails"]["medium"], f"{self.base_url}{self.first_capture.pk}/?label=medium" - ) - - def test_serializer_falls_back_to_route_url_when_width_mismatches_spec(self): - """Any width != the configured spec → route URL so the next browser fetch - triggers lazy regen via the redirect viewset. The check is strict equality, - so this covers both a changed setting (stored wider/narrower than spec) and - legacy rows that recorded PIL's rounded output (e.g. 239 for a 240 spec) - before the #1306 spec-width fix — those self-heal through one regeneration. - """ - configured_width = settings.THUMBNAILS["SIZES"]["small"]["width"] - # +100 = settings changed since last gen; -1 = legacy encoder rounding. - for delta in (100, -1): - with self.subTest(delta=delta): - self.first_capture.thumbnails.update_or_create( - label="small", - defaults={ - "path": f"thumbnails/stale/{delta}.jpg", - "width": configured_width + delta, - "height": 180, - "size": 42, - }, - ) - response = self.client.get(f"/api/v2/captures/{self.first_capture.pk}/?project_id={self.project.pk}") - self.assertEqual(response.status_code, 200) - self.assertURLEqual( - response.json()["thumbnails"]["small"], f"{self.base_url}{self.first_capture.pk}/?label=small" - ) - - def test_serializer_falls_back_to_route_url_when_source_changed(self): - """If the source image was re-uploaded after the cached row was - generated (``source.last_modified > row.last_modified``), the serializer - must emit the route URL so the next browser fetch regenerates via the - redirect viewset. Mirrors the predicate the generator already uses. - - Guards https://github.com/RolnickLab/antenna/pull/1331#discussion_r3373715397. - """ - configured_width = settings.THUMBNAILS["SIZES"]["small"]["width"] - # Row generated at T0. ``last_modified`` is ``auto_now_add`` so set - # ``self.first_capture.last_modified`` to a later timestamp via UPDATE - # below to flip the staleness predicate. - self.first_capture.thumbnails.create( - path="thumbnails/preupload/abc.jpg", label="small", width=configured_width, height=180, size=42 - ) - # Source bytes were re-uploaded after the cached row was written. - SourceImage.objects.filter(pk=self.first_capture.pk).update( - last_modified=timezone.now() + datetime.timedelta(minutes=1) - ) - - response = self.client.get(f"/api/v2/captures/{self.first_capture.pk}/?project_id={self.project.pk}") - self.assertEqual(response.status_code, 200) - # Source-changed → emit route URL so the redirect viewset regenerates. - self.assertURLEqual( - response.json()["thumbnails"]["small"], f"{self.base_url}{self.first_capture.pk}/?label=small" - ) - - def test_thumbnail_urls_model_method_emits_warm_and_route(self): - """``SourceImage.thumbnail_urls`` is the single source of truth for the - per-label URL contract: warm storage URL when a cached row exists at - the configured width, route URL otherwise. - - Exercising the model method directly keeps the contract regression-tested - even if the serializer surface changes (e.g. a future template tag or - management command that needs the same dict). - """ - from django.core.files.storage import default_storage - - configured_width = settings.THUMBNAILS["SIZES"]["small"]["width"] - self.first_capture.thumbnails.create( - path="thumbnails/cached/direct.jpg", label="small", width=configured_width, height=180, size=42 - ) - - # Prefetch via ``with_thumbnails()`` to exercise the warm path; without the - # prefetch the method emits route URLs without querying (see the test below). - capture = SourceImage.objects.with_thumbnails().get(pk=self.first_capture.pk) - urls = capture.thumbnail_urls(request=None) - - # Warm path → direct storage URL for the cached label. - self.assertEqual(urls["small"], default_storage.url("thumbnails/cached/direct.jpg")) - # Cold path → route URL for labels without a cached row. With ``request=None`` - # the URL is path-only (no scheme/host) — matches DRF's reverse() contract. - self.assertIn(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/", urls["medium"]) - self.assertIn("label=medium", urls["medium"]) - - def test_thumbnail_urls_without_prefetch_emits_route_urls_without_querying(self): - """Without prefetched thumbnails, ``thumbnail_urls`` must not fire a - per-object SELECT (an N+1 in list contexts) — it falls back to route URLs. - A cached row exists here, but the un-prefetched call must ignore it rather - than query for it.""" - configured_width = settings.THUMBNAILS["SIZES"]["small"]["width"] - self.first_capture.thumbnails.create( - path="thumbnails/cached/noprefetch.jpg", label="small", width=configured_width, height=180, size=42 - ) - - capture = SourceImage.objects.get(pk=self.first_capture.pk) - with self.assertNumQueries(0): - urls = capture.thumbnail_urls(request=None) - - # Cached row ignored (not prefetched) → route URL, not the storage URL. - self.assertIn(f"/api/v2/captures/thumbnails/{self.first_capture.pk}/", urls["small"]) - self.assertIn("label=small", urls["small"]) - - def test_warm_list_serves_storage_urls_with_single_thumbnail_query(self): - """The contract this PR exists to deliver: a warm gallery list serves direct - storage URLs while reading the thumbnail table once (the prefetch), not once - per row. - - Runtime no longer enforces the prefetch (un-prefetched falls back to route - URLs without querying), so this test is the only guard against both - regressions: dropping ``with_thumbnails()`` from the viewset (→ route URLs, - the perf win silently lost) and a per-row lazy read (→ N+1). Needs a - multi-row fixture — a single row hides an N+1. - """ - from django.core.files.storage import default_storage - from django.db import connection - from django.test.utils import CaptureQueriesContext - - configured_width = settings.THUMBNAILS["SIZES"]["small"]["width"] - captures = list(SourceImage.objects.filter(deployment=self.deployment)) - self.assertGreater(len(captures), 1, "need >1 capture to detect an N+1") - for cap in captures: - cap.thumbnails.create( - path=f"thumbnails/warm/{cap.pk}.jpg", label="small", width=configured_width, height=180, size=42 - ) - - with CaptureQueriesContext(connection) as ctx: - response = self.client.get(f"/api/v2/captures/?project_id={self.project.pk}") - self.assertEqual(response.status_code, 200) - - results = response.json()["results"] - self.assertGreater(len(results), 1) - for rec in results: - # Warm row → direct storage URL (regression guard: dropped prefetch → route URL). - self.assertEqual( - rec["thumbnails"]["small"], - default_storage.url(f"thumbnails/warm/{rec['id']}.jpg"), - ) - - thumb_queries = [q for q in ctx.captured_queries if "sourceimagethumbnail" in q["sql"].lower()] - self.assertLessEqual( - len(thumb_queries), 1, f"thumbnails must be one prefetch, not per-row; got {len(thumb_queries)}" - ) - class TestThumbnailDraftProjectVisibility(APITestCase): """PR #1306 follow-up: draft (non-public) projects must not route thumbnails @@ -6178,6 +6011,148 @@ def test_verified_count_not_inflated_by_collection_join(self): self.assertEqual(rows["Vanessa cardui"]["verified_count"], 2) +class TestDeviceAndSiteFilters(APITestCase): + """Filtering occurrences and taxa by the deployment's device and research site. + + A deployment records both a ``device`` (the camera/hardware configuration) and a + ``research_site``. Users can scope the occurrence and taxa lists to one device or + one site so they can, for example, build a presence matrix per site. These tests + pin that the occurrence list filters exactly to the chosen device/site, that the + taxa list restricts membership the same way (not just its annotated counts), and + that an unknown device/site id is rejected with a 404 on the taxa endpoint. + """ + + def setUp(self): + self.project, self.deployment_a = setup_test_project(reuse=False) + create_taxa(self.project) + self.cardui = Taxon.objects.get(name="Vanessa cardui") + self.atalanta = Taxon.objects.get(name="Vanessa atalanta") + + # Two devices and two sites, each pinned to its own deployment. + self.device_a = Device.objects.create(name="Device A", project=self.project) + self.device_b = Device.objects.create(name="Device B", project=self.project) + self.site_a = Site.objects.create(name="Site A", project=self.project) + self.site_b = Site.objects.create(name="Site B", project=self.project) + + self.deployment_a.device = self.device_a + self.deployment_a.research_site = self.site_a + self.deployment_a.save() + self.deployment_b = Deployment.objects.create( + name="Deployment B", + project=self.project, + device=self.device_b, + research_site=self.site_b, + ) + + # Deployment A only sees cardui; deployment B only sees atalanta. This lets the + # device/site filter be checked by both the occurrence count and the taxa membership. + create_captures(deployment=self.deployment_a, num_nights=1, images_per_night=2) + create_captures(deployment=self.deployment_b, num_nights=1, images_per_night=2) + create_occurrences(deployment=self.deployment_a, num=3, taxon=self.cardui, determination_score=0.9) + create_occurrences(deployment=self.deployment_b, num=2, taxon=self.atalanta, determination_score=0.9) + + def _occurrence_count(self, **params): + query = "&".join(f"{k}={v}" for k, v in params.items()) + res = self.client.get(f"/api/v2/occurrences/?project_id={self.project.pk}&{query}") + self.assertEqual(res.status_code, status.HTTP_200_OK) + return res.json()["count"] + + def _taxa_names(self, **params): + query = "&".join(f"{k}={v}" for k, v in params.items()) + res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&limit=1000&{query}") + self.assertEqual(res.status_code, status.HTTP_200_OK) + return {row["name"] for row in res.json()["results"]} + + def test_occurrences_filtered_by_device(self): + self.assertEqual(self._occurrence_count(), 5) + self.assertEqual(self._occurrence_count(deployment__device=self.device_a.pk), 3) + self.assertEqual(self._occurrence_count(deployment__device=self.device_b.pk), 2) + + def test_occurrences_filtered_by_site(self): + self.assertEqual(self._occurrence_count(deployment__research_site=self.site_a.pk), 3) + self.assertEqual(self._occurrence_count(deployment__research_site=self.site_b.pk), 2) + + def test_taxa_membership_restricted_by_device(self): + # The taxa list must drop to only the taxa observed on the chosen device, not + # merely re-scope the counts of the full taxa set. + self.assertEqual(self._taxa_names(deployment__device=self.device_a.pk), {"Vanessa cardui"}) + self.assertEqual(self._taxa_names(deployment__device=self.device_b.pk), {"Vanessa atalanta"}) + + def test_taxa_membership_restricted_by_site(self): + self.assertEqual(self._taxa_names(deployment__research_site=self.site_a.pk), {"Vanessa cardui"}) + self.assertEqual(self._taxa_names(deployment__research_site=self.site_b.pk), {"Vanessa atalanta"}) + + def test_unknown_device_or_site_returns_404_on_taxa(self): + for param in ("deployment__device", "deployment__research_site"): + res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&{param}=999999") + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND, param) + + def test_unknown_device_returns_empty_occurrences_not_404(self): + """An unknown device/site id on the occurrences endpoint returns 200 with count=0. + + The occurrences endpoint uses DjangoFilterBackend (ORM-level filtering) + which does not validate existence of related objects, so it yields an + empty queryset rather than a 404. This is different from the taxa + endpoint, which explicitly calls Device.objects.get() and raises 404. + """ + for param in ("deployment__device", "deployment__research_site"): + res = self.client.get(f"/api/v2/occurrences/?project_id={self.project.pk}&{param}=999999") + self.assertEqual(res.status_code, status.HTTP_200_OK, param) + self.assertEqual(res.json()["count"], 0, param) + + def test_occurrences_filtered_by_device_and_site_combined(self): + """Combining device and site filters from the same deployment returns the matching occurrences.""" + # device_a and site_a both belong to deployment_a → 3 cardui occurrences. + count = self._occurrence_count( + deployment__device=self.device_a.pk, + deployment__research_site=self.site_a.pk, + ) + self.assertEqual(count, 3) + + def test_occurrences_empty_when_device_and_site_contradict(self): + """When device and site belong to different deployments no occurrences match.""" + # device_a is on deployment_a; site_b is on deployment_b → no overlap. + count = self._occurrence_count( + deployment__device=self.device_a.pk, + deployment__research_site=self.site_b.pk, + ) + self.assertEqual(count, 0) + + def test_taxa_empty_when_device_and_site_contradict(self): + """Taxa list is empty when device and site filters target different deployments.""" + names = self._taxa_names( + deployment__device=self.device_a.pk, + deployment__research_site=self.site_b.pk, + ) + self.assertEqual(names, set()) + + def test_device_without_occurrences_returns_zero_occurrence_count(self): + """A device that exists but has no occurrences assigned returns count=0, not an error.""" + device_empty = Device.objects.create(name="Device Empty", project=self.project) + self.assertEqual(self._occurrence_count(deployment__device=device_empty.pk), 0) + + def test_device_without_occurrences_returns_empty_taxa_list(self): + """A device that exists but has no occurrences returns an empty taxa list.""" + device_empty = Device.objects.create(name="Device Empty 2", project=self.project) + names = self._taxa_names(deployment__device=device_empty.pk) + self.assertEqual(names, set()) + + def test_site_without_occurrences_returns_zero_occurrence_count(self): + """A site that exists but has no occurrences assigned returns count=0, not an error.""" + site_empty = Site.objects.create(name="Site Empty", project=self.project) + self.assertEqual(self._occurrence_count(deployment__research_site=site_empty.pk), 0) + + def test_occurrence_counts_unchanged_without_filter(self): + """Omitting device/site filter returns all occurrences across both deployments.""" + self.assertEqual(self._occurrence_count(), 5) + + def test_taxa_list_contains_both_species_without_filter(self): + """Without a device/site filter the taxa list contains taxa from all deployments.""" + names = self._taxa_names() + self.assertIn("Vanessa cardui", names) + self.assertIn("Vanessa atalanta", names) + + class TestDetectionNullMarker(TestCase): """ Covers the null-marker abstraction added for Issue #1310 follow-up: diff --git a/ui/src/components/filtering/filters/device-filter.test.tsx b/ui/src/components/filtering/filters/device-filter.test.tsx new file mode 100644 index 000000000..bc6ce0ce0 --- /dev/null +++ b/ui/src/components/filtering/filters/device-filter.test.tsx @@ -0,0 +1,98 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { DeviceFilter } from './device-filter' + +// Capture the props that EntityPicker receives so we can assert on them. +let capturedEntityPickerProps: Record = {} + +jest.mock('nova-ui-kit', () => ({ + EntityPicker: (props: Record) => { + capturedEntityPickerProps = props + return null + }, +})) + +jest.mock('data-services/constants', () => ({ + API_ROUTES: { + DEVICES: 'deployments/devices', + }, +})) + +describe('DeviceFilter', () => { + beforeEach(() => { + capturedEntityPickerProps = {} + }) + + test('passes API_ROUTES.DEVICES as the collection prop to EntityPicker', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + expect(capturedEntityPickerProps.collection).toBe('deployments/devices') + }) + + test('passes the current value to EntityPicker', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + expect(capturedEntityPickerProps.value).toBe('42') + }) + + test('passes undefined value to EntityPicker when no value is set', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + expect(capturedEntityPickerProps.value).toBeUndefined() + }) + + test('calls onAdd with the selected value when EntityPicker fires a truthy value', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + const onValueChange = capturedEntityPickerProps.onValueChange as ( + v: string | undefined + ) => void + onValueChange('42') + + expect(onAdd).toHaveBeenCalledTimes(1) + expect(onAdd).toHaveBeenCalledWith('42') + expect(onClear).not.toHaveBeenCalled() + }) + + test('calls onClear when EntityPicker fires an empty string', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + const onValueChange = capturedEntityPickerProps.onValueChange as ( + v: string | undefined + ) => void + onValueChange('') + + expect(onClear).toHaveBeenCalledTimes(1) + expect(onAdd).not.toHaveBeenCalled() + }) + + test('calls onClear when EntityPicker fires undefined', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + const onValueChange = capturedEntityPickerProps.onValueChange as ( + v: string | undefined + ) => void + onValueChange(undefined) + + expect(onClear).toHaveBeenCalledTimes(1) + expect(onAdd).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/ui/src/components/filtering/filters/site-filter.test.tsx b/ui/src/components/filtering/filters/site-filter.test.tsx new file mode 100644 index 000000000..dc0ce81c2 --- /dev/null +++ b/ui/src/components/filtering/filters/site-filter.test.tsx @@ -0,0 +1,98 @@ +import { render } from '@testing-library/react' +import React from 'react' +import { SiteFilter } from './site-filter' + +// Capture the props that EntityPicker receives so we can assert on them. +let capturedEntityPickerProps: Record = {} + +jest.mock('nova-ui-kit', () => ({ + EntityPicker: (props: Record) => { + capturedEntityPickerProps = props + return null + }, +})) + +jest.mock('data-services/constants', () => ({ + API_ROUTES: { + SITES: 'deployments/sites', + }, +})) + +describe('SiteFilter', () => { + beforeEach(() => { + capturedEntityPickerProps = {} + }) + + test('passes API_ROUTES.SITES as the collection prop to EntityPicker', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + expect(capturedEntityPickerProps.collection).toBe('deployments/sites') + }) + + test('passes the current value to EntityPicker', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + expect(capturedEntityPickerProps.value).toBe('99') + }) + + test('passes undefined value to EntityPicker when no value is set', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + expect(capturedEntityPickerProps.value).toBeUndefined() + }) + + test('calls onAdd with the selected value when EntityPicker fires a truthy value', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + const onValueChange = capturedEntityPickerProps.onValueChange as ( + v: string | undefined + ) => void + onValueChange('99') + + expect(onAdd).toHaveBeenCalledTimes(1) + expect(onAdd).toHaveBeenCalledWith('99') + expect(onClear).not.toHaveBeenCalled() + }) + + test('calls onClear when EntityPicker fires an empty string', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + const onValueChange = capturedEntityPickerProps.onValueChange as ( + v: string | undefined + ) => void + onValueChange('') + + expect(onClear).toHaveBeenCalledTimes(1) + expect(onAdd).not.toHaveBeenCalled() + }) + + test('calls onClear when EntityPicker fires undefined', () => { + const onAdd = jest.fn() + const onClear = jest.fn() + + render() + + const onValueChange = capturedEntityPickerProps.onValueChange as ( + v: string | undefined + ) => void + onValueChange(undefined) + + expect(onClear).toHaveBeenCalledTimes(1) + expect(onAdd).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/ui/src/pages/species/species-columns.test.tsx b/ui/src/pages/species/species-columns.test.tsx new file mode 100644 index 000000000..c9d85c1fb --- /dev/null +++ b/ui/src/pages/species/species-columns.test.tsx @@ -0,0 +1,199 @@ +import { render, screen } from '@testing-library/react' +import { Species } from 'data-services/models/species' +import React from 'react' +import { MemoryRouter } from 'react-router-dom' +import { columns } from './species-columns' + +// Mock heavy UI dependencies so the column renderCell functions can be +// rendered in isolation without a full app setup. + +jest.mock('nova-ui-kit', () => ({ + BasicTableCell: ({ children, value }: { children?: React.ReactNode; value?: unknown }) => + children ? {children} : {String(value ?? '')}, + CellTheme: { Bubble: 'Bubble' }, + DateTableCell: () => null, + ImageCellTheme: { Light: 'Light' }, + ImageTableCell: () => null, + TableColumn: {}, + TextAlign: { Right: 'right' }, +})) + +jest.mock('components/determination-score', () => ({ + DeterminationScore: () => null, +})) + +jest.mock('components/taxon-details/taxon-details', () => ({ + TaxonDetails: () => null, +})) + +jest.mock('components/taxon-tags/tag', () => ({ + Tag: () => null, +})) + +jest.mock('utils/language', () => ({ + STRING: {}, + translate: (key: unknown) => String(key), +})) + +// Helper: build a minimal Species instance. +function makeSpecies(id: string): Species { + return new Species({ + id, + name: 'Vanessa cardui', + rank: 'SPECIES', + cover_image_url: null, + occurrences_count: 5, + verified_count: 2, + }) +} + +// Helper: render a single cell wrapped in MemoryRouter and return its container. +function renderCell( + column: ReturnType[number], + item: Species +) { + const { container } = render( + {column.renderCell(item)} + ) + return container +} + +describe('species-columns carryFilters', () => { + const projectId = 'proj-1' + const item = makeSpecies('taxon-7') + + describe('occurrences column', () => { + test('occurrence link includes only taxon when carryFilters is empty', () => { + const cols = columns({ projectId, carryFilters: {} }) + const col = cols.find((c) => c.id === 'occurrences')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + expect(anchor).not.toBeNull() + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + expect(url.searchParams.get('deployment__device')).toBeNull() + expect(url.searchParams.get('deployment__research_site')).toBeNull() + }) + + test('occurrence link includes deployment__device from carryFilters', () => { + const cols = columns({ + projectId, + carryFilters: { deployment__device: '42' }, + }) + const col = cols.find((c) => c.id === 'occurrences')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('deployment__device')).toBe('42') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + }) + + test('occurrence link includes deployment__research_site from carryFilters', () => { + const cols = columns({ + projectId, + carryFilters: { deployment__research_site: '99' }, + }) + const col = cols.find((c) => c.id === 'occurrences')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('deployment__research_site')).toBe('99') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + }) + + test('occurrence link includes both device and site from carryFilters', () => { + const cols = columns({ + projectId, + carryFilters: { deployment__device: '3', deployment__research_site: '8' }, + }) + const col = cols.find((c) => c.id === 'occurrences')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('deployment__device')).toBe('3') + expect(url.searchParams.get('deployment__research_site')).toBe('8') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + }) + + test('taxon in carryFilters is overridden by the item taxon id', () => { + // If carryFilters happened to contain a stale taxon key, the item.id wins. + const cols = columns({ + projectId, + carryFilters: { deployment__device: '3', taxon: 'old-taxon' } as any, + }) + const col = cols.find((c) => c.id === 'occurrences')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + }) + }) + + describe('verified column', () => { + test('verified link includes verified=true and taxon', () => { + const cols = columns({ projectId, carryFilters: {} }) + const col = cols.find((c) => c.id === 'verified')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + expect(url.searchParams.get('verified')).toBe('true') + }) + + test('verified link includes deployment__device from carryFilters', () => { + const cols = columns({ + projectId, + carryFilters: { deployment__device: '42' }, + }) + const col = cols.find((c) => c.id === 'verified')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('deployment__device')).toBe('42') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + expect(url.searchParams.get('verified')).toBe('true') + }) + + test('verified link includes deployment__research_site from carryFilters', () => { + const cols = columns({ + projectId, + carryFilters: { deployment__research_site: '99' }, + }) + const col = cols.find((c) => c.id === 'verified')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('deployment__research_site')).toBe('99') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + expect(url.searchParams.get('verified')).toBe('true') + }) + + test('verified link includes both device and site from carryFilters', () => { + const cols = columns({ + projectId, + carryFilters: { deployment__device: '3', deployment__research_site: '8' }, + }) + const col = cols.find((c) => c.id === 'verified')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('deployment__device')).toBe('3') + expect(url.searchParams.get('deployment__research_site')).toBe('8') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + expect(url.searchParams.get('verified')).toBe('true') + }) + }) + + describe('default carryFilters', () => { + test('columns() works without carryFilters parameter (defaults to {})', () => { + // No carryFilters argument – should not throw and links should work. + const cols = columns({ projectId }) + const col = cols.find((c) => c.id === 'occurrences')! + const container = renderCell(col, item) + const anchor = container.querySelector('a') + const url = new URL(anchor!.getAttribute('href')!, 'http://localhost') + expect(url.searchParams.get('taxon')).toBe('taxon-7') + expect(url.searchParams.get('deployment__device')).toBeNull() + }) + }) +}) \ No newline at end of file diff --git a/ui/src/utils/getAppRoute.test.ts b/ui/src/utils/getAppRoute.test.ts new file mode 100644 index 000000000..c74b5499e --- /dev/null +++ b/ui/src/utils/getAppRoute.test.ts @@ -0,0 +1,156 @@ +import { getAppRoute } from './getAppRoute' + +describe('getAppRoute', () => { + describe('deployment__device filter', () => { + test('includes deployment__device as a query param', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { deployment__device: '42' }, + }) + expect(url).toBe('/projects/1/occurrences?deployment__device=42') + }) + + test('combines deployment__device with taxon filter', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { deployment__device: '42', taxon: '7' }, + }) + const parsed = new URL(url, 'http://localhost') + expect(parsed.searchParams.get('deployment__device')).toBe('42') + expect(parsed.searchParams.get('taxon')).toBe('7') + }) + }) + + describe('deployment__research_site filter', () => { + test('includes deployment__research_site as a query param', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { deployment__research_site: '99' }, + }) + expect(url).toBe('/projects/1/occurrences?deployment__research_site=99') + }) + + test('combines deployment__research_site with taxon and verified filters', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { + deployment__research_site: '99', + taxon: '7', + verified: 'true', + }, + }) + const parsed = new URL(url, 'http://localhost') + expect(parsed.searchParams.get('deployment__research_site')).toBe('99') + expect(parsed.searchParams.get('taxon')).toBe('7') + expect(parsed.searchParams.get('verified')).toBe('true') + }) + }) + + describe('date_end and date_start filters', () => { + test('includes date_end as a query param', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { date_end: '2024-12-31' }, + }) + expect(url).toBe('/projects/1/occurrences?date_end=2024-12-31') + }) + + test('includes date_start as a query param', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { date_start: '2024-01-01' }, + }) + expect(url).toBe('/projects/1/occurrences?date_start=2024-01-01') + }) + + test('includes both date_start and date_end together', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { date_start: '2024-01-01', date_end: '2024-12-31' }, + }) + const parsed = new URL(url, 'http://localhost') + expect(parsed.searchParams.get('date_start')).toBe('2024-01-01') + expect(parsed.searchParams.get('date_end')).toBe('2024-12-31') + }) + }) + + describe('not_taxa_list_id filter', () => { + test('includes not_taxa_list_id as a query param', () => { + const url = getAppRoute({ + to: '/projects/1/taxa', + filters: { not_taxa_list_id: '5' }, + }) + expect(url).toBe('/projects/1/taxa?not_taxa_list_id=5') + }) + }) + + describe('undefined filter values', () => { + test('omits filter keys with undefined values', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { + deployment__device: undefined, + deployment__research_site: undefined, + taxon: '7', + }, + }) + expect(url).toBe('/projects/1/occurrences?taxon=7') + }) + + test('returns base path with no query string when all filters are undefined', () => { + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { + deployment__device: undefined, + deployment__research_site: undefined, + }, + }) + expect(url).toBe('/projects/1/occurrences') + }) + }) + + describe('carryFilters spread pattern (device + site + taxon)', () => { + test('carries deployment__device, deployment__research_site, and taxon together', () => { + const carryFilters: Record = { + deployment__device: '3', + deployment__research_site: '8', + } + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { ...carryFilters, taxon: '12' }, + }) + const parsed = new URL(url, 'http://localhost') + expect(parsed.searchParams.get('deployment__device')).toBe('3') + expect(parsed.searchParams.get('deployment__research_site')).toBe('8') + expect(parsed.searchParams.get('taxon')).toBe('12') + }) + + test('carries deployment__device, deployment__research_site, taxon, and verified together', () => { + const carryFilters: Record = { + deployment__device: '3', + deployment__research_site: '8', + } + const url = getAppRoute({ + to: '/projects/1/occurrences', + filters: { ...carryFilters, taxon: '12', verified: 'true' }, + }) + const parsed = new URL(url, 'http://localhost') + expect(parsed.searchParams.get('deployment__device')).toBe('3') + expect(parsed.searchParams.get('deployment__research_site')).toBe('8') + expect(parsed.searchParams.get('taxon')).toBe('12') + expect(parsed.searchParams.get('verified')).toBe('true') + }) + }) + + describe('base behaviour (regression)', () => { + test('returns base path unchanged when no filters are provided', () => { + const url = getAppRoute({ to: '/projects/1/occurrences' }) + expect(url).toBe('/projects/1/occurrences') + }) + + test('returns base path unchanged when filters is an empty object', () => { + const url = getAppRoute({ to: '/projects/1/occurrences', filters: {} }) + expect(url).toBe('/projects/1/occurrences') + }) + }) +}) \ No newline at end of file diff --git a/ui/src/utils/useFilters.test.ts b/ui/src/utils/useFilters.test.ts new file mode 100644 index 000000000..e35855866 --- /dev/null +++ b/ui/src/utils/useFilters.test.ts @@ -0,0 +1,87 @@ +import { AVAILABLE_FILTERS } from './useFilters' + +// AVAILABLE_FILTERS is a pure factory function that takes a projectId and +// returns the full list of filter configurations. We test it without any +// React context because it does not depend on hooks. + +describe('AVAILABLE_FILTERS', () => { + const filters = AVAILABLE_FILTERS('test-project-1') + + describe('Device filter', () => { + test('includes a filter with field deployment__device', () => { + const deviceFilter = filters.find((f) => f.field === 'deployment__device') + expect(deviceFilter).toBeDefined() + }) + + test('device filter has label "Device"', () => { + const deviceFilter = filters.find((f) => f.field === 'deployment__device') + expect(deviceFilter?.label).toBe('Device') + }) + + test('device filter has no tooltip', () => { + const deviceFilter = filters.find((f) => f.field === 'deployment__device') + expect(deviceFilter?.tooltip).toBeUndefined() + }) + + test('device filter has no validate function', () => { + const deviceFilter = filters.find((f) => f.field === 'deployment__device') + expect(deviceFilter?.validate).toBeUndefined() + }) + }) + + describe('Site filter', () => { + test('includes a filter with field deployment__research_site', () => { + const siteFilter = filters.find( + (f) => f.field === 'deployment__research_site' + ) + expect(siteFilter).toBeDefined() + }) + + test('site filter has label "Site"', () => { + const siteFilter = filters.find( + (f) => f.field === 'deployment__research_site' + ) + expect(siteFilter?.label).toBe('Site') + }) + + test('site filter has no tooltip', () => { + const siteFilter = filters.find( + (f) => f.field === 'deployment__research_site' + ) + expect(siteFilter?.tooltip).toBeUndefined() + }) + + test('site filter has no validate function', () => { + const siteFilter = filters.find( + (f) => f.field === 'deployment__research_site' + ) + expect(siteFilter?.validate).toBeUndefined() + }) + }) + + describe('ordering', () => { + test('device filter appears directly after deployment (station) filter', () => { + const fieldNames = filters.map((f) => f.field) + const deploymentIdx = fieldNames.indexOf('deployment') + const deviceIdx = fieldNames.indexOf('deployment__device') + expect(deploymentIdx).toBeGreaterThanOrEqual(0) + expect(deviceIdx).toBe(deploymentIdx + 1) + }) + + test('site filter appears directly after device filter', () => { + const fieldNames = filters.map((f) => f.field) + const deviceIdx = fieldNames.indexOf('deployment__device') + const siteIdx = fieldNames.indexOf('deployment__research_site') + expect(siteIdx).toBe(deviceIdx + 1) + }) + }) + + describe('no duplicate fields', () => { + test('each field name appears exactly once', () => { + const fieldNames = filters.map((f) => f.field) + const uniqueFields = new Set(fieldNames) + // If there are duplicates the Set will be smaller + expect(fieldNames.length).toBe(uniqueFields.size) + }) + }) +}) \ No newline at end of file