perfect-postcode/frontend/src/lib/map-utils.test.ts
Andras Schmelczer fd2860070a
Some checks failed
Build and publish Docker image / build-and-push (push) Successful in 18m12s
CI / Check (push) Failing after 23m38s
lgtm
2026-06-22 22:12:27 +01:00

238 lines
8.4 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
import {
DENSITY_GRADIENT,
ENUM_PALETTE,
FEATURE_GRADIENT,
BUFFER_MULTIPLIER,
MAP_BOUNDS,
POI_CATEGORY_LOGOS,
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
} from './consts';
import {
emojiToTwemojiUrl,
enumIndexToColor,
getBoundsFromViewState,
getBoundsWithBottomScreenInset,
getVisibleBoundsFromViewState,
getLatitudeAtVerticalPixelOffset,
getFeatureFillColor,
getMapCenterForTargetScreenPoint,
getPoiIconUrl,
selectSpacedItems,
zoomToResolution,
} from './map-utils';
describe('selectSpacedItems', () => {
const mk = (id: string, x: number, y: number) => ({ item: id, x, y });
it('keeps every item when none overlap', () => {
const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 0, 200)];
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept.map((k) => k.item)).toEqual(['a', 'b', 'c']);
});
it('culls items that fall within the spacing of an already-kept item', () => {
// b and c sit on top of a; d is clear.
const items = [mk('a', 100, 100), mk('b', 120, 110), mk('c', 150, 100), mk('d', 400, 400)];
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept.map((k) => k.item)).toEqual(['a', 'd']);
});
it('treats dx and dy independently (far enough on either axis is kept)', () => {
const items = [mk('a', 0, 0), mk('b', 10, 40)]; // dx<130 but dy>=34
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept.map((k) => k.item)).toEqual(['a', 'b']);
});
it('stops at the max cap', () => {
const items = Array.from({ length: 100 }, (_, i) => mk(String(i), i * 500, 0));
const kept = selectSpacedItems(items, 130, 34, 40);
expect(kept).toHaveLength(40);
});
});
describe('map utilities', () => {
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
expect(zoomToResolution(6.9)).toBe(5);
expect(zoomToResolution(7)).toBe(6);
expect(zoomToResolution(10.6)).toBe(8);
expect(zoomToResolution(12)).toBe(9);
expect(zoomToResolution(14)).toBe(9);
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9);
});
it('computes exact viewport bounds by default', () => {
const viewState = { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 };
const bounds = getBoundsFromViewState(viewState, 1200, 800);
const exactBounds = getBoundsFromViewState(viewState, 1200, 800, 1);
const bufferedBounds = getBoundsFromViewState(viewState, 1200, 800, 1.5);
expect(BUFFER_MULTIPLIER).toBe(1);
expect(bounds).toEqual(exactBounds);
expect(bounds.south).toBeLessThan(51.5);
expect(bounds.north).toBeGreaterThan(51.5);
expect(bounds.west).toBeLessThan(-0.1);
expect(bounds.east).toBeGreaterThan(-0.1);
expect(bufferedBounds.south).toBeLessThan(bounds.south);
expect(bufferedBounds.north).toBeGreaterThan(bounds.north);
expect(bufferedBounds.west).toBeLessThan(bounds.west);
expect(bufferedBounds.east).toBeGreaterThan(bounds.east);
});
it('excludes mobile bottom-sheet covered map area from visible bounds', () => {
const viewState = { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 };
const fullBounds = getVisibleBoundsFromViewState(viewState, 390, 800, 0);
const visibleBounds = getVisibleBoundsFromViewState(viewState, 390, 800, 352);
expect(visibleBounds.west).toBeCloseTo(fullBounds.west, 6);
expect(visibleBounds.east).toBeCloseTo(fullBounds.east, 6);
expect(visibleBounds.north).toBeCloseTo(fullBounds.north, 6);
expect(visibleBounds.south).toBeGreaterThan(fullBounds.south);
});
it('moves the map center so a target lands in the requested screen position', () => {
const centered = getMapCenterForTargetScreenPoint(51.5, -0.1, 17, 390, 844, 195, 42.2);
expect(centered.longitude).toBeCloseTo(-0.1, 6);
expect(centered.latitude).toBeLessThan(51.5);
});
it('expands the southern map bound by a covered screen area', () => {
const shiftedSouth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, 320);
const shiftedNorth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, -320);
const expandedBounds = getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 320);
expect(shiftedSouth).toBeLessThan(MAP_BOUNDS[1]);
expect(shiftedNorth).toBeGreaterThan(MAP_BOUNDS[1]);
expect(expandedBounds[0]).toBe(MAP_BOUNDS[0]);
expect(expandedBounds[1]).toBeCloseTo(shiftedSouth, 6);
expect(expandedBounds[2]).toBe(MAP_BOUNDS[2]);
expect(expandedBounds[3]).toBe(MAP_BOUNDS[3]);
expect(getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 0)).toEqual(MAP_BOUNDS);
});
it('builds twemoji URLs and wraps enum colors', () => {
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
expect(() => emojiToTwemojiUrl('')).toThrow('Cannot build a Twemoji URL without an emoji');
expect(enumIndexToColor(ENUM_PALETTE.length, ENUM_PALETTE)).toEqual(ENUM_PALETTE[0]);
});
it('resolves POI category logos and generates a fallback for unknown chains', () => {
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/logos/waitrose.svg');
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
'/assets/poi-icons/logos/the_food_warehouse.png'
);
expect(getPoiIconUrl("Sainsbury's", '🛒', undefined, 'Sainsburys Earlsfield Local')).toBe(
'/assets/poi-icons/brands_2024/sainsburys_local.svg'
);
expect(getPoiIconUrl('Costco', '🛒')).toBe('/assets/poi-icons/logos/costco.svg');
expect(getPoiIconUrl('M&S', '🛒', undefined, 'M&S Simply Food')).toBe(
'/assets/poi-icons/visuals/mns.svg'
);
expect(getPoiIconUrl('Tian Tian', '🛒')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
});
it('keeps POI icon URLs bundled locally', () => {
expect(Object.values(POI_CATEGORY_LOGOS).filter((url) => /^https?:\/\//.test(url))).toEqual([]);
expect(
Object.values(POI_CATEGORY_LOGOS)
.filter((url) => url.startsWith('/assets/poi-icons/'))
.filter((url) => !existsSync(join(process.cwd(), 'public', url.slice(1))))
).toEqual([]);
});
it('does not use pin-shaped SVGs for branded POI logos', () => {
const pinSignatures = ['viewBox="0 0 400 520"', 'C 18.914 185.931'];
const svgUrls = [
...new Set(
Object.values(POI_CATEGORY_LOGOS)
.filter((url) => url.startsWith('/assets/poi-icons/'))
.filter((url) => url.endsWith('.svg'))
),
];
expect(
svgUrls.filter((url) => {
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
return pinSignatures.some((signature) => content.includes(signature));
})
).toEqual([]);
});
it('keeps bundled SVG logos large enough for the map icon atlas', () => {
const svgUrls = [
...new Set(
Object.values(POI_CATEGORY_LOGOS)
.filter((url) => url.startsWith('/assets/poi-icons/'))
.filter((url) => url.endsWith('.svg'))
),
];
expect(
svgUrls.filter((url) => {
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
const width = Number(content.match(/width="([0-9.]+)/)?.[1]);
const height = Number(content.match(/height="([0-9.]+)/)?.[1]);
return Math.max(width, height) < 256;
})
).toEqual([]);
});
it('returns fallback, filtered, enum, feature, and density colors', () => {
expect(
getFeatureFillColor(
null,
undefined,
undefined,
[0, 100],
null,
0,
DENSITY_GRADIENT,
false,
180
)
).toEqual([128, 128, 128, 80]);
expect(
getFeatureFillColor(50, 50, 60, [0, 100], [70, 90], 0, DENSITY_GRADIENT, true, 180)
).toEqual([60, 55, 50, 60]);
expect(
getFeatureFillColor(1, 1, 1, [0, 2], null, 0, DENSITY_GRADIENT, false, 180, 3, ENUM_PALETTE)
).toEqual([...ENUM_PALETTE[1], 180]);
expect(
getFeatureFillColor(
0,
0,
100,
[0, 100],
null,
0,
DENSITY_GRADIENT,
false,
200,
0,
undefined,
FEATURE_GRADIENT
)
).toEqual([...FEATURE_GRADIENT[0].color, 200]);
expect(
getFeatureFillColor(
undefined,
undefined,
undefined,
null,
null,
0,
DENSITY_GRADIENT,
false,
150
)
).toEqual([...DENSITY_GRADIENT[0].color, 150]);
});
});