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
|
* Utilities have a good level of testing
|
||||||
* `Parser` and `Generator` stages 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
|
there are some high-level tests in
|
||||||
`/scripts/sequence/SequenceDiagram_spec.js` but many component types
|
`/scripts/sequence/SequenceDiagram_spec.js`, and a series of image
|
||||||
are not tested at all during rendering beyond ensuring that they can
|
comparison tests in `/scripts/sequence/Readme_spec.js` (testing that
|
||||||
be used without throwing exceptions. The same applies to themes.
|
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.
|
* The editor has a minimal level of testing.
|
||||||
|
|
||||||
If you suspect a failing test is not related to your changes, you can
|
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 y0 = titleY - margin;
|
||||||
const y1 = stagesHeight + margin;
|
const y1 = stagesHeight + margin;
|
||||||
|
|
||||||
|
this.width = x1 - x0;
|
||||||
|
this.height = y1 - y0;
|
||||||
|
|
||||||
this.maskReveal.setAttribute('x', x0);
|
this.maskReveal.setAttribute('x', x0);
|
||||||
this.maskReveal.setAttribute('y', y0);
|
this.maskReveal.setAttribute('y', y0);
|
||||||
this.maskReveal.setAttribute('width', x1 - x0);
|
this.maskReveal.setAttribute('width', this.width);
|
||||||
this.maskReveal.setAttribute('height', y1 - y0);
|
this.maskReveal.setAttribute('height', this.height);
|
||||||
|
|
||||||
this.base.setAttribute('viewBox', (
|
this.base.setAttribute('viewBox', (
|
||||||
x0 + ' ' + y0 + ' ' +
|
x0 + ' ' + y0 + ' ' +
|
||||||
(x1 - x0) + ' ' + (y1 - y0)
|
this.width + ' ' + this.height
|
||||||
));
|
));
|
||||||
this.width = (x1 - x0);
|
|
||||||
this.height = (y1 - y0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_resetState() {
|
_resetState() {
|
||||||
|
@ -6119,9 +6120,6 @@ define('sequence/Exporter',[],() => {
|
||||||
// Thanks, https://stackoverflow.com/a/23522755/1180785
|
// Thanks, https://stackoverflow.com/a/23522755/1180785
|
||||||
const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
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 {
|
return class Exporter {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.latestSVG = null;
|
this.latestSVG = null;
|
||||||
|
@ -6133,45 +6131,38 @@ define('sequence/Exporter',[],() => {
|
||||||
this.latestPNG = null;
|
this.latestPNG = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSVGContent(renderer, size = null) {
|
getSVGContent(renderer) {
|
||||||
let code = renderer.svg().outerHTML;
|
let code = renderer.svg().outerHTML;
|
||||||
if(firefox && size) {
|
|
||||||
// Firefox fails to render SVGs unless they have size
|
// Firefox fails to render SVGs as <img> unless they have size
|
||||||
// attributes on the <svg> tag
|
// attributes on the <svg> tag, so we must set this when
|
||||||
code = code.replace(
|
// exporting from any environment, in case it is opened in FireFox
|
||||||
/^<svg/,
|
code = code.replace(
|
||||||
'<svg width="' + size.width +
|
/^<svg/,
|
||||||
'" height="' + size.height + '" '
|
'<svg width="' + renderer.width +
|
||||||
);
|
'" height="' + renderer.height + '" '
|
||||||
}
|
);
|
||||||
|
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSVGBlob(renderer, size = null) {
|
getSVGBlob(renderer) {
|
||||||
return new Blob(
|
return new Blob(
|
||||||
[this.getSVGContent(renderer, size)],
|
[this.getSVGContent(renderer)],
|
||||||
{type: 'image/svg+xml'}
|
{type: 'image/svg+xml'}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSVGURL(renderer, size = null) {
|
getSVGURL(renderer) {
|
||||||
const blob = this.getSVGBlob(renderer, size);
|
const blob = this.getSVGBlob(renderer);
|
||||||
if(size) {
|
if(this.latestSVG) {
|
||||||
if(this.latestInternalSVG) {
|
URL.revokeObjectURL(this.latestSVG);
|
||||||
URL.revokeObjectURL(this.latestInternalSVG);
|
|
||||||
}
|
|
||||||
this.latestInternalSVG = URL.createObjectURL(blob);
|
|
||||||
return this.latestInternalSVG;
|
|
||||||
} else {
|
|
||||||
if(this.latestSVG) {
|
|
||||||
URL.revokeObjectURL(this.latestSVG);
|
|
||||||
}
|
|
||||||
this.latestSVG = URL.createObjectURL(blob);
|
|
||||||
return this.latestSVG;
|
|
||||||
}
|
}
|
||||||
|
this.latestSVG = URL.createObjectURL(blob);
|
||||||
|
return this.latestSVG;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPNGBlob(renderer, resolution, callback) {
|
getCanvas(renderer, resolution, callback) {
|
||||||
if(!this.canvas) {
|
if(!this.canvas) {
|
||||||
window.devicePixelRatio = 1;
|
window.devicePixelRatio = 1;
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
|
@ -6198,11 +6189,11 @@ define('sequence/Exporter',[],() => {
|
||||||
const render = () => {
|
const render = () => {
|
||||||
this.canvas.width = width;
|
this.canvas.width = width;
|
||||||
this.canvas.height = height;
|
this.canvas.height = height;
|
||||||
this.context.drawImage(img, 0, 0);
|
this.context.drawImage(img, 0, 0, width, height);
|
||||||
if(safariHackaround) {
|
if(safariHackaround) {
|
||||||
document.body.removeChild(safariHackaround);
|
document.body.removeChild(safariHackaround);
|
||||||
}
|
}
|
||||||
this.canvas.toBlob(callback, 'image/png');
|
callback(this.canvas);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.addEventListener('load', () => {
|
img.addEventListener('load', () => {
|
||||||
|
@ -6214,7 +6205,13 @@ define('sequence/Exporter',[],() => {
|
||||||
}
|
}
|
||||||
}, {once: true});
|
}, {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) {
|
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} = {}) {
|
getPNG({resolution = 1, size = null} = {}) {
|
||||||
if(size) {
|
if(size) {
|
||||||
this.renderer.width = size.width;
|
this.renderer.width = size.width;
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -5,11 +5,11 @@
|
||||||
base-uri 'self';
|
base-uri 'self';
|
||||||
default-src 'none';
|
default-src 'none';
|
||||||
script-src 'self' https://cdnjs.cloudflare.com;
|
script-src 'self' https://cdnjs.cloudflare.com;
|
||||||
|
connect-src 'self';
|
||||||
style-src 'self'
|
style-src 'self'
|
||||||
'sha256-ru2GY2rXeOf7PQX5LzK3ckNo21FCDUoRc2f3i0QcD1g='
|
'sha256-ru2GY2rXeOf7PQX5LzK3ckNo21FCDUoRc2f3i0QcD1g='
|
||||||
;
|
;
|
||||||
font-src 'self' data:;
|
font-src 'self' data:;
|
||||||
connect-src 'self';
|
|
||||||
img-src 'self' blob:;
|
img-src 'self' blob:;
|
||||||
form-action 'none';
|
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;
|
return o;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PNG_RESOLUTION = 4;
|
const RESOLUTION = 4;
|
||||||
|
|
||||||
const FAVICON_SRC = (
|
const FAVICON_SRC = (
|
||||||
'theme chunky\n' +
|
'theme chunky\n' +
|
||||||
|
@ -95,7 +95,7 @@
|
||||||
|
|
||||||
document.body.appendChild(hold);
|
document.body.appendChild(hold);
|
||||||
|
|
||||||
diagram.getPNG({resolution: PNG_RESOLUTION, size}).then(({url}) => {
|
diagram.getPNG({resolution: RESOLUTION, size}).then(({url}) => {
|
||||||
raster.setAttribute('src', url);
|
raster.setAttribute('src', url);
|
||||||
downloadPNG.setAttribute('href', url);
|
downloadPNG.setAttribute('href', url);
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,9 +4,6 @@ define(() => {
|
||||||
// Thanks, https://stackoverflow.com/a/23522755/1180785
|
// Thanks, https://stackoverflow.com/a/23522755/1180785
|
||||||
const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
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 {
|
return class Exporter {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.latestSVG = null;
|
this.latestSVG = null;
|
||||||
|
@ -18,45 +15,38 @@ define(() => {
|
||||||
this.latestPNG = null;
|
this.latestPNG = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSVGContent(renderer, size = null) {
|
getSVGContent(renderer) {
|
||||||
let code = renderer.svg().outerHTML;
|
let code = renderer.svg().outerHTML;
|
||||||
if(firefox && size) {
|
|
||||||
// Firefox fails to render SVGs unless they have size
|
// Firefox fails to render SVGs as <img> unless they have size
|
||||||
// attributes on the <svg> tag
|
// attributes on the <svg> tag, so we must set this when
|
||||||
code = code.replace(
|
// exporting from any environment, in case it is opened in FireFox
|
||||||
/^<svg/,
|
code = code.replace(
|
||||||
'<svg width="' + size.width +
|
/^<svg/,
|
||||||
'" height="' + size.height + '" '
|
'<svg width="' + renderer.width +
|
||||||
);
|
'" height="' + renderer.height + '" '
|
||||||
}
|
);
|
||||||
|
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSVGBlob(renderer, size = null) {
|
getSVGBlob(renderer) {
|
||||||
return new Blob(
|
return new Blob(
|
||||||
[this.getSVGContent(renderer, size)],
|
[this.getSVGContent(renderer)],
|
||||||
{type: 'image/svg+xml'}
|
{type: 'image/svg+xml'}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSVGURL(renderer, size = null) {
|
getSVGURL(renderer) {
|
||||||
const blob = this.getSVGBlob(renderer, size);
|
const blob = this.getSVGBlob(renderer);
|
||||||
if(size) {
|
if(this.latestSVG) {
|
||||||
if(this.latestInternalSVG) {
|
URL.revokeObjectURL(this.latestSVG);
|
||||||
URL.revokeObjectURL(this.latestInternalSVG);
|
|
||||||
}
|
|
||||||
this.latestInternalSVG = URL.createObjectURL(blob);
|
|
||||||
return this.latestInternalSVG;
|
|
||||||
} else {
|
|
||||||
if(this.latestSVG) {
|
|
||||||
URL.revokeObjectURL(this.latestSVG);
|
|
||||||
}
|
|
||||||
this.latestSVG = URL.createObjectURL(blob);
|
|
||||||
return this.latestSVG;
|
|
||||||
}
|
}
|
||||||
|
this.latestSVG = URL.createObjectURL(blob);
|
||||||
|
return this.latestSVG;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPNGBlob(renderer, resolution, callback) {
|
getCanvas(renderer, resolution, callback) {
|
||||||
if(!this.canvas) {
|
if(!this.canvas) {
|
||||||
window.devicePixelRatio = 1;
|
window.devicePixelRatio = 1;
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
|
@ -83,11 +73,11 @@ define(() => {
|
||||||
const render = () => {
|
const render = () => {
|
||||||
this.canvas.width = width;
|
this.canvas.width = width;
|
||||||
this.canvas.height = height;
|
this.canvas.height = height;
|
||||||
this.context.drawImage(img, 0, 0);
|
this.context.drawImage(img, 0, 0, width, height);
|
||||||
if(safariHackaround) {
|
if(safariHackaround) {
|
||||||
document.body.removeChild(safariHackaround);
|
document.body.removeChild(safariHackaround);
|
||||||
}
|
}
|
||||||
this.canvas.toBlob(callback, 'image/png');
|
callback(this.canvas);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.addEventListener('load', () => {
|
img.addEventListener('load', () => {
|
||||||
|
@ -99,7 +89,13 @@ define(() => {
|
||||||
}
|
}
|
||||||
}, {once: true});
|
}, {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) {
|
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 y0 = titleY - margin;
|
||||||
const y1 = stagesHeight + margin;
|
const y1 = stagesHeight + margin;
|
||||||
|
|
||||||
|
this.width = x1 - x0;
|
||||||
|
this.height = y1 - y0;
|
||||||
|
|
||||||
this.maskReveal.setAttribute('x', x0);
|
this.maskReveal.setAttribute('x', x0);
|
||||||
this.maskReveal.setAttribute('y', y0);
|
this.maskReveal.setAttribute('y', y0);
|
||||||
this.maskReveal.setAttribute('width', x1 - x0);
|
this.maskReveal.setAttribute('width', this.width);
|
||||||
this.maskReveal.setAttribute('height', y1 - y0);
|
this.maskReveal.setAttribute('height', this.height);
|
||||||
|
|
||||||
this.base.setAttribute('viewBox', (
|
this.base.setAttribute('viewBox', (
|
||||||
x0 + ' ' + y0 + ' ' +
|
x0 + ' ' + y0 + ' ' +
|
||||||
(x1 - x0) + ' ' + (y1 - y0)
|
this.width + ' ' + this.height
|
||||||
));
|
));
|
||||||
this.width = (x1 - x0);
|
|
||||||
this.height = (y1 - y0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_resetState() {
|
_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} = {}) {
|
getPNG({resolution = 1, size = null} = {}) {
|
||||||
if(size) {
|
if(size) {
|
||||||
this.renderer.width = size.width;
|
this.renderer.width = size.width;
|
||||||
|
|
|
@ -124,31 +124,4 @@ defineDescribe('SequenceDiagram', [
|
||||||
'<polygon points="46 31 51 26 46 21"'
|
'<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/SVGShapes_spec',
|
||||||
'svg/PatternedLine_spec',
|
'svg/PatternedLine_spec',
|
||||||
'interface/Interface_spec',
|
'interface/Interface_spec',
|
||||||
|
'image/ImageRegion_spec',
|
||||||
|
'image/Blur_spec',
|
||||||
|
'image/Composition_spec',
|
||||||
|
'image/ImageSimilarity_spec',
|
||||||
'sequence/SequenceDiagram_spec',
|
'sequence/SequenceDiagram_spec',
|
||||||
|
'sequence/SequenceDiagram_visual_spec',
|
||||||
|
'sequence/Readme_spec',
|
||||||
'sequence/Tokeniser_spec',
|
'sequence/Tokeniser_spec',
|
||||||
'sequence/Parser_spec',
|
'sequence/Parser_spec',
|
||||||
'sequence/MarkdownParser_spec',
|
'sequence/MarkdownParser_spec',
|
||||||
|
|
|
@ -20,6 +20,7 @@ define(['jshintConfig', 'specs'], (jshintConfig) => {
|
||||||
|
|
||||||
const PREDEF_TEST = [
|
const PREDEF_TEST = [
|
||||||
'jasmine',
|
'jasmine',
|
||||||
|
'beforeAll',
|
||||||
'beforeEach',
|
'beforeEach',
|
||||||
'afterEach',
|
'afterEach',
|
||||||
'spyOn',
|
'spyOn',
|
||||||
|
|
|
@ -9,6 +9,30 @@
|
||||||
urlArgs: String(Math.random()), // Prevent cache
|
urlArgs: String(Math.random()), // Prevent cache
|
||||||
}, window.getRequirejsCDN()));
|
}, 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'], () => {
|
requirejs(['jasmineBoot'], () => {
|
||||||
// Slightly hacky way of making jasmine work with asynchronously loaded
|
// Slightly hacky way of making jasmine work with asynchronously loaded
|
||||||
// tests while keeping features of jasmine-boot
|
// tests while keeping features of jasmine-boot
|
||||||
|
@ -26,6 +50,10 @@
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jasmine.addMatchers(matchers);
|
||||||
|
});
|
||||||
|
|
||||||
requirejs(['tester/jshintRunner'], (promise) => promise.then(runner));
|
requirejs(['tester/jshintRunner'], (promise) => promise.then(runner));
|
||||||
});
|
});
|
||||||
})());
|
})());
|
||||||
|
|
Loading…
Reference in New Issue