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'); }); });