Add page to Developer Docs on Render Tests (#3377)
This commit is contained in:
@@ -1,150 +0,0 @@
|
||||
These integration tests verify the correctness and consistency of [MapLibre Native](https://github.com/maplibre/maplibre-native) rendering.
|
||||
|
||||
## Organization
|
||||
|
||||
Tests are contained in a directory tree, generally organized by [style specification](https://maplibre.org/maplibre-style-spec/)
|
||||
property: `background-color`, `line-width`, etc., with a second level of directories below that for individual tests. For example, the test for specifying a literal `circle-radius` value lives in [`test/integration/render-tests/circle-radius/literal/`](https://github.com/mapbox/mapbox-gl-js/tree/master/test/integration/render-tests/circle-radius/literal).
|
||||
|
||||
Within a leaf directory is a `style.json` file (e.g. [`circle-radius/literal/style.json`](https://github.com/mapbox/mapbox-gl-js/blob/master/test/integration/render-tests/circle-radius/literal/style.json)), which contains the minimal style needed for the given test case. The style can specify the map size, center, bearing, and pitch, and additional test metadata (e.g. output image dimensions).
|
||||
|
||||
The expected output for a given test case is in `expected.png`, e.g. [`circle-radius/literal/expected.png`](https://github.com/mapbox/mapbox-gl-js/blob/master/test/integration/render-tests/circle-radius/literal/expected.png).
|
||||
|
||||
Supporting files -- glyphs, sprites, and tiles -- live in their own respective subdirectories at the top level. The test
|
||||
harness sets up the environment such that requests for these resources are directed to the correct location.
|
||||
|
||||
The contents of vector tile fixtures can be read using the [`vt2geojson`](https://github.com/mapbox/vt2geojson) tool (see below).
|
||||
|
||||
## Running tests
|
||||
|
||||
To run the entire integration test suite (both render or query tests), from within the maplibre-native directory on Linux run the command:
|
||||
|
||||
```
|
||||
./build/mbgl-render-test-runner --manifestPath metrics/linux-clang8-release-style.json
|
||||
```
|
||||
|
||||
To run only the render/query tests:
|
||||
|
||||
```
|
||||
npm run test-render
|
||||
```
|
||||
|
||||
### Running specific tests
|
||||
|
||||
To run a subset of tests or an individual test, you can pass a specific subdirectory to the `test-render` script. For example, to run all the tests for a given property, e.g. `circle-radius`:
|
||||
```
|
||||
$ npm run test-render circle-radius
|
||||
...
|
||||
* passed circle-radius/antimeridian
|
||||
* passed circle-radius/default
|
||||
* passed circle-radius/function
|
||||
* passed circle-radius/literal
|
||||
* passed circle-radius/property-function
|
||||
* passed circle-radius/zoom-and-property-function
|
||||
6 passed (100.0%)
|
||||
Results at: ./test/integration/render-tests/index.html
|
||||
Done in 2.71s.
|
||||
```
|
||||
Or to run a single test:
|
||||
```
|
||||
$ npm run test-render circle-radius/literal
|
||||
...
|
||||
* passed circle-radius/literal
|
||||
1 passed (100.0%)
|
||||
Results at: ./test/integration/render-tests/index.html
|
||||
Done in 2.32s.
|
||||
```
|
||||
|
||||
### Viewing test results
|
||||
|
||||
During a test run, the test harness will use GL-JS to create an `actual.png` image from the given `style.json`, and will then use [pixelmatch](https://github.com/mapbox/pixelmatch) to compare that image to `expected.png`, generating a `diff.png` highlighting the mismatching pixels (if any) in red.
|
||||
|
||||
After the test(s) have run, you can view the results graphically by opening the `index.html` file generated by the harness:
|
||||
|
||||
```
|
||||
open ./test/integration/render-tests/index.html
|
||||
```
|
||||
or
|
||||
```
|
||||
open ./test/integration/query-tests/index.html
|
||||
```
|
||||
|
||||
## Running tests in the browser
|
||||
|
||||
Query tests can be run in the browser, the server for serving up the test page and test fixtures starts when you run
|
||||
```
|
||||
npm run start
|
||||
```
|
||||
OR
|
||||
```
|
||||
npm run start-debug
|
||||
```
|
||||
|
||||
If you want to run only the test server run:
|
||||
```
|
||||
npm run watch-query
|
||||
```
|
||||
|
||||
Then open the following url in the browser of your choice to start running the tests.
|
||||
```
|
||||
http://localhost:7357/
|
||||
```
|
||||
|
||||
### Running specific tests
|
||||
|
||||
A filter can be specified by using the `filter` query param in the url. E.g, adding
|
||||
```
|
||||
?filter=circle-pitch
|
||||
```
|
||||
to the end of the url will only run the tests that contain `circle-pitch` in the name.
|
||||
|
||||
### Build Notifications
|
||||
|
||||
The terminal window can be very noisy with both the build and the test servers running in the same session.
|
||||
So the server uses platform notifications to inform when the build has finished. If this behaviour is annoying, it can be disabled by setting the following env-var
|
||||
```
|
||||
DISABLE_BUILD_NOTIFICATIONS=true
|
||||
```
|
||||
|
||||
|
||||
## Writing new tests
|
||||
|
||||
To add a new render test:
|
||||
1. Create a new directory `test/integration/render-tests/<property-name>/<new-test-name>`
|
||||
|
||||
2. Create a new `style.json` file within that directory, specifying the map to load. Feel free to copy & modify one of the existing `style.json` files from the `render-tests` subdirectories.
|
||||
|
||||
3. Generate an `expected.png` image from the given style by running the new test with the `UPDATE` flag enabled:
|
||||
```
|
||||
$ UPDATE=1 npm run test-render <property-name>/<new-test-name>
|
||||
```
|
||||
The test will appear to fail, but you'll now see a new `expected.png` in the test directory.
|
||||
|
||||
4. Manually inspect `expected.png` to verify it looks as expected, and optionally run the test again without the update flag (`npm run test-render <property-name>/<new-test-name>`) to watch it pass (enjoy that dopamine kick!)
|
||||
|
||||
5. Commit the new `style.json` and `expected.png` :rocket:
|
||||
|
||||
|
||||
## Reading Vector Tile Fixtures
|
||||
|
||||
Install `vt2geojson`, a command line utility which turns vector tiles into geojson, and `harp`, a simple file server.
|
||||
|
||||
```
|
||||
npm install -g vt2geojson harp
|
||||
```
|
||||
|
||||
Start a static file server
|
||||
```
|
||||
harp server .
|
||||
```
|
||||
|
||||
Read the contents of an entire vector tile
|
||||
|
||||
```
|
||||
vt2geojson -z 14 -y 8803 -x 5374 http://localhost:9000/tiles/14-8803-5374.mvt
|
||||
```
|
||||
|
||||
Read the contents of a particular layer in a vector tile
|
||||
|
||||
```
|
||||
vt2geojson --layer poi_label -z 14 -y 8803 -x 5374 http://localhost:9000/tiles/14-8803-5374.mvt
|
||||
```
|
||||
@@ -1,123 +0,0 @@
|
||||
class NullIsland {
|
||||
constructor() {
|
||||
this.id = 'null-island';
|
||||
this.type = 'custom';
|
||||
this.renderingMode = '2d';
|
||||
}
|
||||
|
||||
onAdd(map, gl) {
|
||||
const vertexSource = `
|
||||
uniform mat4 u_matrix;
|
||||
void main() {
|
||||
gl_Position = u_matrix * vec4(0.5, 0.5, 0.0, 1.0);
|
||||
gl_PointSize = 20.0;
|
||||
}`;
|
||||
|
||||
const fragmentSource = `
|
||||
void main() {
|
||||
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vertexShader, vertexSource);
|
||||
gl.compileShader(vertexShader);
|
||||
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fragmentShader, fragmentSource);
|
||||
gl.compileShader(fragmentShader);
|
||||
|
||||
this.program = gl.createProgram();
|
||||
gl.attachShader(this.program, vertexShader);
|
||||
gl.attachShader(this.program, fragmentShader);
|
||||
gl.linkProgram(this.program);
|
||||
}
|
||||
|
||||
render(gl, matrix) {
|
||||
gl.useProgram(this.program);
|
||||
gl.uniformMatrix4fv(gl.getUniformLocation(this.program, "u_matrix"), false, matrix);
|
||||
gl.drawArrays(gl.POINTS, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
class Tent3D {
|
||||
constructor() {
|
||||
this.id = 'tent-3d';
|
||||
this.type = 'custom';
|
||||
this.renderingMode = '3d';
|
||||
}
|
||||
|
||||
onAdd(map, gl) {
|
||||
|
||||
const vertexSource = `
|
||||
|
||||
attribute vec3 aPos;
|
||||
uniform mat4 uMatrix;
|
||||
|
||||
void main() {
|
||||
gl_Position = uMatrix * vec4(aPos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentSource = `
|
||||
void main() {
|
||||
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
|
||||
gl.shaderSource(vertexShader, vertexSource);
|
||||
gl.compileShader(vertexShader);
|
||||
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
|
||||
gl.shaderSource(fragmentShader, fragmentSource);
|
||||
gl.compileShader(fragmentShader);
|
||||
|
||||
this.program = gl.createProgram();
|
||||
gl.attachShader(this.program, vertexShader);
|
||||
gl.attachShader(this.program, fragmentShader);
|
||||
gl.linkProgram(this.program);
|
||||
gl.validateProgram(this.program);
|
||||
|
||||
this.program.aPos = gl.getAttribLocation(this.program, "aPos");
|
||||
this.program.uMatrix = gl.getUniformLocation(this.program, "uMatrix");
|
||||
|
||||
const x = 0.5 - 0.015;
|
||||
const y = 0.5 - 0.01;
|
||||
const z = 0.01;
|
||||
const d = 0.01;
|
||||
|
||||
const vertexArray = new Float32Array([
|
||||
x, y, 0,
|
||||
x + d, y, 0,
|
||||
x, y + d, z,
|
||||
x + d, y + d, z,
|
||||
x, y + d + d, 0,
|
||||
x + d, y + d + d, 0]);
|
||||
const indexArray = new Uint16Array([
|
||||
0, 1, 2,
|
||||
1, 2, 3,
|
||||
2, 3, 4,
|
||||
3, 4, 5
|
||||
]);
|
||||
|
||||
this.vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);
|
||||
this.indexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indexArray, gl.STATIC_DRAW);
|
||||
}
|
||||
|
||||
render(gl, matrix) {
|
||||
gl.useProgram(this.program);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer);
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer);
|
||||
gl.enableVertexAttribArray(this.program.a_pos);
|
||||
gl.vertexAttribPointer(this.program.aPos, 3, gl.FLOAT, false, 0, 0);
|
||||
gl.uniformMatrix4fv(this.program.uMatrix, false, matrix);
|
||||
gl.drawElements(gl.TRIANGLES, 12, gl.UNSIGNED_SHORT, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
"tent-3d": Tent3D,
|
||||
"null-island": NullIsland
|
||||
};
|
||||
0
metrics/integration/dist/.gitkeep
vendored
0
metrics/integration/dist/.gitkeep
vendored
@@ -1,194 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
/* eslint-disable import/no-commonjs */
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
const localizeURLs = require('./localize-urls');
|
||||
|
||||
const OUTPUT_FILE = 'fixtures.json';
|
||||
|
||||
exports.generateFixtureJson = generateFixtureJson;
|
||||
exports.getAllFixtureGlobs = getAllFixtureGlobs;
|
||||
|
||||
/**
|
||||
* Analyzes the contents of the specified `path.join(rootDirectory, suiteDirectory)`, and inlines
|
||||
* the contents into a single json file which can then be imported and built into a bundle
|
||||
* to be shipped to the browser.
|
||||
*
|
||||
* @param {string} rootDirectory
|
||||
* @param {string} suiteDirectory
|
||||
* @param {boolean} includeImages
|
||||
*/
|
||||
function generateFixtureJson(rootDirectory, suiteDirectory, outputDirectory = 'test/integration/dist', includeImages = false) {
|
||||
const globs = getAllFixtureGlobs(rootDirectory, suiteDirectory);
|
||||
const jsonPaths = globs[0];
|
||||
const imagePaths = globs[1];
|
||||
//Extract the filedata into a flat dictionary
|
||||
const allFiles = {};
|
||||
let allPaths = glob.sync(jsonPaths);
|
||||
if (includeImages) {
|
||||
allPaths = allPaths.concat(glob.sync(imagePaths));
|
||||
}
|
||||
|
||||
//A Set that stores test names that are malformed so they can be removed later
|
||||
const malformedTests = {};
|
||||
|
||||
for (const fixturePath of allPaths) {
|
||||
const testName = path.dirname(fixturePath);
|
||||
const fileName = path.basename(fixturePath);
|
||||
const extension = path.extname(fixturePath);
|
||||
try {
|
||||
if (extension === '.json') {
|
||||
let json = parseJsonFromFile(fixturePath);
|
||||
|
||||
//Special case for style json which needs some preprocessing
|
||||
if (fileName === 'style.json') {
|
||||
json = processStyle(testName, json);
|
||||
}
|
||||
|
||||
allFiles[fixturePath] = json;
|
||||
} else if (extension === '.png') {
|
||||
allFiles[fixturePath] = pngToBase64Str(fixturePath);
|
||||
} else {
|
||||
throw new Error(`${extension} is incompatible , file path ${fixturePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error parsing file: ${fixturePath}`);
|
||||
malformedTests[testName] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-nest by directory path, each directory path defines a testcase.
|
||||
const result = {};
|
||||
for (const fullPath in allFiles) {
|
||||
const testName = path.dirname(fullPath).replace(rootDirectory, '');
|
||||
//Skip if test is malformed
|
||||
if (malformedTests[testName]) { continue; }
|
||||
|
||||
//Lazily initaialize an object to store each file wihin a particular testName
|
||||
if (result[testName] == null) {
|
||||
result[testName] = {};
|
||||
}
|
||||
//Trim extension from filename
|
||||
const fileName = path.basename(fullPath, path.extname(fullPath));
|
||||
result[testName][fileName] = allFiles[fullPath];
|
||||
}
|
||||
|
||||
const outputStr = JSON.stringify(result, null, 4);
|
||||
const outputPath = path.join(outputDirectory, OUTPUT_FILE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.writeFile(outputPath, outputStr, {encoding: 'utf8'}, (err) => {
|
||||
if (err) { reject(err); }
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAllFixtureGlobs(rootDirectory, suiteDirectory) {
|
||||
const basePath = path.join(rootDirectory, suiteDirectory);
|
||||
const jsonPaths = path.join(basePath, '/**/*.json');
|
||||
const imagePaths = path.join(basePath, '/**/*.png');
|
||||
|
||||
return [jsonPaths, imagePaths];
|
||||
}
|
||||
|
||||
function parseJsonFromFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, {encoding: 'utf8'}));
|
||||
}
|
||||
|
||||
function pngToBase64Str(filePath) {
|
||||
return fs.readFileSync(filePath).toString('base64');
|
||||
}
|
||||
|
||||
function processStyle(testName, style) {
|
||||
const clone = JSON.parse(JSON.stringify(style));
|
||||
// 7357 is testem's default port
|
||||
localizeURLs(clone, 7357);
|
||||
|
||||
clone.metadata = clone.metadata || {};
|
||||
clone.metadata.test = Object.assign({
|
||||
testName,
|
||||
width: 512,
|
||||
height: 512,
|
||||
pixelRatio: 1,
|
||||
recycleMap: false,
|
||||
allowed: 0.00015
|
||||
}, clone.metadata.test);
|
||||
|
||||
return clone;
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
/* eslint-disable no-process-exit */
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
import {shuffle} from 'shuffle-seed';
|
||||
import {queue} from 'd3';
|
||||
import colors from 'chalk';
|
||||
import template from 'lodash.template';
|
||||
import createServer from './server';
|
||||
|
||||
export default function (directory, implementation, options, run) {
|
||||
const q = queue(1);
|
||||
const server = createServer();
|
||||
|
||||
const tests = options.tests || [];
|
||||
const ignores = options.ignores || {};
|
||||
|
||||
let sequence = glob.sync(`**/${options.fixtureFilename || 'style.json'}`, {cwd: directory})
|
||||
.map(fixture => {
|
||||
const id = path.dirname(fixture);
|
||||
const style = require(path.join(directory, fixture));
|
||||
|
||||
server.localizeURLs(style);
|
||||
|
||||
style.metadata = style.metadata || {};
|
||||
style.metadata.test = Object.assign({
|
||||
id,
|
||||
ignored: ignores[`${path.basename(directory)}/${id}`],
|
||||
width: 512,
|
||||
height: 512,
|
||||
pixelRatio: 1,
|
||||
recycleMap: options.recycleMap || false,
|
||||
allowed: 0.00015
|
||||
}, style.metadata.test);
|
||||
|
||||
return style;
|
||||
})
|
||||
.filter(style => {
|
||||
const test = style.metadata.test;
|
||||
|
||||
if (tests.length !== 0 && !tests.some(t => test.id.indexOf(t) !== -1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (implementation === 'native' && process.env.BUILDTYPE !== 'Debug' && test.id.match(/^debug\//)) {
|
||||
console.log(colors.gray(`* skipped ${test.id}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/^skip/.test(test.ignored)) {
|
||||
console.log(colors.gray(`* skipped ${test.id} (${test.ignored})`));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (options.shuffle) {
|
||||
console.log(colors.white(`* shuffle seed: `) + colors.bold(`${options.seed}`));
|
||||
sequence = shuffle(sequence, options.seed);
|
||||
}
|
||||
|
||||
q.defer(server.listen);
|
||||
|
||||
sequence.forEach(style => {
|
||||
q.defer((callback) => {
|
||||
const test = style.metadata.test;
|
||||
|
||||
try {
|
||||
run(style, test, handleResult);
|
||||
} catch (error) {
|
||||
handleResult(error);
|
||||
}
|
||||
|
||||
function handleResult (error) {
|
||||
if (error) {
|
||||
test.error = error;
|
||||
}
|
||||
|
||||
if (test.ignored && !test.ok) {
|
||||
test.color = '#9E9E9E';
|
||||
test.status = 'ignored failed';
|
||||
console.log(colors.white(`* ignore ${test.id} (${test.ignored})`));
|
||||
} else if (test.ignored) {
|
||||
test.color = '#E8A408';
|
||||
test.status = 'ignored passed';
|
||||
console.log(colors.yellow(`* ignore ${test.id} (${test.ignored})`));
|
||||
} else if (test.error) {
|
||||
test.color = 'red';
|
||||
test.status = 'errored';
|
||||
console.log(colors.red(`* errored ${test.id}`));
|
||||
} else if (!test.ok) {
|
||||
test.color = 'red';
|
||||
test.status = 'failed';
|
||||
console.log(colors.red(`* failed ${test.id}`));
|
||||
} else {
|
||||
test.color = 'green';
|
||||
test.status = 'passed';
|
||||
console.log(colors.green(`* passed ${test.id}`));
|
||||
}
|
||||
|
||||
callback(null, test);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
q.defer(server.close);
|
||||
|
||||
q.awaitAll((err, results) => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
setTimeout(() => { process.exit(-1); }, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const tests = results.slice(1, -1);
|
||||
|
||||
if (process.env.UPDATE) {
|
||||
console.log(`Updated ${tests.length} tests.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let passedCount = 0,
|
||||
ignoreCount = 0,
|
||||
ignorePassCount = 0,
|
||||
failedCount = 0,
|
||||
erroredCount = 0;
|
||||
|
||||
tests.forEach((test) => {
|
||||
if (test.ignored && !test.ok) {
|
||||
ignoreCount++;
|
||||
} else if (test.ignored) {
|
||||
ignorePassCount++;
|
||||
} else if (test.error) {
|
||||
erroredCount++;
|
||||
} else if (!test.ok) {
|
||||
failedCount++;
|
||||
} else {
|
||||
passedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
const totalCount = passedCount + ignorePassCount + ignoreCount + failedCount + erroredCount;
|
||||
|
||||
if (passedCount > 0) {
|
||||
console.log(colors.green('%d passed (%s%)'),
|
||||
passedCount, (100 * passedCount / totalCount).toFixed(1));
|
||||
}
|
||||
|
||||
if (ignorePassCount > 0) {
|
||||
console.log(colors.yellow('%d passed but were ignored (%s%)'),
|
||||
ignorePassCount, (100 * ignorePassCount / totalCount).toFixed(1));
|
||||
}
|
||||
|
||||
if (ignoreCount > 0) {
|
||||
console.log(colors.white('%d ignored (%s%)'),
|
||||
ignoreCount, (100 * ignoreCount / totalCount).toFixed(1));
|
||||
}
|
||||
|
||||
if (failedCount > 0) {
|
||||
console.log(colors.red('%d failed (%s%)'),
|
||||
failedCount, (100 * failedCount / totalCount).toFixed(1));
|
||||
}
|
||||
|
||||
if (erroredCount > 0) {
|
||||
console.log(colors.red('%d errored (%s%)'),
|
||||
erroredCount, (100 * erroredCount / totalCount).toFixed(1));
|
||||
}
|
||||
|
||||
const resultsTemplate = template(fs.readFileSync(path.join(__dirname, '..', 'results.html.tmpl'), 'utf8'));
|
||||
const itemTemplate = template(fs.readFileSync(path.join(directory, 'result_item.html.tmpl'), 'utf8'));
|
||||
|
||||
const stats = {};
|
||||
for (const test of tests) {
|
||||
stats[test.status] = (stats[test.status] || 0) + 1;
|
||||
}
|
||||
|
||||
const unsuccessful = tests.filter(test =>
|
||||
test.status === 'failed' || test.status === 'errored');
|
||||
|
||||
const resultsShell = resultsTemplate({unsuccessful, tests, stats, shuffle: options.shuffle, seed: options.seed})
|
||||
.split('<!-- results go here -->');
|
||||
|
||||
const p = path.join(directory, options.recycleMap ? 'index-recycle-map.html' : 'index.html');
|
||||
const out = fs.createWriteStream(p);
|
||||
|
||||
const q = queue(1);
|
||||
q.defer(write, out, resultsShell[0]);
|
||||
for (const test of tests) {
|
||||
q.defer(write, out, itemTemplate({r: test, hasFailedTests: unsuccessful.length > 0}));
|
||||
}
|
||||
q.defer(write, out, resultsShell[1]);
|
||||
q.await(() => {
|
||||
out.end();
|
||||
out.on('close', () => {
|
||||
console.log(`Results at: ${p}`);
|
||||
process.exit((failedCount + erroredCount) === 0 ? 0 : 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function write(stream, data, cb) {
|
||||
if (!stream.write(data)) {
|
||||
stream.once('drain', cb);
|
||||
} else {
|
||||
process.nextTick(cb);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import diff from 'diff';
|
||||
|
||||
export function generateDiffLog(expected, actual) {
|
||||
return diff.diffJson(expected, actual).map((hunk) => {
|
||||
if (hunk.added) {
|
||||
return `+ ${hunk.value}`;
|
||||
} else if (hunk.removed) {
|
||||
return `- ${hunk.value}`;
|
||||
} else {
|
||||
return ` ${hunk.value}`;
|
||||
}
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export function deepEqual(a, b) {
|
||||
if (typeof a !== typeof b)
|
||||
return false;
|
||||
if (typeof a === 'number')
|
||||
return Math.abs(a - b) < 1e-10;
|
||||
if (a === 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;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/* eslint-disable import/no-commonjs */
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const colors = require('chalk');
|
||||
|
||||
module.exports = function localizeURLs(style, port) {
|
||||
localizeStyleURLs(style, port);
|
||||
if (style.metadata && style.metadata.test && style.metadata.test.operations) {
|
||||
style.metadata.test.operations.forEach((op) => {
|
||||
if (op[0] === 'addSource') {
|
||||
localizeSourceURLs(op[2], port);
|
||||
} else if (op[0] === 'setStyle') {
|
||||
if (typeof op[1] === 'object') {
|
||||
localizeStyleURLs(op[1], port);
|
||||
return;
|
||||
}
|
||||
|
||||
let styleJSON;
|
||||
try {
|
||||
const relativePath = op[1].replace(/^local:\/\//, '');
|
||||
if (relativePath.startsWith('mapbox-gl-styles')) {
|
||||
styleJSON = fs.readFileSync(path.join(path.dirname(require.resolve('mapbox-gl-styles')), '..', relativePath));
|
||||
} else {
|
||||
styleJSON = fs.readFileSync(path.join(__dirname, '..', relativePath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(colors.blue(`* ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
styleJSON = JSON.parse(styleJSON);
|
||||
} catch (error) {
|
||||
console.log(colors.blue(`* Error while parsing ${op[1]}: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
localizeStyleURLs(styleJSON, port);
|
||||
|
||||
op[1] = styleJSON;
|
||||
op[2] = {diff: false};
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function localizeURL(url, port) {
|
||||
return url.replace(/^local:\/\//, `http://localhost:${port}/`);
|
||||
}
|
||||
|
||||
function localizeMapboxSpriteURL(url, port) {
|
||||
return url.replace(/^mapbox:\/\//, `http://localhost:${port}/`);
|
||||
}
|
||||
|
||||
function localizeMapboxFontsURL(url, port) {
|
||||
return url.replace(/^mapbox:\/\/fonts/, `http://localhost:${port}/glyphs`);
|
||||
}
|
||||
|
||||
function localizeMapboxTilesURL(url, port) {
|
||||
return url.replace(/^mapbox:\/\//, `http://localhost:${port}/tiles/`);
|
||||
}
|
||||
|
||||
function localizeMapboxTilesetURL(url, port) {
|
||||
return url.replace(/^mapbox:\/\//, `http://localhost:${port}/tilesets/`);
|
||||
}
|
||||
|
||||
function localizeSourceURLs(source, port) {
|
||||
for (const tile in source.tiles) {
|
||||
source.tiles[tile] = localizeMapboxTilesURL(source.tiles[tile], port);
|
||||
source.tiles[tile] = localizeURL(source.tiles[tile], port);
|
||||
}
|
||||
|
||||
if (source.urls) {
|
||||
source.urls = source.urls.map((url) => localizeMapboxTilesetURL(url, port));
|
||||
source.urls = source.urls.map((url) => localizeURL(url, port));
|
||||
}
|
||||
|
||||
if (source.url) {
|
||||
source.url = localizeMapboxTilesetURL(source.url, port);
|
||||
source.url = localizeURL(source.url, port);
|
||||
}
|
||||
|
||||
if (source.data && typeof source.data == 'string') {
|
||||
source.data = localizeURL(source.data, port);
|
||||
}
|
||||
}
|
||||
|
||||
function localizeStyleURLs (style, port) {
|
||||
for (const source in style.sources) {
|
||||
localizeSourceURLs(style.sources[source], port);
|
||||
}
|
||||
|
||||
if (style.sprite) {
|
||||
style.sprite = localizeMapboxSpriteURL(style.sprite, port);
|
||||
style.sprite = localizeURL(style.sprite, port);
|
||||
}
|
||||
|
||||
if (style.glyphs) {
|
||||
style.glyphs = localizeMapboxFontsURL(style.glyphs, port);
|
||||
style.glyphs = localizeURL(style.glyphs, port);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
function handleOperation(map, operations, opIndex, doneCb) {
|
||||
const operation = operations[opIndex];
|
||||
const opName = operation[0];
|
||||
//Delegate to special handler if one is available
|
||||
if (opName in operationHandlers) {
|
||||
operationHandlers[opName](map, operation.slice(1), () => {
|
||||
doneCb(opIndex);
|
||||
});
|
||||
} else {
|
||||
map[opName](...operation.slice(1));
|
||||
doneCb(opIndex);
|
||||
}
|
||||
}
|
||||
|
||||
export const operationHandlers = {
|
||||
wait(map, params, doneCb) {
|
||||
const wait = function() {
|
||||
if (map.loaded()) {
|
||||
doneCb();
|
||||
} else {
|
||||
map.once('render', wait);
|
||||
}
|
||||
};
|
||||
wait();
|
||||
},
|
||||
idle(map, params, doneCb) {
|
||||
const idle = function() {
|
||||
if (!map.isMoving()) {
|
||||
doneCb();
|
||||
} else {
|
||||
map.once('render', idle);
|
||||
}
|
||||
};
|
||||
idle();
|
||||
}
|
||||
};
|
||||
|
||||
export function applyOperations(map, operations, doneCb) {
|
||||
// No operations specified, end immediately adn invoke doneCb.
|
||||
if (!operations || operations.length === 0) {
|
||||
doneCb();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start recursive chain
|
||||
const scheduleNextOperation = (lastOpIndex) => {
|
||||
if (lastOpIndex === operations.length - 1) {
|
||||
// Stop recusive chain when at the end of the operations
|
||||
doneCb();
|
||||
return;
|
||||
}
|
||||
|
||||
handleOperation(map, operations, ++lastOpIndex, scheduleNextOperation);
|
||||
};
|
||||
scheduleNextOperation(-1);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
/* eslint-env browser */
|
||||
/* global tape:readonly, mapboxgl:readonly */
|
||||
/* eslint-disable import/no-unresolved */
|
||||
// fixtures.json is automatically generated before this file gets built
|
||||
// refer testem.js#before_tests()
|
||||
import fixtures from '../dist/fixtures.json';
|
||||
import ignores from '../../ignores.json';
|
||||
import {applyOperations} from './operation-handlers';
|
||||
import {deepEqual, generateDiffLog} from './json-diff';
|
||||
|
||||
for (const testName in fixtures) {
|
||||
if (testName in ignores) {
|
||||
tape.skip(testName, testFunc);
|
||||
} else {
|
||||
tape(testName, {timeout: 20000}, testFunc);
|
||||
}
|
||||
}
|
||||
|
||||
function testFunc(t) {
|
||||
// This needs to be read from the `t` object because this function runs async in a closure.
|
||||
const currentTestName = t.name;
|
||||
const style = fixtures[currentTestName].style;
|
||||
const expected = fixtures[currentTestName].expected;
|
||||
const options = style.metadata.test;
|
||||
const skipLayerDelete = style.metadata.skipLayerDelete;
|
||||
|
||||
window.devicePixelRatio = options.pixelRatio;
|
||||
|
||||
//1. Create and position the container, floating at the bottom right
|
||||
const container = document.createElement('div');
|
||||
container.style.position = 'fixed';
|
||||
container.style.bottom = '10px';
|
||||
container.style.right = '10px';
|
||||
container.style.width = `${options.width}px`;
|
||||
container.style.height = `${options.height}px`;
|
||||
document.body.appendChild(container);
|
||||
|
||||
//2. Initialize the Map
|
||||
const map = new mapboxgl.Map({
|
||||
container,
|
||||
style,
|
||||
classes: options.classes,
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
preserveDrawingBuffer: true,
|
||||
axonometric: options.axonometric || false,
|
||||
skew: options.skew || [0, 0],
|
||||
fadeDuration: options.fadeDuration || 0,
|
||||
localIdeographFontFamily: options.localIdeographFontFamily || false,
|
||||
crossSourceCollisions: typeof options.crossSourceCollisions === "undefined" ? true : options.crossSourceCollisions
|
||||
});
|
||||
map.repaint = true;
|
||||
map.once('load', () => {
|
||||
//3. Run the operations on the map
|
||||
applyOperations(map, options.operations, () => {
|
||||
|
||||
//4. Perform query operation and compare results from expected values
|
||||
const results = options.queryGeometry ?
|
||||
map.queryRenderedFeatures(options.queryGeometry, options.queryOptions || {}) :
|
||||
[];
|
||||
|
||||
const actual = results.map((feature) => {
|
||||
const featureJson = JSON.parse(JSON.stringify(feature.toJSON()));
|
||||
if (!skipLayerDelete) delete featureJson.layer;
|
||||
return featureJson;
|
||||
});
|
||||
|
||||
const testMetaData = {
|
||||
name: t.name,
|
||||
actual: map.getCanvas().toDataURL()
|
||||
};
|
||||
const success = deepEqual(actual, expected);
|
||||
if (success) {
|
||||
t.pass(JSON.stringify(testMetaData));
|
||||
} else {
|
||||
testMetaData['difference'] = generateDiffLog(expected, actual);
|
||||
t.fail(JSON.stringify(testMetaData));
|
||||
}
|
||||
//Cleanup WebGL context
|
||||
map.remove();
|
||||
delete map.painter.context.gl;
|
||||
document.body.removeChild(container);
|
||||
t.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import * as diff from 'diff';
|
||||
import {PNG} from 'pngjs';
|
||||
import harness from './harness';
|
||||
|
||||
function deepEqual(a, b) {
|
||||
if (typeof a !== typeof b)
|
||||
return false;
|
||||
if (typeof a === 'number')
|
||||
return Math.abs(a - b) < 1e-10;
|
||||
if (a === 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 query 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 {queryFn} query - a function that performs the query
|
||||
* @returns {undefined} terminates the process when testing is complete
|
||||
*/
|
||||
export function run(implementation, options, query) {
|
||||
const directory = path.join(__dirname, '../query-tests');
|
||||
harness(directory, implementation, options, (style, params, done) => {
|
||||
query(style, params, (err, data, results) => {
|
||||
if (err) return done(err);
|
||||
|
||||
const dir = path.join(directory, params.id);
|
||||
|
||||
if (process.env.UPDATE) {
|
||||
fs.writeFile(path.join(dir, 'expected.json'), JSON.stringify(results, null, 2), done);
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = require(path.join(dir, 'expected.json'));
|
||||
|
||||
params.ok = deepEqual(results, expected);
|
||||
|
||||
if (!params.ok) {
|
||||
const msg = diff.diffJson(expected, results)
|
||||
.map((hunk) => {
|
||||
if (hunk.added) {
|
||||
return `+ ${hunk.value}`;
|
||||
} else if (hunk.removed) {
|
||||
return `- ${hunk.value}`;
|
||||
} else {
|
||||
return ` ${hunk.value}`;
|
||||
}
|
||||
})
|
||||
.join('');
|
||||
|
||||
params.difference = msg;
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
const width = params.width * params.pixelRatio;
|
||||
const height = params.height * params.pixelRatio;
|
||||
|
||||
const color = [255, 0, 0, 255];
|
||||
|
||||
function scaleByPixelRatio(x) {
|
||||
return x * params.pixelRatio;
|
||||
}
|
||||
|
||||
if (!Array.isArray(params.queryGeometry[0])) {
|
||||
const p = params.queryGeometry.map(scaleByPixelRatio);
|
||||
const d = 30;
|
||||
drawAxisAlignedLine([p[0] - d, p[1]], [p[0] + d, p[1]], data, width, height, color);
|
||||
drawAxisAlignedLine([p[0], p[1] - d], [p[0], p[1] + d], data, width, height, color);
|
||||
} else {
|
||||
const a = params.queryGeometry[0].map(scaleByPixelRatio);
|
||||
const b = params.queryGeometry[1].map(scaleByPixelRatio);
|
||||
drawAxisAlignedLine([a[0], a[1]], [a[0], b[1]], data, width, height, color);
|
||||
drawAxisAlignedLine([a[0], b[1]], [b[0], b[1]], data, width, height, color);
|
||||
drawAxisAlignedLine([b[0], b[1]], [b[0], a[1]], data, width, height, color);
|
||||
drawAxisAlignedLine([b[0], a[1]], [a[0], a[1]], data, width, height, color);
|
||||
}
|
||||
|
||||
const actualJSON = path.join(dir, 'actual.json');
|
||||
fs.writeFile(actualJSON, JSON.stringify(results, null, 2), () => {});
|
||||
|
||||
const actualPNG = path.join(dir, 'actual.png');
|
||||
|
||||
const png = new PNG({
|
||||
width: params.width * params.pixelRatio,
|
||||
height: params.height * params.pixelRatio
|
||||
});
|
||||
|
||||
png.data = data;
|
||||
|
||||
png.pack()
|
||||
.pipe(fs.createWriteStream(actualPNG))
|
||||
.on('finish', () => {
|
||||
params.actual = fs.readFileSync(actualPNG).toString('base64');
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function drawAxisAlignedLine(a, b, pixels, width, height, color) {
|
||||
const fromX = clamp(Math.min(a[0], b[0]), 0, width);
|
||||
const toX = clamp(Math.max(a[0], b[0]), 0, width);
|
||||
const fromY = clamp(Math.min(a[1], b[1]), 0, height);
|
||||
const toY = clamp(Math.max(a[1], b[1]), 0, height);
|
||||
|
||||
let index;
|
||||
if (fromX === toX) {
|
||||
for (let y = fromY; y <= toY; y++) {
|
||||
index = getIndex(fromX, y);
|
||||
pixels[index + 0] = color[0];
|
||||
pixels[index + 1] = color[1];
|
||||
pixels[index + 2] = color[2];
|
||||
pixels[index + 3] = color[3];
|
||||
}
|
||||
} else {
|
||||
for (let x = fromX; x <= toX; x++) {
|
||||
index = getIndex(x, fromY);
|
||||
pixels[index + 0] = color[0];
|
||||
pixels[index + 1] = color[1];
|
||||
pixels[index + 2] = color[2];
|
||||
pixels[index + 3] = color[3];
|
||||
}
|
||||
}
|
||||
|
||||
function getIndex(x, y) {
|
||||
return (y * width + x) * 4;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(x, a, b) {
|
||||
return Math.max(a, Math.min(b, x));
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import {PNG} from 'pngjs';
|
||||
import harness from './harness';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import * as glob from 'glob';
|
||||
|
||||
/**
|
||||
* Run the render test suite, compute differences to expected values (making exceptions based on
|
||||
* implementation vagaries), print results to standard output, write test artifacts to the
|
||||
* filesystem (optionally updating expected results), and exit the process with a success or
|
||||
* failure code.
|
||||
*
|
||||
* Caller must supply a `render` function that does the actual rendering and passes the raw image
|
||||
* result on to the `render` function's callback.
|
||||
*
|
||||
* A local server is launched that is capable of serving requests for the source, sprite,
|
||||
* font, and tile assets needed by the tests, and the URLs within the test styles are
|
||||
* rewritten to point to that server.
|
||||
*
|
||||
* As the tests run, results are printed to standard output, and test artifacts are written
|
||||
* to the filesystem. If the environment variable `UPDATE` is set, the expected artifacts are
|
||||
* updated in place based on the test rendering.
|
||||
*
|
||||
* If all the tests are successful, this function exits the process with exit code 0. Otherwise
|
||||
* it exits with 1. If an unexpected error occurs, it exits with -1.
|
||||
*
|
||||
* @param {string} implementation - identify the implementation under test; used to
|
||||
* deal with implementation-specific test exclusions and fudge-factors
|
||||
* @param {Object<string>} [ignores] - map of test names to disable. A key is the relative
|
||||
* path to a test directory, e.g. `"render-tests/background-color/default"`. A value is a string
|
||||
* that by convention links to an issue that explains why the test is currently disabled. By default,
|
||||
* disabled tests will be run, but not fail the test run if the result does not match the expected
|
||||
* result. If the value begins with "skip", the test will not be run at all -- use this for tests
|
||||
* that would crash the test harness entirely if they were run.
|
||||
* @param {renderFn} render - a function that performs the rendering
|
||||
* @returns {undefined} terminates the process when testing is complete
|
||||
*/
|
||||
export function run(implementation, ignores, render) {
|
||||
const options = {ignores, tests:[], shuffle:false, recycleMap:false, seed:makeHash()};
|
||||
|
||||
// https://stackoverflow.com/a/1349426/229714
|
||||
function makeHash() {
|
||||
const array = [];
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
|
||||
for (let i = 0; i < 10; ++i)
|
||||
array.push(possible.charAt(Math.floor(Math.random() * possible.length)));
|
||||
|
||||
// join array elements without commas.
|
||||
return array.join('');
|
||||
}
|
||||
|
||||
function checkParameter(param) {
|
||||
const index = options.tests.indexOf(param);
|
||||
if (index === -1)
|
||||
return false;
|
||||
options.tests.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkValueParameter(defaultValue, param) {
|
||||
const index = options.tests.findIndex((elem) => { return String(elem).startsWith(param); });
|
||||
if (index === -1)
|
||||
return defaultValue;
|
||||
|
||||
const split = String(options.tests.splice(index, 1)).split('=');
|
||||
if (split.length !== 2)
|
||||
return defaultValue;
|
||||
|
||||
return split[1];
|
||||
}
|
||||
|
||||
if (process.argv.length > 2) {
|
||||
options.tests = process.argv.slice(2).filter((value, index, self) => { return self.indexOf(value) === index; }) || [];
|
||||
options.shuffle = checkParameter('--shuffle');
|
||||
options.recycleMap = checkParameter('--recycle-map');
|
||||
options.seed = checkValueParameter(options.seed, '--seed');
|
||||
}
|
||||
|
||||
const directory = path.join(__dirname, '../render-tests');
|
||||
harness(directory, implementation, options, (style, params, done) => {
|
||||
render(style, params, (err, data) => {
|
||||
if (err) return done(err);
|
||||
|
||||
let stats;
|
||||
const dir = path.join(directory, params.id);
|
||||
try {
|
||||
stats = fs.statSync(dir, fs.R_OK | fs.W_OK);
|
||||
if (!stats.isDirectory()) throw new Error();
|
||||
} catch (e) {
|
||||
fs.mkdirSync(dir);
|
||||
}
|
||||
|
||||
const expectedPath = path.join(dir, 'expected.png');
|
||||
const actualPath = path.join(dir, 'actual.png');
|
||||
const diffPath = path.join(dir, 'diff.png');
|
||||
|
||||
const width = Math.floor(params.width * params.pixelRatio);
|
||||
const height = Math.floor(params.height * params.pixelRatio);
|
||||
const actualImg = new PNG({width, height});
|
||||
|
||||
// PNG data must be unassociated (not premultiplied)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const a = data[i * 4 + 3] / 255;
|
||||
if (a !== 0) {
|
||||
data[i * 4 + 0] /= a;
|
||||
data[i * 4 + 1] /= a;
|
||||
data[i * 4 + 2] /= a;
|
||||
}
|
||||
}
|
||||
actualImg.data = data;
|
||||
|
||||
// there may be multiple expected images, covering different platforms
|
||||
const expectedPaths = glob.sync(path.join(dir, 'expected*.png'));
|
||||
|
||||
if (!process.env.UPDATE && expectedPaths.length === 0) {
|
||||
throw new Error('No expected*.png files found; did you mean to run tests with UPDATE=true?');
|
||||
}
|
||||
|
||||
if (process.env.UPDATE) {
|
||||
fs.writeFileSync(expectedPath, PNG.sync.write(actualImg));
|
||||
|
||||
} else {
|
||||
// if we have multiple expected images, we'll compare against each one and pick the one with
|
||||
// the least amount of difference; this is useful for covering features that render differently
|
||||
// depending on platform, i.e. heatmaps use half-float textures for improved rendering where supported
|
||||
let minDiff = Infinity;
|
||||
let minDiffImg, minExpectedBuf;
|
||||
|
||||
for (const path of expectedPaths) {
|
||||
const expectedBuf = fs.readFileSync(path);
|
||||
const expectedImg = PNG.sync.read(expectedBuf);
|
||||
const diffImg = new PNG({width, height});
|
||||
|
||||
const diff = pixelmatch(
|
||||
actualImg.data, expectedImg.data, diffImg.data,
|
||||
width, height, {threshold: 0.1285}) / (width * height);
|
||||
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
minDiffImg = diffImg;
|
||||
minExpectedBuf = expectedBuf;
|
||||
}
|
||||
}
|
||||
|
||||
const diffBuf = PNG.sync.write(minDiffImg, {filterType: 4});
|
||||
const actualBuf = PNG.sync.write(actualImg, {filterType: 4});
|
||||
|
||||
fs.writeFileSync(diffPath, diffBuf);
|
||||
fs.writeFileSync(actualPath, actualBuf);
|
||||
|
||||
params.difference = minDiff;
|
||||
params.ok = minDiff <= params.allowed;
|
||||
|
||||
params.actual = actualBuf.toString('base64');
|
||||
params.expected = minExpectedBuf.toString('base64');
|
||||
params.diff = diffBuf.toString('base64');
|
||||
}
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @callback renderFn
|
||||
* @param {Object} style - style to render
|
||||
* @param {Object} options
|
||||
* @param {number} options.width - render this wide
|
||||
* @param {number} options.height - render this high
|
||||
* @param {number} options.pixelRatio - render with this pixel ratio
|
||||
* @param {boolean} options.shuffle - shuffle tests sequence
|
||||
* @param {String} options.seed - Shuffle seed
|
||||
* @param {boolean} options.recycleMap - trigger map object recycling
|
||||
* @param {renderCallback} callback - callback to call with the results of rendering
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback renderCallback
|
||||
* @param {?Error} error
|
||||
* @param {Buffer} [result] - raw RGBA image data
|
||||
*/
|
||||
@@ -1,50 +0,0 @@
|
||||
/* eslint-disable import/no-commonjs */
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const st = require('st');
|
||||
const {createServer} = require('http');
|
||||
const localizeURLs = require('./localize-urls');
|
||||
|
||||
module.exports = function () {
|
||||
const port = 2900;
|
||||
const integrationMount = st({path: path.join(__dirname, '..')});
|
||||
const mapboxGLStylesMount = st({path: path.dirname(require.resolve('mapbox-gl-styles')), url: 'mapbox-gl-styles'});
|
||||
const mapboxMVTFixturesMount = st({path: path.dirname(require.resolve('@mapbox/mvt-fixtures')), url: 'mvt-fixtures'});
|
||||
const server = createServer((req, res) => {
|
||||
if (req.method === 'POST' && req.url === '/write-file') {
|
||||
let body = '';
|
||||
req.on('data', (data) => {
|
||||
body += data;
|
||||
});
|
||||
req.on('end', () => {
|
||||
|
||||
//Write data to disk
|
||||
const {filePath, data} = JSON.parse(body);
|
||||
fs.writeFile(path.join(process.cwd(), filePath), data, 'base64', () => {
|
||||
res.writeHead(200, {'Content-Type': 'text/html'});
|
||||
res.end('ok');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return mapboxMVTFixturesMount(req, res, () => {
|
||||
return mapboxGLStylesMount(req, res, () => {
|
||||
return integrationMount(req, res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
listen(callback) {
|
||||
server.listen(port, callback);
|
||||
},
|
||||
|
||||
close(callback) {
|
||||
server.close(callback);
|
||||
},
|
||||
|
||||
localizeURLs(style) {
|
||||
return localizeURLs(style, port);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
<div class="test <%- r.status %> <%- (hasFailedTests && /passed/.test(r.status) || /ignored/.test(r.status)) ? 'hide' : '' %>">
|
||||
<h2><span class="label" style="background: <%- r.color %>"><%- r.status %></span> <%- r.id %></h2>
|
||||
<% if (r.status !== 'errored') { %>
|
||||
<img src="data:image/png;base64,<%- r.actual %>">
|
||||
<% } %>
|
||||
<% if (r.error) { %><p style="color: red"><strong>Error:</strong> <%- r.error.message %></p><% } %>
|
||||
<% if (r.difference) { %>
|
||||
<pre><%- r.difference.trim() %></pre>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -1,73 +0,0 @@
|
||||
<style>
|
||||
body { font: 18px/1.2 -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; padding: 10px; }
|
||||
h1 { font-size: 32px; margin-bottom: 0; }
|
||||
button { vertical-align: middle; }
|
||||
h2 { font-size: 24px; font-weight: normal; margin: 10px 0 10px; line-height: 1; }
|
||||
img { margin: 0 10px 10px 0; border: 1px dotted #ccc; }
|
||||
.stats { margin-top: 10px; }
|
||||
.test { border-bottom: 1px dotted #bbb; padding-bottom: 5px; }
|
||||
.tests { border-top: 1px dotted #bbb; margin-top: 10px; }
|
||||
.diff { color: #777; }
|
||||
.test p, .test pre { margin: 0 0 10px; }
|
||||
.test pre { font-size: 14px; }
|
||||
.label { color: white; font-size: 18px; padding: 2px 6px 3px; border-radius: 3px; margin-right: 3px; vertical-align: bottom; display: inline-block; }
|
||||
.hide { display: none; }
|
||||
</style>
|
||||
|
||||
<% if (unsuccessful.length) { %>
|
||||
<h1 style="color: red;">
|
||||
<%- unsuccessful.length %> tests failed.
|
||||
<% } else { %>
|
||||
<h1 style="color: green;">
|
||||
All tests passed!
|
||||
<% } %>
|
||||
<button id='toggle-sequence'>Toggle test sequence</button>
|
||||
<button id='toggle-passed'>Toggle passed tests</button>
|
||||
<button id='toggle-ignored'>Toggle ignored tests</button>
|
||||
</h1>
|
||||
|
||||
<p class="stats"><%- Object.keys(stats).map(status => stats[status] + ' ' + status).join(', ') + '.' %></p>
|
||||
|
||||
<div id='test-sequence' class='hide'>
|
||||
<% if (unsuccessful.length) { %>
|
||||
<p><strong>Failed tests:</strong>
|
||||
<% for (const r of unsuccessful) { %><%- r.id %> <% } %></p>
|
||||
<% } %>
|
||||
|
||||
<p><strong>Test sequence:</strong>
|
||||
<% for (const s of tests) { %><%- s.id %> <% } %></p>
|
||||
|
||||
<% if (shuffle) { %><p><strong>Shuffle seed</strong>: <%- seed %></p><% } %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('mouseover', handleHover);
|
||||
document.addEventListener('mouseout', handleHover);
|
||||
|
||||
function handleHover(e) {
|
||||
var el = e.target;
|
||||
if (el.tagName === 'IMG' && el.dataset.altSrc) {
|
||||
var tmp = el.src;
|
||||
el.src = el.dataset.altSrc;
|
||||
el.dataset.altSrc = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('toggle-passed').addEventListener('click', function (e) {
|
||||
for (const row of document.querySelectorAll('.test.passed')) {
|
||||
row.classList.toggle('hide');
|
||||
}
|
||||
});
|
||||
document.getElementById('toggle-ignored').addEventListener('click', function (e) {
|
||||
for (const row of document.querySelectorAll('.test.ignored')) {
|
||||
row.classList.toggle('hide');
|
||||
}
|
||||
});
|
||||
document.getElementById('toggle-sequence').addEventListener('click', function (e) {
|
||||
document.getElementById('test-sequence').classList.toggle('hide');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tests">
|
||||
<!-- results go here -->
|
||||
</div>
|
||||
@@ -1,14 +0,0 @@
|
||||
import {plugins} from '../../build/rollup_plugins';
|
||||
|
||||
export default {
|
||||
input: 'test/integration/lib/query-browser.js',
|
||||
output: {
|
||||
name: 'queryTests',
|
||||
format: 'iife',
|
||||
sourcemap: 'inline',
|
||||
indent: false,
|
||||
file: 'test/integration/dist/query-test.js'
|
||||
},
|
||||
plugins: plugins(false, false),
|
||||
external: [ 'tape', 'mapboxgl' ]
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
/* eslint-disable no-global-assign */
|
||||
/* eslint-disable import/no-commonjs */
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
require = require("esm")(module);
|
||||
const {generateFixtureJson, getAllFixtureGlobs} = require('./lib/generate-fixture-json');
|
||||
const createServer = require('./lib/server');
|
||||
const buildTape = require('../../build/test/build-tape');
|
||||
const runAll = require('npm-run-all');
|
||||
const chokidar = require('chokidar');
|
||||
const rollup = require('rollup');
|
||||
const notifier = require('node-notifier');
|
||||
const rollupDevConfig = require('../../rollup.config').default;
|
||||
const rollupTestConfig = require('./rollup.config.test').default;
|
||||
|
||||
const rootFixturePath = 'test/integration/';
|
||||
const suitePath = 'query-tests';
|
||||
const fixtureBuildInterval = 2000;
|
||||
|
||||
let beforeHookInvoked = false;
|
||||
let server;
|
||||
|
||||
let fixtureWatcher;
|
||||
const rollupWatchers = {};
|
||||
|
||||
module.exports = {
|
||||
"test_page": "test/integration/testem_page.html",
|
||||
"src_files": [
|
||||
"dist/mapbox-gl-dev.js",
|
||||
"test/integration/dist/query-test.js"
|
||||
],
|
||||
"launch_in_dev": [],
|
||||
"launch_in_ci": [ "Chrome" ],
|
||||
"browser_args": {
|
||||
"Chrome": {
|
||||
"mode": "ci",
|
||||
"args": [ "--headless", "--disable-gpu", "--remote-debugging-port=9222" ]
|
||||
}
|
||||
},
|
||||
"proxies": {
|
||||
"/tiles":{
|
||||
"target": "http://localhost:2900"
|
||||
},
|
||||
"/glyphs":{
|
||||
"target": "http://localhost:2900"
|
||||
},
|
||||
"/tilesets":{
|
||||
"target": "http://localhost:2900"
|
||||
},
|
||||
"/sprites":{
|
||||
"target": "http://localhost:2900"
|
||||
},
|
||||
"/data":{
|
||||
"target": "http://localhost:2900"
|
||||
},
|
||||
"/write-file":{
|
||||
"target": "http://localhost:2900"
|
||||
}
|
||||
},
|
||||
"before_tests"(config, data, callback) {
|
||||
if (!beforeHookInvoked) {
|
||||
server = createServer();
|
||||
const buildPromise = config.appMode === 'ci' ? buildArtifactsCi() : buildArtifactsDev();
|
||||
buildPromise.then(() => {
|
||||
server.listen(callback);
|
||||
}).catch((e) => {
|
||||
callback(e);
|
||||
});
|
||||
|
||||
beforeHookInvoked = true;
|
||||
}
|
||||
},
|
||||
"after_tests"(config, data, callback) {
|
||||
if (config.appMode === 'ci') {
|
||||
server.close(callback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// helper method that builds test artifacts when in CI mode.
|
||||
// Retuns a promise that resolves when all artifacts are built
|
||||
function buildArtifactsCi() {
|
||||
//1. Compile fixture data into a json file, so it can be bundled
|
||||
generateFixtureJson(rootFixturePath, suitePath);
|
||||
//2. Build tape
|
||||
const tapePromise = buildTape();
|
||||
//3. Build test artifacts in parallel
|
||||
const rollupPromise = runAll(['build-query-suite', 'build-dev'], {parallel: true});
|
||||
|
||||
return Promise.all([tapePromise, rollupPromise]);
|
||||
}
|
||||
|
||||
// helper method that starts a bunch of build-watchers and returns a promise
|
||||
// that resolves when all of them have had their first run.
|
||||
function buildArtifactsDev() {
|
||||
return buildTape().then(() => {
|
||||
// A promise that resolves on the first build of fixtures.json
|
||||
return new Promise((resolve, reject) => {
|
||||
fixtureWatcher = chokidar.watch(getAllFixtureGlobs(rootFixturePath, suitePath));
|
||||
let needsRebuild = false;
|
||||
fixtureWatcher.on('ready', () => {
|
||||
generateFixtureJson(rootFixturePath, suitePath);
|
||||
|
||||
//Throttle calls to `generateFixtureJson` to run every 2s
|
||||
setInterval(() => {
|
||||
if (needsRebuild) {
|
||||
generateFixtureJson(rootFixturePath, suitePath);
|
||||
needsRebuild = false;
|
||||
}
|
||||
}, fixtureBuildInterval);
|
||||
|
||||
//Flag needs rebuild when anything changes
|
||||
fixtureWatcher.on('all', () => {
|
||||
needsRebuild = true;
|
||||
});
|
||||
// Resolve promise once chokidar has finished first scan of fixtures
|
||||
resolve();
|
||||
});
|
||||
|
||||
fixtureWatcher.on('error', (e) => reject(e));
|
||||
});
|
||||
}).then(() => {
|
||||
//Helper function that starts a rollup watcher
|
||||
//returns a promise that resolves when the first bundle has finished
|
||||
function startRollupWatcher(name, config) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const watcher = rollup.watch(silenceWarnings(config));
|
||||
rollupWatchers[name] = watcher;
|
||||
|
||||
watcher.on('event', (e) => {
|
||||
if (e.code === 'START') {
|
||||
notify('Query Tests', `${name} bundle started`);
|
||||
}
|
||||
if (e.code === 'END') {
|
||||
notify('Query Tests', `${name} bundle finished`);
|
||||
resolve();
|
||||
}
|
||||
if (e.code === 'FATAL') {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
startRollupWatcher('mapbox-gl', rollupDevConfig),
|
||||
startRollupWatcher('query-suite', rollupTestConfig),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
function silenceWarnings(config) {
|
||||
function addEmptyWarningHandler(configObj) {
|
||||
configObj["onwarn"] = function() {};
|
||||
return configObj;
|
||||
}
|
||||
|
||||
if (Array.isArray(config)) {
|
||||
return config.map(addEmptyWarningHandler);
|
||||
} else {
|
||||
return addEmptyWarningHandler(config);
|
||||
}
|
||||
}
|
||||
|
||||
function notify(title, message) {
|
||||
if (!process.env.DISABLE_BUILD_NOTIFICATIONS) {
|
||||
notifier.notify({title, message});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>
|
||||
Mapbox GL JS Integration Tests
|
||||
</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script src="/testem.js"></script>
|
||||
<script src="/test/integration/dist/tape.js"></script>
|
||||
<script src="/dist/mapbox-gl-dev.js"></script>
|
||||
<script src="/test/integration/dist/query-test.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user