239 lines
7.8 KiB
TypeScript
239 lines
7.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { FeatureMeta } from '../types';
|
|
import { apiUrl, assertOk, buildFilterString, isAbortError, paramsWithLanguage } from './api';
|
|
import { createSchoolFilterKey } from './school-filter';
|
|
import { createSpecificCrimeFilterKey } from './crime-filter';
|
|
import { createElectionVoteShareFilterKey } from './election-filter';
|
|
import { createEthnicityFilterKey } from './ethnicity-filter';
|
|
import { createQualificationFilterKey } from './qualification-filter';
|
|
import { createTenureFilterKey } from './tenure-filter';
|
|
import {
|
|
POI_COUNT_2KM_FILTER_NAME,
|
|
TRANSPORT_DISTANCE_FILTER_NAME,
|
|
createPoiDistanceFilterKey,
|
|
createPoiFilterKey,
|
|
} from './poi-distance-filter';
|
|
|
|
describe('api utilities', () => {
|
|
it('builds API URLs from endpoint names, paths, and params', () => {
|
|
expect(apiUrl('features')).toBe('/api/features');
|
|
expect(apiUrl('/custom/path')).toBe('/custom/path');
|
|
expect(apiUrl('hexagons', new URLSearchParams({ bounds: '1,2,3,4' }))).toBe(
|
|
'/api/hexagons?bounds=1%2C2%2C3%2C4'
|
|
);
|
|
});
|
|
|
|
it('throws helpful errors for non-OK responses', () => {
|
|
expect(() => assertOk(new Response(null, { status: 204 }), 'empty')).not.toThrow();
|
|
expect(() =>
|
|
assertOk(new Response(null, { status: 404, statusText: 'Not Found' }), 'lookup')
|
|
).toThrow('lookup: HTTP 404 Not Found');
|
|
});
|
|
|
|
it('recognizes AbortError instances', () => {
|
|
const abort = new Error('Aborted');
|
|
abort.name = 'AbortError';
|
|
const regular = new Error('nope');
|
|
|
|
expect(isAbortError(abort)).toBe(true);
|
|
expect(isAbortError(regular)).toBe(false);
|
|
});
|
|
|
|
it('adds supported language parameters without overriding explicit languages', () => {
|
|
expect(paramsWithLanguage('lat=51.5&lon=-0.1', 'fr-FR')).toBe('lat=51.5&lon=-0.1&lang=fr');
|
|
expect(paramsWithLanguage('lat=51.5&lang=de', 'fr')).toBe('lat=51.5&lang=de');
|
|
});
|
|
|
|
it('serializes numeric, absolute, and enum filters for backend routes', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: 'Last known price', type: 'numeric', min: 0, max: 1_000_000 },
|
|
{
|
|
name: 'Estimated current price',
|
|
type: 'numeric',
|
|
absolute: true,
|
|
histogram: { min: 0, max: 2_000_000, p1: 0, p99: 2_000_000, counts: [1] },
|
|
},
|
|
{ name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
'Last known price': [100_000, 500_000],
|
|
'Estimated current price': [0, 2_000_000],
|
|
'Property type': ['Flat', 'House'],
|
|
},
|
|
features
|
|
)
|
|
).toBe(
|
|
'Last known price:100000:500000;;Estimated current price:0:inf;;Property type:Flat|House'
|
|
);
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
'Last known price': [100_000, 500_000],
|
|
'Property type': ['Flat'],
|
|
},
|
|
features,
|
|
'Last known price'
|
|
)
|
|
).toBe('Property type:Flat');
|
|
});
|
|
|
|
it('deduplicates repeated synthetic school filters before backend routes', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: 'Good+ primary school catchments', type: 'numeric', min: 0, max: 10 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createSchoolFilterKey('primary', 'good', 1)]: [1, 10],
|
|
[createSchoolFilterKey('primary', 'good', 2)]: [2, 8],
|
|
},
|
|
features
|
|
)
|
|
).toBe('Good+ primary school catchments:2:8');
|
|
});
|
|
|
|
it('serializes specific crime filters using their selected backend crime feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: 'Burglary (/yr, 7y)', type: 'numeric', min: 0, max: 20 },
|
|
{ name: 'Vehicle crime (/yr, 7y)', type: 'numeric', min: 0, max: 30 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createSpecificCrimeFilterKey('Burglary (/yr, 7y)', 1)]: [0, 5],
|
|
[createSpecificCrimeFilterKey('Vehicle crime (/yr, 7y)', 2)]: [1, 10],
|
|
},
|
|
features
|
|
)
|
|
).toBe('Burglary (/yr, 7y):0:5;;Vehicle crime (/yr, 7y):1:10');
|
|
});
|
|
|
|
it('serializes election vote-share filters using their selected backend party feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: '% Labour', type: 'numeric', min: 0, max: 100 },
|
|
{ name: '% Conservative', type: 'numeric', min: 0, max: 100 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createElectionVoteShareFilterKey('% Labour', 1)]: [30, 60],
|
|
[createElectionVoteShareFilterKey('% Conservative', 2)]: [10, 40],
|
|
},
|
|
features
|
|
)
|
|
).toBe('% Labour:30:60;;% Conservative:10:40');
|
|
});
|
|
|
|
it('deduplicates repeated ethnicity filters to the strictest backend range', () => {
|
|
const features: FeatureMeta[] = [{ name: '% White', type: 'numeric', min: 0, max: 100 }];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createEthnicityFilterKey('% White', 1)]: [10, 90],
|
|
[createEthnicityFilterKey('% White', 2)]: [20, 80],
|
|
},
|
|
features
|
|
)
|
|
).toBe('% White:20:80');
|
|
});
|
|
|
|
it('serializes qualification filters using their selected backend band feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: '% Degree or higher', type: 'numeric', min: 0, max: 100 },
|
|
{ name: '% No qualifications', type: 'numeric', min: 0, max: 100 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createQualificationFilterKey('% Degree or higher', 1)]: [20, 60],
|
|
[createQualificationFilterKey('% No qualifications', 2)]: [0, 25],
|
|
},
|
|
features
|
|
)
|
|
).toBe('% Degree or higher:20:60;;% No qualifications:0:25');
|
|
});
|
|
|
|
it('serializes tenure filters using their selected backend band feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: '% Owner occupied', type: 'numeric', min: 0, max: 100 },
|
|
{ name: '% Private rent', type: 'numeric', min: 0, max: 100 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createTenureFilterKey('% Owner occupied', 1)]: [20, 60],
|
|
[createTenureFilterKey('% Private rent', 2)]: [0, 25],
|
|
},
|
|
features
|
|
)
|
|
).toBe('% Owner occupied:20:60;;% Private rent:0:25');
|
|
});
|
|
|
|
it('serializes amenity distance filters using their selected backend feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: 'Distance to nearest amenity (Park) (km)', type: 'numeric', min: 0, max: 2 },
|
|
{ name: 'Distance to nearest amenity (Café) (km)', type: 'numeric', min: 0, max: 5 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createPoiDistanceFilterKey('Distance to nearest amenity (Park) (km)', 1)]: [0, 0.5],
|
|
[createPoiDistanceFilterKey('Distance to nearest amenity (Café) (km)', 2)]: [0, 1],
|
|
},
|
|
features
|
|
)
|
|
).toBe(
|
|
'Distance to nearest amenity (Park) (km):0:0.5;;Distance to nearest amenity (Café) (km):0:1'
|
|
);
|
|
});
|
|
|
|
it('serializes amenity count filters using their selected backend feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: 'Number of amenities (Cafe) within 2km', type: 'numeric', min: 0, max: 20 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createPoiFilterKey(
|
|
POI_COUNT_2KM_FILTER_NAME,
|
|
'Number of amenities (Cafe) within 2km',
|
|
1
|
|
)]: [2, 10],
|
|
},
|
|
features
|
|
)
|
|
).toBe('Number of amenities (Cafe) within 2km:2:10');
|
|
});
|
|
|
|
it('serializes transport distance filters using their selected backend feature', () => {
|
|
const features: FeatureMeta[] = [
|
|
{ name: 'Distance to nearest amenity (Bus stop) (km)', type: 'numeric', min: 0, max: 2 },
|
|
];
|
|
|
|
expect(
|
|
buildFilterString(
|
|
{
|
|
[createPoiFilterKey(
|
|
TRANSPORT_DISTANCE_FILTER_NAME,
|
|
'Distance to nearest amenity (Bus stop) (km)',
|
|
1
|
|
)]: [0, 0.4],
|
|
},
|
|
features
|
|
)
|
|
).toBe('Distance to nearest amenity (Bus stop) (km):0:0.4');
|
|
});
|
|
});
|