Files
MapLibreParticleLab/metrics/integration/lib/expression.js
2023-03-25 14:46:49 +01:00

195 lines
7.1 KiB
JavaScript

import path from 'path';
import * as diff from 'diff';
import fs from 'fs';
import harness from './harness';
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
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 {string} implementation - identify the implementation under test; used to
* deal with implementation-specific test exclusions and fudge-factors
* @param {Object} options
* @param {Array<string>} [options.tests] - array of test names to run; tests not in the array will be skipped
* @param {Array<string>} [options.ignores] - array of test names to ignore.
* @param {} runExpressionTest - a function that runs a single expression test fixture
* @returns {undefined} terminates the process when testing is complete
*/
export function run(implementation, options, runExpressionTest) {
const directory = path.join(__dirname, '../expression-tests');
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, null, 2)}\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 diffJson = (label, expectedJson, actualJson) => {
let text = '';
let html = '';
diff.diffJson(expectedJson, actualJson)
.forEach((hunk) => {
if (hunk.added) {
text += `+ ${hunk.value}`;
html += `<ins> ${hunk.value}</ins>`;
} else if (hunk.removed) {
text += `- ${hunk.value}`;
html += `<del> ${hunk.value}</del>`;
} else {
text += ` ${hunk.value}`;
html += `<span> ${hunk.value}</span>`;
}
});
if (text) {
diffOutput.text += `${label}\n${text}`;
diffOutput.html += `<h3>${label}</h3>\n${html}`;
}
};
if (!compileOk) {
diffJson('Compiled', expected.compiled, result.compiled);
}
if (compileOk && !serializationOk) {
diffJson('Serialized', expected.serialized, result.serialized);
}
if (compileOk && !recompileOk) {
diffJson('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);
}
});
}