commit 6eb8de816087512a2208ac28b23f6db039021ee8 Author: David Evans Date: Sun Oct 22 14:40:19 2017 +0100 Basic functionality (agents and labelled arrows) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..65c5ca8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/README.md b/README.md new file mode 100644 index 0000000..617ff1a --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# Sequence Diagram + +A tool for creating sequence diagrams from a Domain-Specific Language. + +This project includes a web page for editing the diagrams, but the core +logic is available as separate components which can be included in +other projects. + +## Examples + +### Simple Usage + +``` +title Labyrinth + +Bowie -> Gremlin: You remind me of the babe +Gremlin -> Bowie: What babe? +Bowie -> Gremlin: The babe with the power +Gremlin -> Bowie: What power? +note right of Bowie, Gremlin: Most people get muddled here! +Bowie -> Gremlin: 'The power of voodoo' +Gremlin -> Bowie: "Who-do?" +Bowie -> Gremlin: You do! +Gremlin -> Bowie: Do what? +Bowie -> Gremlin: Remind me of the babe! + +Bowie -> Audience: Sings +``` + +### Connection Types + +``` +title Connection Types + +Foo -> Bar: Simple arrow +Foo --> Bar: Dotted arrow +Foo <- Bar: Reversed arrow +Foo <-- Bar: Reversed dotted arrow +Foo <-> Bar: Double arrow +Foo <--> Bar: Double dotted arrow + +# An arrow with no label: +Foo -> Bar + +# Arrows leaving on the left and right of the diagram +[ -> Foo: From the left +[ <- Foo: To the left +Foo -> ]: To the right +Foo <- ]: From the right +[ -> ]: Left to right! +# (etc.) +``` + +### Notes & State + +``` +title Note placements + +note over Foo: Foo says something +note left of Foo: Stuff +note right of Bar: More stuff +note over Foo, Bar: Foo and Bar +note between Foo, Bar: Link + +state over Foo: Foo is ponderous +``` + +### Logic + +``` +title At the bank + +Person -> ATM: Request money +ATM -> Bank: Check funds +if fraud detected + Bank -> Police: "Get 'em!" + Police -> Person: "You're nicked" +else if sufficient funds + ATM -> Bank: Withdraw funds + repeat until all requested money handed over + ATM -> Person: Dispense note + end +else + ATM -> Person: Error +end +``` + +### Short-Lived Agents + +``` +title "Baz doesn't live long" + +Foo -> Bar +begin Baz +Bar -> Baz +Baz -> Foo +end Baz +Foo -> Bar + +# Foo and Bar end with black bars +terminators bar +# (options are: box, bar, cross, none) +``` + +### Alternative Agent Ordering + +``` +define Baz, Foo +Foo -> Bar +Bar -> Baz +``` + +## DSL Basics + +Comments begin with a `#` and end at the next newline: + +``` +# This is a comment +``` + +Meta data can be provided with particular keywords: + +``` +title 'My title here' +``` + +Quoting strings is usually optional, for example these are the same: + +``` +title 'My title here' +title "My title here" +title My title here +title "My title" here +title "My" 'title' "here" +``` + +Each non-metadata line represents a step in the sequence, in order. + +``` +# Draw an arrow from agent "Foo Bar" to agent "Zig Zag" with a label: +# (implicitly creates the agents if they do not already exist) + +Foo Bar -> Zig Zag: Do a thing + +# With quotes, this is the same as: + +'Foo Bar' -> 'Zig Zag': 'Do a thing' +``` + +Blocks surround steps, and can nest: + +``` +if something + Foo -> Bar +else if something else + Foo -> Baz + if more stuff + Baz -> Zig + end +end +``` + +(indentation is ignored) + +## Contributing + +Contributions are welcome! + +If you find a bug or desire a new feature, feel free to report it in +the GitHub issue tracker, or write the code yourself and make a pull +request. + +Pull requests are more likely to be accepted if the code you changed +is tested (write new tests for new features and bug fixes, and update +existing tests where necessary). You can make sure the tests and linter +are passing by opening test.htm + +Note: the linter can't run from the local filesystem, so you'll need to +run a local HTTP server to ensure linting is successful. One option if +you have NPM installed is: + +``` +# Setup +npm install http-server -g; + +# Then +http-server; +``` diff --git a/favicon.png b/favicon.png new file mode 100644 index 0000000..0e639c9 Binary files /dev/null and b/favicon.png differ diff --git a/index.htm b/index.htm new file mode 100644 index 0000000..71e79ed --- /dev/null +++ b/index.htm @@ -0,0 +1,49 @@ + + + + + +Sequence Diagram + + + + + + + + + + + + + + + + + + + diff --git a/scripts/interface/Interface.js b/scripts/interface/Interface.js new file mode 100644 index 0000000..c7a6dcb --- /dev/null +++ b/scripts/interface/Interface.js @@ -0,0 +1,216 @@ +define(['codemirror'], (CodeMirror) => { + 'use strict'; + + const DELAY_AGENTCHANGE = 500; + const DELAY_STAGECHANGE = 250; + const PNG_RESOLUTION = 4; + + function makeText(text = '') { + return document.createTextNode(text); + } + + function makeNode(type, attrs = {}) { + const o = document.createElement(type); + for(let k in attrs) { + if(attrs.hasOwnProperty(k)) { + o.setAttribute(k, attrs[k]); + } + } + return o; + } + + function on(element, events, fn) { + events.forEach((event) => element.addEventListener(event, fn)); + } + + return class Interface { + constructor({ + parser, + generator, + renderer, + defaultCode = '', + }) { + window.devicePixelRatio = 1; + this.canvas = makeNode('canvas'); + this.context = this.canvas.getContext('2d'); + + this.parser = parser; + this.generator = generator; + this.renderer = renderer; + this.defaultCode = defaultCode; + + this.debounced = null; + this.latestSeq = null; + this.renderedSeq = null; + this.canvasDirty = true; + this.svgDirty = true; + this.latestPNG = null; + this.latestSVG = null; + this.updatingPNG = null; + + this._downloadSVGClick = this._downloadSVGClick.bind(this); + this._downloadPNGClick = this._downloadPNGClick.bind(this); + this._downloadPNGFocus = this._downloadPNGFocus.bind(this); + } + + build(container) { + this.codePane = makeNode('div', {'class': 'pane-code'}); + this.viewPane = makeNode('div', {'class': 'pane-view'}); + + this.downloadPNG = makeNode('a', { + 'href': '#', + 'download': 'SequenceDiagram.png', + }); + this.downloadPNG.appendChild(makeText('Download PNG')); + on(this.downloadPNG, [ + 'focus', + 'mouseover', + 'mousedown', + ], this._downloadPNGFocus); + on(this.downloadPNG, ['click'], this._downloadPNGClick); + + this.downloadSVG = makeNode('a', { + 'href': '#', + 'download': 'SequenceDiagram.svg', + }); + this.downloadSVG.appendChild(makeText('SVG')); + on(this.downloadSVG, ['click'], this._downloadSVGClick); + + this.options = makeNode('div', {'class': 'options'}); + this.options.appendChild(this.downloadPNG); + this.options.appendChild(this.downloadSVG); + this.viewPane.appendChild(this.options); + + container.appendChild(this.codePane); + container.appendChild(this.viewPane); + + this.code = new CodeMirror(this.codePane, { + value: this.defaultCode, + mode: '', + }); + this.viewPane.appendChild(this.renderer.svg()); + + this.code.on('change', () => this.update(false)); + this.update(); + } + + redraw(sequence) { + clearTimeout(this.debounced); + this.debounced = null; + this.canvasDirty = true; + this.svgDirty = true; + this.renderedSeq = sequence; + this.renderer.render(sequence); + } + + update(immediate = true) { + const src = this.code.getDoc().getValue(); + let sequence = null; + try { + const parsed = this.parser.parse(src); + sequence = this.generator.generate(parsed); + } catch(e) { + // TODO + // console.log(e); + return; + } + + let delay = 0; + if(!immediate && this.renderedSeq) { + const old = this.renderedSeq; + if(sequence.agents.length !== old.agents.length) { + delay = DELAY_AGENTCHANGE; + } else if(sequence.stages.length !== old.stages.length) { + delay = DELAY_STAGECHANGE; + } + } + + if(delay <= 0) { + this.redraw(sequence); + } else { + clearTimeout(this.debounced); + this.latestSeq = sequence; + this.debounced = setTimeout(() => this.redraw(sequence), delay); + } + } + + forceRender() { + if(this.debounced) { + clearTimeout(this.debounced); + this.redraw(this.latestSeq); + } + } + + getSVGData() { + this.forceRender(); + if(!this.svgDirty) { + return this.latestSVG; + } + this.svgDirty = false; + const src = this.renderer.svg().outerHTML; + const blob = new Blob([src], {type: 'image/svg+xml'}); + if(this.latestSVG) { + URL.revokeObjectURL(this.latestSVG); + } + this.latestSVG = URL.createObjectURL(blob); + return this.latestSVG; + } + + getPNGData(callback) { + this.forceRender(); + if(!this.canvasDirty) { + // TODO: this could cause issues if getPNGData is called + // while another update is ongoing. Needs a more robust fix + callback(this.latestPNG); + return; + } + this.canvasDirty = false; + const width = this.renderer.width * PNG_RESOLUTION; + const height = this.renderer.height * PNG_RESOLUTION; + this.canvas.width = width; + this.canvas.height = height; + const img = new Image(width, height); + img.addEventListener('load', () => { + this.context.drawImage(img, 0, 0); + this.canvas.toBlob((blob) => { + if(this.latestPNG) { + URL.revokeObjectURL(this.latestPNG); + } + this.latestPNG = URL.createObjectURL(blob); + callback(this.latestPNG); + }, 'image/png'); + }, {once: true}); + img.src = this.getSVGData(); + } + + updatePNGLink() { + const nonce = this.updatingPNG = {}; + this.getPNGData((data) => { + if(this.updatingPNG === nonce) { + this.downloadPNG.setAttribute('href', data); + this.updatingPNG = null; + } + }); + } + + _downloadPNGFocus() { + this.forceRender(); + if(this.canvasDirty) { + this.updatePNGLink(); + } + } + + _downloadPNGClick(e) { + if(this.updatingPNG) { + e.preventDefault(); + } else if(this.canvasDirty) { + e.preventDefault(); + this.updatePNGLink(); + } + } + + _downloadSVGClick() { + this.downloadSVG.setAttribute('href', this.getSVGData()); + } + }; +}); diff --git a/scripts/interface/Interface_spec.js b/scripts/interface/Interface_spec.js new file mode 100644 index 0000000..bfa698a --- /dev/null +++ b/scripts/interface/Interface_spec.js @@ -0,0 +1,54 @@ +defineDescribe('Interface', ['./Interface'], (Interface) => { + 'use strict'; + + let parser = null; + let generator = null; + let renderer = null; + let container = null; + let ui = null; + + beforeEach(() => { + parser = jasmine.createSpyObj('parser', ['parse']); + parser.parse.and.returnValue({ + meta: {}, + stages: [], + }); + generator = jasmine.createSpyObj('generator', ['generate']); + generator.generate.and.returnValue({ + meta: {}, + agents: [], + stages: [], + }); + renderer = jasmine.createSpyObj('renderer', ['render', 'svg']); + renderer.svg.and.returnValue(document.createElement('svg')); + container = jasmine.createSpyObj('container', ['appendChild']); + ui = new Interface({ + parser, + generator, + renderer, + defaultCode: 'my default code', + }); + }); + + describe('build', () => { + it('adds elements to the given container', () => { + ui.build(container); + expect(container.appendChild).toHaveBeenCalled(); + }); + + it('creates a code mirror instance with the given code', () => { + ui.build(container); + const constructorArgs = ui.code.constructor; + expect(constructorArgs.options.value).toEqual('my default code'); + }); + }); + + describe('download SVG', () => { + it('triggers a download of the current image in SVG format', () => { + ui.build(container); + expect(ui.downloadSVG.getAttribute('href')).toEqual('#'); + ui.downloadSVG.dispatchEvent(new Event('click')); + expect(ui.downloadSVG.getAttribute('href')).not.toEqual('#'); + }); + }); +}); diff --git a/scripts/jshintConfig.js b/scripts/jshintConfig.js new file mode 100644 index 0000000..01dac7d --- /dev/null +++ b/scripts/jshintConfig.js @@ -0,0 +1,42 @@ +define({ + // Environment + esversion: 6, + browser: true, + typed: true, + + // Error verbosity + bitwise: true, + curly: true, + eqeqeq: true, + forin: true, + freeze: true, + futurehostile: true, + latedef: true, + maxcomplexity: 6, + maxdepth: 3, + maxparams: 4, + maxstatements: 20, + noarg: true, + nocomma: true, + nonbsp: true, + nonew: true, + shadow: 'outer', + singleGroups: false, + strict: true, + trailingcomma: true, + undef: true, + unused: true, + varstmt: true, + + // Deprecated code-style flags: + camelcase: true, + immed: true, + indent: 4, + maxlen: 80, + newcap: true, + noempty: true, + quotmark: 'single', + + // Output options + maxerr: 1000, +}); diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..954257d --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,43 @@ +((() => { + 'use strict'; + + requirejs.config(window.getRequirejsCDN()); + + requirejs([ + 'interface/Interface', + 'sequence/Parser', + 'sequence/Generator', + 'sequence/Renderer', + ], ( + Interface, + Parser, + Generator, + Renderer + ) => { + const defaultCode = ( + '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 ui = new Interface({ + defaultCode, + parser: new Parser(), + generator: new Generator(), + renderer: new Renderer(), + }); + ui.build(document.body); + }); +})()); diff --git a/scripts/requireConfig.js b/scripts/requireConfig.js new file mode 100644 index 0000000..a42721f --- /dev/null +++ b/scripts/requireConfig.js @@ -0,0 +1,36 @@ +((() => { + 'use strict'; + + window.getRequirejsCDN = () => { + const paths = {}; + const hashes = {}; + const metaTags = document.getElementsByTagName('meta'); + for(let i = 0; i < metaTags.length; ++ i) { + const metaTag = metaTags[i]; + const name = metaTag.getAttribute('name'); + if(name && name.startsWith('cdn-')) { + const module = name.substr('cdn-'.length); + let src = metaTag.getAttribute('content'); + if(src.endsWith('.js')) { + src = src.substr(0, src.length - '.js'.length); + } + paths[module] = src; + const integrity = metaTag.getAttribute('data-integrity'); + if(integrity) { + hashes[module] = integrity; + } + } + } + return { + paths, + hashes, + onNodeCreated: (node, config, module) => { + if(config.hashes[module]) { + // Thanks, https://stackoverflow.com/a/37065379/1180785 + node.setAttribute('integrity', config.hashes[module]); + node.setAttribute('crossorigin', 'anonymous'); + } + }, + }; + }; +})()); diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js new file mode 100644 index 0000000..e31eab9 --- /dev/null +++ b/scripts/sequence/Generator.js @@ -0,0 +1,194 @@ +define(() => { + 'use strict'; + + function mergeSets(target, b) { + if(!b) { + return; + } + for(let i = 0; i < b.length; ++ i) { + if(target.indexOf(b[i]) === -1) { + target.push(b[i]); + } + } + } + + function lastElement(list) { + return list[list.length - 1]; + } + + return class Generator { + findAgents(stages) { + const agents = ['[']; + stages.forEach((stage) => { + if(stage.agents) { + mergeSets(agents, stage.agents); + } + }); + + if(agents.indexOf(']') !== -1) { + agents.splice(agents.indexOf(']'), 1); + } + agents.push(']'); + + return agents; + } + + generate({meta = {}, stages}) { + const agents = this.findAgents(stages); + + const agentStates = new Map(); + agents.forEach((agent) => { + agentStates.set(agent, {visible: false, locked: false}); + }); + agentStates.get('[').locked = true; + agentStates.get(']').locked = true; + + const rootStages = []; + let currentSection = { + mode: 'global', + label: '', + stages: rootStages, + }; + let currentNest = { + type: 'block', + agents: [], + root: true, + sections: [currentSection], + }; + const nesting = [currentNest]; + + function beginNested(stage) { + currentSection = { + mode: stage.mode, + label: stage.label, + stages: [], + }; + currentNest = { + type: 'block', + agents: [], + sections: [currentSection], + }; + nesting.push(currentNest); + } + + function splitNested(stage) { + if(currentNest.sections[0].mode !== 'if') { + throw new Error('Invalid block nesting'); + } + currentSection = { + mode: stage.mode, + label: stage.label, + stages: [], + }; + currentNest.sections.push(currentSection); + } + + function addStage(stage) { + currentSection.stages.push(stage); + mergeSets(currentNest.agents, stage.agents); + } + + function endNested() { + if(currentNest.root) { + throw new Error('Invalid block nesting'); + } + const subNest = nesting.pop(); + currentNest = lastElement(nesting); + currentSection = lastElement(currentNest.sections); + if(subNest.agents.length > 0) { + addStage(subNest); + } + } + + function addAgentMod(stageAgents, markVisible, mode) { + if(stageAgents.length === 0) { + return; + } + stageAgents.forEach((agent) => { + agentStates.get(agent).visible = markVisible; + }); + const type = (markVisible ? 'agent begin' : 'agent end'); + const existing = lastElement(currentSection.stages) || {}; + if(existing.type === type && existing.mode === mode) { + mergeSets(existing.agents, stageAgents); + mergeSets(currentNest.agents, stageAgents); + } else { + addStage({ + type, + agents: stageAgents, + mode, + }); + } + } + + function filterVis(stageAgents, visible, implicit = false) { + return stageAgents.filter((agent) => { + const state = agentStates.get(agent); + if(!state.locked) { + return state.visible === visible; + } else if(!implicit) { + throw new Error('Cannot modify agent ' + agent); + } else { + return false; + } + }); + } + + stages.forEach((stage) => { + /* jshint -W074 */ // It's only a switch statement + switch(stage.type) { + case 'agent define': + break; + case 'agent begin': + addAgentMod( + filterVis(stage.agents, false), + true, + stage.mode + ); + break; + case 'agent end': + addAgentMod( + filterVis(stage.agents, true), + false, + stage.mode + ); + break; + case 'block begin': + beginNested(stage); + break; + case 'block split': + splitNested(stage); + break; + case 'block end': + endNested(stage); + break; + default: + addAgentMod( + filterVis(stage.agents, false, true), + true, + 'box' + ); + addStage(stage); + break; + } + }); + + if(nesting.length !== 1) { + throw new Error('Invalid block nesting'); + } + + addAgentMod( + filterVis(agents, true, true), + false, + meta.terminators || 'none' + ); + + return { + meta, + agents, + stages: rootStages, + }; + } + }; +}); + diff --git a/scripts/sequence/Generator_spec.js b/scripts/sequence/Generator_spec.js new file mode 100644 index 0000000..42a4b7b --- /dev/null +++ b/scripts/sequence/Generator_spec.js @@ -0,0 +1,374 @@ +defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { + 'use strict'; + + /* jshint -W071 */ // Allow lots of tests + + const generator = new Generator(); + + const AGENT_DEFINE = 'agent define'; + const AGENT_BEGIN = 'agent begin'; + const AGENT_END = 'agent end'; + + const BLOCK_BEGIN = 'block begin'; + const BLOCK_SPLIT = 'block split'; + const BLOCK_END = 'block end'; + + describe('.generate', () => { + it('propagates metadata', () => { + const input = { + meta: {foo: 'bar'}, + stages: [], + }; + const sequence = generator.generate(input); + expect(sequence.meta).toEqual({foo: 'bar'}); + }); + + it('returns an empty sequence for blank input', () => { + const sequence = generator.generate({stages: []}); + expect(sequence.stages).toEqual([]); + }); + + it('includes implicit hidden left/right agents', () => { + const sequence = generator.generate({stages: []}); + expect(sequence.agents).toEqual(['[', ']']); + }); + + it('returns aggregated agents', () => { + const sequence = generator.generate({stages: [ + {type: '->', agents: ['A', 'B']}, + {type: '<-', agents: ['C', 'D']}, + {type: AGENT_BEGIN, agents: ['E'], mode: 'box'}, + ]}); + expect(sequence.agents).toEqual( + ['[', 'A', 'B', 'C', 'D', 'E', ']'] + ); + }); + + it('always puts the implicit right agent on the right', () => { + const sequence = generator.generate({stages: [ + {type: '->', agents: [']', 'B']}, + ]}); + expect(sequence.agents).toEqual(['[', 'B', ']']); + }); + + it('creates implicit begin stages for agents when used', () => { + const sequence = generator.generate({stages: [ + {type: '->', agents: ['A', 'B']}, + {type: '->', agents: ['B', 'C']}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_BEGIN, agents: ['C'], mode: 'box'}, + {type: '->', agents: ['B', 'C']}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'}, + ]); + }); + + it('creates implicit end stages for all remaining agents', () => { + const sequence = generator.generate({ + meta: { + terminators: 'foo', + }, + stages: [ + {type: '->', agents: ['A', 'B']}, + ], + }); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['A', 'B'], mode: 'foo'}, + ]); + }); + + it('does not create duplicate begin stages', () => { + const sequence = generator.generate({stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B', 'C'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: '->', agents: ['B', 'C']}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['A', 'B', 'C'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: '->', agents: ['B', 'C']}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'}, + ]); + }); + + it('redisplays agents if they have been hidden', () => { + const sequence = generator.generate({stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['B'], mode: 'cross'}, + {type: '->', agents: ['A', 'B']}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['B'], mode: 'cross'}, + {type: AGENT_BEGIN, agents: ['B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['A', 'B'], mode: 'none'}, + ]); + }); + + it('collapses adjacent begin statements', () => { + const sequence = generator.generate({stages: [ + {type: '->', agents: ['A', 'B']}, + {type: AGENT_BEGIN, agents: ['D'], mode: 'box'}, + {type: '->', agents: ['B', 'C']}, + {type: '->', agents: ['C', 'D']}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_BEGIN, agents: ['D', 'C'], mode: 'box'}, + {type: '->', agents: ['B', 'C']}, + {type: '->', agents: ['C', 'D']}, + {type: AGENT_END, agents: ['A', 'B', 'D', 'C'], mode: 'none'}, + ]); + }); + + it('removes superfluous begin statements', () => { + const sequence = generator.generate({stages: [ + {type: '->', agents: ['A', 'B']}, + {type: AGENT_BEGIN, agents: ['A', 'C', 'D'], mode: 'box'}, + {type: AGENT_BEGIN, agents: ['C', 'E'], mode: 'box'}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_BEGIN, agents: ['C', 'D', 'E'], mode: 'box'}, + {type: AGENT_END, agents: [ + 'A', 'B', 'C', 'D', 'E', + ], mode: 'none'}, + ]); + }); + + it('removes superfluous end statements', () => { + const sequence = generator.generate({stages: [ + {type: AGENT_DEFINE, agents: ['E']}, + {type: AGENT_BEGIN, agents: ['C', 'D'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'cross'}, + {type: AGENT_END, agents: ['A', 'D', 'E'], mode: 'cross'}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['C', 'D', 'A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['A', 'B', 'C', 'D'], mode: 'cross'}, + ]); + }); + + it('does not merge different modes of end', () => { + const sequence = generator.generate({stages: [ + {type: AGENT_DEFINE, agents: ['E']}, + {type: AGENT_BEGIN, agents: ['C', 'D'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'cross'}, + ]}); + expect(sequence.stages).toEqual([ + {type: AGENT_BEGIN, agents: ['C', 'D', 'A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'cross'}, + {type: AGENT_END, agents: ['D'], mode: 'none'}, + ]); + }); + + it('records all involved agents in block begin statements', () => { + const sequence = generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: '->', agents: ['A', 'C']}, + {type: BLOCK_END}, + ]}); + + expect(sequence.stages).toEqual([ + {type: 'block', agents: ['A', 'B', 'C'], sections: [ + {mode: 'if', label: 'abc', stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + ]}, + {mode: 'else', label: 'xyz', stages: [ + {type: AGENT_BEGIN, agents: ['C'], mode: 'box'}, + {type: '->', agents: ['A', 'C']}, + ]}, + ]}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'}, + ]); + }); + + it('records all involved agents in nested blocks', () => { + const sequence = generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: BLOCK_BEGIN, mode: 'if', label: 'def'}, + {type: '->', agents: ['A', 'C']}, + {type: BLOCK_END}, + {type: BLOCK_END}, + ]}); + + expect(sequence.stages).toEqual([ + {type: 'block', agents: ['A', 'B', 'C'], sections: [ + {mode: 'if', label: 'abc', stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + ]}, + {mode: 'else', label: 'xyz', stages: [ + {type: 'block', agents: ['C', 'A'], sections: [ + {mode: 'if', label: 'def', stages: [ + {type: AGENT_BEGIN, agents: ['C'], mode: 'box'}, + {type: '->', agents: ['A', 'C']}, + ]}, + ]}, + ]}, + ]}, + {type: AGENT_END, agents: ['A', 'B', 'C'], mode: 'none'}, + ]); + }); + + it('allows empty block parts after split', () => { + const sequence = generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: BLOCK_END}, + ]}); + + expect(sequence.stages).toEqual([ + {type: 'block', agents: ['A', 'B'], sections: [ + {mode: 'if', label: 'abc', stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + ]}, + {mode: 'else', label: 'xyz', stages: []}, + ]}, + {type: AGENT_END, agents: ['A', 'B'], mode: 'none'}, + ]); + }); + + it('allows empty block parts before split', () => { + const sequence = generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_END}, + ]}); + + expect(sequence.stages).toEqual([ + {type: 'block', agents: ['A', 'B'], sections: [ + {mode: 'if', label: 'abc', stages: []}, + {mode: 'else', label: 'xyz', stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + ]}, + ]}, + {type: AGENT_END, agents: ['A', 'B'], mode: 'none'}, + ]); + }); + + it('removes entirely empty blocks', () => { + const sequence = generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: BLOCK_END}, + {type: BLOCK_END}, + ]}); + + expect(sequence.stages).toEqual([]); + }); + + it('removes entirely empty nested blocks', () => { + const sequence = generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: BLOCK_END}, + {type: BLOCK_END}, + ]}); + + + expect(sequence.stages).toEqual([ + {type: 'block', agents: ['A', 'B'], sections: [ + {mode: 'if', label: 'abc', stages: [ + {type: AGENT_BEGIN, agents: ['A', 'B'], mode: 'box'}, + {type: '->', agents: ['A', 'B']}, + ]}, + {mode: 'else', label: 'xyz', stages: []}, + ]}, + {type: AGENT_END, agents: ['A', 'B'], mode: 'none'}, + ]); + }); + + it('rejects unterminated blocks', () => { + expect(() => generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: BLOCK_BEGIN, mode: 'if', label: 'def'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_END}, + ]})).toThrow(); + }); + + it('rejects extra block terminations', () => { + expect(() => generator.generate({stages: [ + {type: BLOCK_END}, + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_END}, + {type: BLOCK_END}, + ]})).toThrow(); + }); + + it('rejects block splitting without a block', () => { + expect(() => generator.generate({stages: [ + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'if', label: 'abc'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_END}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + ]})).toThrow(); + }); + + it('rejects block splitting in non-splittable blocks', () => { + expect(() => generator.generate({stages: [ + {type: BLOCK_BEGIN, mode: 'repeat', label: 'abc'}, + {type: BLOCK_SPLIT, mode: 'else', label: 'xyz'}, + {type: '->', agents: ['A', 'B']}, + {type: BLOCK_END}, + ]})).toThrow(); + }); + + it('rejects attempts to change implicit agents', () => { + expect(() => generator.generate({stages: [ + {type: AGENT_BEGIN, agents: ['['], mode: 'box'}, + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + {type: AGENT_BEGIN, agents: [']'], mode: 'box'}, + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + {type: AGENT_END, agents: ['['], mode: 'cross'}, + ]})).toThrow(); + + expect(() => generator.generate({stages: [ + {type: AGENT_END, agents: [']'], mode: 'cross'}, + ]})).toThrow(); + }); + }); +}); diff --git a/scripts/sequence/Parser.js b/scripts/sequence/Parser.js new file mode 100644 index 0000000..6990590 --- /dev/null +++ b/scripts/sequence/Parser.js @@ -0,0 +1,346 @@ +define(() => { + 'use strict'; + + function execAt(str, reg, i) { + reg.lastIndex = i; + return reg.exec(str); + } + + function unescape(match) { + const c = match[1]; + if(c === 'n') { + return '\n'; + } + return match[1]; + } + + const TOKENS = [ + {start: /#/y, end: /(?=\n)|$/y, omit: true}, + {start: /"/y, end: /"/y, escape: /\\(.)/y, escapeWith: unescape}, + {start: /'/y, end: /'/y, escape: /\\(.)/y, escapeWith: unescape}, + {start: /(?=[^ \t\r\n:+\-<>,])/y, end: /(?=[ \t\r\n:+\-<>,])|$/y}, + {start: /(?=[+\-<>])/y, end: /(?=[^+\-<>])|$/y}, + {start: /,/y, prefix: ','}, + {start: /:/y, prefix: ':'}, + {start: /\n/y, prefix: '\n'}, + ]; + + const BLOCK_TYPES = { + 'if': {type: 'block begin', mode: 'if', skip: []}, + 'else': {type: 'block split', mode: 'else', skip: ['if']}, + 'elif': {type: 'block split', mode: 'else', skip: []}, + 'repeat': {type: 'block begin', mode: 'repeat', skip: []}, + }; + + const CONNECTION_TYPES = [ + '->', + '<-', + '<->', + '-->', + '<--', + '<-->', + ]; + + const TERMINATOR_TYPES = [ + 'none', + 'box', + 'cross', + 'bar', + ]; + + const NOTE_TYPES = { + 'note': { + mode: 'note', + multiAgent: true, + types: { + 'over': {type: 'note over', skip: []}, + 'left': {type: 'note left', skip: ['of']}, + 'right': {type: 'note right', skip: ['of']}, + 'between': {type: 'note between', skip: []}, + }, + }, + 'state': { + mode: 'state', + multiAgent: false, + types: { + 'over': {type: 'note over', skip: []}, + }, + }, + }; + + const AGENT_MANIPULATION_TYPES = { + 'define': {type: 'agent define'}, + 'begin': {type: 'agent begin', mode: 'box'}, + 'end': {type: 'agent end', mode: 'cross'}, + }; + + function tokFindBegin(src, i) { + for(let j = 0; j < TOKENS.length; ++ j) { + const block = TOKENS[j]; + const match = execAt(src, block.start, i); + if(match) { + return { + newBlock: block, + end: !block.end, + append: (block.prefix || ''), + skip: match[0].length, + }; + } + } + return { + newBlock: null, + end: false, + append: '', + skip: 1, + }; + } + + function tokContinuePart(src, i, block) { + if(block.escape) { + const match = execAt(src, block.escape, i); + if(match) { + return { + newBlock: null, + end: false, + append: block.escapeWith(match), + skip: match[0].length, + }; + } + } + const match = execAt(src, block.end, i); + if(match) { + return { + newBlock: null, + end: true, + append: '', + skip: match[0].length, + }; + } + return { + newBlock: null, + end: false, + append: src[i], + skip: 1, + }; + } + + function tokAdvance(src, i, block) { + if(block) { + return tokContinuePart(src, i, block); + } else { + return tokFindBegin(src, i); + } + } + + function skipOver(line, start, skip, error = null) { + if(skip.some((token, i) => (line[start + i] !== token))) { + if(error) { + throw new Error(error + ': ' + line.join(' ')); + } else { + return start; + } + } + return start + skip.length; + } + + function parseCommaList(tokens) { + const list = []; + let current = ''; + tokens.forEach((token) => { + if(token === ',') { + if(current) { + list.push(current); + current = ''; + } + } else { + current += (current ? ' ' : '') + token; + } + }); + if(current) { + list.push(current); + } + return list; + } + + function parseBlockCommand(line) { + if(line[0] === 'end' && line.length === 1) { + return {type: 'block end'}; + } + + const type = BLOCK_TYPES[line[0]]; + if(!type) { + return null; + } + let skip = 1; + if(line.length > skip) { + skip = skipOver(line, skip, type.skip, 'Invalid block command'); + } + skip = skipOver(line, skip, [':']); + return { + type: type.type, + mode: type.mode, + label: line.slice(skip).join(' '), + }; + } + + function parseAgentCommand(line) { + const type = AGENT_MANIPULATION_TYPES[line[0]]; + if(!type) { + return null; + } + if(line.length <= 1) { + return null; + } + return Object.assign({ + agents: parseCommaList(line.slice(1)), + }, type); + } + + function parseNote(line) { + const mode = NOTE_TYPES[line[0]]; + const labelSplit = line.indexOf(':'); + if(!mode || labelSplit === -1) { + return null; + } + const type = mode.types[line[1]]; + if(!type) { + return null; + } + let skip = 2; + skip = skipOver(line, skip, type.skip); + const agents = parseCommaList(line.slice(skip, labelSplit)); + if(agents.length < 1 || (agents.length > 1 && !mode.multiAgent)) { + throw new Error('Invalid ' + line[0] + ': ' + line.join(' ')); + } + return { + type: type.type, + agents, + mode: mode.mode, + label: line.slice(labelSplit + 1).join(' '), + }; + } + + function parseConnection(line) { + let labelSplit = line.indexOf(':'); + if(labelSplit === -1) { + labelSplit = line.length; + } + let typeSplit = -1; + for(let j = 0; j < CONNECTION_TYPES.length; ++ j) { + const p = line.indexOf(CONNECTION_TYPES[j]); + if(p !== -1 && p < labelSplit) { + typeSplit = p; + break; + } + } + if(typeSplit <= 0 || typeSplit === labelSplit - 1) { + return null; + } + return { + type: line[typeSplit], + agents: [ + line.slice(0, typeSplit).join(' '), + line.slice(typeSplit + 1, labelSplit).join(' '), + ], + label: line.slice(labelSplit + 1).join(' '), + }; + } + + function parseMeta(line, meta) { + if(line[0] === 'title') { + meta.title = line.slice(1).join(' '); + return true; + } + if(line[0] === 'terminators') { + if(TERMINATOR_TYPES.indexOf(line[1]) === -1) { + throw new Error('Unrecognised termination: ' + line.join(' ')); + } + meta.terminators = line[1]; + return true; + } + return false; + } + + function parseLine(line, {meta, stages}) { + if(parseMeta(line, meta)) { + return; + } + const stage = ( + parseBlockCommand(line) || + parseAgentCommand(line) || + parseNote(line) || + parseConnection(line) + ); + if(!stage) { + throw new Error('Unrecognised command: ' + line.join(' ')); + } + stages.push(stage); + } + + return class Parser { + tokenise(src) { + const tokens = []; + let block = null; + let current = ''; + for(let i = 0; i <= src.length;) { + const {newBlock, end, append, skip} = tokAdvance(src, i, block); + if(newBlock) { + block = newBlock; + current = ''; + } + current += append; + i += skip; + if(end) { + if(!block.omit) { + tokens.push(current); + } + block = null; + } + } + if(block) { + throw new Error('Unterminated block'); + } + return tokens; + } + + splitLines(tokens) { + const lines = []; + let line = []; + tokens.forEach((token) => { + if(token === '\n') { + if(line.length > 0) { + lines.push(line); + line = []; + } + } else { + line.push(token); + } + }); + if(line.length > 0) { + lines.push(line); + } + return lines; + } + + parseLines(lines) { + const result = { + meta: { + title: '', + terminators: 'none', + }, + stages: [], + }; + + lines.forEach((line) => parseLine(line, result)); + + return result; + } + + parse(src) { + const tokens = this.tokenise(src); + const lines = this.splitLines(tokens); + return this.parseLines(lines); + } + }; +}); + diff --git a/scripts/sequence/Parser_spec.js b/scripts/sequence/Parser_spec.js new file mode 100644 index 0000000..420b379 --- /dev/null +++ b/scripts/sequence/Parser_spec.js @@ -0,0 +1,345 @@ +defineDescribe('Sequence Parser', ['./Parser'], (Parser) => { + 'use strict'; + + /* jshint -W071 */ // Allow lots of tests + + const parser = new Parser(); + + describe('.tokenise', () => { + it('converts the source into atomic tokens', () => { + const input = 'foo bar -> baz'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', 'bar', '->', 'baz']); + }); + + it('splits tokens at flexible boundaries', () => { + const input = 'foo bar->baz'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', 'bar', '->', 'baz']); + }); + + it('parses newlines as tokens', () => { + const input = 'foo bar\nbaz'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', 'bar', '\n', 'baz']); + }); + + it('removes leading and trailing whitespace', () => { + const input = ' foo \t bar\t\n baz'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', 'bar', '\n', 'baz']); + }); + + it('parses quoted strings as single tokens', () => { + const input = 'foo "zig zag" \'abc def\''; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', 'zig zag', 'abc def']); + }); + + it('ignores comments', () => { + const input = 'foo # bar baz\nzig'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', '\n', 'zig']); + }); + + it('ignores quotes within comments', () => { + const input = 'foo # bar "\'baz\nzig'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', '\n', 'zig']); + }); + + it('interprets special characters within quoted strings', () => { + const input = 'foo "zig\\" zag\\n"'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', 'zig" zag\n']); + }); + + it('maintains whitespace and newlines within quoted strings', () => { + const input = 'foo " zig\n zag "'; + const tokens = parser.tokenise(input); + expect(tokens).toEqual(['foo', ' zig\n zag ']); + }); + + it('rejects unterminated quoted values', () => { + expect(() => parser.tokenise('"nope')).toThrow(); + }); + }); + + describe('.splitLines', () => { + it('combines tokens', () => { + const lines = parser.splitLines(['abc', 'd']); + expect(lines).toEqual([ + ['abc', 'd'], + ]); + }); + + it('splits at newlines', () => { + const lines = parser.splitLines(['abc', 'd', '\n', 'e']); + expect(lines).toEqual([ + ['abc', 'd'], + ['e'], + ]); + }); + + it('ignores multiple newlines', () => { + const lines = parser.splitLines(['abc', 'd', '\n', '\n', 'e']); + expect(lines).toEqual([ + ['abc', 'd'], + ['e'], + ]); + }); + + it('ignores trailing newlines', () => { + const lines = parser.splitLines(['abc', 'd', '\n', 'e', '\n']); + expect(lines).toEqual([ + ['abc', 'd'], + ['e'], + ]); + }); + + it('handles empty input', () => { + const lines = parser.splitLines([]); + expect(lines).toEqual([]); + }); + }); + + describe('.parse', () => { + it('returns an empty sequence for blank input', () => { + const parsed = parser.parse(''); + expect(parsed).toEqual({ + meta: { + title: '', + terminators: 'none', + }, + stages: [], + }); + }); + + it('reads title metadata', () => { + const parsed = parser.parse('title foo'); + expect(parsed.meta.title).toEqual('foo'); + }); + + it('reads terminators metadata', () => { + const parsed = parser.parse('terminators bar'); + expect(parsed.meta.terminators).toEqual('bar'); + }); + + it('reads multiple tokens as one when reading values', () => { + const parsed = parser.parse('title foo bar'); + expect(parsed.meta.title).toEqual('foo bar'); + }); + + it('converts entries into abstract form', () => { + const parsed = parser.parse('A -> B'); + expect(parsed.stages).toEqual([ + {type: '->', agents: ['A', 'B'], label: ''}, + ]); + }); + + it('combines multiple tokens into single entries', () => { + const parsed = parser.parse('A B -> C D'); + expect(parsed.stages).toEqual([ + {type: '->', agents: ['A B', 'C D'], label: ''}, + ]); + }); + + it('parses optional labels', () => { + const parsed = parser.parse('A B -> C D: foo bar'); + expect(parsed.stages).toEqual([ + {type: '->', agents: ['A B', 'C D'], label: 'foo bar'}, + ]); + }); + + it('converts multiple entries', () => { + const parsed = parser.parse('A -> B\nB -> A'); + expect(parsed.stages).toEqual([ + {type: '->', agents: ['A', 'B'], label: ''}, + {type: '->', agents: ['B', 'A'], label: ''}, + ]); + }); + + it('ignores blank lines', () => { + const parsed = parser.parse('A -> B\n\nB -> A\n'); + expect(parsed.stages).toEqual([ + {type: '->', agents: ['A', 'B'], label: ''}, + {type: '->', agents: ['B', 'A'], label: ''}, + ]); + }); + + it('recognises all types of connection', () => { + const parsed = parser.parse( + 'A -> B\n' + + 'A <- B\n' + + 'A <-> B\n' + + 'A --> B\n' + + 'A <-- B\n' + + 'A <--> B\n' + ); + expect(parsed.stages).toEqual([ + {type: '->', agents: ['A', 'B'], label: ''}, + {type: '<-', agents: ['A', 'B'], label: ''}, + {type: '<->', agents: ['A', 'B'], label: ''}, + {type: '-->', agents: ['A', 'B'], label: ''}, + {type: '<--', agents: ['A', 'B'], label: ''}, + {type: '<-->', agents: ['A', 'B'], label: ''}, + ]); + }); + + it('ignores arrows within the label', () => { + const parsed = parser.parse( + 'A <- B: B -> A\n' + + 'A -> B: B <- A\n' + ); + expect(parsed.stages).toEqual([ + {type: '<-', agents: ['A', 'B'], label: 'B -> A'}, + {type: '->', agents: ['A', 'B'], label: 'B <- A'}, + ]); + }); + + it('converts notes', () => { + const parsed = parser.parse('note over A: hello there'); + expect(parsed.stages).toEqual([{ + type: 'note over', + agents: ['A'], + mode: 'note', + label: 'hello there', + }]); + }); + + it('converts different note types', () => { + const parsed = parser.parse( + 'note left A: hello there\n' + + 'note left of A: hello there\n' + + 'note right A: hello there\n' + + 'note right of A: hello there\n' + + 'note between A, B: hi\n' + ); + expect(parsed.stages).toEqual([ + { + type: 'note left', + agents: ['A'], + mode: 'note', + label: 'hello there', + }, + { + type: 'note left', + agents: ['A'], + mode: 'note', + label: 'hello there', + }, + { + type: 'note right', + agents: ['A'], + mode: 'note', + label: 'hello there', + }, + { + type: 'note right', + agents: ['A'], + mode: 'note', + label: 'hello there', + }, + { + type: 'note between', + agents: ['A', 'B'], + mode: 'note', + label: 'hi', + }, + ]); + }); + + it('allows multiple agents for notes', () => { + const parsed = parser.parse('note over A B, C D: hi'); + expect(parsed.stages).toEqual([{ + type: 'note over', + agents: ['A B', 'C D'], + mode: 'note', + label: 'hi', + }]); + }); + + it('converts state', () => { + const parsed = parser.parse('state over A: doing stuff'); + expect(parsed.stages).toEqual([{ + type: 'note over', + agents: ['A'], + mode: 'state', + label: 'doing stuff', + }]); + }); + + it('rejects multiple agents for state', () => { + expect(() => parser.parse('state over A, B: hi')).toThrow(); + }); + + it('converts agent commands', () => { + const parsed = parser.parse( + 'define A, B\n' + + 'begin A, B\n' + + 'end A, B\n' + ); + expect(parsed.stages).toEqual([ + {type: 'agent define', agents: ['A', 'B']}, + {type: 'agent begin', agents: ['A', 'B'], mode: 'box'}, + {type: 'agent end', agents: ['A', 'B'], mode: 'cross'}, + ]); + }); + + it('converts conditional blocks', () => { + const parsed = parser.parse( + 'if something happens\n' + + ' A -> B\n' + + 'else if something else\n' + + ' A -> C\n' + + ' C -> B\n' + + 'else\n' + + ' A -> D\n' + + 'end\n' + ); + expect(parsed.stages).toEqual([ + {type: 'block begin', mode: 'if', label: 'something happens'}, + {type: '->', agents: ['A', 'B'], label: ''}, + {type: 'block split', mode: 'else', label: 'something else'}, + {type: '->', agents: ['A', 'C'], label: ''}, + {type: '->', agents: ['C', 'B'], label: ''}, + {type: 'block split', mode: 'else', label: ''}, + {type: '->', agents: ['A', 'D'], label: ''}, + {type: 'block end'}, + ]); + }); + + it('converts loop blocks', () => { + const parsed = parser.parse('repeat until something'); + expect(parsed.stages).toEqual([ + {type: 'block begin', mode: 'repeat', label: 'until something'}, + ]); + }); + + it('rejects invalid inputs', () => { + expect(() => parser.parse('huh')).toThrow(); + }); + + it('rejects partial links', () => { + expect(() => parser.parse('-> A')).toThrow(); + expect(() => parser.parse('A ->')).toThrow(); + expect(() => parser.parse('A -> : hello')).toThrow(); + }); + + it('rejects invalid terminators', () => { + expect(() => parser.parse('terminators foo')).toThrow(); + }); + + it('rejects malformed notes', () => { + expect(() => parser.parse('note over A hello')).toThrow(); + }); + + it('rejects malformed block commands', () => { + expect(() => parser.parse('else nope foo')).toThrow(); + }); + + it('rejects invalid notes', () => { + expect(() => parser.parse('note huh A: hello')).toThrow(); + }); + }); +}); diff --git a/scripts/sequence/Renderer.js b/scripts/sequence/Renderer.js new file mode 100644 index 0000000..1020faf --- /dev/null +++ b/scripts/sequence/Renderer.js @@ -0,0 +1,616 @@ +define(() => { + 'use strict'; + + /* jshint -W071 */ // TODO: break up rendering logic + + function empty(node) { + while(node.childNodes.length > 0) { + node.removeChild(node.lastChild); + } + } + + function mergeSets(target, b) { + if(!b) { + return; + } + for(let i = 0; i < b.length; ++ i) { + if(target.indexOf(b[i]) === -1) { + target.push(b[i]); + } + } + } + + function removeAll(target, b) { + if(!b) { + return; + } + for(let i = 0; i < b.length; ++ i) { + const p = target.indexOf(b[i]); + if(p !== -1) { + target.splice(p, 1); + } + } + } + + const NS = 'http://www.w3.org/2000/svg'; + + const LINE_HEIGHT = 1.3; + const TITLE_MARGIN = 10; + const OUTER_MARGIN = 5; + const BOX_PADDING = 10; + const AGENT_MARGIN = 10; + const AGENT_CROSS_SIZE = 20; + const AGENT_NONE_HEIGHT = 10; + const ACTION_MARGIN = 5; + const ARROW_HEIGHT = 8; + const ARROW_POINT = 4; + const ARROW_LABEL_PADDING = 6; + const ARROW_LABEL_MASK_PADDING = 3; + const ARROW_LABEL_MARGIN_TOP = 2; + const ARROW_LABEL_MARGIN_BOTTOM = 1; + + const ATTRS = { + TITLE: { + 'font-family': 'sans-serif', + 'font-size': 20, + 'class': 'title', + }, + + AGENT_LINE: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }, + AGENT_BOX: { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1, + 'height': 24, + }, + AGENT_BOX_LABEL: { + 'font-family': 'sans-serif', + 'font-size': 12, + }, + AGENT_CROSS: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }, + AGENT_BAR: { + 'fill': '#000000', + 'height': 5, + }, + + ARROW_LINE_SOLID: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + }, + ARROW_LINE_DASH: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1, + 'stroke-dasharray': '2, 2', + }, + ARROW_LABEL: { + 'font-family': 'sans-serif', + 'font-size': 8, + }, + ARROW_LABEL_MASK: { + 'fill': '#FFFFFF', + }, + ARROW_HEAD: { + 'fill': '#000000', + 'stroke': '#000000', + 'stroke-width': 1, + 'stroke-linejoin': 'miter', + }, + }; + + function makeText(text = '') { + return document.createTextNode(text); + } + + function makeSVGNode(type, attrs = {}) { + const o = document.createElementNS(NS, type); + for(let k in attrs) { + if(attrs.hasOwnProperty(k)) { + o.setAttribute(k, attrs[k]); + } + } + return o; + } + + function traverse(stages, fn) { + stages.forEach((stage) => { + fn(stage); + if(stage.type === 'block') { + stage.sections.forEach((section) => { + traverse(section.stages, fn); + }); + } + }); + } + + return class Renderer { + constructor() { + this.base = makeSVGNode('svg', { + 'xmlns': NS, + 'version': '1.1', + 'width': '100%', + 'height': '100%', + }); + + this.title = makeSVGNode('text', Object.assign({ + 'y': ATTRS.TITLE['font-size'] + OUTER_MARGIN, + }, ATTRS.TITLE)); + this.titleText = makeText(); + this.title.appendChild(this.titleText); + this.base.appendChild(this.title); + + this.diagram = makeSVGNode('g'); + this.agentLines = makeSVGNode('g'); + this.agentDecor = makeSVGNode('g'); + this.actions = makeSVGNode('g'); + this.diagram.appendChild(this.agentLines); + this.diagram.appendChild(this.agentDecor); + this.diagram.appendChild(this.actions); + this.base.appendChild(this.diagram); + + this.renderAgentCap = { + 'box': this.renderAgentCapBox.bind(this), + 'cross': this.renderAgentCapCross.bind(this), + 'bar': this.renderAgentCapBar.bind(this), + 'none': this.renderAgentCapNone.bind(this), + }; + + this.renderAction = { + 'agent begin': this.renderAgentBegin.bind(this), + 'agent end': this.renderAgentEnd.bind(this), + '->': this.renderArrow.bind(this, { + lineAttrs: ATTRS.ARROW_LINE_SOLID, left: false, right: true, + }), + '<-': this.renderArrow.bind(this, { + lineAttrs: ATTRS.ARROW_LINE_SOLID, left: true, right: false, + }), + '<->': this.renderArrow.bind(this, { + lineAttrs: ATTRS.ARROW_LINE_SOLID, left: true, right: true, + }), + '-->': this.renderArrow.bind(this, { + lineAttrs: ATTRS.ARROW_LINE_DASH, left: false, right: true, + }), + '<--': this.renderArrow.bind(this, { + lineAttrs: ATTRS.ARROW_LINE_DASH, left: true, right: false, + }), + '<-->': this.renderArrow.bind(this, { + lineAttrs: ATTRS.ARROW_LINE_DASH, left: true, right: true, + }), + 'block': this.renderBlock.bind(this), + 'note over': this.renderNoteOver.bind(this), + 'note left': this.renderNoteLeft.bind(this), + 'note right': this.renderNoteRight.bind(this), + 'note between': this.renderNoteBetween.bind(this), + }; + + this.separationAction = { + 'agent begin': this.separationAgentCap.bind(this), + 'agent end': this.separationAgentCap.bind(this), + '->': this.separationArrow.bind(this), + '<-': this.separationArrow.bind(this), + '<->': this.separationArrow.bind(this), + '-->': this.separationArrow.bind(this), + '<--': this.separationArrow.bind(this), + '<-->': this.separationArrow.bind(this), + 'block': this.separationBlock.bind(this), + 'note over': this.separationNoteOver.bind(this), + 'note left': this.separationNoteLeft.bind(this), + 'note right': this.separationNoteRight.bind(this), + 'note between': this.separationNoteBetween.bind(this), + }; + + this.width = 0; + this.height = 0; + } + + addSeparation(agentInfo, agent, dist) { + let d = agentInfo.separations.get(agent) || 0; + agentInfo.separations.set(agent, Math.max(d, dist)); + } + + separationAgentCap(agentInfos, stage) { + switch(stage.mode) { + case 'box': + case 'bar': + stage.agents.forEach((agent1) => { + const info1 = agentInfos.get(agent1); + const sep1 = ( + info1.labelWidth / 2 + + AGENT_MARGIN + ); + stage.agents.forEach((agent2) => { + if(agent1 === agent2) { + return; + } + const info2 = agentInfos.get(agent2); + this.addSeparation(info1, agent2, + sep1 + info2.labelWidth / 2 + ); + }); + this.visibleAgents.forEach((agent2) => { + if(stage.agents.indexOf(agent2) === -1) { + const info2 = agentInfos.get(agent2); + this.addSeparation(info1, agent2, sep1); + this.addSeparation(info2, agent1, sep1); + } + }); + }); + break; + case 'cross': + stage.agents.forEach((agent1) => { + const info1 = agentInfos.get(agent1); + const sep1 = ( + AGENT_CROSS_SIZE / 2 + + AGENT_MARGIN + ); + stage.agents.forEach((agent2) => { + if(agent1 === agent2) { + return; + } + this.addSeparation(info1, agent2, + sep1 + AGENT_CROSS_SIZE / 2 + ); + }); + this.visibleAgents.forEach((agent2) => { + if(stage.agents.indexOf(agent2) === -1) { + const info2 = agentInfos.get(agent2); + this.addSeparation(info1, agent2, sep1); + this.addSeparation(info2, agent1, sep1); + } + }); + }); + break; + } + if(stage.type === 'agent begin') { + mergeSets(this.visibleAgents, stage.agents); + } else if(stage.type === 'agent end') { + removeAll(this.visibleAgents, stage.agents); + } + } + + separationArrow(agentInfos, stage) { + const w = ( + this.testTextWidth(this.testArrowWidth, stage.label) + + ARROW_POINT * 2 + + ARROW_LABEL_PADDING * 2 + + ATTRS.AGENT_LINE['stroke-width'] + ); + const agent1 = stage.agents[0]; + const agent2 = stage.agents[1]; + this.addSeparation(agentInfos.get(agent1), agent2, w); + this.addSeparation(agentInfos.get(agent2), agent1, w); + } + + separationBlock(/*agentInfos, stage*/) { + // TODO + } + + separationNoteOver(/*agentInfos, stage*/) { + // TODO + } + + separationNoteLeft(/*agentInfos, stage*/) { + // TODO + } + + separationNoteRight(/*agentInfos, stage*/) { + // TODO + } + + separationNoteBetween(/*agentInfos, stage*/) { + // TODO + } + + checkSeparation(agentInfos, stage) { + this.separationAction[stage.type](agentInfos, stage); + } + + renderAgentCapBox({x, labelWidth, label}) { + this.agentDecor.appendChild(makeSVGNode('rect', Object.assign({ + 'x': x - labelWidth / 2, + 'y': this.currentY, + 'width': labelWidth, + }, ATTRS.AGENT_BOX))); + + const name = makeSVGNode('text', Object.assign({ + 'x': x - labelWidth / 2 + BOX_PADDING, + 'y': this.currentY + ( + ATTRS.AGENT_BOX.height + + ATTRS.AGENT_BOX_LABEL['font-size'] * (2 - LINE_HEIGHT) + ) / 2, + }, ATTRS.AGENT_BOX_LABEL)); + name.appendChild(makeText(label)); + this.agentDecor.appendChild(name); + + return { + lineTop: 0, + lineBottom: ATTRS.AGENT_BOX.height, + height: ATTRS.AGENT_BOX.height, + }; + } + + renderAgentCapCross({x}) { + const y = this.currentY; + const d = AGENT_CROSS_SIZE / 2; + + this.agentDecor.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + (x - d) + ' ' + y + + ' L ' + (x + d) + ' ' + (y + d * 2) + + ' M ' + (x + d) + ' ' + y + + ' L ' + (x - d) + ' ' + (y + d * 2) + ), + }, ATTRS.AGENT_CROSS))); + + return { + lineTop: d, + lineBottom: d, + height: d * 2, + }; + } + + renderAgentCapBar({x, labelWidth}) { + this.agentDecor.appendChild(makeSVGNode('rect', Object.assign({ + 'x': x - labelWidth / 2, + 'y': this.currentY, + 'width': labelWidth, + }, ATTRS.AGENT_BAR))); + + return { + lineTop: 0, + lineBottom: ATTRS.AGENT_BAR.height, + height: ATTRS.AGENT_BAR.height, + }; + } + + renderAgentCapNone() { + return { + lineTop: AGENT_NONE_HEIGHT, + lineBottom: 0, + height: AGENT_NONE_HEIGHT, + }; + } + + renderAgentBegin(agentInfos, stage) { + let shifts = {height: 0}; + stage.agents.forEach((agent) => { + const agentInfo = agentInfos.get(agent); + shifts = this.renderAgentCap[stage.mode](agentInfo); + agentInfo.latestYStart = this.currentY + shifts.lineBottom; + }); + this.currentY += shifts.height + ACTION_MARGIN; + } + + renderAgentEnd(agentInfos, stage) { + let shifts = {height: 0}; + stage.agents.forEach((agent) => { + const agentInfo = agentInfos.get(agent); + const x = agentInfo.x; + shifts = this.renderAgentCap[stage.mode](agentInfo); + this.agentLines.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + x + ' ' + agentInfo.latestYStart + + ' L ' + x + ' ' + (this.currentY + shifts.lineTop) + ), + }, ATTRS.AGENT_LINE))); + agentInfo.latestYStart = null; + }); + this.currentY += shifts.height + ACTION_MARGIN; + } + + renderArrow({lineAttrs, left, right}, agentInfos, stage) { + /* jshint -W074, -W071 */ // TODO: tidy this up + const from = agentInfos.get(stage.agents[0]); + const to = agentInfos.get(stage.agents[1]); + + const dy = ARROW_HEIGHT / 2; + const dx = ARROW_POINT; + const dir = (from.x < to.x) ? 1 : -1; + const short = ATTRS.AGENT_LINE['stroke-width']; + let y = this.currentY; + + if(stage.label) { + const mask = makeSVGNode('rect', ATTRS.ARROW_LABEL_MASK); + const label = makeSVGNode('text', ATTRS.ARROW_LABEL); + label.appendChild(makeText(stage.label)); + const sz = ATTRS.ARROW_LABEL['font-size']; + this.actions.appendChild(mask); + this.actions.appendChild(label); + y += Math.max( + dy, + ARROW_LABEL_MARGIN_TOP + + sz * LINE_HEIGHT + + ARROW_LABEL_MARGIN_BOTTOM + ); + const w = label.getComputedTextLength(); + const x = (from.x + to.x - w) / 2; + const yBase = ( + y - + sz * (LINE_HEIGHT - 1) - + ARROW_LABEL_MARGIN_BOTTOM + ); + label.setAttribute('x', x); + label.setAttribute('y', yBase); + mask.setAttribute('x', x - ARROW_LABEL_MASK_PADDING); + mask.setAttribute('y', yBase - sz); + mask.setAttribute('width', w + ARROW_LABEL_MASK_PADDING * 2); + mask.setAttribute('height', sz * LINE_HEIGHT); + } else { + y += dy; + } + + this.actions.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + (from.x + (left ? short : 0) * dir) + ' ' + y + + ' L ' + (to.x - (right ? short : 0) * dir) + ' ' + y + ), + }, lineAttrs))); + + if(left) { + this.actions.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + (from.x + (dx + short) * dir) + ' ' + (y - dy) + + ' L ' + (from.x + short * dir) + ' ' + y + + ' L ' + (from.x + (dx + short) * dir) + ' ' + (y + dy) + + (ATTRS.ARROW_HEAD.fill === 'none' ? '' : ' Z') + ), + }, ATTRS.ARROW_HEAD))); + } + + if(right) { + this.actions.appendChild(makeSVGNode('path', Object.assign({ + 'd': ( + 'M ' + (to.x - (dx + short) * dir) + ' ' + (y - dy) + + ' L ' + (to.x - short * dir) + ' ' + y + + ' L ' + (to.x - (dx + short) * dir) + ' ' + (y + dy) + + (ATTRS.ARROW_HEAD.fill === 'none' ? '' : ' Z') + ), + }, ATTRS.ARROW_HEAD))); + } + + this.currentY = y + dy + ACTION_MARGIN; + } + + renderBlock(/*agentInfos, stage*/) { + // TODO + } + + renderNoteOver(/*agentInfos, stage*/) { + // TODO + } + + renderNoteLeft(/*agentInfos, stage*/) { + // TODO + } + + renderNoteRight(/*agentInfos, stage*/) { + // TODO + } + + renderNoteBetween(/*agentInfos, stage*/) { + // TODO + } + + addAction(agentInfos, stage) { + this.renderAction[stage.type](agentInfos, stage); + } + + makeTextTester(attrs) { + const text = makeText(); + const node = makeSVGNode('text', attrs); + node.appendChild(text); + this.agentDecor.appendChild(node); + return {text, node}; + } + + testTextWidth(tester, text) { + tester.text.nodeValue = text; + return tester.node.getComputedTextLength(); + } + + removeTextTester(tester) { + this.agentDecor.removeChild(tester.node); + } + + buildAgentInfos(agents, stages) { + const testNameWidth = this.makeTextTester(ATTRS.AGENT_BOX_LABEL); + + const agentInfos = new Map(); + agents.forEach((agent) => { + agentInfos.set(agent, { + label: agent, + labelWidth: ( + this.testTextWidth(testNameWidth, agent) + + BOX_PADDING * 2 + ), + x: null, + latestYStart: null, + separations: new Map(), + }); + }); + agentInfos.get('[').labelWidth = 0; + agentInfos.get(']').labelWidth = 0; + + this.removeTextTester(testNameWidth); + + this.testArrowWidth = this.makeTextTester(ATTRS.ARROW_LABEL); + this.visibleAgents = ['[', ']']; + traverse(stages, this.checkSeparation.bind(this, agentInfos)); + this.removeTextTester(this.testArrowWidth); + + let currentX = 0; + agents.forEach((agent) => { + const agentInfo = agentInfos.get(agent); + agentInfo.separations.forEach((dist, otherAgent) => { + const otherAgentInfo = agentInfos.get(otherAgent); + if(otherAgentInfo.x !== null) { + currentX = Math.max(currentX, otherAgentInfo.x + dist); + } + }); + agentInfo.x = currentX; + }); + + return {agentInfos, minX: 0, maxX: currentX}; + } + + updateBounds(stagesHeight) { + const titleWidth = this.title.getComputedTextLength(); + const stagesWidth = (this.maxX - this.minX); + + const width = Math.ceil( + Math.max(stagesWidth, titleWidth) + + OUTER_MARGIN * 2 + ); + const height = Math.ceil( + ATTRS.TITLE['font-size'] * LINE_HEIGHT + + TITLE_MARGIN + + stagesHeight + + OUTER_MARGIN * 2 + ); + + this.diagram.setAttribute('transform', + 'translate(' + ((width - stagesWidth) / 2 - this.minX) + ',' + ( + OUTER_MARGIN + + ATTRS.TITLE['font-size'] * LINE_HEIGHT + + TITLE_MARGIN + ) + ')' + ); + + this.title.setAttribute('x', (width - titleWidth) / 2); + this.base.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + this.width = width; + this.height = height; + } + + render({meta, agents, stages}) { + empty(this.agentLines); + empty(this.agentDecor); + empty(this.actions); + + this.titleText.nodeValue = meta.title || ''; + + const info = this.buildAgentInfos(agents, stages); + + this.minX = info.minX; + this.maxX = info.maxX; + this.currentY = 0; + + traverse(stages, this.addAction.bind(this, info.agentInfos)); + + this.updateBounds(Math.max(this.currentY - ACTION_MARGIN, 0)); + } + + svg() { + return this.base; + } + }; +}); diff --git a/scripts/sequence/Renderer_spec.js b/scripts/sequence/Renderer_spec.js new file mode 100644 index 0000000..ef68aed --- /dev/null +++ b/scripts/sequence/Renderer_spec.js @@ -0,0 +1,29 @@ +defineDescribe('Sequence Renderer', ['./Renderer'], (Renderer) => { + 'use strict'; + + let renderer = null; + + beforeEach(() => { + renderer = new Renderer(); + }); + + describe('.svg', () => { + it('returns an SVG node containing the rendered diagram', () => { + const svg = renderer.svg(); + expect(svg.tagName).toEqual('svg'); + }); + }); + + describe('.render', () => { + it('populates the SVG with content', () => { + renderer.render({ + meta: {title: 'Title'}, + agents: ['[', 'Col 1', 'Col 2', ']'], + stages: [], + }); + const element = renderer.svg(); + const title = element.getElementsByClassName('title')[0]; + expect(title.innerHTML).toEqual('Title'); + }); + }); +}); diff --git a/scripts/specs.js b/scripts/specs.js new file mode 100644 index 0000000..e959fa0 --- /dev/null +++ b/scripts/specs.js @@ -0,0 +1,6 @@ +define([ + 'interface/Interface_spec', + 'sequence/Parser_spec', + 'sequence/Generator_spec', + 'sequence/Renderer_spec', +]); diff --git a/scripts/stubs/codemirror.js b/scripts/stubs/codemirror.js new file mode 100644 index 0000000..55e1c81 --- /dev/null +++ b/scripts/stubs/codemirror.js @@ -0,0 +1,14 @@ +define([], () => { + 'use strict'; + + return function(container, options) { + const spy = jasmine.createSpyObj('CodeMirror', ['on']); + spy.constructor = { + container, + options, + }; + spy.doc = jasmine.createSpyObj('CodeMirror document', ['getValue']); + spy.getDoc = () => spy.doc; + return spy; + }; +}); diff --git a/scripts/tester/jshintRunner.js b/scripts/tester/jshintRunner.js new file mode 100644 index 0000000..65bebe1 --- /dev/null +++ b/scripts/tester/jshintRunner.js @@ -0,0 +1,99 @@ +define(['jshintConfig', 'specs'], (jshintConfig) => { + 'use strict'; + + /* global JSHINT */ + + // Thanks, https://opensoul.org/2011/02/20/jslint-and-jasmine/ + + const extraFiles = [ + 'scripts/main.js', + 'scripts/requireConfig.js', + ]; + + const PREDEF = [ + 'self', + 'define', + 'requirejs', + ]; + + const PREDEF_TEST = [ + 'jasmine', + 'beforeEach', + 'afterEach', + 'spyOn', + 'describe', + 'defineDescribe', + 'it', + 'expect', + 'fail', + ].concat(PREDEF); + + function formatError(error) { + const evidence = (error.evidence || '').replace(/\t/g, ' '); + if(error.code === 'W140') { + // Don't warn about lack of trailing comma for inline objects/lists + const c = evidence.charAt(error.character - 1); + if(c === ']' || c === '}') { + return null; + } + } + return ( + error.code + + ' @' + error.line + ':' + error.character + + ': ' + error.reason + + '\n' + evidence + ); + } + + const lintTestName = 'conforms to the linter'; + + // Until browsers support cache: 'no-cache' + const noCacheSuffix = '?' + Math.random(); + + function check(path, src) { + describe(path, () => it(lintTestName, () => { + const test = ( + path.endsWith('_spec.js') || + path.endsWith('jshintRunner.js') || + path.endsWith('specRunner.js') || + path.includes('/stubs/') + ); + const predef = test ? PREDEF_TEST : PREDEF; + JSHINT(src, Object.assign({predef}, jshintConfig)); + (JSHINT.errors + .map(formatError) + .filter((error) => (error !== null)) + .forEach(fail)); + JSHINT.errors.length = 0; + expect(true).toBe(true); // prevent no-expectation warning + })); + } + + function trimPath(path) { + if(path.indexOf('?') !== -1) { + return path.substr(0, path.indexOf('?')); + } + return path; + } + + function getSource(path) { + return fetch(path + noCacheSuffix, { + mode: 'no-cors', + cache: 'no-cache', + }); + } + + const scriptElements = document.getElementsByTagName('script'); + return Promise.all(Array.prototype.map.call(scriptElements, + (scriptElement) => scriptElement.getAttribute('src')) + .concat(extraFiles) + .filter((path) => !path.includes('://')) + .map(trimPath) + .map((path) => (getSource(path) + .then((response) => response.text()) + .then((src) => check(path, src)) + )) + ).catch(() => describe('source code', () => it(lintTestName, () => { + fail('Failed to run linter against source code'); + }))); +}); diff --git a/scripts/tester/specRunner.js b/scripts/tester/specRunner.js new file mode 100644 index 0000000..1f8b01e --- /dev/null +++ b/scripts/tester/specRunner.js @@ -0,0 +1,31 @@ +((() => { + 'use strict'; + + // Jasmine test configuration. + // See specs.js for the list of spec files + + requirejs.config(Object.assign({ + baseUrl: 'scripts/', + urlArgs: String(Math.random()), // Prevent cache + }, window.getRequirejsCDN())); + + requirejs(['jasmineBoot'], () => { + // Slightly hacky way of making jasmine work with asynchronously loaded + // tests while keeping features of jasmine-boot + const runner = window.onload; + window.onload = undefined; + + // Convenience function wraps: + // define(['d1', 'd2'], (d1, d2) => describe('My Name', () => {...})); + // as: + // defineDescribe('My Name', ['d1', 'd2'], (d1, d2) => {...}); + window.defineDescribe = (name, deps, fn) => { + define(deps, function() { + const args = arguments; + describe(name, () => fn.apply(null, args)); + }); + }; + + requirejs(['tester/jshintRunner'], (promise) => promise.then(runner)); + }); +})()); diff --git a/styles/main.css b/styles/main.css new file mode 100644 index 0000000..6aa8ce3 --- /dev/null +++ b/styles/main.css @@ -0,0 +1,59 @@ +html, body { + margin: 0; + padding: 0; +} + +.pane-code { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 30%; +} + +.pane-code .CodeMirror { + width: 100%; + height: 100%; + background: #EEEEEE; +} + +.pane-view { + position: absolute; + left: 30%; + top: 0; + bottom: 0; + right: 0; +} + +.options { + display: inline-block; + position: fixed; + bottom: 0; + right: 0; + background: #FFFFFF; + border-top-left-radius: 5px; + border-top: 1px solid #EEEEEE; + border-left: 1px solid #EEEEEE; + font-family: sans-serif; + overflow: hidden; +} + +.options a { + display: inline-block; + padding: 5px 10px; +} + +.options a:not(:last-child) { + border-right: 1px solid #EEEEEE; +} + +.options a:link, .options a:visited { + color: #666699; + text-decoration: none; + cursor: pointer; +} + +.options a:active, .options a:hover { + background: #EEEEEE; + color: #6666CC; +} diff --git a/test.htm b/test.htm new file mode 100644 index 0000000..29359c3 --- /dev/null +++ b/test.htm @@ -0,0 +1,67 @@ + + + + + +Tests + + + + + + + + + + + + + + + + + + + + +