Skip to content

Commit aa31760

Browse files
Merge pull request #93 from cgreeno/feat/seq-create
feat(sequence): create and destroy participants
2 parents 8a0d80e + b997236 commit aa31760

8 files changed

Lines changed: 568 additions & 66 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
sequenceDiagram
2+
participant a as Alice
3+
a->>b: Hello Bob?
4+
create actor d as Donald
5+
a->>d: Hello Donald?
6+
---
7+
+-------+ +---+ +--------+
8+
| Alice | | b | | Donald |
9+
+---+---+ +-+-+ +----+---+
10+
| |
11+
| Hello Bob?|
12+
+---------->|
13+
| | |
14+
| Hello Donald? |
15+
+----------------------->|
16+
| | |
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
Alice->>Bob: Hello Bob
3+
create participant Carl
4+
Alice->>Carl: Hi Carl
5+
destroy Carl
6+
Alice-xCarl: We are too many
7+
destroy Bob
8+
Bob->>Alice: I agree
9+
---
10+
+-------+ +-----+ +------+
11+
| Alice | | Bob | | Carl |
12+
+---+---+ +--+--+ +---+--+
13+
| |
14+
| Hello Bob |
15+
+----------->|
16+
| | |
17+
| Hi Carl | |
18+
+------------------------>|
19+
| | |
20+
| We are too many |
21+
+------------------------x|
22+
| | x
23+
| I agree |
24+
|<-----------+
25+
| x
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
sequenceDiagram
2+
participant a as Alice
3+
a->>b: Hello Bob?
4+
create actor d as Donald
5+
a->>d: Hello Donald?
6+
---
7+
┌───────┐ ┌───┐ ┌────────┐
8+
│ Alice │ │ b │ │ Donald │
9+
└───┬───┘ └─┬─┘ └────┬───┘
10+
│ │
11+
│ Hello Bob?│
12+
├──────────►│
13+
│ │ │
14+
│ Hello Donald? │
15+
├───────────────────────►│
16+
│ │ │
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
Alice->>Bob: Hello Bob
3+
create participant Carl
4+
Alice->>Carl: Hi Carl
5+
destroy Carl
6+
Alice-xCarl: We are too many
7+
destroy Bob
8+
Bob->>Alice: I agree
9+
---
10+
┌───────┐ ┌─────┐ ┌──────┐
11+
│ Alice │ │ Bob │ │ Carl │
12+
└───┬───┘ └──┬──┘ └───┬──┘
13+
│ │
14+
│ Hello Bob │
15+
├───────────►│
16+
│ │ │
17+
│ Hi Carl │ │
18+
├────────────────────────►│
19+
│ │ │
20+
│ We are too many │
21+
├────────────────────────×│
22+
│ │ ×
23+
│ I agree │
24+
│◄───────────┤
25+
│ ×

pkg/sequence/create_test.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package sequence
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/AlexanderGrooff/mermaid-ascii/pkg/diagram"
8+
)
9+
10+
// TestCreateParticipant mirrors mermaid's 'should handle simple actor
11+
// creation': create declares the participant, supports the actor keyword and
12+
// an as alias, and binds it to the message that follows.
13+
func TestCreateParticipant(t *testing.T) {
14+
d, err := Parse("sequenceDiagram\nparticipant a as Alice\na ->>b: Hello Bob?\ncreate participant c\nb-->>c: Hello c!\nc ->> b: Hello b?\ncreate actor d as Donald\na ->> d: Hello Donald?")
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
byID := map[string]*Participant{}
19+
for _, p := range d.Participants {
20+
byID[p.ID] = p
21+
}
22+
if byID["c"] == nil || byID["c"].Label != "c" {
23+
t.Errorf("c not declared with its own label: %+v", byID["c"])
24+
}
25+
if byID["d"] == nil || byID["d"].Label != "Donald" {
26+
t.Errorf("create actor alias lost: %+v", byID["d"])
27+
}
28+
if len(d.Created) != 2 {
29+
t.Fatalf("want 2 created participants, got %d", len(d.Created))
30+
}
31+
// The create event must sit immediately before its message.
32+
for i, ev := range d.Events {
33+
if ev.Kind != EventCreate {
34+
continue
35+
}
36+
if i+1 >= len(d.Events) || d.Events[i+1].Kind != EventMessage {
37+
t.Errorf("create event for %q is not followed by its message", ev.Participant.ID)
38+
continue
39+
}
40+
msg := d.Events[i+1].Message
41+
if msg.From != ev.Participant && msg.To != ev.Participant {
42+
t.Errorf("create event for %q bound to an unrelated message", ev.Participant.ID)
43+
}
44+
}
45+
}
46+
47+
// TestDestroyParticipant mirrors 'should handle simple actor destruction'.
48+
func TestDestroyParticipant(t *testing.T) {
49+
d, err := Parse("sequenceDiagram\nparticipant a as Alice\na ->>b: Hello Bob?\ndestroy a\nb-->>a: Hello Alice!\nb ->> c: Where is Alice?\ndestroy c\nb ->> c: Where are you?")
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
var destroyed []string
54+
for i, ev := range d.Events {
55+
if ev.Kind != EventDestroy {
56+
continue
57+
}
58+
destroyed = append(destroyed, ev.Participant.ID)
59+
// The destroy event must sit immediately after its message.
60+
if i == 0 || d.Events[i-1].Kind != EventMessage {
61+
t.Errorf("destroy event for %q is not preceded by its message", ev.Participant.ID)
62+
continue
63+
}
64+
msg := d.Events[i-1].Message
65+
if msg.From != ev.Participant && msg.To != ev.Participant {
66+
t.Errorf("destroy event for %q bound to an unrelated message", ev.Participant.ID)
67+
}
68+
}
69+
if strings.Join(destroyed, ",") != "a,c" {
70+
t.Errorf("destroyed = %v, want [a c]", destroyed)
71+
}
72+
}
73+
74+
// TestCreateAndDestroySameParticipant mirrors 'should handle the creation and
75+
// destruction of the same actor'.
76+
func TestCreateAndDestroySameParticipant(t *testing.T) {
77+
d, err := Parse("sequenceDiagram\na ->>b: Hello Bob?\ncreate participant c\nb ->>c: Hello c!\nc ->> b: Hello b?\ndestroy c\nb ->> c : Bye c !")
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
var kinds []string
82+
for _, ev := range d.Events {
83+
switch ev.Kind {
84+
case EventCreate, EventDestroy:
85+
kinds = append(kinds, ev.Kind.String()+":"+ev.Participant.ID)
86+
}
87+
}
88+
if strings.Join(kinds, ",") != "create:c,destroy:c" {
89+
t.Errorf("events = %v, want create:c then destroy:c", kinds)
90+
}
91+
}
92+
93+
// TestCreateDestroySpacedNames: names with spaces work, as they do elsewhere
94+
// since participant names may contain spaces.
95+
func TestCreateDestroySpacedNames(t *testing.T) {
96+
d, err := Parse("sequenceDiagram\nGW->>C: x\ncreate participant Auth Service\nGW->>Auth Service: validate\ndestroy Auth Service\nGW-xAuth Service: close")
97+
if err != nil {
98+
t.Fatal(err)
99+
}
100+
if len(d.Created) != 1 || d.Created[0].ID != "Auth Service" {
101+
t.Errorf("spaced created name mishandled: %+v", d.Created)
102+
}
103+
}
104+
105+
// TestCreateDestroyErrors: mermaid requires the create/destroy statement to be
106+
// followed by a message that involves the participant.
107+
func TestCreateDestroyErrors(t *testing.T) {
108+
cases := []struct{ name, in, want string }{
109+
{"create never bound to a message", "sequenceDiagram\nA->>B: x\ncreate participant C\nparticipant D", "must be followed by a message"},
110+
{"create at end of diagram", "sequenceDiagram\nA->>B: x\ncreate participant C", "must be followed by a message"},
111+
{"created participant uninvolved", "sequenceDiagram\nA->>B: x\ncreate participant C\nA->>B: y", "must receive the message"},
112+
{"created participant sends its own creating message", "sequenceDiagram\nA->>B: x\ncreate participant C\nC->>A: hello", "must receive the message"},
113+
{"create reuses an existing id", "sequenceDiagram\nA->>C: x\ncreate participant C\nA->>C: y", "id already exists"},
114+
{"create reuses a declared id", "sequenceDiagram\nparticipant C\nA->>B: x\ncreate participant C\nA->>C: y", "id already exists"},
115+
{"destroy uninvolved", "sequenceDiagram\nA->>B: x\ndestroy A\nB->>C: y", "not involved in the following message"},
116+
{"destroy unknown participant", "sequenceDiagram\nA->>B: x\ndestroy Zed\nA->>B: y", "unknown participant"},
117+
{"destroy at end of diagram", "sequenceDiagram\nA->>B: x\ndestroy A", "must be followed by a message"},
118+
{"create requires participant or actor", "sequenceDiagram\nA->>B: x\ncreate C\nA->>C: y", "invalid syntax"},
119+
}
120+
for _, c := range cases {
121+
t.Run(c.name, func(t *testing.T) {
122+
_, err := Parse(c.in)
123+
if err == nil || !strings.Contains(err.Error(), c.want) {
124+
t.Errorf("want error containing %q, got %v", c.want, err)
125+
}
126+
})
127+
}
128+
}
129+
130+
// TestCreateDestroyIntervening: mermaid only consults the pending create or
131+
// destroy when the next message arrives, so other statements may sit in
132+
// between, including a fragment opener that wraps the binding message.
133+
func TestCreateDestroyIntervening(t *testing.T) {
134+
cases := map[string]string{
135+
"fragment between": "sequenceDiagram\nA->>B: x\ncreate participant C\nloop retry\nA->>C: hi\nend",
136+
"note between": "sequenceDiagram\nA->>B: x\ncreate participant C\nNote over A: thinking\nA->>C: hi",
137+
"declaration between": "sequenceDiagram\nA->>B: x\ncreate participant C\nparticipant D\nA->>C: hi",
138+
"activation between": "sequenceDiagram\nA->>B: x\ndestroy B\nactivate A\nA-xB: bye",
139+
}
140+
for name, in := range cases {
141+
t.Run(name, func(t *testing.T) {
142+
if _, err := Parse(in); err != nil {
143+
t.Errorf("statements between create/destroy and its message should be allowed: %v", err)
144+
}
145+
})
146+
}
147+
}
148+
149+
// TestCreateDestroyPreservesNotes: blanking a lifeline must never eat a note
150+
// box border or label text that happens to sit on that column.
151+
func TestCreateDestroyPreservesNotes(t *testing.T) {
152+
for _, ascii := range []bool{false, true} {
153+
d, err := Parse("sequenceDiagram\nparticipant A\nparticipant B\nparticipant C\nA->>B: hi\ndestroy A\nA->>B: bye\nNote over B: xxxxxxxxxxxxxxxxx\nB->>C: after")
154+
if err != nil {
155+
t.Fatal(err)
156+
}
157+
cfg := diagram.DefaultConfig()
158+
cfg.UseAscii = ascii
159+
out, err := Render(d, cfg)
160+
if err != nil {
161+
t.Fatal(err)
162+
}
163+
// The note box must have the same number of border cells on each of its
164+
// three rows: a blanked lifeline column cannot punch through it.
165+
var noteRows []string
166+
for _, line := range strings.Split(out, "\n") {
167+
if strings.Contains(line, "xxxxx") {
168+
noteRows = append(noteRows, line)
169+
}
170+
}
171+
if len(noteRows) != 1 {
172+
t.Fatalf("ascii=%v: expected one note text row, got %d:\n%s", ascii, len(noteRows), out)
173+
}
174+
side := "│"
175+
if ascii {
176+
side = "|"
177+
}
178+
if strings.Count(noteRows[0], side) < 2 {
179+
t.Errorf("ascii=%v: note box lost a border:\n%s", ascii, out)
180+
}
181+
}
182+
}
183+
184+
// TestCreateDestroyRendering: a created lifeline is blank above its creating
185+
// message, a destroyed one ends with the end marker and is blank below it.
186+
func TestCreateDestroyRendering(t *testing.T) {
187+
d, err := Parse("sequenceDiagram\nAlice->>Bob: hello\ncreate participant Carl\nAlice->>Carl: hi\ndestroy Carl\nAlice-xCarl: bye\nBob->>Alice: done")
188+
if err != nil {
189+
t.Fatal(err)
190+
}
191+
out, err := Render(d, diagram.DefaultConfig())
192+
if err != nil {
193+
t.Fatal(err)
194+
}
195+
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
196+
var carlCol int
197+
for _, p := range d.Participants {
198+
if p.ID == "Carl" {
199+
carlCol = p.Index
200+
}
201+
}
202+
if carlCol != 2 {
203+
t.Fatalf("Carl should keep declaration order, got index %d", carlCol)
204+
}
205+
// Rows before the creating message must not draw Carl's lifeline: the
206+
// "hello" message row is the widest point where that is observable.
207+
for i, line := range lines {
208+
if strings.Contains(line, "hello") {
209+
// The row under the header and the hello rows precede creation.
210+
for _, before := range lines[3:i] {
211+
if strings.Count(before, "│") > 2 {
212+
t.Errorf("created lifeline drawn before creation: %q", before)
213+
}
214+
}
215+
break
216+
}
217+
}
218+
if !strings.ContainsRune(out, '×') {
219+
t.Errorf("destroyed lifeline should end with a marker:\n%s", out)
220+
}
221+
// After the marker, Carl's column stays blank.
222+
seenMarker := false
223+
for _, line := range lines {
224+
r := []rune(line)
225+
if seenMarker && len(r) > 24 && r[24] == '│' {
226+
t.Errorf("lifeline continues after destruction: %q", line)
227+
}
228+
if strings.ContainsRune(line, '×') {
229+
seenMarker = true
230+
}
231+
}
232+
}
233+
234+
// TestCreateDestroyWithActivation: the two features compose, and destroying an
235+
// active participant ends both the activation and the lifeline.
236+
func TestCreateDestroyWithActivation(t *testing.T) {
237+
d, err := Parse("sequenceDiagram\nA->>B: x\ncreate participant C\nA->>+C: work\nC-->>-A: result\ndestroy C\nA-xC: close")
238+
if err != nil {
239+
t.Fatal(err)
240+
}
241+
out, err := Render(d, diagram.DefaultConfig())
242+
if err != nil {
243+
t.Fatal(err)
244+
}
245+
if !strings.ContainsRune(out, '┃') {
246+
t.Errorf("activation lost on a created participant:\n%s", out)
247+
}
248+
if !strings.ContainsRune(out, '×') {
249+
t.Errorf("destruction marker missing:\n%s", out)
250+
}
251+
}

0 commit comments

Comments
 (0)