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