diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 2bdbcdf..dfe347c 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -77,11 +77,14 @@ The current state of automated testing is: * Utilities have a good level of testing * `Parser` and `Generator` stages have a good level of testing -* Rendering methods (SVG generation) have a poor level of testing; +* Rendering methods (SVG generation) have a minimal level of testing; there are some high-level tests in - `/scripts/sequence/SequenceDiagram_spec.js` but many component types - are not tested at all during rendering beyond ensuring that they can - be used without throwing exceptions. The same applies to themes. + `/scripts/sequence/SequenceDiagram_spec.js`, and a series of image + comparison tests in `/scripts/sequence/Readme_spec.js` (testing that + the readme screenshots roughly match the current behaviour). Finally + `/scripts/sequence/SequenceDiagram_visual_spec.js` uses coarse image + comparison to test components and interactions using baseline SVGs + from `test-images`. * The editor has a minimal level of testing. If you suspect a failing test is not related to your changes, you can diff --git a/lib/sequence-diagram.js b/lib/sequence-diagram.js index 07c40c4..330ed50 100644 --- a/lib/sequence-diagram.js +++ b/lib/sequence-diagram.js @@ -5992,17 +5992,18 @@ define('sequence/Renderer',[ const y0 = titleY - margin; const y1 = stagesHeight + margin; + this.width = x1 - x0; + this.height = y1 - y0; + this.maskReveal.setAttribute('x', x0); this.maskReveal.setAttribute('y', y0); - this.maskReveal.setAttribute('width', x1 - x0); - this.maskReveal.setAttribute('height', y1 - y0); + this.maskReveal.setAttribute('width', this.width); + this.maskReveal.setAttribute('height', this.height); this.base.setAttribute('viewBox', ( x0 + ' ' + y0 + ' ' + - (x1 - x0) + ' ' + (y1 - y0) + this.width + ' ' + this.height )); - this.width = (x1 - x0); - this.height = (y1 - y0); } _resetState() { @@ -6119,9 +6120,6 @@ define('sequence/Exporter',[],() => { // Thanks, https://stackoverflow.com/a/23522755/1180785 const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); - // Thanks, https://stackoverflow.com/a/9851769/1180785 - const firefox = (typeof window.InstallTrigger !== 'undefined'); - return class Exporter { constructor() { this.latestSVG = null; @@ -6133,45 +6131,38 @@ define('sequence/Exporter',[],() => { this.latestPNG = null; } - getSVGContent(renderer, size = null) { + getSVGContent(renderer) { let code = renderer.svg().outerHTML; - if(firefox && size) { - // Firefox fails to render SVGs unless they have size - // attributes on the tag - code = code.replace( - /^ unless they have size + // attributes on the tag, so we must set this when + // exporting from any environment, in case it is opened in FireFox + code = code.replace( + /^ { const render = () => { this.canvas.width = width; this.canvas.height = height; - this.context.drawImage(img, 0, 0); + this.context.drawImage(img, 0, 0, width, height); if(safariHackaround) { document.body.removeChild(safariHackaround); } - this.canvas.toBlob(callback, 'image/png'); + callback(this.canvas); }; img.addEventListener('load', () => { @@ -6214,7 +6205,13 @@ define('sequence/Exporter',[],() => { } }, {once: true}); - img.src = this.getSVGURL(renderer, {width, height}); + img.src = this.getSVGURL(renderer); + } + + getPNGBlob(renderer, resolution, callback) { + this.getCanvas(renderer, resolution, (canvas) => { + canvas.toBlob(callback, 'image/png'); + }); } getPNGURL(renderer, resolution, callback) { @@ -9463,6 +9460,16 @@ define('sequence/SequenceDiagram',[ }); } + getCanvas({resolution = 1, size = null} = {}) { + if(size) { + this.renderer.width = size.width; + this.renderer.height = size.height; + } + return new Promise((resolve) => { + this.exporter.getCanvas(this.renderer, resolution, resolve); + }); + } + getPNG({resolution = 1, size = null} = {}) { if(size) { this.renderer.width = size.width; diff --git a/lib/sequence-diagram.min.js b/lib/sequence-diagram.min.js index 5dce7f3..dfef072 100644 --- a/lib/sequence-diagram.min.js +++ b/lib/sequence-diagram.min.js @@ -1 +1 @@ -!function(){var e,t,n;!function(r){function s(e,t){return x.call(e,t)}function i(e,t){var n,r,s,i,a,o,l,h,d,g,c,u=t&&t.split("/"),p=b.map,f=p&&p["*"]||{};if(e){for(a=(e=e.split("/")).length-1,b.nodeIdCompat&&w.test(e[a])&&(e[a]=e[a].replace(w,"")),"."===e[0].charAt(0)&&u&&(e=u.slice(0,u.length-1).concat(e)),d=0;d0&&(e.splice(d-1,2),d-=2)}e=e.join("/")}if((u||f)&&p){for(d=(n=e.split("/")).length;d>0;d-=1){if(r=n.slice(0,d).join("/"),u)for(g=u.length;g>0;g-=1)if((s=p[u.slice(0,g).join("/")])&&(s=s[r])){i=s,o=d;break}if(i)break;!l&&f&&f[r]&&(l=f[r],h=d)}!i&&l&&(i=l,o=h),i&&(n.splice(0,o,i),e=n.join("/"))}return e}function a(e,t){return function(){var n=k.call(arguments,0);return"string"!=typeof n[0]&&1===n.length&&n.push(null),c.apply(r,n.concat([e,t]))}}function o(e){return function(t){f[e]=t}}function l(e){if(s(m,e)){var t=m[e];delete m[e],y[e]=!0,g.apply(r,t)}if(!s(f,e)&&!s(y,e))throw new Error("No "+e);return f[e]}function h(e){var t,n=e?e.indexOf("!"):-1;return n>-1&&(t=e.substring(0,n),e=e.substring(n+1,e.length)),[t,e]}function d(e){return e?h(e):[]}var g,c,u,p,f={},m={},b={},y={},x=Object.prototype.hasOwnProperty,k=[].slice,w=/\.js$/;u=function(e,t){var n,r=h(e),s=r[0],a=t[1];return e=r[1],s&&(n=l(s=i(s,a))),s?e=n&&n.normalize?n.normalize(e,function(e){return function(t){return i(t,e)}}(a)):i(e,a):(s=(r=h(e=i(e,a)))[0],e=r[1],s&&(n=l(s))),{f:s?s+"!"+e:e,n:e,pr:s,p:n}},p={require:function(e){return a(e)},exports:function(e){var t=f[e];return void 0!==t?t:f[e]={}},module:function(e){return{id:e,uri:"",exports:f[e],config:function(e){return function(){return b&&b.config&&b.config[e]||{}}}(e)}}},g=function(e,t,n,i){var h,g,c,b,x,k,w,A=[],v=typeof n;if(i=i||e,k=d(i),"undefined"===v||"function"===v){for(t=!t.length&&n.length?["require","exports","module"]:t,x=0;x{"use strict";return class{constructor(){this.listeners=new Map,this.forwards=new Set}addEventListener(e,t){const n=this.listeners.get(e);n?n.push(t):this.listeners.set(e,[t])}removeEventListener(e,t){const n=this.listeners.get(e);if(!n)return;const r=n.indexOf(t);-1!==r&&n.splice(r,1)}countEventListeners(e){return(this.listeners.get(e)||[]).length}removeAllEventListeners(e){e?this.listeners.delete(e):this.listeners.clear()}addEventForwarding(e){this.forwards.add(e)}removeEventForwarding(e){this.forwards.delete(e)}removeAllEventForwardings(){this.forwards.clear()}trigger(e,t=[]){(this.listeners.get(e)||[]).forEach(e=>e.apply(null,t)),this.forwards.forEach(n=>n.trigger(e,t))}}}),n("core/ArrayUtilities",[],()=>{"use strict";function e(e,t,n=null){if(null===n)return e.indexOf(t);for(let r=0;r=e.length)return void s.push(r.slice());const i=e[n];if(!Array.isArray(i))return r.push(i),t(e,n+1,r,s),void r.pop();for(let a=0;a{n.push(...t(e))}),n}}}),n("sequence/CodeMirrorMode",["core/ArrayUtilities"],e=>{"use strict";function t(e,t){return e.v===t.v&&e.prefix===t.prefix&&e.suffix===t.suffix&&e.q===t.q}function n(t,n,r){let s=r.suggest;return Array.isArray(s)||(s=[s]),e.flatMap(s,e=>!0===e?[function(e,t){return Object.keys(t.then).length>0?{v:e,suffix:" ",q:!1}:{v:e,suffix:"\n",q:!1}}(n,r)]:"object"==typeof e?e.known?t["known"+e.known]||[]:[e]:"string"==typeof e&&e?[{v:e,q:""===n}]:[])}function r(r,s){const i=[],a=e.last(s);return Object.keys(a.then).forEach(o=>{let l=a.then[o];"number"==typeof l&&(l=s[s.length-l-1]),e.mergeSets(i,n(r,o,l),t)}),i}function s(n,r,s,{suggest:i,override:a}){let o=null;"object"==typeof i&&i.known&&(o=i.known),r.type&&o!==r.type&&(a&&(r.type=a),e.mergeSets(n["known"+r.type],[{v:r.value,suffix:" ",q:!0}],t),r.type="",r.value=""),o&&(r.type=o,r.value&&(r.value+=s.s),r.value+=s.v)}function i(t,n,i){const a={type:"",value:""};let l=i;const h=[l];return t.line.forEach((n,i)=>{i===t.line.length-1&&(t.completions=r(t,h));const d=n.q?"":n.v;let g=l.then[d];void 0===g?(g=l.then[""],t.isVar=!0):t.isVar=n.q,"number"==typeof g?h.length-=g:h.push(g||o),l=e.last(h),s(t,a,n,l)}),n&&s(t,a,null,{}),t.nextCompletions=r(t,h),t.valid=Boolean(l.then["\n"])||0===Object.keys(l.then).length,l.type}function a(e){const t=e.baseToken||{};return{value:t.v||"",quoted:t.q||!1}}const o={type:"error line-error",then:{"":0}},l=(()=>{function e(e,t){return{type:"string",suggest:t,then:Object.assign({"":0},e)}}function t(e){return{type:"variable",suggest:{known:"Agent"},then:Object.assign({},e,{"":0,",":{type:"operator",suggest:!0,then:{"":1}}})}}function n(e){return{type:"keyword",suggest:[e+" of ",e+": "],then:{of:{type:"keyword",suggest:!0,then:{"":d}},":":{type:"operator",suggest:!0,then:{"":a}},"":d}}}function r(e,t){const n={type:"operator",suggest:!0,then:{"+":o,"-":o,"*":o,"!":o,"":e}};return{"+":{type:"operator",suggest:!0,then:{"+":o,"-":o,"*":n,"!":o,"":e}},"-":{type:"operator",suggest:!0,then:{"+":o,"-":o,"*":n,"!":{type:"operator",then:{"+":o,"-":o,"*":o,"!":o,"":e}},"":e}},"*":{type:"operator",suggest:!0,then:Object.assign({"+":n,"-":n,"*":o,"!":o,"":e},t)},"!":n,"":e}}const s={type:"",suggest:"\n",then:{}},i={type:"",then:{}},a=e({"\n":s}),l={type:"variable",suggest:{known:"Agent"},then:{"":0,"\n":s,",":{type:"operator",suggest:!0,then:{"":1}},as:{type:"keyword",suggest:!0,then:{"":{type:"variable",suggest:{known:"Agent"},then:{"":0,",":{type:"operator",suggest:!0,then:{"":3}},"\n":s}}}}}},h={type:"operator",suggest:!0,then:{"":a,"\n":i}},d=t({":":h}),g={type:"variable",suggest:{known:"Agent"},then:{"":0,",":{type:"operator",suggest:!0,then:{"":d}},":":o}},c={type:"variable",suggest:{known:"Agent"},then:{"":0,",":o,":":h}},u={type:"variable",suggest:{known:"Agent"},then:{"":0,":":{type:"operator",suggest:!0,then:{"":a,"\n":i}},"\n":s}},p={":":{type:"operator",suggest:!0,then:{"":e({as:{type:"keyword",suggest:!0,then:{"":{type:"variable",suggest:{known:"Agent"},then:{"":0,"\n":s}}}}})}}},f={type:"keyword",suggest:!0,then:Object.assign({over:{type:"keyword",suggest:!0,then:{"":t(p)}}},p)},m={"\n":s,":":{type:"operator",suggest:!0,then:{"":a,"\n":i}},with:{type:"keyword",suggest:["with height "],then:{height:{type:"keyword",suggest:!0,then:{"":{type:"number",suggest:["6 ","30 "],then:{"\n":s,":":{type:"operator",suggest:!0,then:{"":a,"\n":i}}}}}}}}},b={title:{type:"keyword",suggest:!0,then:{"":a}},theme:{type:"keyword",suggest:!0,then:{"":{type:"string",suggest:{global:"themes",suffix:"\n"},then:{"":0,"\n":s}}}},headers:{type:"keyword",suggest:!0,then:{none:{type:"keyword",suggest:!0,then:{}},cross:{type:"keyword",suggest:!0,then:{}},box:{type:"keyword",suggest:!0,then:{}},fade:{type:"keyword",suggest:!0,then:{}},bar:{type:"keyword",suggest:!0,then:{}}}},terminators:{type:"keyword",suggest:!0,then:{none:{type:"keyword",suggest:!0,then:{}},cross:{type:"keyword",suggest:!0,then:{}},box:{type:"keyword",suggest:!0,then:{}},fade:{type:"keyword",suggest:!0,then:{}},bar:{type:"keyword",suggest:!0,then:{}}}},divider:{type:"keyword",suggest:!0,then:Object.assign({line:{type:"keyword",suggest:!0,then:m},space:{type:"keyword",suggest:!0,then:m},delay:{type:"keyword",suggest:!0,then:m},tear:{type:"keyword",suggest:!0,then:m}},m)},define:{type:"keyword",suggest:!0,then:{"":l,as:o}},begin:{type:"keyword",suggest:!0,then:{"":l,reference:f,as:o}},end:{type:"keyword",suggest:!0,then:{"":l,as:o,"\n":s}},if:{type:"keyword",suggest:!0,then:{"":a,":":{type:"operator",suggest:!0,then:{"":a}},"\n":s}},else:{type:"keyword",suggest:["else\n","else if: "],then:{if:{type:"keyword",suggest:"if: ",then:{"":a,":":{type:"operator",suggest:!0,then:{"":a}}}},"\n":s}},repeat:{type:"keyword",suggest:!0,then:{"":a,":":{type:"operator",suggest:!0,then:{"":a}},"\n":s}},note:{type:"keyword",suggest:!0,then:{over:{type:"keyword",suggest:!0,then:{"":d}},left:n("left"),right:n("right"),between:{type:"keyword",suggest:!0,then:{"":g}}}},state:{type:"keyword",suggest:"state over ",then:{over:{type:"keyword",suggest:!0,then:{"":c}}}},text:{type:"keyword",suggest:!0,then:{left:n("left"),right:n("right")}},autolabel:{type:"keyword",suggest:!0,then:{off:{type:"keyword",suggest:!0,then:{}},"":e({"\n":s},[{v:"]*>[\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)) + ); +}); diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index 89cb602..e509378 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -433,17 +433,18 @@ define([ const y0 = titleY - margin; const y1 = stagesHeight + margin; + this.width = x1 - x0; + this.height = y1 - y0; + this.maskReveal.setAttribute('x', x0); this.maskReveal.setAttribute('y', y0); - this.maskReveal.setAttribute('width', x1 - x0); - this.maskReveal.setAttribute('height', y1 - y0); + this.maskReveal.setAttribute('width', this.width); + this.maskReveal.setAttribute('height', this.height); this.base.setAttribute('viewBox', ( x0 + ' ' + y0 + ' ' + - (x1 - x0) + ' ' + (y1 - y0) + this.width + ' ' + this.height )); - this.width = (x1 - x0); - this.height = (y1 - y0); } _resetState() { diff --git a/scripts/sequence/SequenceDiagram.js b/scripts/sequence/SequenceDiagram.js index c9e5a6e..38756b7 100644 --- a/scripts/sequence/SequenceDiagram.js +++ b/scripts/sequence/SequenceDiagram.js @@ -128,6 +128,16 @@ define([ }); } + getCanvas({resolution = 1, size = null} = {}) { + if(size) { + this.renderer.width = size.width; + this.renderer.height = size.height; + } + return new Promise((resolve) => { + this.exporter.getCanvas(this.renderer, resolution, resolve); + }); + } + getPNG({resolution = 1, size = null} = {}) { if(size) { this.renderer.width = size.width; diff --git a/scripts/sequence/SequenceDiagram_spec.js b/scripts/sequence/SequenceDiagram_spec.js index 20615ea..772b345 100644 --- a/scripts/sequence/SequenceDiagram_spec.js +++ b/scripts/sequence/SequenceDiagram_spec.js @@ -124,31 +124,4 @@ defineDescribe('SequenceDiagram', [ ' response.text()) - .then(findSamples) - .then((samples) => samples.forEach((code, i) => { - it('Renders readme example #' + (i + 1) + ' without error', () => { - expect(() => diagram.set(code)).not.toThrow(); - }); - })) - ); }); diff --git a/scripts/sequence/SequenceDiagram_visual_spec.js b/scripts/sequence/SequenceDiagram_visual_spec.js new file mode 100644 index 0000000..ffd74af --- /dev/null +++ b/scripts/sequence/SequenceDiagram_visual_spec.js @@ -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); + }); + }); +}); diff --git a/scripts/sequence/test-images/Connect.svg b/scripts/sequence/test-images/Connect.svg new file mode 100644 index 0000000..29e81c2 --- /dev/null +++ b/scripts/sequence/test-images/Connect.svg @@ -0,0 +1 @@ +AB \ No newline at end of file diff --git a/scripts/sequence/test-images/Reference.svg b/scripts/sequence/test-images/Reference.svg new file mode 100644 index 0000000..9757e5a --- /dev/null +++ b/scripts/sequence/test-images/Reference.svg @@ -0,0 +1 @@ +ABCDrefMy ref \ No newline at end of file diff --git a/scripts/specs.js b/scripts/specs.js index 6ca28be..dba814e 100644 --- a/scripts/specs.js +++ b/scripts/specs.js @@ -6,7 +6,13 @@ define([ 'svg/SVGShapes_spec', 'svg/PatternedLine_spec', 'interface/Interface_spec', + 'image/ImageRegion_spec', + 'image/Blur_spec', + 'image/Composition_spec', + 'image/ImageSimilarity_spec', 'sequence/SequenceDiagram_spec', + 'sequence/SequenceDiagram_visual_spec', + 'sequence/Readme_spec', 'sequence/Tokeniser_spec', 'sequence/Parser_spec', 'sequence/MarkdownParser_spec', diff --git a/scripts/tester/jshintRunner.js b/scripts/tester/jshintRunner.js index 89f8b07..0657713 100644 --- a/scripts/tester/jshintRunner.js +++ b/scripts/tester/jshintRunner.js @@ -20,6 +20,7 @@ define(['jshintConfig', 'specs'], (jshintConfig) => { const PREDEF_TEST = [ 'jasmine', + 'beforeAll', 'beforeEach', 'afterEach', 'spyOn', diff --git a/scripts/tester/specRunner.js b/scripts/tester/specRunner.js index 1f8b01e..dc98c99 100644 --- a/scripts/tester/specRunner.js +++ b/scripts/tester/specRunner.js @@ -9,6 +9,30 @@ urlArgs: String(Math.random()), // Prevent cache }, window.getRequirejsCDN())); + const matchers = { + toBeNear: () => { + return { + compare: (actual, expected, range) => { + if( + typeof expected !== 'number' || + typeof range !== 'number' || + range < 0 + ) { + throw new Error( + 'Invalid toBeNear(' + expected + ',' + range + ')' + ); + } + if(typeof actual !== 'number') { + throw new Error('Expected a number, got ' + actual); + } + return { + pass: Math.abs(actual - expected) <= range, + }; + }, + }; + }, + }; + requirejs(['jasmineBoot'], () => { // Slightly hacky way of making jasmine work with asynchronously loaded // tests while keeping features of jasmine-boot @@ -26,6 +50,10 @@ }); }; + beforeAll(() => { + jasmine.addMatchers(matchers); + }); + requirejs(['tester/jshintRunner'], (promise) => promise.then(runner)); }); })());