Skip to content

Commit 0f404a4

Browse files
cgreenoclaude
andcommitted
feat(sequence): box participant groups
box [color] [title] … end wraps its participants' columns in a frame for the whole diagram height, title embedded in the top border (widening the box when the title is longer than its participants). Fill colors — named CSS colors, #hex, rgb()/hsl() — are parsed so they never leak into the title, then discarded: ASCII output has no fill. Arrows and fragment rules crossing a border render a crossing (┼) rather than punching a hole. mermaid's grammar rules hold: only participant/actor declarations inside a box, no nesting, end required. <br> tags in titles become spaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b1b35f6 commit 0f404a4

8 files changed

Lines changed: 548 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
box Payments
3+
participant GW
4+
participant PAY
5+
end
6+
participant S as Shop
7+
GW->>PAY: authorize
8+
PAY-->>GW: ok
9+
GW->>S: receipt
10+
---
11+
+- Payments ---------+
12+
| +----+ +-----+ | +------+
13+
| | GW | | PAY | | | Shop |
14+
| +--+-+ +--+--+ | +---+--+
15+
| | | | |
16+
| | authorize| | |
17+
| +--------->| | |
18+
| | | | |
19+
| | ok | | |
20+
| |<.........+ | |
21+
| | | | |
22+
| | receipt | | |
23+
| +---------------+-------->|
24+
| | | | |
25+
+--------------------+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
sequenceDiagram
2+
box rgb(23, 124, 207) Profiles
3+
participant A
4+
participant B
5+
end
6+
box Green New serwis
7+
participant C
8+
end
9+
A->>B: x
10+
B->>C: y
11+
---
12+
+- Profiles ------+ +- New serwis -+
13+
| +---+ +---+ | | +---+ |
14+
| | A | | B | | | | C | |
15+
| +-+-+ +-+-+ | | +-+-+ |
16+
| | | | | | |
17+
| | x | | | | |
18+
| +-------->| | | | |
19+
| | | | | | |
20+
| | | y | | | |
21+
| | +---+-----+-->| |
22+
| | | | | | |
23+
+-----------------+ +--------------+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
sequenceDiagram
2+
box Payments
3+
participant GW
4+
participant PAY
5+
end
6+
participant S as Shop
7+
GW->>PAY: authorize
8+
PAY-->>GW: ok
9+
GW->>S: receipt
10+
---
11+
┌─ Payments ─────────┐
12+
│ ┌────┐ ┌─────┐ │ ┌──────┐
13+
│ │ GW │ │ PAY │ │ │ Shop │
14+
│ └──┬─┘ └──┬──┘ │ └───┬──┘
15+
│ │ │ │ │
16+
│ │ authorize│ │ │
17+
│ ├─────────►│ │ │
18+
│ │ │ │ │
19+
│ │ ok │ │ │
20+
│ │◄┈┈┈┈┈┈┈┈┈┤ │ │
21+
│ │ │ │ │
22+
│ │ receipt │ │ │
23+
│ ├───────────────┼────────►│
24+
│ │ │ │ │
25+
└────────────────────┘
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
sequenceDiagram
2+
box rgb(23, 124, 207) Profiles
3+
participant A
4+
participant B
5+
end
6+
box Green New serwis
7+
participant C
8+
end
9+
A->>B: x
10+
B->>C: y
11+
---
12+
┌─ Profiles ──────┐ ┌─ New serwis ─┐
13+
│ ┌───┐ ┌───┐ │ │ ┌───┐ │
14+
│ │ A │ │ B │ │ │ │ C │ │
15+
│ └─┬─┘ └─┬─┘ │ │ └─┬─┘ │
16+
│ │ │ │ │ │ │
17+
│ │ x │ │ │ │ │
18+
│ ├────────►│ │ │ │ │
19+
│ │ │ │ │ │ │
20+
│ │ │ y │ │ │ │
21+
│ │ ├───┼─────┼──►│ │
22+
│ │ │ │ │ │ │
23+
└─────────────────┘ └──────────────┘

pkg/sequence/box_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package sequence
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/AlexanderGrooff/mermaid-ascii/pkg/diagram"
8+
)
9+
10+
// TestBoxParsing mirrors mermaid's box spec cases ('should handle box',
11+
// 'box without color', 'box without description') plus the color forms found
12+
// in real diagrams. The fill color has no ASCII meaning: it is parsed away so
13+
// it never leaks into the title, then discarded.
14+
func TestBoxParsing(t *testing.T) {
15+
cases := []struct {
16+
name string
17+
boxLine string
18+
wantTitle string
19+
}{
20+
{"color and title", "box green Group 1", "Group 1"},
21+
{"title only", "box Group 1", "Group 1"},
22+
{"color only", "box aqua", ""},
23+
{"transparent", "box transparent Group 1", "Group 1"},
24+
{"rgb with spaces and br title", "box rgb(23, 124, 207) <br>PROFESSIONAL PROFILES<br>", "PROFESSIONAL PROFILES"},
25+
{"named color then title", "box Green New serwis", "New serwis"},
26+
{"hex color", "box #ff0000 Alerts", "Alerts"},
27+
{"title that is not a color", "box Facade", "Facade"},
28+
{"bare box", "box", ""},
29+
}
30+
for _, c := range cases {
31+
t.Run(c.name, func(t *testing.T) {
32+
d, err := Parse("sequenceDiagram\n" + c.boxLine + "\nparticipant a as Alice\nparticipant b as Bob\nend\nparticipant c as Charlie\na->>b: hi")
33+
if err != nil {
34+
t.Fatal(err)
35+
}
36+
if len(d.Boxes) != 1 {
37+
t.Fatalf("want 1 box, got %d", len(d.Boxes))
38+
}
39+
b := d.Boxes[0]
40+
if b.Title != c.wantTitle {
41+
t.Errorf("title = %q, want %q", b.Title, c.wantTitle)
42+
}
43+
if b.First != 0 || b.Last != 1 {
44+
t.Errorf("box spans participants %d..%d, want 0..1", b.First, b.Last)
45+
}
46+
if len(d.Participants) != 3 {
47+
t.Errorf("want 3 participants (Charlie outside the box), got %d", len(d.Participants))
48+
}
49+
})
50+
}
51+
}
52+
53+
// TestBoxWithActors: actor declarations are valid inside a box.
54+
func TestBoxWithActors(t *testing.T) {
55+
d, err := Parse("sequenceDiagram\nbox Team\nactor a as Alice\nparticipant b as Bob\nend\na->>b: hi")
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
if len(d.Boxes) != 1 || d.Boxes[0].First != 0 || d.Boxes[0].Last != 1 {
60+
t.Fatalf("box not recorded over actor+participant: %+v", d.Boxes)
61+
}
62+
}
63+
64+
// TestMultipleBoxes: two sibling groups, each framing its own participants.
65+
func TestMultipleBoxes(t *testing.T) {
66+
d, err := Parse("sequenceDiagram\nbox One\nparticipant a\nend\nbox Two\nparticipant b\nparticipant c\nend\na->>b: hi")
67+
if err != nil {
68+
t.Fatal(err)
69+
}
70+
if len(d.Boxes) != 2 {
71+
t.Fatalf("want 2 boxes, got %d", len(d.Boxes))
72+
}
73+
if d.Boxes[0].First != 0 || d.Boxes[0].Last != 0 || d.Boxes[1].First != 1 || d.Boxes[1].Last != 2 {
74+
t.Errorf("box ranges wrong: %+v %+v", d.Boxes[0], d.Boxes[1])
75+
}
76+
}
77+
78+
// TestBoxErrors: mermaid's grammar allows only participant declarations
79+
// inside a box, forbids nesting, and requires end.
80+
func TestBoxErrors(t *testing.T) {
81+
cases := []struct{ name, in, want string }{
82+
{"message inside box", "sequenceDiagram\nbox X\nparticipant a\na->>a: hi\nend", "only participant declarations"},
83+
{"fragment inside box", "sequenceDiagram\nbox X\nparticipant a\nloop retry\nend\nend", "only participant declarations"},
84+
{"nested box", "sequenceDiagram\nbox X\nbox Y\nparticipant a\nend\nend", "cannot nest"},
85+
{"unclosed box", "sequenceDiagram\nbox X\nparticipant a\na->>a: hi", "only participant declarations"},
86+
{"unclosed box at EOF", "sequenceDiagram\nbox X\nparticipant a", "unclosed box"},
87+
}
88+
for _, c := range cases {
89+
t.Run(c.name, func(t *testing.T) {
90+
_, err := Parse(c.in)
91+
if err == nil || !strings.Contains(err.Error(), c.want) {
92+
t.Errorf("want error containing %q, got %v", c.want, err)
93+
}
94+
})
95+
}
96+
}
97+
98+
// TestEmptyBoxDropped: a box with no participants frames nothing and is
99+
// dropped rather than rendered as a floating frame.
100+
func TestEmptyBoxDropped(t *testing.T) {
101+
d, err := Parse("sequenceDiagram\nbox Empty\nend\nparticipant a\na->>a: hi")
102+
if err != nil {
103+
t.Fatal(err)
104+
}
105+
if len(d.Boxes) != 0 {
106+
t.Errorf("empty box should be dropped, got %+v", d.Boxes)
107+
}
108+
}
109+
110+
// TestBoxRenderStructure: every body line sits inside the box borders, the
111+
// title is embedded whole in the top border, and a message crossing the
112+
// border renders a crossing, not a hole.
113+
func TestBoxRenderStructure(t *testing.T) {
114+
d, err := Parse("sequenceDiagram\nbox Payments Group\nparticipant GW\nparticipant PAY\nend\nparticipant S as Shop\nGW->>PAY: auth\nGW->>S: receipt")
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
out, err := Render(d, diagram.DefaultConfig())
119+
if err != nil {
120+
t.Fatal(err)
121+
}
122+
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
123+
if !strings.Contains(lines[0], "─ Payments Group ") {
124+
t.Errorf("title missing from top border: %q", lines[0])
125+
}
126+
if !strings.HasPrefix(lines[0], "┌") || !strings.HasPrefix(lines[len(lines)-1], "└") {
127+
t.Errorf("missing box corners: first %q last %q", lines[0], lines[len(lines)-1])
128+
}
129+
// Every line between top and bottom border starts with the box vertical.
130+
for i := 1; i < len(lines)-1; i++ {
131+
if !strings.HasPrefix(lines[i], "│") {
132+
t.Errorf("line %d not inside box: %q", i, lines[i])
133+
}
134+
}
135+
// The receipt arrow leaves the box: its row must cross the border with ┼.
136+
found := false
137+
for _, l := range lines {
138+
if strings.Contains(l, "receipt") || strings.Contains(l, "►") {
139+
if strings.Contains(l, "┼") {
140+
found = true
141+
}
142+
}
143+
}
144+
if !found {
145+
t.Error("arrow crossing the box border should render ┼")
146+
}
147+
}

0 commit comments

Comments
 (0)