Basic functionality (agents and labelled arrows)
This commit is contained in:
commit
6eb8de8160
|
@ -0,0 +1,165 @@
|
|||
GNU LESSER GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
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.
|
|
@ -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;
|
||||
```
|
Binary file not shown.
After Width: | Height: | Size: 662 B |
|
@ -0,0 +1,49 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="content-security-policy" content="
|
||||
base-uri 'self';
|
||||
default-src 'none';
|
||||
script-src
|
||||
'self'
|
||||
'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk='
|
||||
'sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo='
|
||||
;
|
||||
style-src 'self' https://cdnjs.cloudflare.com;
|
||||
img-src 'self' blob:;
|
||||
form-action 'none';
|
||||
">
|
||||
|
||||
<title>Sequence Diagram</title>
|
||||
<link rel="icon" href="favicon.png">
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.css"
|
||||
integrity="sha256-Zg9EoB1hB8n8EVhx/D07lT5dD3ZZqjJbxlDmHx8jsMc="
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
|
||||
<link rel="stylesheet" href="styles/main.css">
|
||||
|
||||
<script src="scripts/requireConfig.js"></script>
|
||||
|
||||
<script
|
||||
data-main="scripts/main"
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"
|
||||
integrity="sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk="
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<meta
|
||||
name="cdn-codemirror"
|
||||
content="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.31.0/codemirror.min.js"
|
||||
data-integrity="sha256-eue5ceZRwKVQ1OXOZSyU7MXCTZMlqsPi/TOIqh1Vlzo="
|
||||
>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<noscript>This tool requires Javascript!</noscript>
|
||||
|
||||
</body>
|
||||
</html>
|
|
@ -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());
|
||||
}
|
||||
};
|
||||
});
|
|
@ -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('#');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
})());
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
})());
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,6 @@
|
|||
define([
|
||||
'interface/Interface_spec',
|
||||
'sequence/Parser_spec',
|
||||
'sequence/Generator_spec',
|
||||
'sequence/Renderer_spec',
|
||||
]);
|
|
@ -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;
|
||||
};
|
||||
});
|
|
@ -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');
|
||||
})));
|
||||
});
|
|
@ -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));
|
||||
});
|
||||
})());
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="content-security-policy" content="
|
||||
base-uri 'self';
|
||||
default-src 'none';
|
||||
script-src
|
||||
'self'
|
||||
'sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk='
|
||||
'sha256-nbDuV0lauU6Rzhc3T39vSmQ64+K0R8Kp556x6W5Xxg4='
|
||||
'sha256-3t+j0EiiLhROsCHCLF+g/h4gPsyH5agBoZeAcOrydRM='
|
||||
'sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8='
|
||||
'sha256-SYY59VkHlf1wmU3zhsnII/kb51ODoHy6ub8qaq0eAcY='
|
||||
;
|
||||
connect-src 'self';
|
||||
style-src 'self' https://cdnjs.cloudflare.com;
|
||||
img-src 'self' data: blob:;
|
||||
form-action 'none';
|
||||
">
|
||||
|
||||
<title>Tests</title>
|
||||
<link rel="icon" href="favicon.png">
|
||||
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine.min.css"
|
||||
integrity="sha256-VrOKUZ4Ge9kapNTEARs4xFN+//KC1DdjdOSRMqiHw8s="
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jshint/2.9.5/jshint.min.js"
|
||||
integrity="sha256-SYY59VkHlf1wmU3zhsnII/kb51ODoHy6ub8qaq0eAcY="
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine.min.js"
|
||||
integrity="sha256-nbDuV0lauU6Rzhc3T39vSmQ64+K0R8Kp556x6W5Xxg4="
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/jasmine-html.min.js"
|
||||
integrity="sha256-3t+j0EiiLhROsCHCLF+g/h4gPsyH5agBoZeAcOrydRM="
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<script src="scripts/requireConfig.js"></script>
|
||||
|
||||
<script
|
||||
data-main="scripts/tester/specRunner"
|
||||
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.5/require.min.js"
|
||||
integrity="sha256-0SGl1PJNDyJwcV5T+weg2zpEMrh7xvlwO4oXgvZCeZk="
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
<meta
|
||||
name="cdn-jasmineBoot"
|
||||
content="https://cdnjs.cloudflare.com/ajax/libs/jasmine/2.8.0/boot.min.js"
|
||||
data-integrity="sha256-Re9XxIL3x1flvE6WD58jWPdDzKYQLXwxS2HAVfmM6Z8="
|
||||
>
|
||||
|
||||
<meta name="cdn-codemirror" content="stubs/codemirror">
|
||||
|
||||
<!-- test files defined in scripts/specs.js -->
|
||||
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue