Skip to content

Commit b997236

Browse files
cgreenoclaude
andcommitted
feat(sequence): create and destroy participants
create participant X and create actor X as Y declare a participant whose lifeline starts at the message that follows, and destroy X ends X's lifeline at the message that follows. Following mermaid, a created participant must be the recipient of the message that creates it, since it cannot send the message that brings it into being, while a destroyed one may be at either end. Creating an id that already exists is rejected the way mermaid rejects it, pointing at an as alias instead. Other statements may sit between the create or destroy and its message, so the binding message can be wrapped in a fragment. The lifeline is drawn blank above a created participant and blank below a destroyed one, with a cross marking the end. The participant's header box still sits at the top of the diagram rather than moving down to the creation row, which is where mermaid puts it: moving it would mean reflowing the header row per message, and the blank lifeline already shows when the participant enters. buildLifeline now decides each lifeline cell from the tracked state, so labels and note boxes painted over a lifeline are never disturbed, and the activation tracker generalises into that state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 7deee09 commit b997236

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)