Introduce unit test based on image comparison (#3988)

Attempt to make easier the creation of unit tests that check the drawing output. Until now, this was done by checking calls on a 'fake' context, which is hard to maintain (need to update pixel values by hands) and also not reliable when optimizing code (i.e. different calls sequence but same result).

As of now, it's possible to define 'auto' tests based on JSON/PNG fixtures: chart is generated from the JSON file and compared to the associated PNG image. The image diff is done using `pixelmatch`. As an example (and in preparation of the `filler` plugin), add auto tests for the line element `fill` options.
This commit is contained in:
Simon Brunel 2017-03-05 17:49:12 +01:00 committed by Tanner Linsley
parent c216c0af76
commit 1ca0ffb5d5
30 changed files with 835 additions and 3 deletions

View File

@ -171,6 +171,8 @@ function validHTMLTask() {
function startTest() {
return [
{pattern: './test/fixtures/**/*.json', included: false},
{pattern: './test/fixtures/**/*.png', included: false},
'./node_modules/moment/min/moment.min.js',
'./test/jasmine.index.js',
'./src/**/*.js',

View File

@ -37,6 +37,7 @@
"karma-jasmine": "^1.1.0",
"karma-jasmine-html-reporter": "^0.2.2",
"merge-stream": "^1.0.0",
"pixelmatch": "^4.0.2",
"vinyl-source-stream": "^1.1.0",
"watchify": "^3.7.0",
"yargs": "^5.0.0"

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 255, 0.25)",
"data": [null, null, 2, 3, 4, -4, -2, 1, 0]
}, {
"backgroundColor": "rgba(0, 255, 0, 0.25)",
"data": [6, 2, null, 4, 5, null, null, 2, 1]
}, {
"backgroundColor": "rgba(255, 0, 0, 0.25)",
"data": [7, 3, 4, 5, 6, 1, 4, null, null]
}, {
"backgroundColor": "rgba(0, 0, 255, 0.25)",
"data": [8, 7, 6, -6, -4, -6, 4, 5, 8]
}]
},
"options": {
"responsive": false,
"spanGaps": true,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "bottom",
"tension": 0
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 255, 0.25)",
"data": [null, null, 2, 3, 4, -4, -2, 1, 0]
}, {
"backgroundColor": "rgba(0, 255, 0, 0.25)",
"data": [6, 2, null, 4, 5, null, null, 2, 1]
}, {
"backgroundColor": "rgba(255, 0, 0, 0.25)",
"data": [7, 3, 4, 5, 6, 1, 4, null, null]
}, {
"backgroundColor": "rgba(0, 0, 255, 0.25)",
"data": [8, 7, 6, -6, -4, -6, 4, 5, 8]
}]
},
"options": {
"responsive": false,
"spanGaps": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "bottom",
"tension": 0
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 4, 2, 1, -1, 1, 2]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [4, 2, null, 3, 2.5, null, -2, 1.5, 3]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [3.5, 2, 1, 2.5, -2, 3, -1, null, null]
}, {
"backgroundColor": "rgba(128, 0, 128, 0.25)",
"data": [5, 6, 5, -2, -4, -3, 4, 2, 4.5]
}]
},
"options": {
"responsive": false,
"spanGaps": true,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"cubicInterpolationMode": "monotone",
"borderColor": "transparent",
"fill": "zero"
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 4, 2, 1, -1, 1, 2]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [4, 2, null, 3, 2.5, null, -2, 1.5, 3]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [3.5, 2, 1, 2.5, -2, 3, -1, null, null]
}, {
"backgroundColor": "rgba(128, 0, 128, 0.25)",
"data": [5, 6, 5, -2, -4, -3, 4, 2, 4.5]
}]
},
"options": {
"responsive": false,
"spanGaps": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"cubicInterpolationMode": "monotone",
"borderColor": "transparent",
"fill": "zero"
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,56 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 4, 2, 1, -1, 1, 2]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [4, 2, null, 3, 2.5, null, -2, 1.5, 3]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [3.5, 2, 1, 2.5, -2, 3, -1, null, null]
}, {
"backgroundColor": "rgba(128, 0, 128, 0.25)",
"data": [5, 6, 5, -2, -4, -3, 4, 2, 4.5]
}]
},
"options": {
"responsive": false,
"spanGaps": true,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"cubicInterpolationMode": "monotone",
"borderColor": "transparent",
"stepped": true,
"fill": "zero"
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,56 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 4, 2, 1, -1, 1, 2]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [4, 2, null, 3, 2.5, null, -2, 1.5, 3]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [3.5, 2, 1, 2.5, -2, 3, -1, null, null]
}, {
"backgroundColor": "rgba(128, 0, 128, 0.25)",
"data": [5, 6, 5, -2, -4, -3, 4, 2, 4.5]
}]
},
"options": {
"responsive": false,
"spanGaps": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"cubicInterpolationMode": "monotone",
"borderColor": "transparent",
"stepped": true,
"fill": "zero"
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 3, 4, -4, -2, 1, 0]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [5.5, 2, null, 4, 5, null, null, 2, 1]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [7, 3, 4, 5, 6, 1, 4, null, null]
}, {
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [8, 7, 6.5, -6, -4, -6, 4, 5, 8]
}]
},
"options": {
"responsive": false,
"spanGaps": true,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "top",
"tension": 0
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 3, 4, -4, -2, 1, 0]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [5.5, 2, null, 4, 5, null, null, 2, 1]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [7, 3, 4, 5, 6, 1, 4, null, null]
}, {
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [8, 7, 6.5, -6, -4, -6, 4, 5, 8]
}]
},
"options": {
"responsive": false,
"spanGaps": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "top",
"tension": 0
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 3, 4, -4, -2, 1, 0]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [6, 2, null, 4, 5, null, null, 2, 1]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [7, 3, 4, 5, 6, 1, 4, null, null]
}, {
"backgroundColor": "rgba(0, 64, 192, 0.25)",
"data": [8, 7, 6, -6, -4, -6, 4, 5, 8]
}]
},
"options": {
"responsive": false,
"spanGaps": true,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "zero",
"tension": 0
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,55 @@
{
"config": {
"type": "line",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 3, 4, -4, -2, 1, 0]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [6, 2, null, 4, 5, null, null, 2, 1]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [7, 3, 4, 5, 6, 1, 4, null, null]
}, {
"backgroundColor": "rgba(0, 64, 192, 0.25)",
"data": [8, 7, 6, -6, -4, -6, 4, 5, 8]
}]
},
"options": {
"responsive": false,
"spanGaps": false,
"legend": false,
"title": false,
"scales": {
"xAxes": [{
"ticks": {
"display": false
}
}],
"yAxes": [{
"ticks": {
"display": false
}
}]
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "zero",
"tension": 0
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 512
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,50 @@
{
"config": {
"type": "radar",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 4, 2, 1, -1, 1, 2]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [4, 2, null, 3, 2.5, null, -2, 1.5, 3]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [3.5, 2, 1, 2.5, -2, 3, -1, null, null]
}, {
"backgroundColor": "rgba(128, 0, 128, 0.25)",
"data": [5, 6, 5, -2, -4, -3, 4, 2, 4.5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scale": {
"pointLabels": {
"fontSize": 0
},
"ticks": {
"display": false
}
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"tension": 0.5,
"fill": "zero"
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 256
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,49 @@
{
"config": {
"type": "radar",
"data": {
"labels": ["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"datasets": [{
"backgroundColor": "rgba(0, 0, 192, 0.25)",
"data": [null, null, 2, 4, 2, 1, -1, 1, 2]
}, {
"backgroundColor": "rgba(0, 192, 0, 0.25)",
"data": [4, 2, null, 3, 2.5, null, -2, 1.5, 3]
}, {
"backgroundColor": "rgba(192, 0, 0, 0.25)",
"data": [3.5, 2, 1, 2.5, -2, 3, -1, null, null]
}, {
"backgroundColor": "rgba(128, 0, 128, 0.25)",
"data": [5, 6, 5, -2, -4, -3, 4, 2, 4.5]
}]
},
"options": {
"responsive": false,
"legend": false,
"title": false,
"scale": {
"pointLabels": {
"fontSize": 0
},
"ticks": {
"display": false
}
},
"elements": {
"point": {
"radius": 0
},
"line": {
"borderColor": "transparent",
"fill": "zero"
}
}
}
},
"options": {
"canvas": {
"height": 256,
"width": 256
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -37,6 +37,8 @@ var utils = require('./jasmine.utils');
'position: absolute' +
'}');
jasmine.specsFromFixtures = utils.specsFromFixtures;
beforeEach(function() {
jasmine.addMatchers(matchers);
});

View File

@ -1,5 +1,56 @@
'use strict';
var pixelmatch = require('pixelmatch');
var utils = require('./jasmine.utils');
function toPercent(value) {
return Math.round(value * 10000) / 100;
}
function createImageData(w, h) {
var canvas = utils.createCanvas(w, h);
var context = canvas.getContext('2d');
return context.getImageData(0, 0, w, h);
}
function canvasFromImageData(data) {
var canvas = utils.createCanvas(data.width, data.height);
var context = canvas.getContext('2d');
context.putImageData(data, 0, 0);
return canvas;
}
function buildPixelMatchPreview(actual, expected, diff, threshold, tolerance, count) {
var ratio = count / (actual.width * actual.height);
var wrapper = document.createElement('div');
wrapper.style.cssText = 'display: flex; overflow-y: auto';
[
{data: actual, label: 'Actual'},
{data: expected, label: 'Expected'},
{data: diff, label:
'diff: ' + count + 'px ' +
'(' + toPercent(ratio) + '%)<br/>' +
'thr: ' + toPercent(threshold) + '%, ' +
'tol: '+ toPercent(tolerance) + '%'
}
].forEach(function(values) {
var item = document.createElement('div');
item.style.cssText = 'text-align: center; font: 12px monospace; line-height: 1.4; margin: 8px';
item.innerHTML = '<div style="margin: 8px; height: 32px">' + values.label + '</div>';
item.appendChild(canvasFromImageData(values.data));
wrapper.appendChild(item);
});
// WORKAROUND: https://github.com/karma-runner/karma-jasmine/issues/139
wrapper.indexOf = function() {
return -1;
};
return wrapper;
}
function toBeCloseToPixel() {
return {
compare: function(actual, expected) {
@ -105,9 +156,50 @@ function toBeChartOfSize() {
};
}
function toEqualImageData() {
return {
compare: function(actual, expected, opts) {
var message = null;
var debug = opts.debug || false;
var tolerance = opts.tolerance === undefined? 0.001 : opts.tolerance;
var threshold = opts.threshold === undefined? 0.1 : opts.threshold;
var ctx, idata, ddata, w, h, count, ratio;
if (actual instanceof Chart) {
ctx = actual.ctx;
} else if (actual instanceof HTMLCanvasElement) {
ctx = actual.getContext('2d');
} else if (actual instanceof CanvasRenderingContext2D) {
ctx = actual;
}
if (ctx) {
h = expected.height;
w = expected.width;
idata = ctx.getImageData(0, 0, w, h);
ddata = createImageData(w, h);
count = pixelmatch(idata.data, expected.data, ddata.data, w, h, {threshold: threshold});
ratio = count / (w * h);
if ((ratio > tolerance) || debug) {
message = buildPixelMatchPreview(idata, expected, ddata, threshold, tolerance, count);
}
} else {
message = 'Input value is not a valid image source.';
}
return {
message: message,
pass: !message
};
}
};
}
module.exports = {
toBeCloseToPixel: toBeCloseToPixel,
toEqualOneOf: toEqualOneOf,
toBeValidChart: toBeValidChart,
toBeChartOfSize: toBeChartOfSize
toBeChartOfSize: toBeChartOfSize,
toEqualImageData: toEqualImageData
};

View File

@ -1,3 +1,40 @@
/* global __karma__ */
function loadJSON(url, callback) {
var request = new XMLHttpRequest();
request.onreadystatechange = function() {
if (request.readyState === 4) {
return callback(JSON.parse(request.responseText));
}
};
request.overrideMimeType('application/json');
request.open('GET', url, true);
request.send(null);
}
function createCanvas(w, h) {
var canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
return canvas;
}
function readImageData(url, callback) {
var image = new Image();
image.onload = function() {
var h = image.height;
var w = image.width;
var canvas = createCanvas(w, h);
var ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, w, h);
callback(ctx.getImageData(0, 0, w, h));
};
image.src = url;
}
/**
* Injects a new canvas (and div wrapper) and creates teh associated Chart instance
* using the given config. Additional options allow tweaking elements generation.
@ -69,8 +106,53 @@ function injectCSS(css) {
head.appendChild(style);
}
function specFromFixture(description, inputs) {
it(inputs.json, function(done) {
loadJSON(inputs.json, function(json) {
var chart = acquireChart(json.config, json.options);
if (!inputs.png) {
fail('Missing PNG comparison file for ' + inputs.json);
if (!json.debug) {
releaseChart(chart);
}
done();
}
readImageData(inputs.png, function(expected) {
expect(chart).toEqualImageData(expected, json);
releaseChart(chart);
done();
});
});
});
}
function specsFromFixtures(path) {
var regex = new RegExp('(^/base/test/fixtures/' + path + '.+)\\.(png|json)');
var inputs = {};
Object.keys(__karma__.files || {}).forEach(function(file) {
var matches = file.match(regex);
var name = matches && matches[1];
var type = matches && matches[2];
if (name && type) {
inputs[name] = inputs[name] || {};
inputs[name][type] = file;
}
});
return function() {
Object.keys(inputs).forEach(function(key) {
specFromFixture(key, inputs[key]);
});
};
}
module.exports = {
injectCSS: injectCSS,
createCanvas: createCanvas,
acquireChart: acquireChart,
releaseChart: releaseChart
releaseChart: releaseChart,
specsFromFixtures: specsFromFixtures
};

View File

@ -1,5 +1,7 @@
// Tests for the line element
describe('Line element tests', function() {
describe('Chart.elements.Line', function() {
describe('auto', jasmine.specsFromFixtures('element.line'));
it('should be constructed', function() {
var line = new Chart.elements.Line({
_datasetindex: 2,