diff --git a/.github/workflows/test-expression.yml b/.github/workflows/test-expression.yml index 7a458ee72c..7c6c45dfcc 100644 --- a/.github/workflows/test-expression.yml +++ b/.github/workflows/test-expression.yml @@ -18,5 +18,4 @@ jobs: node-version: 16 architecture: x64 - run: npm ci - - run: npm run build-dev - run: npm run test-expression diff --git a/package.json b/package.json index 2812ec2242..a2e241f1f2 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,7 @@ "test-browser": "jest ./test/integration/browser", "test-render": "node --loader ts-node/esm --experimental-specifier-resolution=node --experimental-json-modules --max-old-space-size=2048 test/integration/render/render.test.ts", "test-query": "jest test/integration/query", - "test-expression": "node --loader ts-node/esm --experimental-specifier-resolution=node test/integration/expression/expression.test.ts", + "test-expression": "jest test/integration/expression", "test-unit": "jest ./src", "codegen": "npm run generate-style-code && npm run generate-struct-arrays && npm run generate-style-spec && npm run generate-shaders", "benchmark": "node --loader ts-node/esm --experimental-specifier-resolution=node bench/run-benchmarks.ts", diff --git a/test/integration/expression/expression.test.ts b/test/integration/expression/expression.test.ts index f96de945c4..331bba72c0 100644 --- a/test/integration/expression/expression.test.ts +++ b/test/integration/expression/expression.test.ts @@ -1,89 +1,74 @@ -import {fileURLToPath} from 'url'; - -import {run} from './expression'; +import path from 'path'; +import fs from 'fs'; +import glob from 'glob'; import {createPropertyExpression} from '../../../src/style-spec/expression'; import {isFunction} from '../../../src/style-spec/function'; import convertFunction from '../../../src/style-spec/function/convert'; import {toString} from '../../../src/style-spec/expression/types'; import {CanonicalTileID} from '../../../src/source/tile_id'; -import MercatorCoordinate from '../../../src/geo/mercator_coordinate'; -import Point from '@mapbox/point-geometry'; +import {getGeometry} from './lib/geometry'; +import {stringify} from './lib/util'; +import {deepEqual, stripPrecision} from '../lib/json-diff'; +import {ExpressionFixture} from './fixture-types'; -const ignores = {}; +const decimalSigFigs = 6; -function getPoint(coord, canonical) { - const p: Point = canonical.getTilePoint(MercatorCoordinate.fromLngLat({lng: coord[0], lat: coord[1]}, 0)); - p.x = Math.round(p.x); - p.y = Math.round(p.y); - return p; -} +const expressionTestFileNames = glob.sync('**/test.json', {cwd: __dirname}); +describe('expression', () => { -function convertPoint(coord, canonical, out) { - out.push([getPoint(coord, canonical)]); -} + expressionTestFileNames.forEach((expressionTestFileName: any) => { + test(expressionTestFileName, (done) => { -function convertPoints(coords, canonical, out) { - for (let i = 0; i < coords.length; i++) { - convertPoint(coords[i], canonical, out); - } -} + const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, expressionTestFileName), 'utf8')); -function convertLine(line, canonical, out) { - const l = []; - for (let i = 0; i < line.length; i++) { - l.push(getPoint(line[i], canonical)); - } - out.push(l); -} + try { + const result = evaluateFixture(fixture); -function convertLines(lines, canonical, out) { - for (let i = 0; i < lines.length; i++) { - convertLine(lines[i], canonical, out); - } -} + if (process.env.UPDATE) { + fixture.expected = { + compiled: result.compiled, + outputs: stripPrecision(result.outputs, decimalSigFigs), + serialized: result.serialized + }; -function getGeometry(feature, geometry, canonical) { - if (geometry.coordinates) { - const coords = geometry.coordinates; - const type = geometry.type; - feature.type = type; - feature.geometry = []; - if (type === 'Point') { - convertPoint(coords, canonical, feature.geometry); - } else if (type === 'MultiPoint') { - feature.type = 'Point'; - convertPoints(coords, canonical, feature.geometry); - } else if (type === 'LineString') { - convertLine(coords, canonical, feature.geometry); - } else if (type === 'MultiLineString') { - feature.type = 'LineString'; - convertLines(coords, canonical, feature.geometry); - } else if (type === 'Polygon') { - convertLines(coords, canonical, feature.geometry); - } else if (type === 'MultiPolygon') { - feature.type = 'Polygon'; - for (let i = 0; i < coords.length; i++) { - const polygon = []; - convertLines(coords[i], canonical, polygon); - feature.geometry.push(polygon); - } - } - } -} + delete fixture.metadata; -let tests; + const dir = path.join(__dirname, expressionTestFileName); + fs.writeFile(path.join(dir, 'test.json'), `${stringify(fixture)}\n`, done); + return; + } -// @ts-ignore -const __filename = fileURLToPath(import.meta.url); + const expected = fixture.expected; + const compileOk = deepEqual(result.compiled, expected.compiled, decimalSigFigs); + const evalOk = compileOk && deepEqual(result.outputs, expected.outputs, decimalSigFigs); + + let recompileOk = true; + let roundTripOk = true; + let serializationOk = true; + if (expected.compiled.result !== 'error') { + serializationOk = compileOk && deepEqual(expected.serialized, result.serialized, decimalSigFigs); + recompileOk = compileOk && deepEqual(result.recompiled, expected.compiled, decimalSigFigs); + roundTripOk = recompileOk && deepEqual(result.roundTripOutputs, expected.outputs, decimalSigFigs); + } -if (process.argv[1] === __filename && process.argv.length > 2) { - tests = process.argv.slice(2); -} + expect(compileOk).toBeTruthy(); + expect(evalOk).toBeTruthy(); + expect(recompileOk).toBeTruthy(); + expect(roundTripOk).toBeTruthy(); + expect(serializationOk).toBeTruthy(); -run('js', {ignores, tests}, (fixture) => { + done(); + } catch (e) { + done(e); + } + + }); + }); + +}); + +function evaluateFixture(fixture) { const spec = Object.assign({}, fixture.propertySpec); - let availableImages; - let canonical; if (!spec['property-type']) { spec['property-type'] = 'data-driven'; @@ -96,72 +81,6 @@ run('js', {ignores, tests}, (fixture) => { }; } - const evaluateExpression = (expression, compilationResult) => { - if (expression.result === 'error') { - compilationResult.result = 'error'; - compilationResult.errors = expression.value.map((err) => ({ - key: err.key, - error: err.message - })); - return; - } - - const evaluationResult = []; - - expression = expression.value; - const type = expression._styleExpression.expression.type; // :scream: - - compilationResult.result = 'success'; - compilationResult.isFeatureConstant = expression.kind === 'constant' || expression.kind === 'camera'; - compilationResult.isZoomConstant = expression.kind === 'constant' || expression.kind === 'source'; - compilationResult.type = toString(type); - - for (const input of fixture.inputs || []) { - try { - const feature: { - properties: any; - id?: any; - type?: any; - } = {properties: input[1].properties || {}}; - availableImages = input[0].availableImages || []; - if ('canonicalID' in input[0]) { - const id = input[0].canonicalID; - canonical = new CanonicalTileID(id.z, id.x, id.y); - } else { - canonical = null; - } - - if ('id' in input[1]) { - feature.id = input[1].id; - } - if ('geometry' in input[1]) { - if (canonical !== null) { - getGeometry(feature, input[1].geometry, canonical); - } else { - feature.type = input[1].geometry.type; - } - } - - let value = expression.evaluateWithoutErrorHandling(input[0], feature, {}, canonical, availableImages); - - if (type.kind === 'color') { - value = [value.r, value.g, value.b, value.a]; - } - evaluationResult.push(value); - } catch (error) { - if (error.name === 'ExpressionEvaluationError') { - evaluationResult.push({error: error.toJSON()}); - } else { - evaluationResult.push({error: error.message}); - } - } - } - - if (fixture.inputs) { - return evaluationResult; - } - }; - const result: { compiled: any; recompiled: any; @@ -169,6 +88,7 @@ run('js', {ignores, tests}, (fixture) => { serialized?: any; roundTripOutputs?: any; } = {compiled: {}, recompiled: {}}; + const expression = (() => { if (isFunction(fixture.expression)) { return createPropertyExpression(convertFunction(fixture.expression, spec), spec); @@ -177,11 +97,11 @@ run('js', {ignores, tests}, (fixture) => { } })(); - result.outputs = evaluateExpression(expression, result.compiled); + result.outputs = evaluateExpression(fixture, expression, result.compiled); if (expression.result === 'success') { // @ts-ignore result.serialized = expression.value._styleExpression.expression.serialize(); - result.roundTripOutputs = evaluateExpression( + result.roundTripOutputs = evaluateExpression(fixture, createPropertyExpression(result.serialized, spec), result.recompiled); // Type is allowed to change through serialization @@ -191,4 +111,74 @@ run('js', {ignores, tests}, (fixture) => { } return result; -}); +} + +function evaluateExpression (fixture: ExpressionFixture, expression, compilationResult) { + + let availableImages; + let canonical; + + if (expression.result === 'error') { + compilationResult.result = 'error'; + compilationResult.errors = expression.value.map((err) => ({ + key: err.key, + error: err.message + })); + return; + } + + const evaluationResult = []; + + expression = expression.value; + const type = expression._styleExpression.expression.type; // :scream: + + compilationResult.result = 'success'; + compilationResult.isFeatureConstant = expression.kind === 'constant' || expression.kind === 'camera'; + compilationResult.isZoomConstant = expression.kind === 'constant' || expression.kind === 'source'; + compilationResult.type = toString(type); + + for (const input of fixture.inputs || []) { + try { + const feature: { + properties: any; + id?: any; + type?: any; + } = {properties: input[1].properties || {}}; + availableImages = input[0].availableImages || []; + if ('canonicalID' in input[0]) { + const id = input[0].canonicalID; + canonical = new CanonicalTileID(id.z, id.x, id.y); + } else { + canonical = null; + } + + if ('id' in input[1]) { + feature.id = input[1].id; + } + if ('geometry' in input[1]) { + if (canonical !== null) { + getGeometry(feature, input[1].geometry, canonical); + } else { + feature.type = input[1].geometry.type; + } + } + + let value = expression.evaluateWithoutErrorHandling(input[0], feature, {}, canonical, availableImages); + + if (type.kind === 'color') { + value = [value.r, value.g, value.b, value.a]; + } + evaluationResult.push(value); + } catch (error) { + if (error.name === 'ExpressionEvaluationError') { + evaluationResult.push({error: error.toJSON()}); + } else { + evaluationResult.push({error: error.message}); + } + } + } + + if (fixture.inputs) { + return evaluationResult; + } +} diff --git a/test/integration/expression/expression.ts b/test/integration/expression/expression.ts deleted file mode 100644 index 9586f8a18e..0000000000 --- a/test/integration/expression/expression.ts +++ /dev/null @@ -1,200 +0,0 @@ -import path, {dirname} from 'path'; -import {diffJson} from 'diff'; -import fs from 'fs'; -import harness from '../lib/harness'; -import compactStringify from 'json-stringify-pretty-compact'; -import {fileURLToPath} from 'url'; - -// @ts-ignore -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// we have to handle this edge case here because we have test fixtures for this -// edge case, and we don't want UPDATE=1 to mess with them -function stringify(v) { - let s = compactStringify(v); - // http://timelessrepo.com/json-isnt-a-javascript-subset - if (s.indexOf('\u2028') >= 0) { - s = s.replace(/\u2028/g, '\\u2028'); - } - if (s.indexOf('\u2029') >= 0) { - s = s.replace(/\u2029/g, '\\u2029'); - } - return s; -} - -const decimalSigFigs = 6; - -function stripPrecision(x) { - // Intended for test output serialization: - // strips down to 6 decimal sigfigs but stops at decimal point - if (typeof x === 'number') { - if (x === 0) { return x; } - - const multiplier = Math.pow(10, - Math.max(0, - decimalSigFigs - Math.ceil(Math.log10(Math.abs(x))))); - - // We strip precision twice in a row here to avoid cases where - // stripping an already stripped number will modify its value - // due to bad floating point precision luck - // eg `Math.floor(8.16598 * 100000) / 100000` -> 8.16597 - const firstStrip = Math.floor(x * multiplier) / multiplier; - return Math.floor(firstStrip * multiplier) / multiplier; - } else if (typeof x !== 'object') { - return x; - } else if (Array.isArray(x)) { - return x.map(stripPrecision); - } else { - const stripped = {}; - for (const key of Object.keys(x)) { - stripped[key] = stripPrecision(x[key]); - } - return stripped; - } -} - -function deepEqual(a, b) { - if (typeof a !== typeof b) - return false; - if (typeof a === 'number') { - return stripPrecision(a) === stripPrecision(b); - } - if (a === null || b === null || typeof a !== 'object') - return a === b; - - const ka = Object.keys(a); - const kb = Object.keys(b); - - if (ka.length !== kb.length) - return false; - - ka.sort(); - kb.sort(); - - for (let i = 0; i < ka.length; i++) - if (ka[i] !== kb[i] || !deepEqual(a[ka[i]], b[ka[i]])) - return false; - - return true; -} - -/** - * Run the expression suite. - * - * @param implementation - identify the implementation under test; used to - * deal with implementation-specific test exclusions and fudge-factors - * @param options - * @param options.tests - array of test names to run; tests not in the array will be skipped - * @param options.ignores - array of test names to ignore. - * @param runExpressionTest - a function that runs a single expression test fixture - * @returns terminates the process when testing is complete - */ -export function run(implementation: string, options: { - tests?: any; ignores?: any; fixtureFilename?: any; -}, runExpressionTest) { - const directory = path.join(__dirname); - options.fixtureFilename = 'test.json'; - harness(directory, implementation, options, (fixture, params, done) => { - try { - const result = runExpressionTest(fixture, params); - const dir = path.join(directory, params.id); - - if (process.env.UPDATE) { - fixture.expected = { - compiled: result.compiled, - outputs: stripPrecision(result.outputs), - serialized: result.serialized - }; - - delete fixture.metadata; - - fs.writeFile(path.join(dir, 'test.json'), `${stringify(fixture)}\n`, done); - return; - } - - const expected = fixture.expected; - const compileOk = deepEqual(result.compiled, expected.compiled); - const evalOk = compileOk && deepEqual(result.outputs, expected.outputs); - - let recompileOk = true; - let roundTripOk = true; - let serializationOk = true; - if (expected.compiled.result !== 'error') { - serializationOk = compileOk && deepEqual(expected.serialized, result.serialized); - recompileOk = compileOk && deepEqual(result.recompiled, expected.compiled); - roundTripOk = recompileOk && deepEqual(result.roundTripOutputs, expected.outputs); - } - - params.ok = compileOk && evalOk && recompileOk && roundTripOk && serializationOk; - - const diffOutput = { - text: '', - html: '' - }; - - const diffJsonWrap = (label, expectedJson, actualJson) => { - let text = ''; - let html = ''; - diffJson(expectedJson, actualJson) - .forEach((hunk) => { - if (hunk.added) { - text += `+ ${hunk.value}`; - html += ` ${hunk.value}`; - } else if (hunk.removed) { - text += `- ${hunk.value}`; - html += ` ${hunk.value}`; - } else { - text += ` ${hunk.value}`; - html += ` ${hunk.value}`; - } - }); - if (text) { - diffOutput.text += `${label}\n${text}`; - diffOutput.html += `

${label}

\n${html}`; - } - }; - - if (!compileOk) { - diffJsonWrap('Compiled', expected.compiled, result.compiled); - } - if (compileOk && !serializationOk) { - diffJsonWrap('Serialized', expected.serialized, result.serialized); - } - if (compileOk && !recompileOk) { - diffJsonWrap('Serialized and re-compiled', expected.compiled, result.recompiled); - } - - const diffOutputs = (testOutputs) => { - return expected.outputs.map((expectedOutput, i) => { - if (!deepEqual(expectedOutput, testOutputs[i])) { - return `f(${JSON.stringify(fixture.inputs[i])})\nExpected: ${JSON.stringify(expectedOutput)}\nActual: ${JSON.stringify(testOutputs[i])}`; - } - return false; - }) - .filter(Boolean) - .join('\n'); - }; - - if (compileOk && !evalOk) { - const differences = `Original\n${diffOutputs(result.outputs)}\n`; - diffOutput.text += differences; - diffOutput.html += differences; - } - if (recompileOk && !roundTripOk) { - const differences = `\nRoundtripped through serialize()\n${diffOutputs(result.roundTripOutputs)}\n`; - diffOutput.text += differences; - diffOutput.html += differences; - } - - params.difference = diffOutput.html; - if (diffOutput.text) { console.log(diffOutput.text); } - - params.expression = compactStringify(fixture.expression); - params.serialized = compactStringify(result.serialized); - - done(); - } catch (e) { - done(e); - } - }); -} diff --git a/test/integration/expression/fixture-types.ts b/test/integration/expression/fixture-types.ts new file mode 100644 index 0000000000..c6fa9effa7 --- /dev/null +++ b/test/integration/expression/fixture-types.ts @@ -0,0 +1,15 @@ +export type ExpressionFixture = { + expression: any[]; + inputs:any[]; + expected: { + compiled?: { + result?: any; + isFeatureConstant?: any; + isZoomConstant?: any; + type?: any; + }; + outputs? : any; + serialized?: any; + }; +} + diff --git a/test/integration/expression/lib/geometry.ts b/test/integration/expression/lib/geometry.ts new file mode 100644 index 0000000000..dc485f2fbd --- /dev/null +++ b/test/integration/expression/lib/geometry.ts @@ -0,0 +1,70 @@ + +import MercatorCoordinate from '../../../../src/geo/mercator_coordinate'; +import Point from '@mapbox/point-geometry'; +import {CanonicalTileID} from '../../../../src/source/tile_id'; +import {LngLatLike} from '../../../../src/geo/lng_lat'; + +function getPoint(coord: LngLatLike, canonical: CanonicalTileID): Point { + const p: Point = canonical.getTilePoint(MercatorCoordinate.fromLngLat({lng: coord[0], lat: coord[1]}, 0)); + p.x = Math.round(p.x); + p.y = Math.round(p.y); + return p; +} + +function convertPoint(coord: LngLatLike, canonical: CanonicalTileID): Point[] { + return [getPoint(coord, canonical)]; +} + +function convertPoints(coords: LngLatLike[], canonical: CanonicalTileID): Point[][] { + const o: Point[][] = []; + for (let i = 0; i < coords.length; i++) { + o.push(convertPoint(coords[i], canonical)); + } + + return o; +} + +function convertLine(line: LngLatLike[], canonical: CanonicalTileID): Point[] { + const l: Point[] = []; + for (let i = 0; i < line.length; i++) { + l.push(getPoint(line[i], canonical)); + } + return l; +} + +function convertLines(lines: LngLatLike[][], canonical: CanonicalTileID): Point[][] { + const l: Point[][] = []; + for (let i = 0; i < lines.length; i++) { + l.push(convertLine(lines[i], canonical)); + } + return l; +} + +export function getGeometry(feature, geometry, canonical: CanonicalTileID) { + if (geometry.coordinates) { + const coords = geometry.coordinates; + const type = geometry.type; + feature.type = type; + feature.geometry = []; + if (type === 'Point') { + feature.geometry.push(convertPoint(coords, canonical)); + } else if (type === 'MultiPoint') { + feature.type = 'Point'; + feature.geometry.push(...convertPoints(coords, canonical)); + } else if (type === 'LineString') { + feature.geometry.push(convertLine(coords, canonical)); + } else if (type === 'MultiLineString') { + feature.type = 'LineString'; + feature.geometry.push(...convertLines(coords, canonical)); + } else if (type === 'Polygon') { + feature.geometry.push(...convertLines(coords, canonical)); + } else if (type === 'MultiPolygon') { + feature.type = 'Polygon'; + for (let i = 0; i < coords.length; i++) { + const polygon = []; + polygon.push(...convertLines(coords[i], canonical)); + feature.geometry.push(polygon); + } + } + } +} diff --git a/test/integration/expression/lib/util.ts b/test/integration/expression/lib/util.ts new file mode 100644 index 0000000000..667ca3da6a --- /dev/null +++ b/test/integration/expression/lib/util.ts @@ -0,0 +1,15 @@ +import compactStringify from 'json-stringify-pretty-compact'; + +// we have to handle this edge case here because we have test fixtures for this +// edge case, and we don't want UPDATE=1 to mess with them +export function stringify(v) { + let s = compactStringify(v); + + if (s.indexOf('\u2028') >= 0) { + s = s.replace(/\u2028/g, '\\u2028'); + } + if (s.indexOf('\u2029') >= 0) { + s = s.replace(/\u2029/g, '\\u2029'); + } + return s; +} diff --git a/test/integration/expression/resultItemTemplate.ts b/test/integration/expression/resultItemTemplate.ts deleted file mode 100644 index ace8fbc599..0000000000 --- a/test/integration/expression/resultItemTemplate.ts +++ /dev/null @@ -1,17 +0,0 @@ -// eslint-disable-next-line no-unused-expressions -(meta) => `
-

${meta.r.status} ${meta.r.id}

-
${meta.r.expression}
- - ${meta.r.error ? `

Error: ${meta.r.error.message}

` : ''} - - ${meta.r.difference ? ` - Difference: -
${meta.r.difference}
- ` : ''} - - ${meta.r.serialized ? ` - Serialized: -
${meta.r.serialized}
- ` : ''} -
`; diff --git a/test/integration/lib/json-diff.ts b/test/integration/lib/json-diff.ts index 8c4d95e2ca..86784f3999 100644 --- a/test/integration/lib/json-diff.ts +++ b/test/integration/lib/json-diff.ts @@ -12,11 +12,12 @@ export function generateDiffLog(expected, actual) { }).join(''); } -export function deepEqual(a, b) { +export function deepEqual(a, b, decimalSigFigs = 10): boolean { if (typeof a !== typeof b) return false; - if (typeof a === 'number') - return Math.abs(a - b) < 1e-10; + if (typeof a === 'number') { + return stripPrecision(a, decimalSigFigs) === stripPrecision(b, decimalSigFigs); + } if (a === null || typeof a !== 'object') return a === b; @@ -30,8 +31,37 @@ export function deepEqual(a, b) { kb.sort(); for (let i = 0; i < ka.length; i++) - if (ka[i] !== kb[i] || !deepEqual(a[ka[i]], b[ka[i]])) + if (ka[i] !== kb[i] || !deepEqual(a[ka[i]], b[ka[i]], decimalSigFigs)) return false; return true; } + +export function stripPrecision(x, decimalSigFigs = 10) { + // Intended for test output serialization: + // strips down to 6 decimal sigfigs but stops at decimal point + if (typeof x === 'number') { + if (x === 0) { return x; } + + const multiplier = Math.pow(10, + Math.max(0, + decimalSigFigs - Math.ceil(Math.log10(Math.abs(x))))); + + // We strip precision twice in a row here to avoid cases where + // stripping an already stripped number will modify its value + // due to bad floating point precision luck + // eg `Math.floor(8.16598 * 100000) / 100000` -> 8.16597 + const firstStrip = Math.floor(x * multiplier) / multiplier; + return Math.floor(firstStrip * multiplier) / multiplier; + } else if (typeof x !== 'object') { + return x; + } else if (Array.isArray(x)) { + return x.map(stripPrecision); + } else { + const stripped = {}; + for (const key of Object.keys(x)) { + stripped[key] = stripPrecision(x[key]); + } + return stripped; + } +}