553 lines
14 KiB
TypeScript
553 lines
14 KiB
TypeScript
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<Response>((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<string, [number, number]>;
|
|
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<string, [number, number]>;
|
|
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<string, [number, number]>;
|
|
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);
|
|
});
|
|
});
|