Add image-based testing to verify readme screenshots and some rendering tests
This commit is contained in:
parent
5283c511e9
commit
d015795a90
|
@ -77,11 +77,14 @@ The current state of automated testing is:
|
|||
|
||||
* Utilities have a good level of testing
|
||||
* `Parser` and `Generator` stages have a good level of testing
|
||||
* Rendering methods (SVG generation) have a poor level of testing;
|
||||
* Rendering methods (SVG generation) have a minimal level of testing;
|
||||
there are some high-level tests in
|
||||
`/scripts/sequence/SequenceDiagram_spec.js` but many component types
|
||||
are not tested at all during rendering beyond ensuring that they can
|
||||
be used without throwing exceptions. The same applies to themes.
|
||||
`/scripts/sequence/SequenceDiagram_spec.js`, and a series of image
|
||||
comparison tests in `/scripts/sequence/Readme_spec.js` (testing that
|
||||
the readme screenshots roughly match the current behaviour). Finally
|
||||
`/scripts/sequence/SequenceDiagram_visual_spec.js` uses coarse image
|
||||
comparison to test components and interactions using baseline SVGs
|
||||
from `test-images`.
|
||||
* The editor has a minimal level of testing.
|
||||
|
||||
If you suspect a failing test is not related to your changes, you can
|
||||
|
|
|
@ -5992,17 +5992,18 @@ define('sequence/Renderer',[
|
|||
const y0 = titleY - margin;
|
||||
const y1 = stagesHeight + margin;
|
||||
|
||||
this.width = x1 - x0;
|
||||
this.height = y1 - y0;
|
||||
|
||||
this.maskReveal.setAttribute('x', x0);
|
||||
this.maskReveal.setAttribute('y', y0);
|
||||
this.maskReveal.setAttribute('width', x1 - x0);
|
||||
this.maskReveal.setAttribute('height', y1 - y0);
|
||||
this.maskReveal.setAttribute('width', this.width);
|
||||
this.maskReveal.setAttribute('height', this.height);
|
||||
|
||||
this.base.setAttribute('viewBox', (
|
||||
x0 + ' ' + y0 + ' ' +
|
||||
(x1 - x0) + ' ' + (y1 - y0)
|
||||
this.width + ' ' + this.height
|
||||
));
|
||||
this.width = (x1 - x0);
|
||||
this.height = (y1 - y0);
|
||||
}
|
||||
|
||||
_resetState() {
|
||||
|
@ -6119,9 +6120,6 @@ define('sequence/Exporter',[],() => {
|
|||
// Thanks, https://stackoverflow.com/a/23522755/1180785
|
||||
const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
// Thanks, https://stackoverflow.com/a/9851769/1180785
|
||||
const firefox = (typeof window.InstallTrigger !== 'undefined');
|
||||
|
||||
return class Exporter {
|
||||
constructor() {
|
||||
this.latestSVG = null;
|
||||
|
@ -6133,45 +6131,38 @@ define('sequence/Exporter',[],() => {
|
|||
this.latestPNG = null;
|
||||
}
|
||||
|
||||
getSVGContent(renderer, size = null) {
|
||||
getSVGContent(renderer) {
|
||||
let code = renderer.svg().outerHTML;
|
||||
if(firefox && size) {
|
||||
// Firefox fails to render SVGs unless they have size
|
||||
// attributes on the <svg> tag
|
||||
|
||||
// Firefox fails to render SVGs as <img> unless they have size
|
||||
// attributes on the <svg> tag, so we must set this when
|
||||
// exporting from any environment, in case it is opened in FireFox
|
||||
code = code.replace(
|
||||
/^<svg/,
|
||||
'<svg width="' + size.width +
|
||||
'" height="' + size.height + '" '
|
||||
'<svg width="' + renderer.width +
|
||||
'" height="' + renderer.height + '" '
|
||||
);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
getSVGBlob(renderer, size = null) {
|
||||
getSVGBlob(renderer) {
|
||||
return new Blob(
|
||||
[this.getSVGContent(renderer, size)],
|
||||
[this.getSVGContent(renderer)],
|
||||
{type: 'image/svg+xml'}
|
||||
);
|
||||
}
|
||||
|
||||
getSVGURL(renderer, size = null) {
|
||||
const blob = this.getSVGBlob(renderer, size);
|
||||
if(size) {
|
||||
if(this.latestInternalSVG) {
|
||||
URL.revokeObjectURL(this.latestInternalSVG);
|
||||
}
|
||||
this.latestInternalSVG = URL.createObjectURL(blob);
|
||||
return this.latestInternalSVG;
|
||||
} else {
|
||||
getSVGURL(renderer) {
|
||||
const blob = this.getSVGBlob(renderer);
|
||||
if(this.latestSVG) {
|
||||
URL.revokeObjectURL(this.latestSVG);
|
||||
}
|
||||
this.latestSVG = URL.createObjectURL(blob);
|
||||
return this.latestSVG;
|
||||
}
|
||||
}
|
||||
|
||||
getPNGBlob(renderer, resolution, callback) {
|
||||
getCanvas(renderer, resolution, callback) {
|
||||
if(!this.canvas) {
|
||||
window.devicePixelRatio = 1;
|
||||
this.canvas = document.createElement('canvas');
|
||||
|
@ -6198,11 +6189,11 @@ define('sequence/Exporter',[],() => {
|
|||
const render = () => {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.context.drawImage(img, 0, 0);
|
||||
this.context.drawImage(img, 0, 0, width, height);
|
||||
if(safariHackaround) {
|
||||
document.body.removeChild(safariHackaround);
|
||||
}
|
||||
this.canvas.toBlob(callback, 'image/png');
|
||||
callback(this.canvas);
|
||||
};
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
|
@ -6214,7 +6205,13 @@ define('sequence/Exporter',[],() => {
|
|||
}
|
||||
}, {once: true});
|
||||
|
||||
img.src = this.getSVGURL(renderer, {width, height});
|
||||
img.src = this.getSVGURL(renderer);
|
||||
}
|
||||
|
||||
getPNGBlob(renderer, resolution, callback) {
|
||||
this.getCanvas(renderer, resolution, (canvas) => {
|
||||
canvas.toBlob(callback, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
getPNGURL(renderer, resolution, callback) {
|
||||
|
@ -9463,6 +9460,16 @@ define('sequence/SequenceDiagram',[
|
|||
});
|
||||
}
|
||||
|
||||
getCanvas({resolution = 1, size = null} = {}) {
|
||||
if(size) {
|
||||
this.renderer.width = size.width;
|
||||
this.renderer.height = size.height;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.exporter.getCanvas(this.renderer, resolution, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
getPNG({resolution = 1, size = null} = {}) {
|
||||
if(size) {
|
||||
this.renderer.width = size.width;
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -5,11 +5,11 @@
|
|||
base-uri 'self';
|
||||
default-src 'none';
|
||||
script-src 'self' https://cdnjs.cloudflare.com;
|
||||
connect-src 'self';
|
||||
style-src 'self'
|
||||
'sha256-ru2GY2rXeOf7PQX5LzK3ckNo21FCDUoRc2f3i0QcD1g='
|
||||
;
|
||||
font-src 'self' data:;
|
||||
connect-src 'self';
|
||||
img-src 'self' blob:;
|
||||
form-action 'none';
|
||||
">
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Binary file not shown.
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 38 KiB |
|
@ -0,0 +1,60 @@
|
|||
define(['./ImageRegion'], (ImageRegion) => {
|
||||
'use strict';
|
||||
|
||||
function makeGaussianKernel(size) {
|
||||
const sz = Math.ceil(size * 3);
|
||||
const kernel = new Float32Array(sz * 2 + 1);
|
||||
const norm = 1 / (size * Math.sqrt(Math.PI * 2));
|
||||
const expNorm = -0.5 / (size * size);
|
||||
for(let i = -sz; i <= sz; ++ i) {
|
||||
kernel[i+sz] = Math.exp(i * i * expNorm) * norm;
|
||||
}
|
||||
return {kernel, sz};
|
||||
}
|
||||
|
||||
function blur1D(region, size, {target = null} = {}) {
|
||||
/* jshint -W073 */ // There are 4 dimensions to traverse in a hot loop
|
||||
|
||||
target = region.checkOrMakeTarget(target);
|
||||
|
||||
const {width, height, stepX, stepY, dim} = region;
|
||||
const {kernel, sz} = makeGaussianKernel(size);
|
||||
|
||||
for(let x = 0; x < width; ++ x) {
|
||||
const i0 = -Math.min(sz, x);
|
||||
const i1 = Math.min(sz, width - x);
|
||||
for(let d = 0; d < dim; ++ d) {
|
||||
const psx = region.indexOf(x, 0, d);
|
||||
const ptx = target.indexOf(x, 0, d);
|
||||
for(let y = 0; y < height; ++ y) {
|
||||
const ps = psx + y * stepY;
|
||||
let accum = 0;
|
||||
for(let i = i0; i < i1; ++ i) {
|
||||
accum += region.values[ps+i*stepX] * kernel[i+sz];
|
||||
}
|
||||
target.values[ptx + y * target.stepY] = accum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function blur2D(region, size, {target = null, temp = null} = {}) {
|
||||
target = region.checkOrMakeTarget(target);
|
||||
|
||||
temp = blur1D(region, size, {target: temp});
|
||||
blur1D(temp.transposed(), size, {target: target.transposed()});
|
||||
return target;
|
||||
}
|
||||
|
||||
ImageRegion.prototype.blur = function(size, options) {
|
||||
return blur2D(this, size, options);
|
||||
};
|
||||
|
||||
return {
|
||||
makeGaussianKernel,
|
||||
blur1D,
|
||||
blur2D,
|
||||
};
|
||||
});
|
|
@ -0,0 +1,83 @@
|
|||
defineDescribe('Blur', [
|
||||
'./Blur',
|
||||
'./ImageRegion',
|
||||
], (
|
||||
Blur,
|
||||
ImageRegion
|
||||
) => {
|
||||
'use strict';
|
||||
|
||||
const PRECISION = 0.01;
|
||||
|
||||
function diracDelta(pad) {
|
||||
const values = [];
|
||||
const dim = pad * 2 + 1;
|
||||
for(let y = 0; y < dim; ++ y) {
|
||||
for(let x = 0; x < dim; ++ x) {
|
||||
values[y*dim+x] = (y === pad && x === pad) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return ImageRegion.fromValues(dim, dim, values);
|
||||
}
|
||||
|
||||
describe('blur2D', () => {
|
||||
it('returns a new image of the same size', () => {
|
||||
const input = diracDelta(20);
|
||||
const output = Blur.blur2D(input, 1);
|
||||
expect(output).not.toBe(input);
|
||||
expect(output.width).toEqual(input.width);
|
||||
expect(output.height).toEqual(input.height);
|
||||
expect(output.dim).toEqual(input.dim);
|
||||
expect(output.values).not.toBe(input.values);
|
||||
});
|
||||
|
||||
it('uses the given target if specified', () => {
|
||||
const input = diracDelta(20);
|
||||
const target = ImageRegion.ofSize(41, 41, 1);
|
||||
const output = Blur.blur2D(input, 1, {target});
|
||||
expect(output).toBe(target);
|
||||
});
|
||||
|
||||
it('blurs the given image using a gaussian kernel', () => {
|
||||
const output = Blur.blur2D(diracDelta(20), 1);
|
||||
|
||||
expect(output.sum()).toBeNear(1, PRECISION);
|
||||
|
||||
expect(output.get(20, 20)).toBeNear(0.159, PRECISION);
|
||||
|
||||
const v01 = output.get(20, 21);
|
||||
expect(v01).toBeNear(0.096, PRECISION);
|
||||
expect(output.get(21, 20)).toBeNear(v01, PRECISION);
|
||||
expect(output.get(19, 20)).toBeNear(v01, PRECISION);
|
||||
expect(output.get(20, 19)).toBeNear(v01, PRECISION);
|
||||
|
||||
const v11 = output.get(21, 21);
|
||||
expect(v11).toBeNear(0.058, PRECISION);
|
||||
expect(output.get(21, 19)).toBeNear(v11, PRECISION);
|
||||
expect(output.get(19, 21)).toBeNear(v11, PRECISION);
|
||||
expect(output.get(19, 19)).toBeNear(v11, PRECISION);
|
||||
|
||||
expect(output.get(20, 22)).toBeNear(0.021, PRECISION);
|
||||
expect(output.get(21, 22)).toBeNear(0.013, PRECISION);
|
||||
expect(output.get(22, 22)).toBeNear(0.002, PRECISION);
|
||||
|
||||
expect(output.get(10, 10)).toBeNear(0, PRECISION);
|
||||
});
|
||||
|
||||
it('fills in 0s outside the region', () => {
|
||||
const output = Blur.blur2D(diracDelta(0), 1);
|
||||
expect(output.get(0, 0)).toBeNear(0.159, PRECISION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageRegion.blur', () => {
|
||||
it('is added to the ImageRegion prototype', () => {
|
||||
expect(diracDelta(0).blur).toEqual(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('invokes blur2D', () => {
|
||||
const output = diracDelta(0).blur(1);
|
||||
expect(output.get(0, 0)).toBeNear(0.159, PRECISION);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
define(['./ImageRegion'], (ImageRegion) => {
|
||||
'use strict';
|
||||
|
||||
function compose(input1, input2, fn, {target = null} = {}) {
|
||||
input1.checkCompatible(input2);
|
||||
target = input1.checkOrMakeTarget(target);
|
||||
|
||||
const {width, height, dim} = target;
|
||||
|
||||
for(let x = 0; x < width; ++ x) {
|
||||
for(let y = 0; y < height; ++ y) {
|
||||
const pt = target.indexOf(x, y);
|
||||
const p1 = input1.indexOf(x, y);
|
||||
const p2 = input2.indexOf(x, y);
|
||||
for(let d = 0; d < dim; ++ d) {
|
||||
target.values[pt+d] = fn(
|
||||
input1.values[p1+d],
|
||||
input2.values[p2+d]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function subtract(input1, input2, options) {
|
||||
return compose(input1, input2, (a, b) => (a - b), options);
|
||||
}
|
||||
|
||||
function difference(input1, input2, options) {
|
||||
return compose(input1, input2, (a, b) => Math.abs(a - b), options);
|
||||
}
|
||||
|
||||
ImageRegion.prototype.subtract = function(b, options) {
|
||||
return subtract(this, b, options);
|
||||
};
|
||||
|
||||
ImageRegion.prototype.difference = function(b, options) {
|
||||
return difference(this, b, options);
|
||||
};
|
||||
|
||||
return {
|
||||
subtract,
|
||||
difference,
|
||||
};
|
||||
});
|
|
@ -0,0 +1,75 @@
|
|||
defineDescribe('Composition', [
|
||||
'./Composition',
|
||||
'./ImageRegion',
|
||||
], (
|
||||
Composition,
|
||||
ImageRegion
|
||||
) => {
|
||||
'use strict';
|
||||
|
||||
let inputA;
|
||||
let inputB;
|
||||
|
||||
beforeEach(() => {
|
||||
inputA = ImageRegion.fromValues(2, 2, [
|
||||
0, 1,
|
||||
0, 1,
|
||||
]);
|
||||
inputB = ImageRegion.fromValues(2, 2, [
|
||||
0, 0,
|
||||
1, 1,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('subtract', () => {
|
||||
it('returns a new image of the same size', () => {
|
||||
const output = Composition.subtract(inputA, inputB);
|
||||
expect(output).not.toBe(inputA);
|
||||
expect(output).not.toBe(inputB);
|
||||
expect(output.width).toEqual(inputA.width);
|
||||
expect(output.height).toEqual(inputA.height);
|
||||
expect(output.dim).toEqual(inputA.dim);
|
||||
expect(output.values).not.toBe(inputA.values);
|
||||
expect(output.values).not.toBe(inputB.values);
|
||||
});
|
||||
|
||||
it('uses the given target if specified', () => {
|
||||
const output = Composition.subtract(
|
||||
inputA,
|
||||
inputB,
|
||||
{target: inputA}
|
||||
);
|
||||
expect(output).toBe(inputA);
|
||||
});
|
||||
|
||||
it('calculates the difference for each pixel', () => {
|
||||
const output = Composition.subtract(inputA, inputB);
|
||||
expect(output.get(0, 0)).toEqual(0);
|
||||
expect(output.get(1, 0)).toEqual(1);
|
||||
expect(output.get(0, 1)).toEqual(-1);
|
||||
expect(output.get(1, 1)).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageRegion.subtract', () => {
|
||||
it('is added to the ImageRegion prototype', () => {
|
||||
expect(inputA.subtract).toEqual(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('invokes subtract', () => {
|
||||
const output = inputA.subtract(inputB);
|
||||
expect(output.get(0, 1)).toEqual(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageRegion.difference', () => {
|
||||
it('is added to the ImageRegion prototype', () => {
|
||||
expect(inputA.difference).toEqual(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('invokes abs(subtract)', () => {
|
||||
const output = inputA.difference(inputB);
|
||||
expect(output.get(0, 1)).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,469 @@
|
|||
define(() => {
|
||||
'use strict';
|
||||
|
||||
function makeCanvas(width, height) {
|
||||
window.devicePixelRatio = 1;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext('2d');
|
||||
return {canvas, context};
|
||||
}
|
||||
|
||||
function proportionalSize(
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
{width = null, height = null} = {}
|
||||
) {
|
||||
if(width === null) {
|
||||
if(height === null) {
|
||||
return {
|
||||
width: baseWidth,
|
||||
height: baseHeight,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
width: Math.round(baseWidth * height / baseHeight),
|
||||
height,
|
||||
};
|
||||
}
|
||||
} else if(height === null) {
|
||||
return {
|
||||
width,
|
||||
height: Math.round(baseHeight * width / baseWidth),
|
||||
};
|
||||
} else {
|
||||
return {width, height};
|
||||
}
|
||||
}
|
||||
|
||||
class ImageRegion {
|
||||
constructor(width, height, values, {
|
||||
origin = 0,
|
||||
stepX = 0,
|
||||
stepY = 0,
|
||||
dim = 1,
|
||||
} = {}) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.values = values;
|
||||
this.origin = origin;
|
||||
this.stepX = stepX || dim;
|
||||
this.stepY = stepY || (width * dim);
|
||||
this.dim = dim;
|
||||
}
|
||||
|
||||
hasSize(width, height, dim) {
|
||||
return (
|
||||
this.width === width &&
|
||||
this.height === height &&
|
||||
this.dim === dim
|
||||
);
|
||||
}
|
||||
|
||||
checkCompatible(region, {dim = 0} = {}) {
|
||||
if(!dim) {
|
||||
dim = this.dim;
|
||||
} else if(this.dim !== dim) {
|
||||
throw new Error('Expected region with dimension ' + dim);
|
||||
}
|
||||
|
||||
if(!region.hasSize(this.width, this.height, dim)) {
|
||||
throw new Error(
|
||||
'Region sizes do not match; ' +
|
||||
this.width + 'x' + this.height +
|
||||
' (dim ' + this.dim + ')' +
|
||||
' <> ' +
|
||||
region.width + 'x' + region.height +
|
||||
' (dim ' + region.dim + ')'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validateDimension(dim) {
|
||||
if(dim < 0 || dim >= this.dim) {
|
||||
throw new Error('Invalid dimension');
|
||||
}
|
||||
}
|
||||
|
||||
inBounds(x, y) {
|
||||
return (
|
||||
x >= 0 &&
|
||||
x < this.width &&
|
||||
y >= 0 &&
|
||||
y < this.height
|
||||
);
|
||||
}
|
||||
|
||||
indexOf(x, y, dim = 0) {
|
||||
return (
|
||||
this.origin +
|
||||
x * this.stepX +
|
||||
y * this.stepY +
|
||||
dim
|
||||
);
|
||||
}
|
||||
|
||||
get(x, y, {dim = 0, outside = 0} = {}) {
|
||||
this.validateDimension(dim);
|
||||
if(!this.inBounds(x, y)) {
|
||||
return outside;
|
||||
}
|
||||
return this.getFast(x, y, dim);
|
||||
}
|
||||
|
||||
getFast(x, y, dim = 0) {
|
||||
return this.values[this.indexOf(x, y, dim)];
|
||||
}
|
||||
|
||||
set(x, y, value, {dim = 0} = {}) {
|
||||
this.validateDimension(dim);
|
||||
if(!this.inBounds(x, y)) {
|
||||
return;
|
||||
}
|
||||
return this.setFast(x, y, dim, value);
|
||||
}
|
||||
|
||||
setFast(x, y, dim, value) {
|
||||
this.values[this.indexOf(x, y, dim)] = value;
|
||||
}
|
||||
|
||||
fill(fn) {
|
||||
if(typeof fn === 'number') {
|
||||
const v = fn;
|
||||
fn = () => v;
|
||||
}
|
||||
for(let y = 0; y < this.height; ++ y) {
|
||||
const py = this.indexOf(0, y);
|
||||
for(let x = 0; x < this.width; ++ x) {
|
||||
for(let d = 0; d < this.dim; ++ d) {
|
||||
this.values[py + x * this.stepX + d] = fn({x, y, d});
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
fillVec(fn) {
|
||||
if(Array.isArray(fn)) {
|
||||
const v = fn;
|
||||
fn = () => v;
|
||||
}
|
||||
for(let y = 0; y < this.height; ++ y) {
|
||||
const py = this.indexOf(0, y);
|
||||
for(let x = 0; x < this.width; ++ x) {
|
||||
const r = fn({x, y});
|
||||
for(let d = 0; d < this.dim; ++ d) {
|
||||
this.values[py + x * this.stepX + d] = r[d];
|
||||
}
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
reduce(fn, value = null) {
|
||||
for(let y = 0; y < this.height; ++ y) {
|
||||
const py = this.indexOf(0, y);
|
||||
for(let x = 0; x < this.width; ++ x) {
|
||||
for(let d = 0; d < this.dim; ++ d) {
|
||||
value = fn(
|
||||
value,
|
||||
{x, y, d, v: this.values[py + x * this.stepX + d]}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
sum() {
|
||||
return this.reduce((accum, {v}) => accum + v, 0);
|
||||
}
|
||||
|
||||
average() {
|
||||
return this.sum() / (this.width * this.height * this.dim);
|
||||
}
|
||||
|
||||
max() {
|
||||
return this.reduce(
|
||||
(accum, {v}) => Math.max(accum, v),
|
||||
this.values[this.origin]
|
||||
);
|
||||
}
|
||||
|
||||
min() {
|
||||
return this.reduce(
|
||||
(accum, {v}) => Math.min(accum, v),
|
||||
this.values[this.origin]
|
||||
);
|
||||
}
|
||||
|
||||
absMax() {
|
||||
return this.reduce((accum, {v}) => Math.max(accum, Math.abs(v)), 0);
|
||||
}
|
||||
|
||||
checkOrMakeTarget(target, {dim = 0, fill = null} = {}) {
|
||||
if(!dim) {
|
||||
dim = this.dim;
|
||||
}
|
||||
|
||||
if(!target) {
|
||||
return this.makeCopy({dim, fill: fill || 0});
|
||||
}
|
||||
|
||||
if(!target.hasSize(this.width, this.height, dim)) {
|
||||
throw new Error(
|
||||
'Wanted region of size ' +
|
||||
this.width + 'x' + this.height +
|
||||
' (dim ' + dim + ')' +
|
||||
' but got ' +
|
||||
target.width + 'x' + target.height +
|
||||
' (dim ' + target.dim + ')'
|
||||
);
|
||||
}
|
||||
|
||||
if(fill !== null) {
|
||||
target.fill(fill);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
_copyData(target, dim) {
|
||||
const stepY = this.width * dim;
|
||||
for(let y = 0; y < this.height; ++ y) {
|
||||
const py = this.indexOf(0, y);
|
||||
for(let x = 0; x < this.width; ++ x) {
|
||||
const ps = py + x * this.stepX;
|
||||
const pd = y * stepY + x * dim;
|
||||
for(let d = 0; d < dim; ++ d) {
|
||||
target[pd + d] = this.values[ps + d];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
makeCopy({dim = 0, fill = null} = {}) {
|
||||
if(!dim) {
|
||||
dim = this.dim;
|
||||
}
|
||||
|
||||
const stepY = this.width * dim;
|
||||
const values = new Float32Array(this.height * stepY);
|
||||
if(fill === null) {
|
||||
this._copyData(values, dim);
|
||||
} else if(fill) {
|
||||
values.fill(fill);
|
||||
}
|
||||
|
||||
return new ImageRegion(this.width, this.height, values, {
|
||||
origin: 0,
|
||||
stepX: dim,
|
||||
stepY,
|
||||
dim,
|
||||
});
|
||||
}
|
||||
|
||||
transposed() {
|
||||
return new ImageRegion(this.height, this.width, this.values, {
|
||||
origin: this.origin,
|
||||
stepX: this.stepY,
|
||||
stepY: this.stepX,
|
||||
dim: this.dim,
|
||||
});
|
||||
}
|
||||
|
||||
channel(dim, count = 1) {
|
||||
this.validateDimension(dim);
|
||||
this.validateDimension(dim + count - 1);
|
||||
return new ImageRegion(this.width, this.height, this.values, {
|
||||
origin: this.origin + dim,
|
||||
stepX: this.stepX,
|
||||
stepY: this.stepY,
|
||||
dim: count,
|
||||
});
|
||||
}
|
||||
|
||||
getProportionalSize(size) {
|
||||
return proportionalSize(this.width, this.height, size);
|
||||
}
|
||||
|
||||
_sumIn(xr, yr, d) {
|
||||
let sum = 0;
|
||||
let my = yr.lm;
|
||||
for(let y = yr.li; y <= yr.hi; ++ y) {
|
||||
let mx = xr.lm;
|
||||
for(let x = xr.li; x <= xr.hi; ++ x) {
|
||||
sum += this.values[this.indexOf(x, y, d)] * mx * my;
|
||||
mx = (x + 1 === xr.hi) ? xr.hm : 1;
|
||||
}
|
||||
my = (y + 1 === yr.hi) ? yr.hm : 1;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
resize(size) {
|
||||
const {width, height} = this.getProportionalSize(size);
|
||||
if(width === this.width && height === this.height) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const dim = this.dim;
|
||||
const values = new Float32Array(width * height * dim);
|
||||
|
||||
const mx = this.width / width;
|
||||
const my = this.height / height;
|
||||
const norm = 1 / (mx * my);
|
||||
|
||||
function ranges(l, h) {
|
||||
/* jshint -W016 */ // faster than Math.floor
|
||||
const li = (l | 0);
|
||||
const hi = (h | 0);
|
||||
const lm = (hi === li) ? (h - l) : (li + 1 - l);
|
||||
const hm = h - hi;
|
||||
if(hm < 0.001) {
|
||||
return {li, hi: hi - 1, lm, hm: 1};
|
||||
} else {
|
||||
return {li, hi, lm, hm};
|
||||
}
|
||||
}
|
||||
|
||||
const xrs = [];
|
||||
for(let x = 0; x < width; ++ x) {
|
||||
xrs[x] = ranges(x * mx, (x + 1) * mx);
|
||||
}
|
||||
|
||||
for(let y = 0; y < height; ++ y) {
|
||||
const yr = ranges(y * my, (y + 1) * my);
|
||||
for(let x = 0; x < width; ++ x) {
|
||||
const xr = xrs[x];
|
||||
const p = (y * width + x) * dim;
|
||||
for(let d = 0; d < dim; ++ d) {
|
||||
values[p+d] = this._sumIn(xr, yr, d) * norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageRegion(width, height, values, {dim});
|
||||
}
|
||||
|
||||
getSuggestedChannels({red = null, green = null, blue = null} = {}) {
|
||||
return {
|
||||
red: (red === null) ? 0 : red,
|
||||
green: (green === null) ? ((this.dim > 1) ? 1 : 0) : green,
|
||||
blue: (blue === null) ? (this.dim - 1) : blue,
|
||||
};
|
||||
}
|
||||
|
||||
populateImageData(dat, {
|
||||
rangeLow = -1,
|
||||
rangeHigh = 1,
|
||||
channels = {},
|
||||
} = {}) {
|
||||
const {red, green, blue} = this.getSuggestedChannels(channels);
|
||||
const mult = 255 / (rangeHigh - rangeLow);
|
||||
for(let y = 0; y < this.height; ++ y) {
|
||||
const py = this.indexOf(0, y);
|
||||
for(let x = 0; x < this.width; ++ x) {
|
||||
const ps = py + x * this.stepX;
|
||||
const pd = (y * this.width + x) * 4;
|
||||
const r = this.values[ps+red];
|
||||
const g = this.values[ps+green];
|
||||
const b = this.values[ps+blue];
|
||||
if(Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
|
||||
dat.data[pd ] = 255;
|
||||
dat.data[pd+1] = 0;
|
||||
dat.data[pd+2] = 0;
|
||||
} else {
|
||||
dat.data[pd ] = (r - rangeLow) * mult;
|
||||
dat.data[pd+1] = (g - rangeLow) * mult;
|
||||
dat.data[pd+2] = (b - rangeLow) * mult;
|
||||
}
|
||||
dat.data[pd+3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
asCanvas(options) {
|
||||
const {canvas, context} = makeCanvas(this.width, this.height);
|
||||
const dat = context.createImageData(this.width, this.height);
|
||||
this.populateImageData(dat, options);
|
||||
context.putImageData(dat, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
}
|
||||
|
||||
ImageRegion.ofSize = (width, height, dim = 1) => {
|
||||
const values = new Float32Array(width * height * dim);
|
||||
return new ImageRegion(width, height, values, {dim});
|
||||
};
|
||||
|
||||
ImageRegion.fromValues = (width, height, values, dim = 1) => {
|
||||
const converted = new Float32Array(width * height * dim);
|
||||
for(let i = 0; i < width * height * dim; ++ i) {
|
||||
converted[i] = values[i];
|
||||
}
|
||||
|
||||
return new ImageRegion(width, height, converted, {dim});
|
||||
};
|
||||
|
||||
ImageRegion.fromFunction = (width, height, fn, dim = 1) => {
|
||||
return ImageRegion.ofSize(width, height, dim).fill(fn);
|
||||
};
|
||||
|
||||
ImageRegion.fromCanvas = (canvas) => {
|
||||
let context;
|
||||
if(canvas.getContext) {
|
||||
context = canvas.getContext('2d');
|
||||
} else {
|
||||
context = canvas;
|
||||
}
|
||||
|
||||
const width = context.canvas.width;
|
||||
const height = context.canvas.height;
|
||||
const dat = context.getImageData(0, 0, width, height);
|
||||
const d = dat.data;
|
||||
const values = new Float32Array(width * height);
|
||||
for(let y = 0; y < height; ++ y) {
|
||||
for(let x = 0; x < width; ++ x) {
|
||||
const pr = y * width + x;
|
||||
const ps = pr * 4;
|
||||
const lum = (d[ps] + d[ps+1] + d[ps+2]) / (255 * 3);
|
||||
const a = d[ps+3] / 255;
|
||||
values[pr] = (lum * 2 - 1) * a;
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageRegion(width, height, values);
|
||||
};
|
||||
|
||||
ImageRegion.fromImage = (image, size) => {
|
||||
const {canvas, context} = makeCanvas(image.width, image.height);
|
||||
context.drawImage(image, 0, 0, image.width, image.height);
|
||||
return ImageRegion.fromCanvas(canvas).resize(size);
|
||||
};
|
||||
|
||||
ImageRegion.loadURL = (url, size = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
image.removeEventListener('error', reject);
|
||||
const resolution = size.resolution || 1;
|
||||
image.width = image.naturalWidth * resolution;
|
||||
image.height = image.naturalHeight * resolution;
|
||||
|
||||
document.body.appendChild(image);
|
||||
const canvas = ImageRegion.fromImage(image, size);
|
||||
document.body.removeChild(image);
|
||||
resolve(canvas);
|
||||
}, {once: true});
|
||||
|
||||
image.addEventListener('error', reject);
|
||||
|
||||
image.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
return ImageRegion;
|
||||
});
|
|
@ -0,0 +1,189 @@
|
|||
defineDescribe('ImageRegion', ['./ImageRegion'], (ImageRegion) => {
|
||||
'use strict';
|
||||
|
||||
function makeCanvas(w, h) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dat = ctx.createImageData(w, h);
|
||||
return {canvas, ctx, dat};
|
||||
}
|
||||
|
||||
function setPix(dat, x, y, rgba) {
|
||||
const p = (y * dat.width + x) * 4;
|
||||
dat.data[p ] = rgba[0];
|
||||
dat.data[p+1] = rgba[1];
|
||||
dat.data[p+2] = rgba[2];
|
||||
dat.data[p+3] = rgba[3];
|
||||
}
|
||||
|
||||
const PRECISION = 0.01;
|
||||
|
||||
describe('ofSize', () => {
|
||||
it('returns an empty region of the requested size', () => {
|
||||
const r = ImageRegion.ofSize(2, 2);
|
||||
expect(r.width).toEqual(2);
|
||||
expect(r.height).toEqual(2);
|
||||
expect(r.origin).toEqual(0);
|
||||
expect(r.stepX).toEqual(1);
|
||||
expect(r.stepY).toEqual(2);
|
||||
expect(r.dim).toEqual(1);
|
||||
expect(r.values[0]).toEqual(0);
|
||||
expect(r.values[1]).toEqual(0);
|
||||
expect(r.values[2]).toEqual(0);
|
||||
expect(r.values[3]).toEqual(0);
|
||||
});
|
||||
|
||||
it('accepts an optional dimension value', () => {
|
||||
const r = ImageRegion.ofSize(2, 2, 2);
|
||||
expect(r.width).toEqual(2);
|
||||
expect(r.height).toEqual(2);
|
||||
expect(r.origin).toEqual(0);
|
||||
expect(r.stepX).toEqual(2);
|
||||
expect(r.stepY).toEqual(4);
|
||||
expect(r.dim).toEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromValues', () => {
|
||||
it('copies the given data', () => {
|
||||
const input = [
|
||||
3, 2,
|
||||
6, 9,
|
||||
];
|
||||
const r = ImageRegion.fromValues(2, 2, input);
|
||||
expect(r.width).toEqual(2);
|
||||
expect(r.height).toEqual(2);
|
||||
expect(r.origin).toEqual(0);
|
||||
expect(r.stepX).toEqual(1);
|
||||
expect(r.stepY).toEqual(2);
|
||||
expect(r.dim).toEqual(1);
|
||||
expect(r.values).not.toBe(input);
|
||||
expect(r.values[0]).toEqual(3);
|
||||
expect(r.values[1]).toEqual(2);
|
||||
expect(r.values[2]).toEqual(6);
|
||||
expect(r.values[3]).toEqual(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromCanvas', () => {
|
||||
it('converts canvas image data into luminosity', () => {
|
||||
const {canvas, ctx, dat} = makeCanvas(3, 3);
|
||||
setPix(dat, 0, 0, [ 0, 0, 0, 0]);
|
||||
setPix(dat, 1, 0, [255, 0, 0, 0]);
|
||||
setPix(dat, 2, 0, [255, 255, 255, 0]);
|
||||
setPix(dat, 0, 1, [ 0, 0, 0, 255]);
|
||||
setPix(dat, 1, 1, [255, 0, 0, 255]);
|
||||
setPix(dat, 2, 1, [255, 255, 255, 255]);
|
||||
setPix(dat, 0, 2, [ 0, 0, 0, 128]);
|
||||
setPix(dat, 1, 2, [255, 0, 0, 128]);
|
||||
setPix(dat, 2, 2, [255, 255, 255, 128]);
|
||||
ctx.putImageData(dat, 0, 0);
|
||||
|
||||
const r = ImageRegion.fromCanvas(canvas);
|
||||
expect(r.width).toEqual(3);
|
||||
expect(r.height).toEqual(3);
|
||||
expect(r.origin).toEqual(0);
|
||||
expect(r.stepX).toEqual(1);
|
||||
expect(r.stepY).toEqual(3);
|
||||
expect(r.dim).toEqual(1);
|
||||
expect(r.values[0]).toBeNear( 0, PRECISION);
|
||||
expect(r.values[1]).toBeNear( 0, PRECISION);
|
||||
expect(r.values[2]).toBeNear( 0, PRECISION);
|
||||
expect(r.values[3]).toBeNear( -1, PRECISION);
|
||||
expect(r.values[4]).toBeNear(-1/3, PRECISION);
|
||||
expect(r.values[5]).toBeNear( 1, PRECISION);
|
||||
expect(r.values[6]).toBeNear(-1/2, PRECISION);
|
||||
expect(r.values[7]).toBeNear(-1/6, PRECISION);
|
||||
expect(r.values[8]).toBeNear( 1/2, PRECISION);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('returns the pixel at the given location', () => {
|
||||
const r = ImageRegion.fromValues(2, 2, [
|
||||
3, 2,
|
||||
6, 9,
|
||||
]);
|
||||
expect(r.get(0, 0)).toEqual(3);
|
||||
expect(r.get(1, 0)).toEqual(2);
|
||||
expect(r.get(0, 1)).toEqual(6);
|
||||
expect(r.get(1, 1)).toEqual(9);
|
||||
});
|
||||
|
||||
it('returns the given "outside" value for extreme coordinates', () => {
|
||||
const r = ImageRegion.fromValues(2, 2, [
|
||||
3, 2,
|
||||
6, 9,
|
||||
]);
|
||||
expect(r.get(-1, 0)).toEqual(0);
|
||||
expect(r.get(0, -1)).toEqual(0);
|
||||
expect(r.get(3, 0)).toEqual(0);
|
||||
expect(r.get(0, 3)).toEqual(0);
|
||||
expect(r.get(-1, -1, {outside: 3})).toEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transposed', () => {
|
||||
it('returns a transposed view on the data', () => {
|
||||
const r = ImageRegion.fromValues(3, 2, [
|
||||
-9, 2, 1,
|
||||
6, 8, 0,
|
||||
]);
|
||||
const t = r.transposed();
|
||||
expect(t.width).toEqual(2);
|
||||
expect(t.height).toEqual(3);
|
||||
expect(t.stepX).toEqual(3);
|
||||
expect(t.stepY).toEqual(1);
|
||||
expect(t.origin).toEqual(0);
|
||||
expect(t.values).toBe(r.values);
|
||||
expect(t.get(0, 0)).toEqual(-9);
|
||||
expect(t.get(1, 0)).toEqual(6);
|
||||
expect(t.get(0, 1)).toEqual(2);
|
||||
expect(t.get(1, 1)).toEqual(8);
|
||||
expect(t.get(0, 2)).toEqual(1);
|
||||
expect(t.get(1, 2)).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sum', () => {
|
||||
it('returns the sum of all pixel values', () => {
|
||||
const r = ImageRegion.fromValues(2, 2, [
|
||||
-9, 2,
|
||||
6, 8,
|
||||
]);
|
||||
expect(r.sum()).toEqual(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('max', () => {
|
||||
it('returns the maximum pixel value', () => {
|
||||
const r = ImageRegion.fromValues(2, 2, [
|
||||
-9, 2,
|
||||
6, 8,
|
||||
]);
|
||||
expect(r.max()).toEqual(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('min', () => {
|
||||
it('returns the minimum pixel value', () => {
|
||||
const r = ImageRegion.fromValues(2, 2, [
|
||||
-9, 2,
|
||||
6, 8,
|
||||
]);
|
||||
expect(r.min()).toEqual(-9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('absMax', () => {
|
||||
it('returns the maximum absolute pixel value', () => {
|
||||
const r = ImageRegion.fromValues(2, 2, [
|
||||
-9, 2,
|
||||
6, 8,
|
||||
]);
|
||||
expect(r.absMax()).toEqual(9);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,114 @@
|
|||
define(['./ImageRegion', './Blur', './Composition'], (ImageRegion) => {
|
||||
'use strict';
|
||||
|
||||
const VALUE_THRESH = 0.05;
|
||||
|
||||
function calcDiff(region1, region2, {
|
||||
pixelThresh = 5,
|
||||
temp = null,
|
||||
} = {}) {
|
||||
region1.checkCompatible(region2);
|
||||
temp = region1.checkOrMakeTarget(temp);
|
||||
const b1 = region1.blur(pixelThresh, {temp});
|
||||
const b2 = region2.blur(pixelThresh, {temp});
|
||||
return b1.difference(b2, {target: b1});
|
||||
}
|
||||
|
||||
function isSimilar(region1, region2, options = {}) {
|
||||
const diff = calcDiff(region1, region2, options);
|
||||
return diff.max() < (options.valueThresh || VALUE_THRESH);
|
||||
}
|
||||
|
||||
ImageRegion.prototype.isSimilar = function(b, options) {
|
||||
return isSimilar(this, b, options);
|
||||
};
|
||||
|
||||
function makeImageComparison(actual, expected, options, message) {
|
||||
const o = document.createElement('div');
|
||||
|
||||
const cActual = actual.asCanvas();
|
||||
cActual.setAttribute('title', 'actual');
|
||||
|
||||
const cExpected = expected.asCanvas();
|
||||
cExpected.setAttribute('title', 'expected');
|
||||
|
||||
const diff = calcDiff(actual, expected, options);
|
||||
const cDiff = diff.asCanvas({
|
||||
rangeLow: 0,
|
||||
rangeHigh: options.valueThresh || VALUE_THRESH,
|
||||
});
|
||||
cDiff.setAttribute('title', 'difference');
|
||||
|
||||
const diffSharp = actual.difference(expected);
|
||||
const cDiffSharp = diffSharp.asCanvas({
|
||||
rangeLow: 0,
|
||||
rangeHigh: options.valueThresh || VALUE_THRESH,
|
||||
});
|
||||
cDiffSharp.setAttribute('title', 'sharp difference');
|
||||
|
||||
o.appendChild(document.createTextNode(message));
|
||||
o.appendChild(document.createElement('br'));
|
||||
o.appendChild(cActual);
|
||||
o.appendChild(cExpected);
|
||||
o.appendChild(cDiff);
|
||||
o.appendChild(cDiffSharp);
|
||||
return o;
|
||||
}
|
||||
|
||||
const matchers = {
|
||||
toLookLike: () => {
|
||||
return {
|
||||
compare: (actual, expected, options = {}) => {
|
||||
if(actual.isSimilar(expected, options)) {
|
||||
return {pass: true};
|
||||
}
|
||||
|
||||
document.body.appendChild(makeImageComparison(
|
||||
actual,
|
||||
expected,
|
||||
options,
|
||||
'Image comparison (expected similar)'
|
||||
));
|
||||
|
||||
const details = (options.details ?
|
||||
'; details: ' + options.details : ''
|
||||
);
|
||||
|
||||
return {
|
||||
pass: false,
|
||||
message: 'Expected images to be similar; ' +
|
||||
'see below for comparison' + details,
|
||||
};
|
||||
},
|
||||
|
||||
negativeCompare: (actual, expected, options) => {
|
||||
if(!actual.isSimilar(expected, options)) {
|
||||
return {pass: true};
|
||||
}
|
||||
|
||||
document.body.appendChild(makeImageComparison(
|
||||
actual,
|
||||
expected,
|
||||
options,
|
||||
'Image comparison (expected different)'
|
||||
));
|
||||
|
||||
const details = (options.details ?
|
||||
'; details: ' + options.details : ''
|
||||
);
|
||||
|
||||
return {
|
||||
pass: false,
|
||||
message: 'Expected images to differ; ' +
|
||||
'see below for comparison' + details,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
isSimilar,
|
||||
matchers,
|
||||
};
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
defineDescribe('ImageSimilarity', [
|
||||
'./ImageSimilarity',
|
||||
'./ImageRegion',
|
||||
], (
|
||||
ImageSimilarity,
|
||||
ImageRegion
|
||||
) => {
|
||||
'use strict';
|
||||
|
||||
let inputA;
|
||||
let inputB;
|
||||
let inputC;
|
||||
|
||||
beforeEach(() => {
|
||||
inputA = ImageRegion.fromFunction(100, 100,
|
||||
({x, y}) => (Math.sin(x * 0.2) * Math.cos(y * 0.3))
|
||||
);
|
||||
inputB = ImageRegion.fromFunction(100, 100,
|
||||
({x, y}) => (Math.sin(x * 0.2 + 0.1) * Math.cos(y * 0.3 - 0.1))
|
||||
);
|
||||
inputC = ImageRegion.fromFunction(100, 100,
|
||||
({x}) => (Math.sin(x * 0.2))
|
||||
);
|
||||
});
|
||||
|
||||
describe('isSimilar', () => {
|
||||
it('returns true for identical images', () => {
|
||||
expect(ImageSimilarity.isSimilar(inputA, inputA)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for similar images', () => {
|
||||
expect(ImageSimilarity.isSimilar(inputA, inputB)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different images', () => {
|
||||
expect(ImageSimilarity.isSimilar(inputA, inputC)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImageRegion.isSimilar', () => {
|
||||
it('is added to the ImageRegion prototype', () => {
|
||||
expect(inputA.isSimilar).toEqual(jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('invokes isSimilar', () => {
|
||||
const output = inputA.isSimilar(inputA);
|
||||
expect(output).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('jasmine.toLookLike', () => {
|
||||
it('can be registered with Jasmine', () => {
|
||||
jasmine.addMatchers(ImageSimilarity.matchers);
|
||||
expect(inputA).toLookLike(inputB);
|
||||
expect(inputA).not.toLookLike(inputC);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -17,7 +17,7 @@
|
|||
return o;
|
||||
}
|
||||
|
||||
const PNG_RESOLUTION = 4;
|
||||
const RESOLUTION = 4;
|
||||
|
||||
const FAVICON_SRC = (
|
||||
'theme chunky\n' +
|
||||
|
@ -95,7 +95,7 @@
|
|||
|
||||
document.body.appendChild(hold);
|
||||
|
||||
diagram.getPNG({resolution: PNG_RESOLUTION, size}).then(({url}) => {
|
||||
diagram.getPNG({resolution: RESOLUTION, size}).then(({url}) => {
|
||||
raster.setAttribute('src', url);
|
||||
downloadPNG.setAttribute('href', url);
|
||||
});
|
||||
|
|
|
@ -4,9 +4,6 @@ define(() => {
|
|||
// Thanks, https://stackoverflow.com/a/23522755/1180785
|
||||
const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
// Thanks, https://stackoverflow.com/a/9851769/1180785
|
||||
const firefox = (typeof window.InstallTrigger !== 'undefined');
|
||||
|
||||
return class Exporter {
|
||||
constructor() {
|
||||
this.latestSVG = null;
|
||||
|
@ -18,45 +15,38 @@ define(() => {
|
|||
this.latestPNG = null;
|
||||
}
|
||||
|
||||
getSVGContent(renderer, size = null) {
|
||||
getSVGContent(renderer) {
|
||||
let code = renderer.svg().outerHTML;
|
||||
if(firefox && size) {
|
||||
// Firefox fails to render SVGs unless they have size
|
||||
// attributes on the <svg> tag
|
||||
|
||||
// Firefox fails to render SVGs as <img> unless they have size
|
||||
// attributes on the <svg> tag, so we must set this when
|
||||
// exporting from any environment, in case it is opened in FireFox
|
||||
code = code.replace(
|
||||
/^<svg/,
|
||||
'<svg width="' + size.width +
|
||||
'" height="' + size.height + '" '
|
||||
'<svg width="' + renderer.width +
|
||||
'" height="' + renderer.height + '" '
|
||||
);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
getSVGBlob(renderer, size = null) {
|
||||
getSVGBlob(renderer) {
|
||||
return new Blob(
|
||||
[this.getSVGContent(renderer, size)],
|
||||
[this.getSVGContent(renderer)],
|
||||
{type: 'image/svg+xml'}
|
||||
);
|
||||
}
|
||||
|
||||
getSVGURL(renderer, size = null) {
|
||||
const blob = this.getSVGBlob(renderer, size);
|
||||
if(size) {
|
||||
if(this.latestInternalSVG) {
|
||||
URL.revokeObjectURL(this.latestInternalSVG);
|
||||
}
|
||||
this.latestInternalSVG = URL.createObjectURL(blob);
|
||||
return this.latestInternalSVG;
|
||||
} else {
|
||||
getSVGURL(renderer) {
|
||||
const blob = this.getSVGBlob(renderer);
|
||||
if(this.latestSVG) {
|
||||
URL.revokeObjectURL(this.latestSVG);
|
||||
}
|
||||
this.latestSVG = URL.createObjectURL(blob);
|
||||
return this.latestSVG;
|
||||
}
|
||||
}
|
||||
|
||||
getPNGBlob(renderer, resolution, callback) {
|
||||
getCanvas(renderer, resolution, callback) {
|
||||
if(!this.canvas) {
|
||||
window.devicePixelRatio = 1;
|
||||
this.canvas = document.createElement('canvas');
|
||||
|
@ -83,11 +73,11 @@ define(() => {
|
|||
const render = () => {
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.context.drawImage(img, 0, 0);
|
||||
this.context.drawImage(img, 0, 0, width, height);
|
||||
if(safariHackaround) {
|
||||
document.body.removeChild(safariHackaround);
|
||||
}
|
||||
this.canvas.toBlob(callback, 'image/png');
|
||||
callback(this.canvas);
|
||||
};
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
|
@ -99,7 +89,13 @@ define(() => {
|
|||
}
|
||||
}, {once: true});
|
||||
|
||||
img.src = this.getSVGURL(renderer, {width, height});
|
||||
img.src = this.getSVGURL(renderer);
|
||||
}
|
||||
|
||||
getPNGBlob(renderer, resolution, callback) {
|
||||
this.getCanvas(renderer, resolution, (canvas) => {
|
||||
canvas.toBlob(callback, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
getPNGURL(renderer, resolution, callback) {
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
defineDescribe('Readme', [
|
||||
'./SequenceDiagram',
|
||||
'image/ImageRegion',
|
||||
'image/ImageSimilarity',
|
||||
], (
|
||||
SequenceDiagram,
|
||||
ImageRegion,
|
||||
ImageSimilarity
|
||||
) => {
|
||||
'use strict';
|
||||
|
||||
const RESOLUTION = 4;
|
||||
|
||||
const SAMPLE_REGEX = new RegExp(
|
||||
/(?:<img src="([^"]*)"[^>]*>[\s]*)?```(?!shell).*\n([^]+?)```/g
|
||||
);
|
||||
|
||||
const SCREENSHOT_BLACKLIST = [
|
||||
// Renders differently but correctly in different browsers
|
||||
'screenshots/Themes.png',
|
||||
];
|
||||
|
||||
function findSamples(content) {
|
||||
SAMPLE_REGEX.lastIndex = 0;
|
||||
const results = [];
|
||||
while(true) {
|
||||
const match = SAMPLE_REGEX.exec(content);
|
||||
if(!match) {
|
||||
break;
|
||||
}
|
||||
results.push({
|
||||
file: match[1],
|
||||
code: match[2],
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function makeSampleTests({file, code}, index) {
|
||||
describe('example #' + (index + 1), () => {
|
||||
if(file && !SCREENSHOT_BLACKLIST.includes(file)) {
|
||||
it('looks like ' + file + ' when rendered', (done) => {
|
||||
jasmine.addMatchers(ImageSimilarity.matchers);
|
||||
let actual = null;
|
||||
new SequenceDiagram(code)
|
||||
.getCanvas({resolution: RESOLUTION})
|
||||
.then((c) => {
|
||||
actual = ImageRegion
|
||||
.fromCanvas(c)
|
||||
.resize({width: 150});
|
||||
})
|
||||
.then(() => ImageRegion.loadURL(
|
||||
file,
|
||||
{width: actual.width, height: actual.height}
|
||||
))
|
||||
.then((expected) => {
|
||||
expect(actual).toLookLike(expected);
|
||||
})
|
||||
.catch(fail)
|
||||
.then(done);
|
||||
});
|
||||
} else {
|
||||
it('renders without error', () => {
|
||||
expect(() => new SequenceDiagram(code)).not.toThrow();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (fetch('README.md')
|
||||
.then((response) => response.text())
|
||||
.then(findSamples)
|
||||
.then((samples) => samples.forEach(makeSampleTests))
|
||||
);
|
||||
});
|
|
@ -433,17 +433,18 @@ define([
|
|||
const y0 = titleY - margin;
|
||||
const y1 = stagesHeight + margin;
|
||||
|
||||
this.width = x1 - x0;
|
||||
this.height = y1 - y0;
|
||||
|
||||
this.maskReveal.setAttribute('x', x0);
|
||||
this.maskReveal.setAttribute('y', y0);
|
||||
this.maskReveal.setAttribute('width', x1 - x0);
|
||||
this.maskReveal.setAttribute('height', y1 - y0);
|
||||
this.maskReveal.setAttribute('width', this.width);
|
||||
this.maskReveal.setAttribute('height', this.height);
|
||||
|
||||
this.base.setAttribute('viewBox', (
|
||||
x0 + ' ' + y0 + ' ' +
|
||||
(x1 - x0) + ' ' + (y1 - y0)
|
||||
this.width + ' ' + this.height
|
||||
));
|
||||
this.width = (x1 - x0);
|
||||
this.height = (y1 - y0);
|
||||
}
|
||||
|
||||
_resetState() {
|
||||
|
|
|
@ -128,6 +128,16 @@ define([
|
|||
});
|
||||
}
|
||||
|
||||
getCanvas({resolution = 1, size = null} = {}) {
|
||||
if(size) {
|
||||
this.renderer.width = size.width;
|
||||
this.renderer.height = size.height;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.exporter.getCanvas(this.renderer, resolution, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
getPNG({resolution = 1, size = null} = {}) {
|
||||
if(size) {
|
||||
this.renderer.width = size.width;
|
||||
|
|
|
@ -124,31 +124,4 @@ defineDescribe('SequenceDiagram', [
|
|||
'<polygon points="46 31 51 26 46 21"'
|
||||
);
|
||||
});
|
||||
|
||||
const SAMPLE_REGEX = new RegExp(
|
||||
/```(?!shell).*\n([^]+?)```/g
|
||||
);
|
||||
|
||||
function findSamples(content) {
|
||||
SAMPLE_REGEX.lastIndex = 0;
|
||||
const results = [];
|
||||
while(true) {
|
||||
const match = SAMPLE_REGEX.exec(content);
|
||||
if(!match) {
|
||||
break;
|
||||
}
|
||||
results.push(match[1]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return (fetch('README.md')
|
||||
.then((response) => response.text())
|
||||
.then(findSamples)
|
||||
.then((samples) => samples.forEach((code, i) => {
|
||||
it('Renders readme example #' + (i + 1) + ' without error', () => {
|
||||
expect(() => diagram.set(code)).not.toThrow();
|
||||
});
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
defineDescribe('SequenceDiagram Visuals', [
|
||||
'./SequenceDiagram',
|
||||
'image/ImageRegion',
|
||||
'image/ImageSimilarity',
|
||||
], (
|
||||
SequenceDiagram,
|
||||
ImageRegion,
|
||||
ImageSimilarity
|
||||
) => {
|
||||
'use strict';
|
||||
|
||||
const RESOLUTION = 4;
|
||||
|
||||
const IMAGE_BASE_PATH = 'scripts/sequence/test-images/';
|
||||
|
||||
const TESTS = {
|
||||
'Connect.svg': 'A -> B',
|
||||
'Reference.svg': (
|
||||
'begin A, B, C, D\n' +
|
||||
'begin reference over B, C: My ref as E\n' +
|
||||
'* -> A\n' +
|
||||
'A -> E\n' +
|
||||
'E -> +D\n' +
|
||||
'-D -> E\n' +
|
||||
'E -> A\n' +
|
||||
'end E\n'
|
||||
),
|
||||
};
|
||||
|
||||
Object.entries(TESTS).forEach(([image, code]) => {
|
||||
it('renders ' + image + ' as expected', (done) => {
|
||||
jasmine.addMatchers(ImageSimilarity.matchers);
|
||||
let actual = null;
|
||||
new SequenceDiagram(code)
|
||||
.getCanvas({resolution: RESOLUTION})
|
||||
.then((c) => {
|
||||
actual = ImageRegion.fromCanvas(c).resize({width: 150});
|
||||
})
|
||||
.then(() => ImageRegion.loadURL(IMAGE_BASE_PATH + image, {
|
||||
width: actual.width,
|
||||
height: actual.height,
|
||||
resolution: RESOLUTION,
|
||||
}))
|
||||
.then((expected) => {
|
||||
expect(actual).toLookLike(expected, {
|
||||
details: 'Code is: \n' + code,
|
||||
});
|
||||
})
|
||||
.catch(fail)
|
||||
.then(done);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="-5 -5 96.0078125 70.6" width="96.0078125" height="70.6"><defs></defs><defs><mask id="R0LineMask" maskUnits="userSpaceOnUse"><rect fill="#FFFFFF" x="-5" y="-5" width="96.0078125" height="70.6"></rect></mask></defs><g mask="url(#R0LineMask)"><line x1="24.001953125" y1="25.6" x2="24.001953125" y2="60.6" class="agent-1-line" fill="none" stroke="#000000" stroke-width="1"></line><line x1="62.005859375" y1="25.6" x2="62.005859375" y2="60.6" class="agent-2-line" fill="none" stroke="#000000" stroke-width="1"></line></g><g></g><g><rect x="10" y="0" width="28.00390625" height="25.6" fill="#FFFFFF" stroke="#000000" stroke-width="1"></rect><rect x="48.00390625" y="0" width="28.00390625" height="25.6" fill="#FFFFFF" stroke="#000000" stroke-width="1"></rect><path d="M24.001953125 40.6L59.005859375 40.6" fill="none" stroke="#000000" stroke-width="1"></path><polygon points="56.505859375 45.6 61.505859375 40.6 56.505859375 35.6" fill="#000000" stroke-width="0" stroke-linejoin="miter"></polygon></g><g><g class="region"><rect x="10" y="0" width="28.00390625" height="25.6" fill="transparent" class="outline"></rect><text x="24.001953125" font-family="sans-serif" font-size="12" line-height="1.3" text-anchor="middle" y="17">A</text></g><g class="region"><rect x="48.00390625" y="0" width="28.00390625" height="25.6" fill="transparent" class="outline"></rect><text x="62.005859375" font-family="sans-serif" font-size="12" line-height="1.3" text-anchor="middle" y="17">B</text></g><g class="region"><path d="M24.001953125,35.6L62.005859375,35.6L62.005859375,45.6L24.001953125,45.6Z" fill="transparent" class="outline"></path></g><g class="region"><rect x="19.001953125" y="50.6" width="10" height="10" fill="transparent" class="outline"></rect></g><g class="region"><rect x="57.005859375" y="50.6" width="10" height="10" fill="transparent" class="outline"></rect></g></g></svg>
|
After Width: | Height: | Size: 1.9 KiB |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 7.2 KiB |
|
@ -6,7 +6,13 @@ define([
|
|||
'svg/SVGShapes_spec',
|
||||
'svg/PatternedLine_spec',
|
||||
'interface/Interface_spec',
|
||||
'image/ImageRegion_spec',
|
||||
'image/Blur_spec',
|
||||
'image/Composition_spec',
|
||||
'image/ImageSimilarity_spec',
|
||||
'sequence/SequenceDiagram_spec',
|
||||
'sequence/SequenceDiagram_visual_spec',
|
||||
'sequence/Readme_spec',
|
||||
'sequence/Tokeniser_spec',
|
||||
'sequence/Parser_spec',
|
||||
'sequence/MarkdownParser_spec',
|
||||
|
|
|
@ -20,6 +20,7 @@ define(['jshintConfig', 'specs'], (jshintConfig) => {
|
|||
|
||||
const PREDEF_TEST = [
|
||||
'jasmine',
|
||||
'beforeAll',
|
||||
'beforeEach',
|
||||
'afterEach',
|
||||
'spyOn',
|
||||
|
|
|
@ -9,6 +9,30 @@
|
|||
urlArgs: String(Math.random()), // Prevent cache
|
||||
}, window.getRequirejsCDN()));
|
||||
|
||||
const matchers = {
|
||||
toBeNear: () => {
|
||||
return {
|
||||
compare: (actual, expected, range) => {
|
||||
if(
|
||||
typeof expected !== 'number' ||
|
||||
typeof range !== 'number' ||
|
||||
range < 0
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid toBeNear(' + expected + ',' + range + ')'
|
||||
);
|
||||
}
|
||||
if(typeof actual !== 'number') {
|
||||
throw new Error('Expected a number, got ' + actual);
|
||||
}
|
||||
return {
|
||||
pass: Math.abs(actual - expected) <= range,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
requirejs(['jasmineBoot'], () => {
|
||||
// Slightly hacky way of making jasmine work with asynchronously loaded
|
||||
// tests while keeping features of jasmine-boot
|
||||
|
@ -26,6 +50,10 @@
|
|||
});
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
jasmine.addMatchers(matchers);
|
||||
});
|
||||
|
||||
requirejs(['tester/jshintRunner'], (promise) => promise.then(runner));
|
||||
});
|
||||
})());
|
||||
|
|
Loading…
Reference in New Issue