From d1c810e2afb03b4293bceb3bc2c7927184b0df34 Mon Sep 17 00:00:00 2001 From: David Evans Date: Mon, 30 Oct 2017 21:22:20 +0000 Subject: [PATCH] Add integration testing --- README.md | 9 +- scripts/sequence/Renderer.js | 16 +- scripts/sequence/sequence_integration_spec.js | 283 ++++++++++++++++++ scripts/specs.js | 1 + scripts/stubs/SVGTextBlock.js | 158 ++++++++++ scripts/svg/SVGShapes.js | 3 +- 6 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 scripts/sequence/sequence_integration_spec.js create mode 100644 scripts/stubs/SVGTextBlock.js diff --git a/README.md b/README.md index b7091c6..3abb451 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,8 @@ if fraud detected end Police else if sufficient funds ATM -> Bank: Withdraw funds - repeat until all requested money handed over + repeat until "all requested money + has been handed over" ATM -> Person: Dispense note end else @@ -172,7 +173,7 @@ A -> B B -> A # Return to the defined marker -# (should be interpreted as no-higher-then the marker; may still be +# (should be interpreted as no-higher-then the marker; may be # pushed down to keep relative action ordering consistent) simultaneously with some primary process: @@ -181,8 +182,8 @@ D -> C end D C -> A -# The marker name is optional; using "simultaneously:" with no marker -# will jump to the top of the entire sequence. +# The marker name is optional; using "simultaneously:" with no +# marker will jump to the top of the entire sequence. ``` ## DSL Basics diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js index a44b16b..8cf6d7b 100644 --- a/scripts/sequence/Renderer.js +++ b/scripts/sequence/Renderer.js @@ -52,7 +52,9 @@ define([ } return class Renderer { - constructor(theme) { + constructor(theme, { + SVGTextBlockClass = SVGTextBlock, + } = {}) { this.separationAgentCap = { 'box': this.separationAgentCapBox.bind(this), 'cross': this.separationAgentCapCross.bind(this), @@ -110,6 +112,7 @@ define([ this.height = 0; this.marks = new Map(); this.theme = theme; + this.SVGTextBlockClass = SVGTextBlockClass; this.currentSequence = null; this.buildStaticElements(); } @@ -132,9 +135,9 @@ define([ this.base.appendChild(this.sections); this.base.appendChild(this.actionShapes); this.base.appendChild(this.actionLabels); - this.title = new SVGTextBlock(this.base); + this.title = new this.SVGTextBlockClass(this.base); - this.sizer = new SVGTextBlock.SizeTester(this.base); + this.sizer = new this.SVGTextBlockClass.SizeTester(this.base); } findExtremes(agents) { @@ -397,6 +400,7 @@ define([ labelAttrs: config.labelAttrs, boxLayer: this.actionShapes, labelLayer: this.actionLabels, + SVGTextBlockClass: this.SVGTextBlockClass, }); return { @@ -543,6 +547,7 @@ define([ labelAttrs: config.label.loopbackAttrs, boxLayer: this.mask, labelLayer: this.actionLabels, + SVGTextBlockClass: this.SVGTextBlockClass, }); const r = config.loopbackRadius; const x1 = ( @@ -611,6 +616,7 @@ define([ labelAttrs: config.label.attrs, boxLayer: this.mask, labelLayer: this.actionLabels, + SVGTextBlockClass: this.SVGTextBlockClass, }); this.actionShapes.appendChild(svg.make('line', Object.assign({ @@ -659,7 +665,7 @@ define([ this.currentY += config.margin.top; const y = this.currentY + config.padding.top; - const labelNode = new SVGTextBlock(this.actionLabels, { + const labelNode = new this.SVGTextBlockClass(this.actionLabels, { attrs: config.labelAttrs, text: label, y, @@ -799,6 +805,7 @@ define([ labelAttrs: config.section.mode.labelAttrs, boxLayer: this.blocks, labelLayer: this.actionLabels, + SVGTextBlockClass: this.SVGTextBlockClass, }); const labelRender = SVGShapes.renderBoxedText(label, { @@ -809,6 +816,7 @@ define([ labelAttrs: config.section.label.labelAttrs, boxLayer: this.mask, labelLayer: this.actionLabels, + SVGTextBlockClass: this.SVGTextBlockClass, }); this.currentY += ( diff --git a/scripts/sequence/sequence_integration_spec.js b/scripts/sequence/sequence_integration_spec.js new file mode 100644 index 0000000..ebbbfcb --- /dev/null +++ b/scripts/sequence/sequence_integration_spec.js @@ -0,0 +1,283 @@ +/* jshint -W072 */ +defineDescribe('Sequence Renderer', [ + './Parser', + './Generator', + './Renderer', + './themes/Basic', + 'stubs/SVGTextBlock', +], ( + Parser, + Generator, + Renderer, + Theme, + SVGTextBlock +) => { + 'use strict'; + + let parser = null; + let generator = null; + let renderer = null; + let theme = null; + + beforeEach(() => { + theme = new Theme(); + parser = new Parser(); + generator = new Generator(); + renderer = new Renderer(theme, {SVGTextBlockClass: SVGTextBlock}); + document.body.appendChild(renderer.svg()); + }); + + afterEach(() => { + document.body.removeChild(renderer.svg()); + }); + + function getSimplifiedContent(r) { + return (r.svg().outerHTML + .replace(/<\/g>/g, '') + .replace(' xmlns="http://www.w3.org/2000/svg" version="1.1"', '') + ); + } + + it('Renders empty diagrams without error', () => { + const parsed = parser.parse(''); + const sequence = generator.generate(parsed); + renderer.render(sequence); + expect(getSimplifiedContent(renderer)).toEqual( + '' + + '' + ); + }); + + it('Renders simple metadata', () => { + const parsed = parser.parse('title My title here'); + const sequence = generator.generate(parsed); + renderer.render(sequence); + + expect(getSimplifiedContent(renderer)).toEqual( + '' + + 'My title here' + + '' + ); + }); + + it('Renders simple components', () => { + const parsed = parser.parse('A -> B'); + const sequence = generator.generate(parsed); + renderer.render(sequence); + + const content = getSimplifiedContent(renderer); + expect(content).toContain( + '' + ); + + // Agent 1 + expect(content).toContain( + ' { + const parsed = parser.parse( + 'title Labyrinth\n' + + '\n' + + 'Bowie -> Gremlin: You remind me of the babe\n' + + 'Gremlin -> Bowie: What babe?\n' + + 'Bowie -> Gremlin: The babe with the power\n' + + 'Gremlin -> Bowie: What power?\n' + + 'note right of Bowie, Gremlin: Most people get muddled here!\n' + + 'Bowie -> Gremlin: \'The power of voodoo\'\n' + + 'Gremlin -> Bowie: "Who-do?"\n' + + 'Bowie -> Gremlin: You do!\n' + + 'Gremlin -> Bowie: Do what?\n' + + 'Bowie -> Gremlin: Remind me of the babe!\n' + + '\n' + + 'Bowie -> Audience: Sings\n' + + '\n' + + 'terminators box\n' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Connection Types" example without error', () => { + const parsed = parser.parse( + 'title Connection Types\n' + + '\n' + + 'Foo -> Bar: Simple arrow\n' + + 'Foo --> Bar: Dashed arrow\n' + + 'Foo <- Bar: Reversed arrow\n' + + 'Foo <-- Bar: Reversed dashed arrow\n' + + 'Foo <-> Bar: Double arrow\n' + + 'Foo <--> Bar: Double dashed arrow\n' + + '\n' + + '# An arrow with no label:\n' + + 'Foo -> Bar\n' + + '\n' + + 'Foo -> Foo: Foo talks to itself\n' + + '\n' + + '# Arrows leaving on the left and right of the diagram\n' + + '[ -> Foo: From the left\n' + + '[ <- Foo: To the left\n' + + 'Foo -> ]: To the right\n' + + 'Foo <- ]: From the right\n' + + '[ -> ]: Left to right!\n' + + '# (etc.)\n' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Notes & State" example without error', () => { + const parsed = parser.parse( + 'title Note Placements\n' + + '\n' + + 'note over Foo: Foo says something\n' + + 'note left of Foo: Stuff\n' + + 'note right of Bar: More stuff\n' + + 'note over Foo, Bar: "Foo and Bar\n' + + 'on multiple lines"\n' + + 'note between Foo, Bar: Link\n' + + '\n' + + 'text right: \'Comments\\nOver here\!\'\n' + + '\n' + + 'state over Foo: Foo is ponderous' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Logic" example without error', () => { + const parsed = parser.parse( + 'title At the Bank\n' + + '\n' + + 'begin Person, ATM, Bank\n' + + 'Person -> ATM: Request money\n' + + 'ATM -> Bank: Check funds\n' + + 'if fraud detected\n' + + ' Bank -> Police: "Get \'em!"\n' + + ' Police -> Person: "You\'re nicked"\n' + + ' end Police\n' + + 'else if sufficient funds\n' + + ' ATM -> Bank: Withdraw funds\n' + + ' repeat until "all requested money\n' + + ' has been handed over"\n' + + ' ATM -> Person: Dispense note\n' + + ' end\n' + + 'else\n' + + ' ATM -> Person: Error\n' + + 'end' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Multiline Text" example without error', () => { + const parsed = parser.parse( + 'title \'My Multiline\n' + + 'Title\'\n' + + '\n' + + 'note over Foo: \'Also possible\\nwith escapes\'\n' + + '\n' + + 'Foo -> Bar: \'Lines of text\\non this arrow\'\n' + + '\n' + + 'if \'Even multiline\\ninside conditions like this\'\n' + + ' Foo -> \'Multiline\\nagent\'\n' + + 'end\n' + + '\n' + + 'state over Foo: \'Newlines here,\\ntoo!\'' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Short-Lived Agents" example without error', () => { + const parsed = parser.parse( + 'title "Baz doesn\'t live long"\n' + + '\n' + + 'Foo -> Bar\n' + + 'begin Baz\n' + + 'Bar -> Baz\n' + + 'Baz -> Foo\n' + + 'end Baz\n' + + 'Foo -> Bar\n' + + '\n' + + '# Foo and Bar end with black bars\n' + + 'terminators bar\n' + + '# (options are: box, bar, cross, none)' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Alternative Agent Ordering" example without error', () => { + const parsed = parser.parse( + 'define Baz, Foo\n' + + 'Foo -> Bar\n' + + 'Bar -> Baz\n' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); + + it('Renders the "Simultaneous Actions" example without error', () => { + const parsed = parser.parse( + 'begin A, B, C, D\n' + + 'A -> C\n' + + '\n' + + '# Define a marker which can be returned to later\n' + + '\n' + + 'some primary process:\n' + + 'A -> B\n' + + 'B -> A\n' + + 'A -> B\n' + + 'B -> A\n' + + '\n' + + '# Return to the defined marker\n' + + '# (should be interpreted as no-higher-then the marker; may be\n' + + '# pushed down to keep relative action ordering consistent)\n' + + '\n' + + 'simultaneously with some primary process:\n' + + 'C -> D\n' + + 'D -> C\n' + + 'end D\n' + + 'C -> A\n' + + '\n' + + '# The marker name is optional; using "simultaneously:" with no\n' + + '# marker will jump to the top of the entire sequence.' + ); + const sequence = generator.generate(parsed); + expect(() => renderer.render(sequence)).not.toThrow(); + }); +}); diff --git a/scripts/specs.js b/scripts/specs.js index c5295df..9b4280a 100644 --- a/scripts/specs.js +++ b/scripts/specs.js @@ -8,4 +8,5 @@ define([ 'sequence/Generator_spec', 'sequence/Renderer_spec', 'sequence/themes/Basic_spec', + 'sequence/sequence_integration_spec', ]); diff --git a/scripts/stubs/SVGTextBlock.js b/scripts/stubs/SVGTextBlock.js new file mode 100644 index 0000000..77ea7ca --- /dev/null +++ b/scripts/stubs/SVGTextBlock.js @@ -0,0 +1,158 @@ +define(['svg/SVGUtilities'], (svg) => { + 'use strict'; + + // Simplified text block renderer, which assumes all characters render as + // 1x1 px squares for repeatable renders in all browsers + + function merge(state, newState) { + for(let k in state) { + if(state.hasOwnProperty(k)) { + if(newState[k] !== null && newState[k] !== undefined) { + state[k] = newState[k]; + } + } + } + } + + class SVGTextBlock { + constructor(container, initialState = {}) { + this.container = container; + this.state = { + attrs: {}, + text: '', + x: 0, + y: 0, + }; + this.width = 0; + this.height = 0; + this.nodes = []; + this.set(initialState); + } + + _rebuildNodes(count) { + if(count > this.nodes.length) { + const attrs = Object.assign({ + 'x': this.state.x, + }, this.state.attrs); + + while(this.nodes.length < count) { + const element = svg.make('text', attrs); + const text = svg.makeText(); + element.appendChild(text); + this.container.appendChild(element); + this.nodes.push({element, text}); + } + } else { + while(this.nodes.length > count) { + const {element} = this.nodes.pop(); + this.container.removeChild(element); + } + } + } + + _reset() { + this._rebuildNodes(0); + this.width = 0; + this.height = 0; + } + + _renderText() { + if(!this.state.text) { + this._reset(); + return; + } + + const lines = this.state.text.split('\n'); + this._rebuildNodes(lines.length); + + let maxWidth = 0; + this.nodes.forEach(({text, element}, i) => { + text.nodeValue = lines[i]; + maxWidth = Math.max(maxWidth, lines[i].length); + }); + this.width = maxWidth; + } + + _updateX() { + this.nodes.forEach(({element}) => { + element.setAttribute('x', this.state.x); + }); + } + + _updateY() { + this.nodes.forEach(({element}, i) => { + element.setAttribute('y', this.state.y + i); + }); + this.height = this.nodes.length; + } + + firstLine() { + if(this.nodes.length > 0) { + return this.nodes[0].element; + } else { + return null; + } + } + + set(newState) { + const oldState = Object.assign({}, this.state); + merge(this.state, newState); + + if(this.state.attrs !== oldState.attrs) { + this._reset(); + oldState.text = ''; + } + + const oldNodes = this.nodes.length; + + if(this.state.text !== oldState.text) { + this._renderText(); + } + + if(this.state.x !== oldState.x) { + this._updateX(); + } + + if(this.state.y !== oldState.y || this.nodes.length !== oldNodes) { + this._updateY(); + } + } + } + + class SizeTester { + measure(attrs, content) { + if(!content) { + return {width: 0, height: 0}; + } + + const lines = content.split('\n'); + let width = 0; + lines.forEach((line) => { + width = Math.max(width, line.length); + }); + + return { + width, + height: lines.length, + }; + } + + measureHeight(attrs, content) { + if(!content) { + return 0; + } + + return content.split('\n').length; + } + + resetCache() { + } + + detach() { + } + } + + SVGTextBlock.SizeTester = SizeTester; + + return SVGTextBlock; +}); diff --git a/scripts/svg/SVGShapes.js b/scripts/svg/SVGShapes.js index d17c648..9521e12 100644 --- a/scripts/svg/SVGShapes.js +++ b/scripts/svg/SVGShapes.js @@ -43,6 +43,7 @@ define(['./SVGUtilities', './SVGTextBlock'], (svg, SVGTextBlock) => { boxLayer, labelLayer, boxRenderer = null, + SVGTextBlockClass = SVGTextBlock, }) { if(!text) { return {width: 0, height: 0, label: null, box: null}; @@ -65,7 +66,7 @@ define(['./SVGUtilities', './SVGTextBlock'], (svg, SVGTextBlock) => { break; } - const label = new SVGTextBlock(labelLayer, { + const label = new SVGTextBlockClass(labelLayer, { attrs: labelAttrs, text, x: anchorX,