@zombie-mermaid/mermaid-parser 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zombie-mermaid/mermaid-parser",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "license": "MIT",
5
5
  "description": "Parsers for class, ER, sequence, and XY chart diagrams — the per-type parse functions both svg-renderer and ascii-renderer import directly.",
6
6
  "repository": {
@@ -35,6 +35,6 @@
35
35
  "LICENSE"
36
36
  ],
37
37
  "dependencies": {
38
- "@zombie-mermaid/core": "3.1.0"
38
+ "@zombie-mermaid/core": "3.2.0"
39
39
  }
40
40
  }
@@ -0,0 +1,145 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseArchitecture } from '../index.ts'
4
+
5
+ const parse = (src: string) => parseArchitecture(splitStatements(src))
6
+ const body = (b: string) => parse(`architecture-beta\n${b}`)
7
+
8
+ describe('parseArchitecture', () => {
9
+ it('accepts both header spellings', () => {
10
+ expect(parse('architecture-beta\nservice a').services).toHaveLength(1)
11
+ expect(parse('architecture\nservice a').services).toHaveLength(1)
12
+ })
13
+
14
+ it('parses a group with icon, title and no parent', () => {
15
+ expect(body('group api(cloud)[API]').groups).toEqual([
16
+ { id: 'api', icon: 'cloud', title: 'API', parent: undefined },
17
+ ])
18
+ })
19
+
20
+ it('parses nested groups, including a parent declared later', () => {
21
+ const d = body('group inner in outer\ngroup outer')
22
+ expect(d.groups.map((g) => [g.id, g.parent])).toEqual([
23
+ ['inner', 'outer'],
24
+ ['outer', undefined],
25
+ ])
26
+ })
27
+
28
+ it('parses services with and without icon/title/parent', () => {
29
+ const d = body(
30
+ 'group g\nservice a(database)[DB] in g\nservice b[Bee]\nservice c(server)\nservice d',
31
+ )
32
+ expect(d.services).toEqual([
33
+ { id: 'a', icon: 'database', title: 'DB', parent: 'g' },
34
+ { id: 'b', icon: undefined, title: 'Bee', parent: undefined },
35
+ { id: 'c', icon: 'server', title: 'c', parent: undefined },
36
+ { id: 'd', icon: undefined, title: 'd', parent: undefined },
37
+ ])
38
+ })
39
+
40
+ it('strips quotes from custom icon names and keeps hyphenated ids', () => {
41
+ const d = body('service my-svc("logos:aws")[AWS]')
42
+ expect(d.services[0]).toMatchObject({ id: 'my-svc', icon: 'logos:aws' })
43
+ })
44
+
45
+ it('parses junctions with and without a parent', () => {
46
+ const d = body('group g\njunction j1 in g\njunction j2')
47
+ expect(d.junctions).toEqual([
48
+ { id: 'j1', parent: 'g' },
49
+ { id: 'j2', parent: undefined },
50
+ ])
51
+ })
52
+
53
+ it('ignores align statements', () => {
54
+ const d = body('service a\nservice b\nalign row a b\nalign column a b')
55
+ expect(d.edges).toEqual([])
56
+ expect(d.services).toHaveLength(2)
57
+ })
58
+
59
+ it.each([
60
+ ['--', false, false],
61
+ ['-->', false, true],
62
+ ['<--', true, false],
63
+ ['<-->', true, true],
64
+ ])('parses the %s edge operator', (op, start, end) => {
65
+ const [e] = body(`service a\nservice b\na:R ${op} L:b`).edges
66
+ expect(e).toMatchObject({ arrowStart: start, arrowEnd: end })
67
+ })
68
+
69
+ it.each(['L', 'R', 'T', 'B'] as const)(
70
+ 'parses source port %s and target port %s',
71
+ (p) => {
72
+ for (const q of ['L', 'R', 'T', 'B'] as const) {
73
+ const [e] = body(`service a\nservice b\na:${p} -- ${q}:b`).edges
74
+ expect(e).toMatchObject({
75
+ source: 'a',
76
+ sourcePort: p,
77
+ target: 'b',
78
+ targetPort: q,
79
+ sourceGroup: false,
80
+ targetGroup: false,
81
+ })
82
+ }
83
+ },
84
+ )
85
+
86
+ it('parses {group} modifiers on either end', () => {
87
+ const d = body(
88
+ 'group g\ngroup h\nservice a in g\nservice b in h\na{group}:R -- L:b\na:R -- L:b{group}\na{group}:R -- L:b{group}',
89
+ )
90
+ expect(d.edges.map((e) => [e.sourceGroup, e.targetGroup])).toEqual([
91
+ [true, false],
92
+ [false, true],
93
+ [true, true],
94
+ ])
95
+ })
96
+
97
+ it('allows edges to junctions and {group} on a junction', () => {
98
+ const d = body('group g\njunction j in g\nservice a\na:R -- L:j{group}')
99
+ expect(d.edges[0]).toMatchObject({ target: 'j', targetGroup: true })
100
+ })
101
+
102
+ it('allows an edge to precede the declaration it refers to', () => {
103
+ const d = body('a:R -- L:b\nservice a\nservice b')
104
+ expect(d.edges).toHaveLength(1)
105
+ })
106
+
107
+ it('splits statements on semicolons and ignores comments', () => {
108
+ const d = parse(
109
+ 'architecture-beta\n%% a comment\nservice a; service b\na:R -- L:b',
110
+ )
111
+ expect(d.services.map((s) => s.id)).toEqual(['a', 'b'])
112
+ })
113
+
114
+ it('converts <br> in titles to newlines', () => {
115
+ const d = body('service a[Line1<br/>Line2]')
116
+ expect(d.services[0]?.title).toBe('Line1\nLine2')
117
+ })
118
+
119
+ describe('errors', () => {
120
+ it.each([
121
+ ['service a\nservice a', /line 3.*duplicate id "a"/s],
122
+ ['group a\nservice a', /line 3.*duplicate id "a"/s],
123
+ ['service a\njunction a', /line 3.*duplicate id "a"/s],
124
+ ['service a in nope', /line 2.*must name a declared group/s],
125
+ ['service a\nservice b in a', /line 3.*must name a declared group/s],
126
+ ['junction j in nope', /line 2.*must name a declared group/s],
127
+ ['group g in g', /line 2.*cannot be inside itself/s],
128
+ ['group a in b\ngroup b in c\ngroup c in a', /nested inside itself/],
129
+ ['service a\na:R -- L:zzz', /line 3.*unknown id "zzz"/s],
130
+ ['service a\nzzz:R -- L:a', /line 3.*unknown id "zzz"/s],
131
+ ['group g\nservice a\na:R -- L:g', /line 4.*not the group "g"/s],
132
+ ['group g\nservice a\ng:R -- L:a', /line 4.*not the group "g"/s],
133
+ ['service a\nservice b\na{group}:R -- L:b', /line 4.*needs "a" to be/s],
134
+ ['service a\nservice b\na:R -- L:b{group}', /line 4.*needs "b" to be/s],
135
+ ['service a\nbogus line', /line 3.*unrecognized statement/s],
136
+ ['service a\nservice b\na:X -- L:b', /line 4.*unrecognized/s],
137
+ ['service a\nservice b\na:R - L:b', /line 4.*unrecognized/s],
138
+ ['service a\nservice b\na R -- L:b', /line 4.*unrecognized/s],
139
+ ['align row a', /line 2.*unrecognized/s],
140
+ ['group', /line 2.*unrecognized/s],
141
+ ])('rejects %j', (b, message) => {
142
+ expect(() => body(b)).toThrow(message)
143
+ })
144
+ })
145
+ })
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseArchitecture, architectureToGraph } from '../index.ts'
4
+
5
+ const lower = (b: string) =>
6
+ architectureToGraph(
7
+ parseArchitecture(splitStatements(`architecture-beta\n${b}`)),
8
+ )
9
+ const two = 'service a\nservice b\n'
10
+
11
+ describe('architectureToGraph', () => {
12
+ it('maps icons to shapes and unknown icons to rectangles', () => {
13
+ const g = lower(
14
+ 'service a(database)\nservice b(disk)\nservice c(cloud)\nservice d(internet)\nservice e(server)\nservice f("logos:aws")\nservice g',
15
+ )
16
+ const shapes = [...g.nodes.values()].map((n) => n.shape)
17
+ expect(shapes).toEqual([
18
+ 'cylinder',
19
+ 'cylinder',
20
+ 'stadium',
21
+ 'circle',
22
+ 'rectangle',
23
+ 'rectangle',
24
+ 'rectangle',
25
+ ])
26
+ })
27
+
28
+ it('nests groups three deep and places nodes in their own group', () => {
29
+ const g = lower(
30
+ 'group a\ngroup b in a\ngroup c in b\nservice s in c\nservice t in a\njunction j in b',
31
+ )
32
+ const a = g.subgraphs[0]!
33
+ expect(g.subgraphs).toHaveLength(1)
34
+ expect(a.nodeIds).toEqual(['t'])
35
+ expect(a.children[0]?.id).toBe('b')
36
+ expect(a.children[0]?.nodeIds).toEqual(['j'])
37
+ expect(a.children[0]?.children[0]).toMatchObject({
38
+ id: 'c',
39
+ nodeIds: ['s'],
40
+ })
41
+ })
42
+
43
+ it('keeps sibling top-level groups as separate roots', () => {
44
+ const g = lower('group a\ngroup b\nservice s in a\nservice t in b')
45
+ expect(g.subgraphs.map((s) => s.id)).toEqual(['a', 'b'])
46
+ })
47
+
48
+ it('draws a junction as an unlabelled filled circle', () => {
49
+ expect(lower('junction j').nodes.get('j')).toEqual({
50
+ id: 'j',
51
+ label: '',
52
+ shape: 'filled-circle',
53
+ })
54
+ })
55
+
56
+ describe('{group} edges', () => {
57
+ it('attaches the source end to the enclosing group', () => {
58
+ const [e] = lower(
59
+ 'group g\nservice a in g\nservice b\na{group}:R -- L:b',
60
+ ).edges
61
+ expect(e).toMatchObject({ source: 'g', target: 'b' })
62
+ })
63
+
64
+ it('attaches the target end to the enclosing group', () => {
65
+ const [e] = lower(
66
+ 'group g\nservice a in g\nservice b\nb:R -- L:a{group}',
67
+ ).edges
68
+ expect(e).toMatchObject({ source: 'b', target: 'g' })
69
+ })
70
+
71
+ it('attaches both ends, and uses the immediate (innermost) group', () => {
72
+ const [e] = lower(
73
+ 'group o\ngroup i in o\nservice a in i\njunction j in o\na{group}:R -- L:j{group}',
74
+ ).edges
75
+ expect(e).toMatchObject({ source: 'i', target: 'o' })
76
+ })
77
+
78
+ it('follows an edge reversal', () => {
79
+ const [e] = lower(
80
+ 'group g\nservice a in g\nservice b\na{group}:L -- R:b',
81
+ ).edges
82
+ expect(e).toMatchObject({ source: 'b', target: 'g' })
83
+ })
84
+ })
85
+
86
+ describe('flow direction', () => {
87
+ it.each([
88
+ ['a:R -- L:b', 'LR'],
89
+ ['a:L -- R:b', 'LR'],
90
+ ['a:B -- T:b', 'TB'],
91
+ ['a:T -- B:b', 'TB'],
92
+ // Tie goes to LR.
93
+ ['a:R -- T:b', 'LR'],
94
+ ['a:R -- L:b\na:R -- B:b\na:T -- B:b', 'LR'],
95
+ ['a:B -- T:b\na:R -- B:b', 'TB'],
96
+ ])('%j -> %s', (edges, dir) => {
97
+ expect(lower(two + edges).direction).toBe(dir)
98
+ })
99
+
100
+ it('defaults to LR with no edges', () => {
101
+ expect(lower('service a').direction).toBe('LR')
102
+ })
103
+ })
104
+
105
+ describe('edge reversal', () => {
106
+ it.each([
107
+ // [edge, source, target, arrowStart, arrowEnd]
108
+ ['a:R -- L:b', 'a', 'b'],
109
+ ['a:L -- R:b', 'b', 'a'],
110
+ ['a:R -- R:b', 'a', 'b'],
111
+ // A back-side target port wins: the edge is not reversed.
112
+ ['a:L -- L:b', 'a', 'b'],
113
+ ])('LR: %s runs %s -> %s', (edge, source, target) => {
114
+ const [e] = lower(two + edge).edges
115
+ expect([e?.source, e?.target]).toEqual([source, target])
116
+ })
117
+
118
+ it.each([
119
+ ['a:B -- T:b', 'a', 'b'],
120
+ ['a:T -- B:b', 'b', 'a'],
121
+ ])('TB: %s runs %s -> %s', (edge, source, target) => {
122
+ const [e] = lower(two + edge).edges
123
+ expect([e?.source, e?.target]).toEqual([source, target])
124
+ })
125
+
126
+ it('does not reverse an edge whose ports are on the other axis', () => {
127
+ // Direction is TB (two vertical edges outweigh one horizontal), and
128
+ // L/R ports say nothing about top/bottom order.
129
+ const g = lower(two + 'a:L -- R:b\na:B -- T:b\na:B -- T:b')
130
+ expect(g.direction).toBe('TB')
131
+ expect([g.edges[0]?.source, g.edges[0]?.target]).toEqual(['a', 'b'])
132
+ })
133
+
134
+ it.each([
135
+ ['a:R -- L:b', false, false],
136
+ ['a:R --> L:b', false, true],
137
+ ['a:R <-- L:b', true, false],
138
+ ['a:R <--> L:b', true, true],
139
+ ])('keeps arrowheads on %s', (edge, start, end) => {
140
+ expect(lower(two + edge).edges[0]).toMatchObject({
141
+ hasArrowStart: start,
142
+ hasArrowEnd: end,
143
+ style: 'solid',
144
+ })
145
+ })
146
+
147
+ it.each([
148
+ // Reversed edges swap which end carries the arrowhead.
149
+ ['a:L --> R:b', true, false],
150
+ ['a:L <-- R:b', false, true],
151
+ ['a:L <--> R:b', true, true],
152
+ ['a:L -- R:b', false, false],
153
+ ])('swaps arrowheads when reversing %s', (edge, start, end) => {
154
+ expect(lower(two + edge).edges[0]).toMatchObject({
155
+ source: 'b',
156
+ target: 'a',
157
+ hasArrowStart: start,
158
+ hasArrowEnd: end,
159
+ })
160
+ })
161
+ })
162
+
163
+ it('carries no flowchart styling', () => {
164
+ const g = lower(two + 'a:R -- L:b')
165
+ expect(g.classDefs.size).toBe(0)
166
+ expect(g.nodeStyles.size).toBe(0)
167
+ expect(g.linkStyles.size).toBe(0)
168
+ })
169
+ })
@@ -0,0 +1,265 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseC4Diagram } from '../index.ts'
4
+
5
+ const parse = (src: string) => parseC4Diagram(splitStatements(src))
6
+
7
+ describe('parseC4Diagram', () => {
8
+ it('parses every variant header', () => {
9
+ const cases = [
10
+ ['C4Context', 'context'],
11
+ ['C4Container', 'container'],
12
+ ['C4Component', 'component'],
13
+ ['C4Dynamic', 'dynamic'],
14
+ ['C4Deployment', 'deployment'],
15
+ ] as const
16
+ for (const [header, variant] of cases) {
17
+ expect(parse(header).variant).toBe(variant)
18
+ }
19
+ })
20
+
21
+ it('rejects a non-C4 header', () => {
22
+ expect(() => parse('flowchart TD')).toThrow(
23
+ /expected a header of C4Context/,
24
+ )
25
+ })
26
+
27
+ it('parses persons, systems and external variants', () => {
28
+ const d = parse(`C4Context
29
+ title Bank
30
+ Person(c, "Customer", "A user")
31
+ Person_Ext(x, "Auditor")
32
+ System(s, "Banking", "Core")
33
+ SystemDb_Ext(db, "Ledger", "Books")
34
+ SystemQueue(q, "Bus")`)
35
+ expect(d.title).toBe('Bank')
36
+ expect(
37
+ d.elements.map((e) => [e.alias, e.kind, e.shape, e.external]),
38
+ ).toEqual([
39
+ ['c', 'person', 'default', false],
40
+ ['x', 'person', 'default', true],
41
+ ['s', 'system', 'default', false],
42
+ ['db', 'system', 'db', true],
43
+ ['q', 'system', 'queue', false],
44
+ ])
45
+ expect(d.elements[0]).toMatchObject({
46
+ label: 'Customer',
47
+ description: 'A user',
48
+ })
49
+ })
50
+
51
+ it('reads technology before description for containers and components', () => {
52
+ const d = parse(`C4Container
53
+ Container(api, "API", "Node.js", "Serves JSON")
54
+ ComponentDb(store, "Store", "Postgres")`)
55
+ expect(d.elements[0]).toMatchObject({
56
+ technology: 'Node.js',
57
+ description: 'Serves JSON',
58
+ })
59
+ expect(d.elements[1]).toMatchObject({
60
+ kind: 'component',
61
+ shape: 'db',
62
+ technology: 'Postgres',
63
+ })
64
+ expect(d.elements[1]!.description).toBeUndefined()
65
+ })
66
+
67
+ it('keeps commas and parentheses inside quoted arguments', () => {
68
+ const d = parse('C4Context\nSystem(s, "A, B (C)", "one, two")')
69
+ expect(d.elements[0]).toMatchObject({
70
+ label: 'A, B (C)',
71
+ description: 'one, two',
72
+ })
73
+ })
74
+
75
+ it('drops $named arguments', () => {
76
+ const d = parse(
77
+ 'C4Context\nSystem(s, "S", "d", $tags="t", $link="https://x.test")',
78
+ )
79
+ expect(d.elements[0]).toMatchObject({ label: 'S', description: 'd' })
80
+ })
81
+
82
+ it('nests boundaries and assigns elements to the innermost one', () => {
83
+ const d = parse(`C4Container
84
+ Enterprise_Boundary(e, "Corp") {
85
+ System_Boundary(sb, "Sys") {
86
+ Container(a, "A", "Go")
87
+ }
88
+ Person(p, "P")
89
+ }
90
+ Container(out, "Out", "Go")`)
91
+ expect(d.boundaries).toHaveLength(1)
92
+ const e = d.boundaries[0]!
93
+ expect(e.elementAliases).toEqual(['p'])
94
+ expect(e.children[0]).toMatchObject({ alias: 'sb', elementAliases: ['a'] })
95
+ expect(d.elements.map((x) => x.alias)).toEqual(['a', 'p', 'out'])
96
+ })
97
+
98
+ it('accepts a brace on its own line and a boundary type', () => {
99
+ const d = parse(`C4Context
100
+ Boundary(b, "B", "region")
101
+ {
102
+ System(s, "S")
103
+ }`)
104
+ expect(d.boundaries[0]).toMatchObject({
105
+ alias: 'b',
106
+ type: 'region',
107
+ elementAliases: ['s'],
108
+ })
109
+ })
110
+
111
+ it('parses deployment nodes as boundaries with type and description', () => {
112
+ const d = parse(`C4Deployment
113
+ Deployment_Node(n, "Server", "Ubuntu", "Prod box") {
114
+ Container(app, "App", "Java")
115
+ }`)
116
+ expect(d.boundaries[0]).toMatchObject({
117
+ type: 'Ubuntu',
118
+ description: 'Prod box',
119
+ elementAliases: ['app'],
120
+ })
121
+ })
122
+
123
+ it('parses relationships, BiRel, directional variants and RelIndex', () => {
124
+ const d = parse(`C4Dynamic
125
+ System(a, "A")
126
+ System(b, "B")
127
+ Rel(a, b, "Calls", "HTTPS")
128
+ BiRel(a, b, "Syncs")
129
+ Rel_Back(b, a, "Replies")
130
+ RelIndex(1, a, b, "First")`)
131
+ expect(d.relationships).toEqual([
132
+ {
133
+ from: 'a',
134
+ to: 'b',
135
+ label: 'Calls',
136
+ technology: 'HTTPS',
137
+ bidirectional: false,
138
+ index: '1',
139
+ },
140
+ { from: 'a', to: 'b', label: 'Syncs', bidirectional: true, index: '2' },
141
+ {
142
+ from: 'b',
143
+ to: 'a',
144
+ label: 'Replies',
145
+ bidirectional: false,
146
+ reversed: true,
147
+ index: '3',
148
+ },
149
+ { from: 'a', to: 'b', label: 'First', bidirectional: false, index: '4' },
150
+ ])
151
+ })
152
+
153
+ it('numbers relationships by position in C4Dynamic only, ignoring the RelIndex argument (as Mermaid does)', () => {
154
+ const src = (head: string) => `${head}
155
+ System(a, "A")
156
+ System(b, "B")
157
+ RelIndex(9, a, b, "First")
158
+ Rel(b, a, "Second")`
159
+ const dyn = parse(src('C4Dynamic')).relationships.map((r) => r.index)
160
+ expect(dyn).toEqual(['1', '2'])
161
+ const ctx = parse(src('C4Context')).relationships.map((r) => r.index)
162
+ expect(ctx).toEqual([undefined, undefined])
163
+ })
164
+
165
+ it("gives every boundary Mermaid's type text", () => {
166
+ const d = parse(`C4Deployment
167
+ Enterprise_Boundary(e, "E") {
168
+ System_Boundary(s, "S") {
169
+ Container_Boundary(c, "C") {
170
+ Boundary(g, "G")
171
+ Boundary(t, "T", "region")
172
+ Node(n, "N")
173
+ Deployment_Node(m, "M", "Ubuntu")
174
+ }
175
+ }
176
+ }`)
177
+ const types = new Map<string, string | undefined>()
178
+ const walk = (bs: typeof d.boundaries): void =>
179
+ bs.forEach((b) => {
180
+ types.set(b.alias, b.type)
181
+ walk(b.children)
182
+ })
183
+ walk(d.boundaries)
184
+ expect(Object.fromEntries(types)).toEqual({
185
+ e: 'ENTERPRISE',
186
+ s: 'SYSTEM',
187
+ c: 'CONTAINER',
188
+ g: 'system',
189
+ t: 'region',
190
+ n: 'node',
191
+ m: 'Ubuntu',
192
+ })
193
+ })
194
+
195
+ it('records Rel_U/D/L/R placement hints and leaves the rest unhinted', () => {
196
+ const d = parse(`C4Context
197
+ System(a, "A")
198
+ System(b, "B")
199
+ Rel_U(a, b, "1")
200
+ Rel_Up(a, b, "2")
201
+ Rel_D(a, b, "3")
202
+ Rel_Down(a, b, "4")
203
+ Rel_L(a, b, "5")
204
+ Rel_Left(a, b, "6")
205
+ Rel_R(a, b, "7")
206
+ Rel_Right(a, b, "8")
207
+ Rel(a, b, "9")
208
+ Rel_Back(a, b, "10")`)
209
+ expect(d.relationships.map((r) => r.layout)).toEqual([
210
+ 'up',
211
+ 'up',
212
+ 'down',
213
+ 'down',
214
+ 'left',
215
+ 'left',
216
+ 'right',
217
+ 'right',
218
+ undefined,
219
+ undefined,
220
+ ])
221
+ })
222
+
223
+ it('silently ignores style and layout macros', () => {
224
+ const d = parse(`C4Context
225
+ System(a, "A")
226
+ UpdateElementStyle(a, $bgColor="red")
227
+ UpdateLayoutConfig($c4ShapeInRow="2")
228
+ SHOW_LEGEND()`)
229
+ expect(d.elements).toHaveLength(1)
230
+ })
231
+
232
+ describe('errors', () => {
233
+ it('reports duplicate aliases with a line number', () => {
234
+ expect(() => parse('C4Context\nSystem(a, "A")\nSystem(a, "B")')).toThrow(
235
+ /line 3: duplicate alias "a"/,
236
+ )
237
+ })
238
+ it('reports unknown macros', () => {
239
+ expect(() => parse('C4Context\nWidget(a, "A")')).toThrow(
240
+ /unknown C4 macro "Widget"/,
241
+ )
242
+ })
243
+ it('reports unrecognized statements', () => {
244
+ expect(() => parse('C4Context\nnonsense here')).toThrow(
245
+ /unrecognized statement/,
246
+ )
247
+ })
248
+ it('reports relationships to undeclared aliases', () => {
249
+ expect(() =>
250
+ parse('C4Context\nSystem(a, "A")\nRel(a, ghost, "x")'),
251
+ ).toThrow(/undeclared alias "ghost"/)
252
+ })
253
+ it('reports unbalanced braces', () => {
254
+ expect(() =>
255
+ parse('C4Context\nBoundary(b, "B") {\nSystem(a, "A")'),
256
+ ).toThrow(/missing its closing/)
257
+ expect(() => parse('C4Context\n}')).toThrow(/unmatched "}"/)
258
+ })
259
+ it('reports unbalanced quotes', () => {
260
+ expect(() => parse('C4Context\nSystem(a, "A)')).toThrow(
261
+ /unbalanced quotes/,
262
+ )
263
+ })
264
+ })
265
+ })