Basic functionality (agents and labelled arrows)

This commit is contained in:
David Evans 2017-10-22 14:40:19 +01:00
commit 6eb8de8160
21 changed files with 2973 additions and 0 deletions

165
LICENSE Normal file
View File

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

188
README.md Normal file
View File

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

BIN
favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

49
index.htm Normal file
View File

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

View File

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

View File

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

42
scripts/jshintConfig.js Normal file
View File

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

43
scripts/main.js Normal file
View File

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

36
scripts/requireConfig.js Normal file
View File

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

View File

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

View File

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

346
scripts/sequence/Parser.js Normal file
View File

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

View File

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

View File

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

View File

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

6
scripts/specs.js Normal file
View File

@ -0,0 +1,6 @@
define([
'interface/Interface_spec',
'sequence/Parser_spec',
'sequence/Generator_spec',
'sequence/Renderer_spec',
]);

View File

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

View File

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

View File

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

59
styles/main.css Normal file
View File

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

67
test.htm Normal file
View File

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