Add image-based testing to verify readme screenshots and some rendering tests

This commit is contained in:
David Evans 2018-01-30 23:18:30 +00:00
parent 5283c511e9
commit d015795a90
26 changed files with 1361 additions and 111 deletions

View File

@ -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

View File

@ -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
// exporting from any environment, in case it is opened in FireFox
code = code.replace( code = code.replace(
/^<svg/, /^<svg/,
'<svg width="' + size.width + '<svg width="' + renderer.width +
'" height="' + size.height + '" ' '" 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.latestInternalSVG) {
URL.revokeObjectURL(this.latestInternalSVG);
}
this.latestInternalSVG = URL.createObjectURL(blob);
return this.latestInternalSVG;
} else {
if(this.latestSVG) { if(this.latestSVG) {
URL.revokeObjectURL(this.latestSVG); URL.revokeObjectURL(this.latestSVG);
} }
this.latestSVG = URL.createObjectURL(blob); this.latestSVG = URL.createObjectURL(blob);
return this.latestSVG; 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

View File

@ -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

60
scripts/image/Blur.js Normal file
View File

@ -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,
};
});

View File

@ -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);
});
});
});

View File

@ -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,
};
});

View File

@ -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);
});
});
});

View File

@ -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;
});

View File

@ -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);
});
});
});

View File

@ -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,
};
});

View File

@ -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);
});
});
});

View File

@ -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);
}); });

View File

@ -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
// exporting from any environment, in case it is opened in FireFox
code = code.replace( code = code.replace(
/^<svg/, /^<svg/,
'<svg width="' + size.width + '<svg width="' + renderer.width +
'" height="' + size.height + '" ' '" 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.latestInternalSVG) {
URL.revokeObjectURL(this.latestInternalSVG);
}
this.latestInternalSVG = URL.createObjectURL(blob);
return this.latestInternalSVG;
} else {
if(this.latestSVG) { if(this.latestSVG) {
URL.revokeObjectURL(this.latestSVG); URL.revokeObjectURL(this.latestSVG);
} }
this.latestSVG = URL.createObjectURL(blob); this.latestSVG = URL.createObjectURL(blob);
return this.latestSVG; 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) {

View File

@ -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))
);
});

View File

@ -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() {

View File

@ -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;

View File

@ -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();
});
}))
);
}); });

View File

@ -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);
});
});
});

View File

@ -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

View File

@ -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',

View File

@ -20,6 +20,7 @@ define(['jshintConfig', 'specs'], (jshintConfig) => {
const PREDEF_TEST = [ const PREDEF_TEST = [
'jasmine', 'jasmine',
'beforeAll',
'beforeEach', 'beforeEach',
'afterEach', 'afterEach',
'spyOn', 'spyOn',

View File

@ -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));
}); });
})()); })());