these too
This commit is contained in:
parent
d4dde21ad2
commit
90c47afe17
11 changed files with 1045 additions and 0 deletions
67
frontend/src/lib/api.test.ts
Normal file
67
frontend/src/lib/api.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { apiUrl, assertOk, buildFilterString, isAbortError } from './api';
|
||||
|
||||
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('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');
|
||||
});
|
||||
});
|
||||
78
frontend/src/lib/external-search.test.ts
Normal file
78
frontend/src/lib/external-search.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildPropertySearchUrls } from './external-search';
|
||||
|
||||
describe('external property search URLs', () => {
|
||||
it('returns null when no postcode is available', () => {
|
||||
expect(
|
||||
buildPropertySearchUrls({
|
||||
location: { lat: 51.5, lon: -0.1, resolution: 8 },
|
||||
filters: {},
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('builds Rightmove, OnTheMarket, and Zoopla URLs with snapped filters', () => {
|
||||
const urls = buildPropertySearchUrls({
|
||||
location: {
|
||||
lat: 51.501,
|
||||
lon: -0.141,
|
||||
resolution: 8,
|
||||
postcode: 'SW1A 1AA',
|
||||
isPostcode: false,
|
||||
},
|
||||
rightmoveLocationId: 'POSTCODE^123456',
|
||||
filters: {
|
||||
'Last known price': [123_456, 376_000],
|
||||
'Property type': ['Detached', 'Flats/Maisonettes'],
|
||||
'Leasehold/Freehold': ['Freehold'],
|
||||
'Number of bedrooms & living rooms': [2, 4],
|
||||
},
|
||||
});
|
||||
|
||||
expect(urls).not.toBeNull();
|
||||
const rightmove = new URL(urls!.rightmove!);
|
||||
const onthemarket = new URL(urls!.onthemarket);
|
||||
const zoopla = new URL(urls!.zoopla);
|
||||
|
||||
expect(rightmove.hostname).toBe('www.rightmove.co.uk');
|
||||
expect(rightmove.searchParams.get('searchLocation')).toBe('SW1A 1AA');
|
||||
expect(rightmove.searchParams.get('locationIdentifier')).toBe('POSTCODE^123456');
|
||||
expect(rightmove.searchParams.get('radius')).toBe('0.5');
|
||||
expect(rightmove.searchParams.get('minPrice')).toBe('120000');
|
||||
expect(rightmove.searchParams.get('maxPrice')).toBe('400000');
|
||||
expect(rightmove.searchParams.get('minBedrooms')).toBe('1');
|
||||
expect(rightmove.searchParams.get('maxBedrooms')).toBe('3');
|
||||
expect(rightmove.searchParams.get('propertyTypes')).toBe('detached,flat');
|
||||
expect(rightmove.searchParams.get('tenureTypes')).toBe('FREEHOLD');
|
||||
|
||||
expect(onthemarket.pathname).toBe('/for-sale/property/sw1a-1aa/');
|
||||
expect(onthemarket.searchParams.get('radius')).toBe('0.5');
|
||||
expect(onthemarket.searchParams.get('min-price')).toBe('120000');
|
||||
expect(onthemarket.searchParams.get('max-price')).toBe('400000');
|
||||
expect(onthemarket.searchParams.getAll('prop-types')).toEqual(['detached', 'flats']);
|
||||
|
||||
expect(zoopla.searchParams.get('q')).toBe('SW1A 1AA');
|
||||
expect(zoopla.searchParams.get('radius')).toBe('0.5');
|
||||
expect(zoopla.searchParams.get('price_min')).toBe('100000');
|
||||
expect(zoopla.searchParams.get('price_max')).toBe('400000');
|
||||
expect(zoopla.searchParams.getAll('property_sub_type')).toEqual(['detached', 'flat']);
|
||||
});
|
||||
|
||||
it('omits Rightmove when location identifier is missing and uses zero radius for postcodes', () => {
|
||||
const urls = buildPropertySearchUrls({
|
||||
location: {
|
||||
lat: 51.501,
|
||||
lon: -0.141,
|
||||
resolution: 9,
|
||||
postcode: 'E1 6AN',
|
||||
isPostcode: true,
|
||||
},
|
||||
filters: {},
|
||||
});
|
||||
|
||||
expect(urls?.rightmove).toBeNull();
|
||||
expect(new URL(urls!.onthemarket).searchParams.get('radius')).toBe('0.25');
|
||||
expect(new URL(urls!.zoopla).searchParams.get('radius')).toBe('0.25');
|
||||
});
|
||||
});
|
||||
42
frontend/src/lib/format.test.ts
Normal file
42
frontend/src/lib/format.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildPercentileScale,
|
||||
calculateHistogramMean,
|
||||
formatFilterValue,
|
||||
formatTransactionDate,
|
||||
parseInputValue,
|
||||
roundedPercentages,
|
||||
} from './format';
|
||||
|
||||
describe('format utilities', () => {
|
||||
it('formats compact filter values and transaction dates', () => {
|
||||
expect(formatFilterValue(1250)).toBe('1.3k');
|
||||
expect(formatFilterValue(1_250_000)).toBe('1.3M');
|
||||
expect(formatFilterValue(1250, true)).toBe('1250');
|
||||
expect(formatTransactionDate(2024.5)).toBe('Jul 2024');
|
||||
});
|
||||
|
||||
it('parses user-entered compact numeric values', () => {
|
||||
expect(parseInputValue('£1.25M', { prefix: '£', step: 5000 })).toBe(1_250_000);
|
||||
expect(parseInputValue('45 sqm', { suffix: ' sqm' })).toBe(45);
|
||||
expect(parseInputValue('2.5万')).toBe(25_000);
|
||||
expect(parseInputValue('not a number')).toBeNull();
|
||||
});
|
||||
|
||||
it('rounds percentages so displayed values sum to 100', () => {
|
||||
expect(roundedPercentages([1, 1, 1], 3)).toEqual([34, 33, 33]);
|
||||
expect(roundedPercentages([1, 2, 3], 6, 1)).toEqual([16.7, 33.3, 50]);
|
||||
expect(roundedPercentages([5, 5], 0)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it('maps histogram percentiles and weighted means consistently', () => {
|
||||
const histogram = { min: 0, p1: 10, p99: 90, max: 100, counts: [10, 80, 10] };
|
||||
const scale = buildPercentileScale(histogram);
|
||||
|
||||
expect(scale.toValue(0)).toBe(0);
|
||||
expect(scale.toValue(50)).toBeCloseTo(50);
|
||||
expect(scale.toPercentile(50)).toBeCloseTo(50);
|
||||
expect(calculateHistogramMean(histogram)).toBeCloseTo(50);
|
||||
});
|
||||
});
|
||||
93
frontend/src/lib/map-utils.test.ts
Normal file
93
frontend/src/lib/map-utils.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DENSITY_GRADIENT, ENUM_PALETTE, FEATURE_GRADIENT } from './consts';
|
||||
import {
|
||||
emojiToTwemojiUrl,
|
||||
enumIndexToColor,
|
||||
getBoundsFromViewState,
|
||||
getFeatureFillColor,
|
||||
zoomToResolution,
|
||||
} from './map-utils';
|
||||
|
||||
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(14)).toBe(9);
|
||||
});
|
||||
|
||||
it('computes buffered bounds around a view state', () => {
|
||||
const bounds = getBoundsFromViewState(
|
||||
{ latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 },
|
||||
1200,
|
||||
800
|
||||
);
|
||||
|
||||
expect(bounds.south).toBeLessThan(51.5);
|
||||
expect(bounds.north).toBeGreaterThan(51.5);
|
||||
expect(bounds.west).toBeLessThan(-0.1);
|
||||
expect(bounds.east).toBeGreaterThan(-0.1);
|
||||
});
|
||||
|
||||
it('builds twemoji URLs and wraps enum colors', () => {
|
||||
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
|
||||
expect(emojiToTwemojiUrl('')).toBe('/assets/twemoji/1f4cd.png');
|
||||
expect(enumIndexToColor(ENUM_PALETTE.length)).toEqual(ENUM_PALETTE[0]);
|
||||
});
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
87
frontend/src/lib/url-state.test.ts
Normal file
87
frontend/src/lib/url-state.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { FeatureMeta } from '../types';
|
||||
import { parseUrlState, stateToParams } from './url-state';
|
||||
|
||||
describe('url-state', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
it('parses view, filters, POIs, tab, postcode, and travel-time params', () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
'',
|
||||
'/?lat=51.5074&lon=-0.1278&zoom=12.5&filter=Last%20known%20price:100000:500000&filter=Property%20type:Flat|House&poi=supermarket&tab=properties&pc=SW1A%201AA&tt=transit:kings-cross:Kings%20Cross:b:0:30'
|
||||
);
|
||||
|
||||
const state = parseUrlState();
|
||||
|
||||
expect(state.viewState).toEqual({
|
||||
latitude: 51.5074,
|
||||
longitude: -0.1278,
|
||||
zoom: 12.5,
|
||||
pitch: 0,
|
||||
});
|
||||
expect(state.filters).toEqual({
|
||||
'Last known price': [100000, 500000],
|
||||
'Property type': ['Flat', 'House'],
|
||||
});
|
||||
expect(state.poiCategories).toEqual(new Set(['supermarket']));
|
||||
expect(state.tab).toBe('properties');
|
||||
expect(state.postcode).toBe('SW1A 1AA');
|
||||
expect(state.travelTime?.entries).toEqual([
|
||||
{
|
||||
mode: 'transit',
|
||||
slug: 'kings-cross',
|
||||
label: 'Kings Cross',
|
||||
timeRange: [0, 30],
|
||||
useBest: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('serializes map state and active filters into stable URL params', () => {
|
||||
const features: FeatureMeta[] = [
|
||||
{ name: 'Last known price', type: 'numeric' },
|
||||
{ name: 'Property type', type: 'enum', values: ['Flat', 'House'] },
|
||||
];
|
||||
|
||||
const params = stateToParams(
|
||||
{ latitude: 51.50742, longitude: -0.12781, zoom: 12.47 },
|
||||
{
|
||||
'Last known price': [100000, 500000],
|
||||
'Property type': ['Flat', 'House'],
|
||||
},
|
||||
features,
|
||||
new Set(['supermarket']),
|
||||
'properties',
|
||||
[
|
||||
{
|
||||
mode: 'bicycle',
|
||||
slug: 'bank',
|
||||
label: 'Bank',
|
||||
useBest: false,
|
||||
timeRange: [5, 25],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(params.get('lat')).toBe('51.5074');
|
||||
expect(params.get('lon')).toBe('-0.1278');
|
||||
expect(params.get('zoom')).toBe('12.5');
|
||||
expect(params.getAll('filter')).toEqual([
|
||||
'Last known price:100000:500000',
|
||||
'Property type:Flat|House',
|
||||
]);
|
||||
expect(params.getAll('poi')).toEqual(['supermarket']);
|
||||
expect(params.get('tab')).toBe('properties');
|
||||
expect(params.getAll('tt')).toEqual(['bicycle:bank:Bank:5:25']);
|
||||
});
|
||||
|
||||
it('omits the default area tab', () => {
|
||||
const params = stateToParams(null, {}, [], new Set(), 'area');
|
||||
|
||||
expect(params.has('tab')).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue