perfect-postcode/frontend/src/hooks/usePoiLayers.test.ts
2026-05-26 19:45:13 +01:00

225 lines
6.9 KiB
TypeScript

import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { POI } from '../types';
import { usePoiLayers } from './usePoiLayers';
const supermarket: POI = {
id: 'poi-1',
name: 'Market Hall',
category: 'Supermarket',
group: 'Groceries',
lat: 51.5,
lng: -0.12,
emoji: '🛒',
};
const waitrose: POI = {
id: 'poi-3',
name: 'Waitrose Marylebone',
category: 'Waitrose',
group: 'Groceries',
lat: 51.52,
lng: -0.15,
emoji: '🛒',
};
const busStop: POI = {
id: 'poi-2',
name: 'High Street Stop',
category: 'Bus stop',
group: 'Public Transport',
lat: 51.501,
lng: -0.121,
emoji: '🚌',
};
const foodWarehouse: POI = {
id: 'poi-4',
name: 'Iceland Avonmead Food Warehouse',
category: 'Iceland',
icon_category: 'The Food Warehouse',
group: 'Groceries',
lat: 51.49,
lng: -0.18,
emoji: '🛒',
};
function layerById(layers: readonly unknown[], id: string) {
const layer = layers.find((item) => (item as { id?: string }).id === id);
if (!layer) throw new Error(`Layer ${id} not found`);
return layer as { props: Record<string, unknown> };
}
type PoiIconDef = { url: string; width: number; height: number };
type PoiColor = [number, number, number, number];
describe('usePoiLayers', () => {
it('returns the expected layer stack', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
);
expect(result.current.poiLayers.map((layer) => layer.id)).toEqual([
'poi-shadow',
'poi-background',
'poi-icons',
'poi-clusters',
'poi-cluster-text',
]);
});
it('uses POI category logos for map marker icons', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [waitrose], zoom: 15, isDark: false })
);
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => PoiIconDef;
const getSize = iconLayer.props.getSize as (poi: POI) => number;
expect(getIcon(waitrose)).toMatchObject({
url: '/assets/poi-icons/logos/waitrose.svg',
width: 96,
height: 48,
});
expect(getSize(waitrose)).toBe(14);
});
it('prefers POI fascia icon categories for map marker icons', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [foodWarehouse], zoom: 15, isDark: false })
);
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => PoiIconDef;
const getSize = iconLayer.props.getSize as (poi: POI) => number;
expect(getIcon(foodWarehouse)).toMatchObject({
url: '/assets/poi-icons/logos/the_food_warehouse.png',
width: 96,
height: 48,
});
expect(getSize(foodWarehouse)).toBe(14);
});
it('keeps generic emoji POIs at the compact marker size', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
);
const iconLayer = layerById(result.current.poiLayers, 'poi-icons');
const getIcon = iconLayer.props.getIcon as (poi: POI) => PoiIconDef;
const getSize = iconLayer.props.getSize as (poi: POI) => number;
expect(getIcon(supermarket)).toMatchObject({
url: '/assets/twemoji/1f6d2.png',
width: 72,
height: 72,
});
expect(getSize(supermarket)).toBe(11);
});
it('hides the circular marker badge behind bundled logo icons', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [waitrose, supermarket], zoom: 15, isDark: false })
);
const shadowLayer = layerById(result.current.poiLayers, 'poi-shadow');
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
const getShadowRadius = shadowLayer.props.getRadius as (poi: POI) => number;
const getBackgroundRadius = backgroundLayer.props.getRadius as (poi: POI) => number;
const getFillColor = backgroundLayer.props.getFillColor as (poi: POI) => PoiColor;
const getLineColor = backgroundLayer.props.getLineColor as (poi: POI) => PoiColor;
expect(getShadowRadius(waitrose)).toBe(0);
expect(getBackgroundRadius(waitrose)).toBe(14);
expect(getFillColor(waitrose)).toEqual([0, 0, 0, 0]);
expect(getLineColor(waitrose)).toEqual([0, 0, 0, 0]);
expect(getShadowRadius(supermarket)).toBe(10);
expect(getBackgroundRadius(supermarket)).toBe(8);
expect(getFillColor(supermarket)).toEqual([255, 255, 255, 255]);
expect(getLineColor(supermarket)).toEqual([34, 197, 94, 255]);
});
it('hides minor POI categories until the configured zoom threshold', () => {
const { result, rerender } = renderHook(
({ zoom }) => usePoiLayers({ pois: [busStop], zoom, isDark: false }),
{ initialProps: { zoom: 13 } }
);
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([]);
expect(result.current.visiblePois).toEqual([]);
rerender({ zoom: 14 });
expect(layerById(result.current.poiLayers, 'poi-background').props.data).toEqual([busStop]);
expect(result.current.visiblePois).toEqual([busStop]);
});
it('keeps POI hover popup state in sync with layer hover events', () => {
const { result } = renderHook(() =>
usePoiLayers({ pois: [supermarket], zoom: 15, isDark: false })
);
const backgroundLayer = layerById(result.current.poiLayers, 'poi-background');
act(() => {
(backgroundLayer.props.onHover as (info: unknown) => void)({
object: supermarket,
x: 42,
y: 88,
});
});
expect(result.current.popupInfo).toEqual({
x: 42,
y: 88,
name: supermarket.name,
category: supermarket.category,
icon_category: undefined,
group: supermarket.group,
emoji: supermarket.emoji,
id: supermarket.id,
});
act(() => {
result.current.clearPopupInfo();
});
expect(result.current.popupInfo).toBeNull();
});
it('creates cluster hover popup state from clustered POIs', () => {
const clusteredPois = Array.from(
{ length: 4 },
(_, index): POI => ({
...supermarket,
id: `clustered-${index}`,
name: `Clustered ${index}`,
lat: 51.5 + index * 0.0001,
lng: -0.12 - index * 0.0001,
})
);
const { result } = renderHook(() =>
usePoiLayers({ pois: clusteredPois, zoom: 5, isDark: true })
);
const clusterLayer = layerById(result.current.poiLayers, 'poi-clusters');
const clusters = clusterLayer.props.data as Array<{ count: number; lng: number; lat: number }>;
expect(clusters).toHaveLength(1);
expect(clusters[0].count).toBe(4);
act(() => {
(clusterLayer.props.onHover as (info: unknown) => void)({
object: clusters[0],
x: 12,
y: 24,
});
});
expect(result.current.popupInfo).toMatchObject({
x: 12,
y: 24,
name: '4 places',
isCluster: true,
clusterCount: 4,
});
});
});