From 7e774f09bbce1d69c3281bc9cff4986ac7cbe2cb Mon Sep 17 00:00:00 2001 From: Michael Kreil <github@michael-kreil.de> Date: Sun, 25 Feb 2024 13:11:30 +0100 Subject: [PATCH] test: improve tilejson --- src/types/tilejson.test.ts | 144 ++++++++++++++++++++++++++----------- src/types/tilejson.ts | 77 ++++++++++++++------ src/types/vector_layer.ts | 2 +- 3 files changed, 157 insertions(+), 66 deletions(-) diff --git a/src/types/tilejson.test.ts b/src/types/tilejson.test.ts index 63ed499..8d9b031 100644 --- a/src/types/tilejson.test.ts +++ b/src/types/tilejson.test.ts @@ -3,52 +3,110 @@ import { isTileJSONSpecification } from './tilejson'; describe('isTileJSONSpecification', () => { - it('should return true for a valid TileJSONSpecificationRaster object', () => { - const rasterSpec = { - type: 'raster', - format: 'png', - tiles: ['http://example.com/{z}/{x}/{y}.png'], - }; - expect(isTileJSONSpecification(rasterSpec)).toBeTruthy(); - }); - - it('should throw an error for an invalid TileJSONSpecification object', () => { - const invalidSpec = { - type: 'vector', // Missing required 'vector_layers' property for 'vector' type - format: 'pbf', - tiles: ['http://example.com/{z}/{x}/{y}.pbf'], - }; - expect(() => isTileJSONSpecification(invalidSpec)).toThrow(); - }); - - // Test for valid TileJSONSpecificationVector object - it('should return true for a valid TileJSONSpecificationVector object', () => { - const vectorSpec = { - type: 'vector', - format: 'pbf', - tiles: ['http://example.com/{z}/{x}/{y}.pbf'], - vector_layers: [{ id: 'layer1', fields: { property1: 'Number' }, description: 'A test layer' }], - }; - expect(isTileJSONSpecification(vectorSpec)).toBeTruthy(); - }); - - // Test for missing 'tiles' property + const validVectorSpec = { + tilejson: '3.0.0', + type: 'vector', + format: 'pbf', + tiles: ['http://example.com/{z}/{x}/{y}.pbf'], + vector_layers: [{ id: 'layer1', fields: { property1: 'Number' }, description: 'A test layer' }], + }; + const validRasterSpec = { + tilejson: '3.0.0', + type: 'raster', + format: 'png', + tiles: ['http://example.com/{z}/{x}/{y}.png'], + }; + + it('should return true for a valid raster source', () => { + expect(isTileJSONSpecification({ ...validRasterSpec })).toBeTruthy(); + }); + + it('should return true for a valid vector source', () => { + expect(isTileJSONSpecification({ ...validVectorSpec })).toBeTruthy(); + }); + + it('should throw an error if not object', () => { + expect(() => isTileJSONSpecification(null)).toThrow('spec must be an object'); + expect(() => isTileJSONSpecification(1)).toThrow('spec must be an object'); + }); + + it('should throw an error if type is unknown', () => { + expect(() => isTileJSONSpecification({ ...validRasterSpec, type: 'cake' })).toThrow('spec.type must be "raster" or "vector"'); + }); + + it('should throw an error if raster source has incompatible format', () => { + expect(() => isTileJSONSpecification({ ...validRasterSpec, format: 'pbf' })).toThrow('spec.format must be "avif", "jpg", "png", or "webp" for raster sources'); + }); + + it('should throw an error if vector source has incompatible format', () => { + expect(() => isTileJSONSpecification({ ...validVectorSpec, format: 'png' })).toThrow('spec.format must be "pbf" for vector sources'); + }); + it('should throw an error if the tiles property is missing', () => { - const missingTilesSpec = { - type: 'raster', - format: 'png', - }; - expect(() => isTileJSONSpecification(missingTilesSpec)).toThrow('spec.tiles must be an array of strings'); + expect(() => isTileJSONSpecification({ ...validRasterSpec, tiles: undefined })).toThrow('spec.tiles must be an array of strings'); + }); + + it('should throw an error for missing vector_layers in vector source', () => { + expect(() => isTileJSONSpecification({ ...validVectorSpec, vector_layers: undefined })).toThrow('spec.vector_layers is invalid: Expected an array of layers'); }); - // Test for invalid 'bounds' property it('should throw an error if the bounds property is invalid', () => { - const invalidBoundsSpec = { - type: 'vector', - format: 'pbf', - tiles: ['http://example.com/{z}/{x}/{y}.pbf'], - bounds: [180, -90, 190, 90], // Invalid longitude - }; - expect(() => isTileJSONSpecification(invalidBoundsSpec)).toThrow(); + [ + { bounds: [-181, -90, 180, 90], errorMessage: 'spec.bounds[0] must be between -180 and 180' }, + { bounds: [-180, -91, 180, 90], errorMessage: 'spec.bounds[1] must be between -90 and 90' }, + { bounds: [-180, -90, 181, 90], errorMessage: 'spec.bounds[2] must be between -180 and 180' }, + { bounds: [-180, -90, 180, 91], errorMessage: 'spec.bounds[3] must be between -90 and 90' }, + { bounds: [180, -90, -180, 90], errorMessage: 'spec.bounds[0] must be smaller than spec.bounds[2]' }, + { bounds: [-180, 90, 180, -90], errorMessage: 'spec.bounds[1] must be smaller than spec.bounds[3]' }, + ].forEach(({ bounds, errorMessage }) => { + expect(() => isTileJSONSpecification({ ...validVectorSpec, bounds })).toThrow(errorMessage); + }); + }); + + it('should throw an error if the center property is invalid', () => { + [ + { center: [-181, 0], errorMessage: 'spec.center[0] must be between -180 and 180' }, + { center: [181, 0], errorMessage: 'spec.center[0] must be between -180 and 180' }, + { center: [0, -91], errorMessage: 'spec.center[1] must be between -90 and 90' }, + { center: [0, 91], errorMessage: 'spec.center[1] must be between -90 and 90' }, + ].forEach(({ center, errorMessage }) => { + expect(() => isTileJSONSpecification({ ...validVectorSpec, center })).toThrow(errorMessage); + }); + }); + + describe('check every property', () => { + [ + ['tilejson', '"3.0.0"', '3.0.0', '2.0.0'], + ['tiles', 'an array of strings', ['url'], 'url', [], [1], 1], + ['attribution', 'a string if present', 'valid', 1], + ['bounds', 'an array of four numbers if present', [1, 2, 3, 4], ['1', '2', '3', '4'], [1, 2, 3], [], 'invalid'], + ['center', 'an array of two numbers if present', [1, 2], ['1', '2'], [1, 2, 3], [], 'invalid'], + ['data', 'an array of strings if present', ['url'], 'url', [1], 1], + ['description', 'a string if present', 'valid', 1], + ['fillzoom', 'a positive integer if present', 5, 'invalid', -1], + ['format', 'a string', 'pbf', 1], + ['grids', 'an array of strings if present', ['1', '2', '3', '4'], [1, 2, 3, 4], 'invalid'], + ['legend', 'a string if present', 'valid', 1], + ['maxzoom', 'a positive integer if present', 5, 'invalid', -1], + ['minzoom', 'a positive integer if present', 5, 'invalid', -1], + ['name', 'a string if present', 'valid', 1], + ['scheme', '"tms" or "xyz" if present', 'xyz', 'invalid', 1], + ['template', 'a string if present', 'valid', 1], + ].forEach(test => { + const key = test[0] as string; + const errorMessage = test[1] as string; + const values = test.slice(2) as unknown[]; + it(key, () => { + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (i === 0) { + expect(isTileJSONSpecification({ ...validVectorSpec, [key]: value })).toBe(true); + } else { + expect(() => isTileJSONSpecification({ ...validVectorSpec, [key]: value })) + .toThrow(`spec.${key} must be ${errorMessage}`); + } + } + }); + }); }); }); diff --git a/src/types/tilejson.ts b/src/types/tilejson.ts index 220108a..329567b 100644 --- a/src/types/tilejson.ts +++ b/src/types/tilejson.ts @@ -3,19 +3,20 @@ import { isVectorLayers } from './vector_layer'; /** Basic structure for TileJSON specification, applicable to both raster and vector types. */ export interface TileJSONSpecificationBasic { - tilejson?: '3.0.0'; - attribution?: string; + tilejson: '3.0.0'; tiles: string[]; - scheme?: 'tms' | 'xyz'; + attribution?: string; bounds?: [number, number, number, number]; center?: [number, number]; + data?: string[]; description?: string; fillzoom?: number; grids?: string[]; legend?: string; - minzoom?: number; maxzoom?: number; + minzoom?: number; name?: string; + scheme?: 'tms' | 'xyz'; template?: string; } @@ -48,42 +49,72 @@ export function isTileJSONSpecification(spec: unknown): spec is TileJSONSpecific const obj = spec as Record<string, unknown>; // Common property validation - if (obj.tilejson !== undefined && obj.tilejson !== '3.0.0') { - throw Error('spec.tilejson must be "3.0.0" if present'); + if (obj.tilejson !== '3.0.0') { + throw Error('spec.tilejson must be "3.0.0"'); } + if (obj.attribution !== undefined && typeof obj.attribution !== 'string') { throw Error('spec.attribution must be a string if present'); } - if (obj.scheme !== undefined && obj.scheme !== 'xyz' && obj.scheme !== 'tms') { - throw Error('spec.scheme must be "tms" or "xyz" if present'); + + if (obj.bounds !== undefined) { + if (!Array.isArray(obj.bounds) || obj.bounds.length !== 4 || obj.bounds.some(num => typeof num !== 'number')) { + throw Error('spec.bounds must be an array of four numbers if present'); + } + const a = obj.bounds as [number, number, number, number]; + if (a[0] < -180 || a[0] > 180) throw Error('spec.bounds[0] must be between -180 and 180'); + if (a[1] < -90 || a[1] > 90) throw Error('spec.bounds[1] must be between -90 and 90'); + if (a[2] < -180 || a[2] > 180) throw Error('spec.bounds[2] must be between -180 and 180'); + if (a[3] < -90 || a[3] > 90) throw Error('spec.bounds[3] must be between -90 and 90'); + if (a[0] > a[2]) throw Error('spec.bounds[0] must be smaller than spec.bounds[2]'); + if (a[1] > a[3]) throw Error('spec.bounds[1] must be smaller than spec.bounds[3]'); } - if (obj.bounds !== undefined && (!Array.isArray(obj.bounds) || obj.bounds.length !== 4 || obj.bounds.some(num => typeof num !== 'number'))) { - throw Error('spec.bounds must be an array of four numbers if present'); + + if (obj.center !== undefined) { + if (!Array.isArray(obj.center) || obj.center.length !== 2 || obj.center.some(num => typeof num !== 'number')) { + throw Error('spec.center must be an array of two numbers if present'); + } + const a = obj.center as [number, number]; + if (a[0] < -180 || a[0] > 180) throw Error('spec.center[0] must be between -180 and 180'); + if (a[1] < -90 || a[1] > 90) throw Error('spec.center[1] must be between -90 and 90'); } - if (obj.center !== undefined && (!Array.isArray(obj.center) || obj.center.length !== 2 || obj.center.some(num => typeof num !== 'number'))) { - throw Error('spec.center must be an array of two numbers if present'); + + if (obj.data !== undefined && (!Array.isArray(obj.data) || obj.data.some(url => typeof url !== 'string'))) { + throw Error('spec.data must be an array of strings if present'); } + if (obj.description !== undefined && typeof obj.description !== 'string') { throw Error('spec.description must be a string if present'); } - if (obj.fillzoom !== undefined && typeof obj.fillzoom !== 'number') { - throw Error('spec.fillzoom must be a number if present'); + + if (obj.fillzoom !== undefined && (typeof obj.fillzoom !== 'number' || (obj.fillzoom < 0))) { + throw Error('spec.fillzoom must be a positive integer if present'); } + if (obj.grids !== undefined && (!Array.isArray(obj.grids) || obj.grids.some(url => typeof url !== 'string'))) { throw Error('spec.grids must be an array of strings if present'); } + if (obj.legend !== undefined && typeof obj.legend !== 'string') { throw Error('spec.legend must be a string if present'); } - if (obj.minzoom !== undefined && typeof obj.minzoom !== 'number') { - throw Error('spec.minzoom must be a number if present'); + + if (obj.minzoom !== undefined && (typeof obj.minzoom !== 'number' || (obj.minzoom < 0))) { + throw Error('spec.minzoom must be a positive integer if present'); } - if (obj.maxzoom !== undefined && typeof obj.maxzoom !== 'number') { - throw Error('spec.maxzoom must be a number if present'); + + if (obj.maxzoom !== undefined && (typeof obj.maxzoom !== 'number' || (obj.maxzoom < 0))) { + throw Error('spec.maxzoom must be a positive integer if present'); } + if (obj.name !== undefined && typeof obj.name !== 'string') { throw Error('spec.name must be a string if present'); } + + if (obj.scheme !== undefined && obj.scheme !== 'xyz' && obj.scheme !== 'tms') { + throw Error('spec.scheme must be "tms" or "xyz" if present'); + } + if (obj.template !== undefined && typeof obj.template !== 'string') { throw Error('spec.template must be a string if present'); } @@ -98,14 +129,16 @@ export function isTileJSONSpecification(spec: unknown): spec is TileJSONSpecific if (obj.type === 'raster') { if (!['avif', 'jpg', 'png', 'webp'].includes(obj.format)) { - throw Error('spec.format must be "avif", "jpg", "png", or "webp"'); + throw Error('spec.format must be "avif", "jpg", "png", or "webp" for raster sources'); } } else if (obj.type === 'vector') { if (obj.format !== 'pbf') { - throw Error('spec.format must be "pbf"'); + throw Error('spec.format must be "pbf" for vector sources'); } - if (!isVectorLayers(obj.vector_layers)) { - throw Error('spec.vector_layers must be an array of VectorLayer'); + try { + if (!isVectorLayers(obj.vector_layers)) throw Error('spec.vector_layers is invalid'); + } catch (error) { + throw Error('spec.vector_layers is invalid: ' + String((error instanceof Error) ? error.message : error)); } } else { throw Error('spec.type must be "raster" or "vector"'); diff --git a/src/types/vector_layer.ts b/src/types/vector_layer.ts index 9067f20..4c63b25 100644 --- a/src/types/vector_layer.ts +++ b/src/types/vector_layer.ts @@ -61,7 +61,7 @@ export function isVectorLayers(layers: unknown): layers is VectorLayer[] { } catch (error) { // Assuming `isVectorLayer` throws an error with a meaningful message, you can rethrow it // Alternatively, customize the error message or handle the error as needed - throw new Error(`Layer[${index}] at invalid: ${String(error)}`); + throw new Error(`Layer[${index}] at invalid: ${String((error instanceof Error) ? error.message : error)}`); } });