From 587a6d7f266e0626420bc76898113e34a27dbcd0 Mon Sep 17 00:00:00 2001 From: David Evans Date: Tue, 21 Nov 2017 00:04:30 +0000 Subject: [PATCH] Add support for reference boxes [#21] --- scripts/core/ArrayUtilities.js | 9 + scripts/core/ArrayUtilities_spec.js | 14 + scripts/main.js | 30 ++ scripts/sequence/Generator.js | 316 ++++++++++++++++---- scripts/sequence/Generator_spec.js | 428 +++++++++++++++++++++++++-- scripts/sequence/components/Block.js | 10 +- scripts/sequence/themes/Basic.js | 25 +- scripts/sequence/themes/Chunky.js | 25 +- 8 files changed, 748 insertions(+), 109 deletions(-) diff --git a/scripts/core/ArrayUtilities.js b/scripts/core/ArrayUtilities.js index f84e95a..df588e7 100644 --- a/scripts/core/ArrayUtilities.js +++ b/scripts/core/ArrayUtilities.js @@ -81,6 +81,14 @@ define(() => { return target; } + function flatMap(list, fn) { + const result = []; + list.forEach((item) => { + result.push(...fn(item)); + }); + return result; + } + return { indexOf, mergeSets, @@ -89,5 +97,6 @@ define(() => { remove, last, combine, + flatMap, }; }); diff --git a/scripts/core/ArrayUtilities_spec.js b/scripts/core/ArrayUtilities_spec.js index c0fb9c9..75a24a8 100644 --- a/scripts/core/ArrayUtilities_spec.js +++ b/scripts/core/ArrayUtilities_spec.js @@ -186,4 +186,18 @@ defineDescribe('ArrayUtilities', ['./ArrayUtilities'], (array) => { ]); }); }); + + describe('.flatMap', () => { + it('applies the given function to all elements of the input', () => { + const fn = (x) => ([x + 1]); + const p1 = [2, 7]; + expect(array.flatMap(p1, fn)).toEqual([3, 8]); + }); + + it('flattens the result', () => { + const fn = (x) => ([x + 1, x + 2]); + const p1 = [2, 7]; + expect(array.flatMap(p1, fn)).toEqual([3, 4, 8, 9]); + }); + }); }); diff --git a/scripts/main.js b/scripts/main.js index 1146c8c..45d0fe9 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -136,6 +136,36 @@ 'end' ), }, + { + title: 'References', + code: ( + 'begin reference: {Label} as {Name}\n' + + '{Agent1} -> {Name}\n' + + 'end {Name}' + ), + preview: ( + 'begin A\n' + + 'begin reference: "See 1.3" as myRef\n' + + 'A -> myRef\n' + + 'myRef -> A\n' + + 'end myRef' + ), + }, + { + title: 'References over agents', + code: ( + 'begin reference over {Covered}: {Label} as {Name}\n' + + '{Agent1} -> {Name}\n' + + 'end {Name}' + ), + preview: ( + 'begin A, B, C\n' + + 'begin reference over B, C: "See 1.3" as myRef\n' + + 'A -> myRef\n' + + 'myRef -> A\n' + + 'end myRef' + ), + }, { title: 'Note over agent', code: 'note over {Agent1}: {Message}', diff --git a/scripts/sequence/Generator.js b/scripts/sequence/Generator.js index ec8e8d5..625ab46 100644 --- a/scripts/sequence/Generator.js +++ b/scripts/sequence/Generator.js @@ -2,28 +2,39 @@ define(['core/ArrayUtilities'], (array) => { 'use strict'; class AgentState { - constructor(visible, locked = false) { + constructor({ + visible = false, + locked = false, + blocked = false, + highlighted = false, + group = null, + covered = false, + } = {}) { this.visible = visible; - this.highlighted = false; this.locked = locked; + this.blocked = blocked; + this.highlighted = highlighted; + this.group = group; + this.covered = covered; } } + AgentState.LOCKED = new AgentState({locked: true}); + AgentState.DEFAULT = new AgentState(); - function agentEqCheck(a, b) { - return a.name === b.name; - } - - function makeAgent(name, {anchorRight = false} = {}) { - return {name, anchorRight}; - } - - function getAgentName(agent) { - return agent.name; - } - - function agentHasFlag(flag, has = true) { - return (agent) => (agent.flags.includes(flag) === has); - } + const Agent = { + equals: (a, b) => { + return a.name === b.name; + }, + make: (name, {anchorRight = false} = {}) => { + return {name, anchorRight}; + }, + getName: (agent) => { + return agent.name; + }, + hasFlag: (flag, has = true) => { + return (agent) => (agent.flags.includes(flag) === has); + }, + }; const MERGABLE = { 'agent begin': { @@ -173,14 +184,14 @@ define(['core/ArrayUtilities'], (array) => { } function addBounds(target, agentL, agentR, involvedAgents = null) { - array.remove(target, agentL, agentEqCheck); - array.remove(target, agentR, agentEqCheck); + array.remove(target, agentL, Agent.equals); + array.remove(target, agentR, Agent.equals); let indexL = 0; let indexR = target.length; if(involvedAgents) { const found = (involvedAgents - .map((agent) => array.indexOf(target, agent, agentEqCheck)) + .map((agent) => array.indexOf(target, agent, Agent.equals)) .filter((p) => (p !== -1)) ); indexL = found.reduce((a, b) => Math.min(a, b), target.length); @@ -189,10 +200,9 @@ define(['core/ArrayUtilities'], (array) => { target.splice(indexL, 0, agentL); target.splice(indexR + 1, 0, agentR); - } - const LOCKED_AGENT = new AgentState(false, true); - const DEFAULT_AGENT = new AgentState(false); + return {indexL, indexR: indexR + 1}; + } const NOTE_DEFAULT_AGENTS = { 'note over': [{name: '[', flags: []}, {name: ']', flags: []}], @@ -204,6 +214,7 @@ define(['core/ArrayUtilities'], (array) => { constructor() { this.agentStates = new Map(); this.agentAliases = new Map(); + this.activeGroups = new Map(); this.agents = []; this.labelPattern = null; this.blockCount = 0; @@ -229,8 +240,10 @@ define(['core/ArrayUtilities'], (array) => { 'note right': this.handleNote.bind(this), 'note between': this.handleNote.bind(this), }; + this.expandGroupedAgent = this.expandGroupedAgent.bind(this); this.handleStage = this.handleStage.bind(this); this.convertAgent = this.convertAgent.bind(this); + this.endGroup = this.endGroup.bind(this); } convertAgent({alias, name}) { @@ -252,7 +265,7 @@ define(['core/ArrayUtilities'], (array) => { } this.agentAliases.set(alias, name); } - return makeAgent(this.agentAliases.get(name) || name); + return Agent.make(this.agentAliases.get(name) || name); } addStage(stage, isVisible = true) { @@ -288,8 +301,44 @@ define(['core/ArrayUtilities'], (array) => { } defineAgents(colAgents) { - array.mergeSets(this.currentNest.agents, colAgents, agentEqCheck); - array.mergeSets(this.agents, colAgents, agentEqCheck); + array.mergeSets(this.currentNest.agents, colAgents, Agent.equals); + array.mergeSets(this.agents, colAgents, Agent.equals); + } + + getAgentState(agent) { + return this.agentStates.get(agent.name) || AgentState.DEFAULT; + } + + updateAgentState(agent, change) { + const state = this.agentStates.get(agent.name); + if(state) { + Object.assign(state, change); + } else { + this.agentStates.set(agent.name, new AgentState(change)); + } + } + + validateAgents(agents, { + allowGrouped = false, + rejectGrouped = false, + } = {}) { + agents.forEach((agent) => { + const state = this.getAgentState(agent); + if(state.covered) { + throw new Error( + 'Agent ' + agent.name + ' is hidden behind group' + ); + } + if(rejectGrouped && state.group !== null) { + throw new Error('Agent ' + agent.name + ' is in a group'); + } + if(state.blocked && (!allowGrouped || state.group === null)) { + throw new Error('Duplicate agent name: ' + agent.name); + } + if(agent.name.startsWith('__')) { + throw new Error(agent.name + ' is a reserved name'); + } + }); } setAgentVis(colAgents, visible, mode, checked = false) { @@ -299,8 +348,8 @@ define(['core/ArrayUtilities'], (array) => { return false; } seen.add(agent.name); - const state = this.agentStates.get(agent.name) || DEFAULT_AGENT; - if(state.locked) { + const state = this.getAgentState(agent); + if(state.locked || state.blocked) { if(checked) { throw new Error( 'Cannot begin/end agent: ' + agent.name @@ -315,26 +364,21 @@ define(['core/ArrayUtilities'], (array) => { return null; } filteredAgents.forEach((agent) => { - const state = this.agentStates.get(agent.name); - if(state) { - state.visible = visible; - } else { - this.agentStates.set(agent.name, new AgentState(visible)); - } + this.updateAgentState(agent, {visible}); }); this.defineAgents(filteredAgents); return { type: (visible ? 'agent begin' : 'agent end'), - agentNames: filteredAgents.map(getAgentName), + agentNames: filteredAgents.map(Agent.getName), mode, }; } setAgentHighlight(colAgents, highlighted, checked = false) { const filteredAgents = colAgents.filter((agent) => { - const state = this.agentStates.get(agent.name) || DEFAULT_AGENT; - if(state.locked) { + const state = this.getAgentState(agent); + if(state.locked || state.blocked) { if(checked) { throw new Error( 'Cannot highlight agent: ' + agent.name @@ -349,20 +393,19 @@ define(['core/ArrayUtilities'], (array) => { return null; } filteredAgents.forEach((agent) => { - const state = this.agentStates.get(agent.name); - state.highlighted = highlighted; + this.updateAgentState(agent, {highlighted}); }); return { type: 'agent highlight', - agentNames: filteredAgents.map(getAgentName), + agentNames: filteredAgents.map(Agent.getName), highlighted, }; } beginNested(mode, label, name, ln) { - const leftAgent = makeAgent(name + '[', {anchorRight: true}); - const rightAgent = makeAgent(name + ']'); + const leftAgent = Agent.make(name + '[', {anchorRight: true}); + const rightAgent = Agent.make(name + ']'); const agents = [leftAgent, rightAgent]; const stages = []; this.currentSection = { @@ -384,17 +427,21 @@ define(['core/ArrayUtilities'], (array) => { hasContent: false, sections: [this.currentSection], }; - this.agentStates.set(leftAgent.name, LOCKED_AGENT); - this.agentStates.set(rightAgent.name, LOCKED_AGENT); + this.agentStates.set(leftAgent.name, AgentState.LOCKED); + this.agentStates.set(rightAgent.name, AgentState.LOCKED); this.nesting.push(this.currentNest); return {agents, stages}; } - handleBlockBegin({ln, mode, label}) { + nextBlockName() { const name = '__BLOCK' + this.blockCount; - this.beginNested(mode, label, name, ln); ++ this.blockCount; + return name; + } + + handleBlockBegin({ln, mode, label}) { + this.beginNested(mode, label, this.nextBlockName(), ln); } handleBlockSplit({ln, mode, label}) { @@ -450,8 +497,85 @@ define(['core/ArrayUtilities'], (array) => { } } - handleGroupBegin() { - throw new Error('Groups are not supported yet'); + makeGroupDetails(agents, alias) { + const colAgents = agents.map(this.convertAgent); + this.validateAgents(colAgents, {rejectGrouped: true}); + if(this.agentStates.has(alias)) { + throw new Error('Duplicate agent name: ' + alias); + } + const name = this.nextBlockName(); + const leftAgent = Agent.make(name + '[', {anchorRight: true}); + const rightAgent = Agent.make(name + ']'); + this.agentStates.set(leftAgent.name, AgentState.LOCKED); + this.agentStates.set(rightAgent.name, AgentState.LOCKED); + this.updateAgentState( + {name: alias}, + {blocked: true, group: alias} + ); + this.defineAgents(colAgents); + const {indexL, indexR} = addBounds( + this.agents, + leftAgent, + rightAgent, + colAgents + ); + + const agentsCovered = []; + const agentsContained = colAgents.slice(); + for(let i = indexL + 1; i < indexR; ++ i) { + agentsCovered.push(this.agents[i]); + } + array.removeAll(agentsCovered, agentsContained, Agent.equals); + + return { + colAgents, + leftAgent, + rightAgent, + agentsContained, + agentsCovered, + }; + } + + handleGroupBegin({agents, mode, label, alias}) { + const details = this.makeGroupDetails(agents, alias); + + details.agentsContained.forEach((agent) => { + this.updateAgentState(agent, {group: alias}); + }); + details.agentsCovered.forEach((agent) => { + this.updateAgentState(agent, {covered: true}); + }); + this.activeGroups.set(alias, details); + this.addStage(this.setAgentVis(details.colAgents, true, 'box')); + this.addStage({ + type: 'block begin', + mode, + label, + left: details.leftAgent.name, + right: details.rightAgent.name, + }); + } + + endGroup({name}) { + const details = this.activeGroups.get(name); + if(!details) { + return null; + } + this.activeGroups.delete(name); + + details.agentsContained.forEach((agent) => { + this.updateAgentState(agent, {group: null}); + }); + details.agentsCovered.forEach((agent) => { + this.updateAgentState(agent, {covered: false}); + }); + this.updateAgentState({name}, {group: null}); + + return { + type: 'block end', + left: details.leftAgent.name, + right: details.rightAgent.name, + }; } handleMark({name}) { @@ -496,38 +620,82 @@ define(['core/ArrayUtilities'], (array) => { return result; } - handleConnect({agents, label, options}) { + expandGroupedAgent(agent) { + const group = this.getAgentState(agent).group; + if(!group) { + return [agent]; + } + const details = this.activeGroups.get(group); + return [details.leftAgent, details.rightAgent]; + } + + expandGroupedAgentConnection(agents) { + const agents1 = this.expandGroupedAgent(agents[0]); + const agents2 = this.expandGroupedAgent(agents[1]); + let ind1 = array.indexOf(this.agents, agents1[0], Agent.equals); + let ind2 = array.indexOf(this.agents, agents2[0], Agent.equals); + if(ind1 === -1) { + ind1 = this.agents.length; + } + if(ind2 === -1) { + ind2 = this.agents.length; + } + if(ind1 === ind2) { + // Self-connection + return [array.last(agents1), array.last(agents2)]; + } else if(ind1 < ind2) { + return [array.last(agents1), agents2[0]]; + } else { + return [agents1[0], array.last(agents2)]; + } + } + + filterConnectFlags(agents) { const beginAgents = (agents - .filter(agentHasFlag('begin')) + .filter(Agent.hasFlag('begin')) .map(this.convertAgent) ); const endAgents = (agents - .filter(agentHasFlag('end')) + .filter(Agent.hasFlag('end')) .map(this.convertAgent) ); - if(array.hasIntersection(beginAgents, endAgents, agentEqCheck)) { + if(array.hasIntersection(beginAgents, endAgents, Agent.equals)) { throw new Error('Cannot set agent visibility multiple times'); } const startAgents = (agents - .filter(agentHasFlag('start')) + .filter(Agent.hasFlag('start')) .map(this.convertAgent) ); const stopAgents = (agents - .filter(agentHasFlag('stop')) + .filter(Agent.hasFlag('stop')) .map(this.convertAgent) ); array.mergeSets(stopAgents, endAgents); - if(array.hasIntersection(startAgents, stopAgents, agentEqCheck)) { + if(array.hasIntersection(startAgents, stopAgents, Agent.equals)) { throw new Error('Cannot set agent highlighting multiple times'); } - const colAgents = agents.map(this.convertAgent); - const agentNames = colAgents.map(getAgentName); + this.validateAgents(beginAgents); + this.validateAgents(endAgents); + this.validateAgents(startAgents); + this.validateAgents(stopAgents); + + return {beginAgents, endAgents, startAgents, stopAgents}; + } + + handleConnect({agents, label, options}) { + const flags = this.filterConnectFlags(agents); + + let colAgents = agents.map(this.convertAgent); + this.validateAgents(colAgents, {allowGrouped: true}); + colAgents = this.expandGroupedAgentConnection(colAgents); + + const agentNames = colAgents.map(Agent.getName); this.defineAgents(colAgents); const implicitBegin = (agents - .filter(agentHasFlag('begin', false)) + .filter(Agent.hasFlag('begin', false)) .map(this.convertAgent) ); this.addStage(this.setAgentVis(implicitBegin, true, 'box')); @@ -540,11 +708,11 @@ define(['core/ArrayUtilities'], (array) => { }; this.addParallelStages([ - this.setAgentVis(beginAgents, true, 'box', true), - this.setAgentHighlight(startAgents, true, true), + this.setAgentVis(flags.beginAgents, true, 'box', true), + this.setAgentHighlight(flags.startAgents, true, true), connectStage, - this.setAgentHighlight(stopAgents, false, true), - this.setAgentVis(endAgents, false, 'cross', true), + this.setAgentHighlight(flags.stopAgents, false, true), + this.setAgentVis(flags.endAgents, false, 'cross', true), ]); } @@ -555,7 +723,10 @@ define(['core/ArrayUtilities'], (array) => { } else { colAgents = agents.map(this.convertAgent); } - const agentNames = colAgents.map(getAgentName); + + this.validateAgents(colAgents, {allowGrouped: true}); + colAgents = array.flatMap(colAgents, this.expandGroupedAgent); + const agentNames = colAgents.map(Agent.getName); const uniqueAgents = new Set(agentNames).size; if(type === 'note between' && uniqueAgents < 2) { throw new Error('note between requires at least 2 agents'); @@ -573,19 +744,30 @@ define(['core/ArrayUtilities'], (array) => { } handleAgentDefine({agents}) { - this.defineAgents(agents.map(this.convertAgent)); + const colAgents = agents.map(this.convertAgent); + this.validateAgents(colAgents); + this.defineAgents(colAgents); } handleAgentBegin({agents, mode}) { const colAgents = agents.map(this.convertAgent); + this.validateAgents(colAgents); this.addStage(this.setAgentVis(colAgents, true, mode, true)); } handleAgentEnd({agents, mode}) { - const colAgents = agents.map(this.convertAgent); + const groupAgents = (agents + .filter((agent) => this.activeGroups.has(agent.name)) + ); + const colAgents = (agents + .filter((agent) => !this.activeGroups.has(agent.name)) + .map(this.convertAgent) + ); + this.validateAgents(colAgents); this.addParallelStages([ this.setAgentHighlight(colAgents, false), this.setAgentVis(colAgents, false, mode, true), + ...groupAgents.map(this.endGroup), ]); } @@ -608,6 +790,7 @@ define(['core/ArrayUtilities'], (array) => { this.agentStates.clear(); this.markers.clear(); this.agentAliases.clear(); + this.activeGroups.clear(); this.agents.length = 0; this.blockCount = 0; this.nesting.length = 0; @@ -622,6 +805,9 @@ define(['core/ArrayUtilities'], (array) => { (this.currentSection.header.ln + 1) ); } + if(this.activeGroups.size > 0) { + throw new Error('Unterminated group'); + } const terminators = meta.terminators || 'none'; diff --git a/scripts/sequence/Generator_spec.js b/scripts/sequence/Generator_spec.js index 06ba545..ead0ccf 100644 --- a/scripts/sequence/Generator_spec.js +++ b/scripts/sequence/Generator_spec.js @@ -266,9 +266,9 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { it('rejects attempts to jump to markers not yet defined', () => { expect(() => generator.generate({stages: [ - {type: 'async', target: 'foo'}, + {type: 'async', target: 'foo', ln: 10}, {type: 'mark', name: 'foo'}, - ]})).toThrow(); + ]})).toThrow(new Error('Unknown marker: foo at line 11')); }); it('returns aggregated agents', () => { @@ -329,14 +329,18 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { expect(() => generator.generate({stages: [ PARSED.defineAgents([{name: 'Foo', alias: 'B', flags: []}]), PARSED.defineAgents([{name: 'Bar', alias: 'B', flags: []}]), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Cannot use B as an alias; it is already in use at line 1' + )); }); it('rejects using agent names as aliases', () => { expect(() => generator.generate({stages: [ PARSED.defineAgents([{name: 'Foo', alias: 'B', flags: []}]), PARSED.defineAgents([{name: 'Bar', alias: 'Foo', flags: []}]), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Cannot use Foo as an alias; it is already in use at line 1' + )); }); it('creates implicit begin stages for agents when used', () => { @@ -675,28 +679,36 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { 'A', {name: 'B', alias: '', flags: ['start', 'stop']}, ]), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Cannot set agent highlighting multiple times at line 1' + )); expect(() => generator.generate({stages: [ PARSED.connect([ {name: 'A', alias: '', flags: ['start']}, {name: 'A', alias: '', flags: ['stop']}, ]), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Cannot set agent highlighting multiple times at line 1' + )); expect(() => generator.generate({stages: [ PARSED.connect([ 'A', {name: 'B', alias: '', flags: ['begin', 'end']}, ]), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Cannot set agent visibility multiple times at line 1' + )); expect(() => generator.generate({stages: [ PARSED.connect([ {name: 'A', alias: '', flags: ['begin']}, {name: 'A', alias: '', flags: ['end']}, ]), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Cannot set agent visibility multiple times at line 1' + )); }); it('adds implicit highlight end with implicit terminator', () => { @@ -948,7 +960,7 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { PARSED.blockBegin('if', 'abc'), PARSED.blockSplit('else', 'xyz'), PARSED.blockEnd(), - ]})).toThrow(); + ]})).toThrow(new Error('Empty block at line 1')); }); it('rejects blocks containing only define statements / markers', () => { @@ -957,18 +969,306 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { PARSED.defineAgents(['A']), {type: 'mark', name: 'foo'}, PARSED.blockEnd(), - ]})).toThrow(); + ]})).toThrow(new Error('Empty block at line 1')); }); it('rejects entirely empty nested blocks', () => { expect(() => generator.generate({stages: [ - PARSED.blockBegin('if', 'abc'), + PARSED.blockBegin('if', 'abc', {ln: 10}), PARSED.connect(['A', 'B']), - PARSED.blockSplit('else', 'xyz'), - PARSED.blockBegin('if', 'abc'), - PARSED.blockEnd(), - PARSED.blockEnd(), - ]})).toThrow(); + PARSED.blockSplit('else', 'xyz', {ln: 20}), + PARSED.blockBegin('if', 'abc', {ln: 30}), + PARSED.blockEnd({ln: 40}), + PARSED.blockEnd({ln: 50}), + ]})).toThrow(new Error('Empty block at line 41')); + }); + + it('converts groups into block commands', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B']), + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]}); + + const bounds = { + left: '__BLOCK0[', + right: '__BLOCK0]', + }; + + expect(sequence.agents).toEqual([ + {name: '[', anchorRight: true}, + {name: '__BLOCK0[', anchorRight: true}, + {name: 'A', anchorRight: false}, + {name: 'B', anchorRight: false}, + {name: '__BLOCK0]', anchorRight: false}, + {name: ']', anchorRight: false}, + ]); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + GENERATED.blockBegin('ref', 'Foo', bounds), + GENERATED.blockEnd(bounds), + GENERATED.endAgents(['A', 'B']), + ]); + }); + + it('adds implicit begin statements when creating groups', () => { + const sequence = generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.stages).toEqual([ + GENERATED.beginAgents(['A', 'B'], {mode: 'box'}), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('augments explicit begin statements when creating groups', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A']), + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.stages).toEqual([ + GENERATED.beginAgents(['A', 'B'], {mode: 'box'}), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('rejects unterminated groups', () => { + expect(() => generator.generate({stages: [ + PARSED.beginAgents(['A', 'B']), + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + ]})).toThrow(new Error('Unterminated group')); + }); + + it('uses group agent list when positioning bounds', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.agents).toEqual([ + {name: '[', anchorRight: true}, + {name: 'A', anchorRight: false}, + {name: '__BLOCK0[', anchorRight: true}, + {name: 'B', anchorRight: false}, + {name: 'C', anchorRight: false}, + {name: '__BLOCK0]', anchorRight: false}, + {name: 'D', anchorRight: false}, + {name: ']', anchorRight: false}, + ]); + }); + + it('implicitly adds contained agents to groups', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D', 'E']), + PARSED.groupBegin('Bar', ['B', 'D'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.agents).toEqual([ + {name: '[', anchorRight: true}, + {name: 'A', anchorRight: false}, + {name: '__BLOCK0[', anchorRight: true}, + {name: 'B', anchorRight: false}, + {name: 'C', anchorRight: false}, + {name: 'D', anchorRight: false}, + {name: '__BLOCK0]', anchorRight: false}, + {name: 'E', anchorRight: false}, + {name: ']', anchorRight: false}, + ]); + }); + + it('repoints explicit group connectors at bounds', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.connect(['A', 'Bar']), + PARSED.connect(['D', 'Bar']), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['A', '__BLOCK0[']), + GENERATED.connect(['D', '__BLOCK0]']), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('correctly positions new agents when repointing at bounds', () => { + const sequence1 = generator.generate({stages: [ + PARSED.beginAgents(['B', 'C']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.connect(['D', 'Bar']), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence1.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['D', '__BLOCK0]']), + jasmine.anything(), + jasmine.anything(), + ]); + + const sequence2 = generator.generate({stages: [ + PARSED.beginAgents(['B', 'C']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.connect(['Bar', 'D']), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence2.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['__BLOCK0]', 'D']), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('repoints explicit group notes at bounds', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.note('note over', ['Bar']), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.note('note over', ['__BLOCK0[', '__BLOCK0]']), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('repoints group self-connections to right bound', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.connect(['B', 'B']), + PARSED.connect(['Bar', 'Bar']), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['__BLOCK0]', '__BLOCK0]']), + GENERATED.connect(['__BLOCK0]', '__BLOCK0]']), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('rejects using an agent in multiple groups simultaneously', () => { + expect(() => generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.groupBegin('Baz', ['B', 'C'], {label: 'Foob'}), + PARSED.endAgents(['Bar']), + PARSED.endAgents(['Baz']), + ]})).toThrow(new Error('Agent B is in a group at line 1')); + }); + + it('rejects explicit group connectors after ending', () => { + expect(() => generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + PARSED.connect(['B', 'Bar']), + ]})).toThrow(new Error('Duplicate agent name: Bar at line 1')); + }); + + it('rejects notes over groups after ending', () => { + expect(() => generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + PARSED.note('note over', ['Bar']), + ]})).toThrow(new Error('Duplicate agent name: Bar at line 1')); + }); + + it('repoints implicit group connectors at bounds', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.connect(['A', 'C']), + PARSED.connect(['D', 'C']), + PARSED.endAgents(['Bar']), + ]}); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['A', '__BLOCK0[']), + GENERATED.connect(['D', '__BLOCK0]']), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('does not repoint implicit group connectors after ending', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('Bar', ['B', 'C'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + PARSED.connect(['A', 'C']), + PARSED.connect(['D', 'C']), + ]}); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['A', 'C']), + GENERATED.connect(['D', 'C']), + jasmine.anything(), + ]); + }); + + it('can connect multiple reference blocks', () => { + const sequence = generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('AB', ['A', 'B'], {label: 'Foo'}), + PARSED.groupBegin('CD', ['C', 'D'], {label: 'Foo'}), + PARSED.connect(['AB', 'CD']), + PARSED.connect(['CD', 'AB']), + PARSED.endAgents(['AB']), + PARSED.endAgents(['CD']), + ]}); + + expect(sequence.stages).toEqual([ + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + GENERATED.connect(['__BLOCK0]', '__BLOCK1[']), + GENERATED.connect(['__BLOCK1[', '__BLOCK0]']), + jasmine.anything(), + jasmine.anything(), + jasmine.anything(), + ]); + }); + + it('rejects interactions with agents hidden beneath references', () => { + expect(() => generator.generate({stages: [ + PARSED.beginAgents(['A', 'B', 'C', 'D']), + PARSED.groupBegin('AC', ['A', 'C'], {label: 'Foo'}), + PARSED.connect(['B', 'D']), + PARSED.endAgents(['AC']), + ]})).toThrow(new Error('Agent B is hidden behind group at line 1')); }); it('rejects unterminated blocks', () => { @@ -988,27 +1288,35 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { it('rejects extra block terminations', () => { expect(() => generator.generate({stages: [ PARSED.blockEnd(), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Invalid block nesting (too many "end"s) at line 1' + )); expect(() => generator.generate({stages: [ PARSED.blockBegin('if', 'abc'), PARSED.connect(['A', 'B']), - PARSED.blockEnd(), - PARSED.blockEnd(), - ]})).toThrow(); + PARSED.blockEnd({ln: 10}), + PARSED.blockEnd({ln: 20}), + ]})).toThrow(new Error( + 'Invalid block nesting (too many "end"s) at line 21' + )); }); it('rejects block splitting without a block', () => { expect(() => generator.generate({stages: [ PARSED.blockSplit('else', 'xyz'), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Invalid block nesting ("else" inside global) at line 1' + )); expect(() => generator.generate({stages: [ PARSED.blockBegin('if', 'abc'), PARSED.connect(['A', 'B']), PARSED.blockEnd(), PARSED.blockSplit('else', 'xyz'), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Invalid block nesting ("else" inside global) at line 1' + )); }); it('rejects block splitting in non-splittable blocks', () => { @@ -1017,7 +1325,9 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { PARSED.blockSplit('else', 'xyz'), PARSED.connect(['A', 'B']), PARSED.blockEnd(), - ]})).toThrow(); + ]})).toThrow(new Error( + 'Invalid block nesting ("else" inside repeat) at line 1' + )); }); it('passes notes through', () => { @@ -1043,7 +1353,9 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { mode: 'foo', label: 'bar', }), - ]})).toThrow(); + ]})).toThrow(new Error( + 'note between requires at least 2 agents at line 1' + )); }); it('defaults to showing notes around the entire diagram', () => { @@ -1059,22 +1371,80 @@ defineDescribe('Sequence Generator', ['./Generator'], (Generator) => { ]); }); - it('rejects attempts to change implicit agents', () => { + it('rejects creating agents with the same name as a group', () => { + expect(() => generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + PARSED.beginAgents(['Bar']), + PARSED.endAgents(['Bar']), + ]})).toThrow(new Error('Duplicate agent name: Bar at line 1')); + + expect(() => generator.generate({stages: [ + PARSED.beginAgents(['Bar']), + PARSED.endAgents(['Bar']), + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]})).toThrow(new Error('Duplicate agent name: Bar at line 1')); + }); + + it('rejects explicit interactions with virtual group agents', () => { + expect(() => generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.connect(['C', '__BLOCK0[']), + PARSED.endAgents(['Bar']), + ]})).toThrow(new Error('__BLOCK0[ is a reserved name at line 1')); + + expect(() => generator.generate({stages: [ + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + PARSED.connect(['C', '__BLOCK0[']), + ]})).toThrow(new Error('__BLOCK0[ is a reserved name at line 1')); + + expect(() => generator.generate({stages: [ + PARSED.connect(['C', '__BLOCK0[']), + PARSED.groupBegin('Bar', ['A', 'B'], {label: 'Foo'}), + PARSED.endAgents(['Bar']), + ]})).toThrow(new Error('__BLOCK0[ is a reserved name at line 1')); + }); + + it('rejects explicit interactions with virtual block agents', () => { + expect(() => generator.generate({stages: [ + PARSED.blockBegin('if', 'abc'), + PARSED.connect(['C', '__BLOCK0[']), + PARSED.blockEnd(), + ]})).toThrow(new Error('__BLOCK0[ is a reserved name at line 1')); + + expect(() => generator.generate({stages: [ + PARSED.blockBegin('if', 'abc'), + PARSED.connect(['A', 'B']), + PARSED.blockEnd(), + PARSED.connect(['C', '__BLOCK0[']), + ]})).toThrow(new Error('__BLOCK0[ is a reserved name at line 1')); + + expect(() => generator.generate({stages: [ + PARSED.connect(['C', '__BLOCK0[']), + PARSED.blockBegin('if', 'abc'), + PARSED.connect(['A', 'B']), + PARSED.blockEnd(), + ]})).toThrow(new Error('__BLOCK0[ is a reserved name at line 1')); + }); + + it('rejects attempts to change virtual agents', () => { expect(() => generator.generate({stages: [ PARSED.beginAgents(['[']), - ]})).toThrow(); + ]})).toThrow(new Error('Cannot begin/end agent: [ at line 1')); expect(() => generator.generate({stages: [ PARSED.beginAgents([']']), - ]})).toThrow(); + ]})).toThrow(new Error('Cannot begin/end agent: ] at line 1')); expect(() => generator.generate({stages: [ PARSED.endAgents(['[']), - ]})).toThrow(); + ]})).toThrow(new Error('Cannot begin/end agent: [ at line 1')); expect(() => generator.generate({stages: [ PARSED.endAgents([']']), - ]})).toThrow(); + ]})).toThrow(new Error('Cannot begin/end agent: ] at line 1')); }); }); }); diff --git a/scripts/sequence/components/Block.js b/scripts/sequence/components/Block.js index 51f1e75..f5cde5e 100644 --- a/scripts/sequence/components/Block.js +++ b/scripts/sequence/components/Block.js @@ -99,7 +99,10 @@ define([ } render(stage, env) { - env.state.blocks.set(stage.left, env.primaryY); + env.state.blocks.set(stage.left, { + mode: stage.mode, + startY: env.primaryY, + }); return super.render(stage, env, true); } } @@ -119,16 +122,17 @@ define([ render({left, right}, env) { const config = env.theme.block; - const startY = env.state.blocks.get(left); + const {startY, mode} = env.state.blocks.get(left); const agentInfoL = env.agentInfos.get(left); const agentInfoR = env.agentInfos.get(right); + const configMode = config.modes[mode] || config.modes['']; env.blockLayer.appendChild(svg.make('rect', Object.assign({ 'x': agentInfoL.x, 'y': startY, 'width': agentInfoR.x - agentInfoL.x, 'height': env.primaryY - startY, - }, config.boxAttrs))); + }, configMode.boxAttrs))); return env.primaryY + config.margin.bottom + env.theme.actionMargin; } diff --git a/scripts/sequence/themes/Basic.js b/scripts/sequence/themes/Basic.js index 79e5632..fdcbc03 100644 --- a/scripts/sequence/themes/Basic.js +++ b/scripts/sequence/themes/Basic.js @@ -131,12 +131,25 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => { top: 0, bottom: 0, }, - boxAttrs: { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 1.5, - 'rx': 2, - 'ry': 2, + modes: { + 'ref': { + boxAttrs: { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 1.5, + 'rx': 2, + 'ry': 2, + }, + }, + '': { + boxAttrs: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 1.5, + 'rx': 2, + 'ry': 2, + }, + }, }, section: { padding: { diff --git a/scripts/sequence/themes/Chunky.js b/scripts/sequence/themes/Chunky.js index 8ff48ac..8486c4d 100644 --- a/scripts/sequence/themes/Chunky.js +++ b/scripts/sequence/themes/Chunky.js @@ -139,12 +139,25 @@ define(['core/ArrayUtilities', 'svg/SVGShapes'], (array, SVGShapes) => { top: 0, bottom: 0, }, - boxAttrs: { - 'fill': 'none', - 'stroke': '#000000', - 'stroke-width': 4, - 'rx': 5, - 'ry': 5, + modes: { + 'ref': { + boxAttrs: { + 'fill': '#FFFFFF', + 'stroke': '#000000', + 'stroke-width': 4, + 'rx': 5, + 'ry': 5, + }, + }, + '': { + boxAttrs: { + 'fill': 'none', + 'stroke': '#000000', + 'stroke-width': 4, + 'rx': 5, + 'ry': 5, + }, + }, }, section: { padding: {