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