import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useMapData } from './useMapData'; import type { TravelTimeEntry } from './useTravelTime'; import type { ApiResponse, Bounds, FeatureMeta, ViewChangeParams } from '../types'; vi.mock('../lib/pocketbase', () => ({ default: { authStore: { isValid: false, token: '' } }, })); function response(features: ApiResponse['features']): Response { return new Response(JSON.stringify({ features }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } function viewChange(bounds: Bounds): ViewChangeParams { return { resolution: 8, bounds, zoom: 10, latitude: (bounds.south + bounds.north) / 2, longitude: (bounds.west + bounds.east) / 2, }; } async function flushPromises() { await Promise.resolve(); await Promise.resolve(); } describe('useMapData', () => { const requests: Array<{ url: string; resolve: (response: Response) => void }> = []; const noTravelTimeEntries: TravelTimeEntry[] = []; beforeEach(() => { vi.useFakeTimers(); requests.length = 0; vi.stubGlobal( 'fetch', vi.fn((url: string | URL | Request) => { return new Promise((resolve) => { requests.push({ url: String(url), resolve }); }); }) ); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); it('ignores a stale map response after the view has already changed', async () => { const { result } = renderHook(() => useMapData({ filters: {}, features: [], viewFeature: null, activeFeature: null, pinnedFeature: null, travelTimeEntries: noTravelTimeEntries, }) ); await act(async () => { result.current.handleViewChange(viewChange({ south: 1, west: 1, north: 2, east: 2 })); }); await act(async () => { vi.advanceTimersByTime(150); }); expect(requests).toHaveLength(1); await act(async () => { result.current.handleViewChange(viewChange({ south: 3, west: 3, north: 4, east: 4 })); }); await act(async () => { requests[0].resolve(response([{ h3: 'old', count: 99, lat: 1.5, lon: 1.5 }])); await flushPromises(); }); expect(result.current.data).toEqual([]); await act(async () => { vi.advanceTimersByTime(150); }); expect(requests).toHaveLength(2); await act(async () => { requests[1].resolve(response([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }])); await flushPromises(); }); expect(result.current.data).toEqual([{ h3: 'new', count: 7, lat: 3.5, lon: 3.5 }]); }); it('stores the visible map center separately from the rendered map center', async () => { const { result } = renderHook(() => useMapData({ filters: {}, features: [], viewFeature: null, activeFeature: null, pinnedFeature: null, travelTimeEntries: noTravelTimeEntries, }) ); await act(async () => { result.current.handleViewChange({ ...viewChange({ south: 1, west: 1, north: 2, east: 2 }), latitude: 51.5, longitude: -0.1, visibleLatitude: 51.6, visibleLongitude: -0.2, }); }); expect(result.current.currentView).toEqual({ latitude: 51.5, longitude: -0.1, zoom: 10 }); expect(result.current.currentVisibleView).toEqual({ latitude: 51.6, longitude: -0.2, zoom: 10, }); }); it('resets the colour range to visible drag preview data while a slider is active', async () => { const bounds = { south: 1, west: 1, north: 2, east: 2 }; const features: FeatureMeta[] = [ { name: 'price', type: 'numeric', min: 0, max: 100, }, ]; const filters = { price: [20, 80] as [number, number] }; const { result, rerender } = renderHook( ({ activeFeature, filterRange, }: { activeFeature: string | null; filterRange: [number, number] | null; }) => useMapData({ filters, features, viewFeature: 'price', activeFeature, pinnedFeature: null, filterRange, travelTimeEntries: noTravelTimeEntries, }), { initialProps: { activeFeature: null as string | null, filterRange: filters.price, }, } ); await act(async () => { result.current.handleViewChange(viewChange(bounds)); }); await act(async () => { vi.advanceTimersByTime(150); }); await act(async () => { requests[0].resolve( response([ { h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 }, { h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 }, ]) ); await flushPromises(); }); expect(result.current.colorRange?.[0]).toBeCloseTo(23); expect(result.current.colorRange?.[1]).toBeCloseTo(77); await act(async () => { rerender({ activeFeature: 'price', filterRange: filters.price }); await flushPromises(); }); expect(requests).toHaveLength(2); const previewData = [ { h3: 'preview-outside-low', count: 1, lat: 1.1, lon: 1.1, min_price: 0, max_price: 10, avg_price: 5, }, { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, min_price: 20, max_price: 20, avg_price: 20, }, { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, min_price: 80, max_price: 80, avg_price: 80, }, { h3: 'preview-outside-high', count: 1, lat: 1.9, lon: 1.9, min_price: 90, max_price: 100, avg_price: 95, }, ]; await act(async () => { requests[1].resolve(response(previewData)); await flushPromises(); }); expect(result.current.data).toEqual(previewData); expect(result.current.colorRange?.[0]).toBeCloseTo(23); expect(result.current.colorRange?.[1]).toBeCloseTo(77); }); it('does not use metadata min/max while slider preview colour data is loading', async () => { const bounds = { south: 1, west: 1, north: 2, east: 2 }; const features: FeatureMeta[] = [ { name: 'price', type: 'numeric', min: 0, max: 100, }, ]; const filters = { price: [20, 80] as [number, number] }; const { result, rerender } = renderHook( ({ viewFeature, activeFeature, }: { viewFeature: string | null; activeFeature: string | null; }) => useMapData({ filters, features, viewFeature, activeFeature, pinnedFeature: null, travelTimeEntries: noTravelTimeEntries, }), { initialProps: { viewFeature: null as string | null, activeFeature: null as string | null, }, } ); await act(async () => { result.current.handleViewChange(viewChange(bounds)); }); await act(async () => { vi.advanceTimersByTime(150); }); await act(async () => { requests[0].resolve( response([ { h3: 'density-low', count: 1, lat: 1.25, lon: 1.25 }, { h3: 'density-high', count: 1, lat: 1.75, lon: 1.75 }, ]) ); await flushPromises(); }); await act(async () => { rerender({ viewFeature: 'price', activeFeature: 'price', }); await flushPromises(); }); expect(result.current.colorRange).toBeNull(); await act(async () => { requests[1].resolve( response([ { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 }, { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 }, ]) ); await flushPromises(); }); expect(result.current.colorRange?.[0]).toBeCloseTo(5); expect(result.current.colorRange?.[1]).toBeCloseTo(95); }); it('does not use stale committed feature data while slider preview colour data is loading', async () => { const bounds = { south: 1, west: 1, north: 2, east: 2 }; const features: FeatureMeta[] = [ { name: 'price', type: 'numeric', min: 0, max: 1_000, }, ]; const { result, rerender } = renderHook( ({ filters, activeFeature, }: { filters: Record; activeFeature: string | null; }) => useMapData({ filters, features, viewFeature: 'price', activeFeature, pinnedFeature: null, travelTimeEntries: noTravelTimeEntries, }), { initialProps: { filters: { price: [0, 1_000] as [number, number] }, activeFeature: null as string | null, }, } ); await act(async () => { result.current.handleViewChange(viewChange(bounds)); }); await act(async () => { vi.advanceTimersByTime(150); }); await act(async () => { requests[0].resolve( response([ { h3: 'stale-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 }, { h3: 'stale-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 1_000 }, ]) ); await flushPromises(); }); expect(result.current.colorRange?.[1]).toBeCloseTo(950); await act(async () => { rerender({ filters: { price: [20, 80] }, activeFeature: 'price', }); await flushPromises(); }); expect(result.current.colorRange).toBeNull(); await act(async () => { requests[1].resolve( response([ { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 }, { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 }, ]) ); await flushPromises(); }); expect(result.current.colorRange?.[0]).toBeCloseTo(23); expect(result.current.colorRange?.[1]).toBeCloseTo(77); }); it('does not reuse cached drag preview data when the drag request changes', async () => { const bounds = { south: 1, west: 1, north: 2, east: 2 }; const features: FeatureMeta[] = [ { name: 'price', type: 'numeric', min: 0, max: 100, }, ]; const committedData = [ { h3: 'committed-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 }, { h3: 'committed-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 }, ]; const previewData = [ { h3: 'preview-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 }, { h3: 'preview-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 }, ]; const { result, rerender } = renderHook( ({ filters, activeFeature, }: { filters: Record; activeFeature: string | null; }) => useMapData({ filters, features, viewFeature: 'price', activeFeature, pinnedFeature: null, travelTimeEntries: noTravelTimeEntries, }), { initialProps: { filters: { price: [20, 80] as [number, number] }, activeFeature: null as string | null, }, } ); await act(async () => { result.current.handleViewChange(viewChange(bounds)); }); await act(async () => { vi.advanceTimersByTime(150); }); await act(async () => { requests[0].resolve(response(committedData)); await flushPromises(); }); await act(async () => { rerender({ filters: { price: [20, 80] }, activeFeature: 'price', }); await flushPromises(); }); await act(async () => { requests[1].resolve(response(previewData)); await flushPromises(); }); expect(result.current.data).toEqual(previewData); await act(async () => { rerender({ filters: { price: [10, 90] }, activeFeature: 'price', }); }); expect(result.current.data).toEqual(committedData); }); it('resets a pinned colour range after a slider commits a new filter', async () => { const bounds = { south: 1, west: 1, north: 2, east: 2 }; const features: FeatureMeta[] = [ { name: 'price', type: 'numeric', min: 0, max: 100, }, ]; const { result, rerender } = renderHook( ({ filters, activeFeature, }: { filters: Record; activeFeature: string | null; }) => useMapData({ filters, features, viewFeature: 'price', activeFeature, pinnedFeature: 'price', travelTimeEntries: noTravelTimeEntries, }), { initialProps: { filters: { price: [20, 80] as [number, number] }, activeFeature: null as string | null, }, } ); await act(async () => { result.current.handleViewChange(viewChange(bounds)); }); await act(async () => { vi.advanceTimersByTime(150); }); await act(async () => { requests[0].resolve( response([ { h3: 'old-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 20 }, { h3: 'old-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 80 }, ]) ); await flushPromises(); }); expect(result.current.colorRange?.[0]).toBeCloseTo(23); expect(result.current.colorRange?.[1]).toBeCloseTo(77); await act(async () => { rerender({ filters: { price: [20, 80] }, activeFeature: 'price', }); await flushPromises(); }); await act(async () => { rerender({ filters: { price: [10, 90] }, activeFeature: null, }); }); await act(async () => { vi.advanceTimersByTime(150); }); const committedRequest = requests[requests.length - 1]; await act(async () => { committedRequest.resolve( response([ { h3: 'new-low', count: 1, lat: 1.25, lon: 1.25, avg_price: 0 }, { h3: 'new-high', count: 1, lat: 1.75, lon: 1.75, avg_price: 100 }, ]) ); await flushPromises(); }); expect(result.current.colorRange?.[0]).toBeCloseTo(5); expect(result.current.colorRange?.[1]).toBeCloseTo(95); }); });