@tldraw/mermaid 4.6.0-internal.c7df3c92455a

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.
Files changed (55) hide show
  1. package/dist-cjs/blueprint.js +17 -0
  2. package/dist-cjs/blueprint.js.map +7 -0
  3. package/dist-cjs/colors.js +173 -0
  4. package/dist-cjs/colors.js.map +7 -0
  5. package/dist-cjs/createMermaidDiagram.js +144 -0
  6. package/dist-cjs/createMermaidDiagram.js.map +7 -0
  7. package/dist-cjs/flowchartDiagram.js +202 -0
  8. package/dist-cjs/flowchartDiagram.js.map +7 -0
  9. package/dist-cjs/index.d.ts +114 -0
  10. package/dist-cjs/index.js +34 -0
  11. package/dist-cjs/index.js.map +7 -0
  12. package/dist-cjs/renderBlueprint.js +314 -0
  13. package/dist-cjs/renderBlueprint.js.map +7 -0
  14. package/dist-cjs/sequenceDiagram.js +686 -0
  15. package/dist-cjs/sequenceDiagram.js.map +7 -0
  16. package/dist-cjs/stateDiagram.js +373 -0
  17. package/dist-cjs/stateDiagram.js.map +7 -0
  18. package/dist-cjs/svgParsing.js +187 -0
  19. package/dist-cjs/svgParsing.js.map +7 -0
  20. package/dist-cjs/utils.js +75 -0
  21. package/dist-cjs/utils.js.map +7 -0
  22. package/dist-esm/blueprint.mjs +1 -0
  23. package/dist-esm/blueprint.mjs.map +7 -0
  24. package/dist-esm/colors.mjs +153 -0
  25. package/dist-esm/colors.mjs.map +7 -0
  26. package/dist-esm/createMermaidDiagram.mjs +114 -0
  27. package/dist-esm/createMermaidDiagram.mjs.map +7 -0
  28. package/dist-esm/flowchartDiagram.mjs +188 -0
  29. package/dist-esm/flowchartDiagram.mjs.map +7 -0
  30. package/dist-esm/index.d.mts +114 -0
  31. package/dist-esm/index.mjs +14 -0
  32. package/dist-esm/index.mjs.map +7 -0
  33. package/dist-esm/renderBlueprint.mjs +298 -0
  34. package/dist-esm/renderBlueprint.mjs.map +7 -0
  35. package/dist-esm/sequenceDiagram.mjs +666 -0
  36. package/dist-esm/sequenceDiagram.mjs.map +7 -0
  37. package/dist-esm/stateDiagram.mjs +359 -0
  38. package/dist-esm/stateDiagram.mjs.map +7 -0
  39. package/dist-esm/svgParsing.mjs +167 -0
  40. package/dist-esm/svgParsing.mjs.map +7 -0
  41. package/dist-esm/utils.mjs +55 -0
  42. package/dist-esm/utils.mjs.map +7 -0
  43. package/package.json +62 -0
  44. package/src/blueprint.ts +75 -0
  45. package/src/colors.ts +215 -0
  46. package/src/createMermaidDiagram.test.ts +31 -0
  47. package/src/createMermaidDiagram.ts +155 -0
  48. package/src/flowchartDiagram.ts +232 -0
  49. package/src/index.ts +18 -0
  50. package/src/mermaidDiagrams.test.ts +880 -0
  51. package/src/renderBlueprint.ts +373 -0
  52. package/src/sequenceDiagram.ts +851 -0
  53. package/src/stateDiagram.ts +477 -0
  54. package/src/svgParsing.ts +240 -0
  55. package/src/utils.ts +73 -0
@@ -0,0 +1,880 @@
1
+ import type { FlowEdge, FlowSubGraph, FlowVertex } from 'mermaid/dist/diagrams/flowchart/types.js'
2
+ import type { Actor, Message } from 'mermaid/dist/diagrams/sequence/types.js'
3
+ import type { StateStmt } from 'mermaid/dist/diagrams/state/stateDb.d.ts'
4
+ import type { DiagramMermaidBlueprint } from './blueprint'
5
+ import { flowchartToBlueprint } from './flowchartDiagram'
6
+ import {
7
+ countSequenceEvents,
8
+ sequenceToBlueprint,
9
+ type ParsedSequenceLayout,
10
+ } from './sequenceDiagram'
11
+ import { stateToBlueprint } from './stateDiagram'
12
+ import type { ParsedCluster, ParsedDiagramLayout, ParsedEdge, ParsedNode } from './svgParsing'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function node(id: string, cx: number, cy: number, w: number, h: number): ParsedNode {
19
+ return { id, center: { x: cx, y: cy }, width: w, height: h }
20
+ }
21
+
22
+ function cluster(id: string, x: number, y: number, w: number, h: number): ParsedCluster {
23
+ return { id, topLeft: { x, y }, width: w, height: h }
24
+ }
25
+
26
+ function edge(start: string, end: string, points: [number, number][]): ParsedEdge {
27
+ return { start, end, points: points.map(([x, y]) => ({ x, y })) }
28
+ }
29
+
30
+ function diagramLayout(
31
+ nodes: ParsedNode[],
32
+ clusters: ParsedCluster[] = [],
33
+ edges: ParsedEdge[] = []
34
+ ): ParsedDiagramLayout {
35
+ return {
36
+ nodes: new Map(nodes.map((n) => [n.id, n])),
37
+ clusters: new Map(clusters.map((c) => [c.id, c])),
38
+ edges,
39
+ }
40
+ }
41
+
42
+ function vertex(
43
+ id: string,
44
+ opts: { text?: string; type?: string; classes?: string[]; styles?: string[] } = {}
45
+ ): [string, FlowVertex] {
46
+ return [
47
+ id,
48
+ {
49
+ id,
50
+ text: opts.text ?? id,
51
+ type: (opts.type ?? 'rect') as FlowVertex['type'],
52
+ classes: opts.classes ?? [],
53
+ styles: opts.styles ?? [],
54
+ } as FlowVertex,
55
+ ]
56
+ }
57
+
58
+ function flowEdge(start: string, end: string, opts: Partial<FlowEdge> = {}): FlowEdge {
59
+ return {
60
+ start,
61
+ end,
62
+ type: opts.type ?? 'arrow_point',
63
+ text: opts.text ?? '',
64
+ stroke: opts.stroke ?? 'normal',
65
+ style: opts.style,
66
+ } as FlowEdge
67
+ }
68
+
69
+ function subGraph(id: string, title: string, nodes: string[]): FlowSubGraph {
70
+ return { id, title, nodes } as FlowSubGraph
71
+ }
72
+
73
+ function findNode(bp: DiagramMermaidBlueprint, id: string) {
74
+ return bp.nodes.find((n) => n.id === id)
75
+ }
76
+
77
+ function findNodeByLabel(bp: DiagramMermaidBlueprint, label: string) {
78
+ return bp.nodes.find((n) => n.label === label)
79
+ }
80
+
81
+ function findEdge(bp: DiagramMermaidBlueprint, from: string, to: string) {
82
+ return bp.edges.find((e) => e.startNodeId === from && e.endNodeId === to)
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Flowchart tests
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe('flowchartToBlueprint', () => {
90
+ it('maps nodes with correct id, label, geo, and positions', () => {
91
+ const layout = diagramLayout([node('A', 100, 50, 80, 40), node('B', 100, 150, 60, 60)])
92
+ const vertices = new Map([
93
+ vertex('A', { text: 'Start', type: 'rect' }),
94
+ vertex('B', { text: 'Is it?', type: 'diamond' }),
95
+ ])
96
+ const edges = [flowEdge('A', 'B')]
97
+
98
+ const bp = flowchartToBlueprint(layout, vertices, edges)
99
+
100
+ expect(bp.nodes).toHaveLength(2)
101
+ const a = findNode(bp, 'A')!
102
+ expect(a.label).toBe('Start')
103
+ expect(a.geo).toBe('rectangle')
104
+ expect(a.x).toBe(100 - 80 / 2)
105
+ expect(a.y).toBe(50 - 40 / 2)
106
+ expect(a.w).toBe(80)
107
+ expect(a.h).toBe(40)
108
+
109
+ const b = findNode(bp, 'B')!
110
+ expect(b.label).toBe('Is it?')
111
+ expect(b.geo).toBe('diamond')
112
+ })
113
+
114
+ it('maps edge labels and arrowhead types', () => {
115
+ const layout = diagramLayout(
116
+ [node('A', 100, 50, 80, 40), node('B', 300, 50, 80, 40)],
117
+ [],
118
+ [
119
+ edge('A', 'B', [
120
+ [140, 50],
121
+ [260, 50],
122
+ ]),
123
+ ]
124
+ )
125
+ const vertices = new Map([vertex('A'), vertex('B')])
126
+ const edges = [flowEdge('A', 'B', { text: 'Yes', type: 'arrow_point' })]
127
+
128
+ const bp = flowchartToBlueprint(layout, vertices, edges)
129
+
130
+ expect(bp.edges).toHaveLength(1)
131
+ expect(bp.edges[0].label).toBe('Yes')
132
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
133
+ expect(bp.edges[0].startNodeId).toBe('A')
134
+ expect(bp.edges[0].endNodeId).toBe('B')
135
+ })
136
+
137
+ it('maps various geo shape types', () => {
138
+ const types: [string, string][] = [
139
+ ['diamond', 'diamond'],
140
+ ['ellipse', 'ellipse'],
141
+ ['circle', 'ellipse'],
142
+ ['hexagon', 'hexagon'],
143
+ ['trapezoid', 'trapezoid'],
144
+ ['lean_right', 'rhombus'],
145
+ ['lean_left', 'rhombus-2'],
146
+ ['rect', 'rectangle'],
147
+ ['round', 'rectangle'],
148
+ ['subroutine', 'rectangle'],
149
+ ]
150
+
151
+ for (const [mermaidType, expectedGeo] of types) {
152
+ const id = `node_${mermaidType}`
153
+ const layout = diagramLayout([node(id, 0, 0, 80, 40)])
154
+ const vertices = new Map([vertex(id, { type: mermaidType })])
155
+
156
+ const bp = flowchartToBlueprint(layout, vertices, [])
157
+ expect(findNode(bp, id)!.geo).toBe(expectedGeo)
158
+ }
159
+ })
160
+
161
+ it('handles circle type with equal width/height', () => {
162
+ const layout = diagramLayout([node('C', 100, 100, 60, 80)])
163
+ const vertices = new Map([vertex('C', { type: 'circle' })])
164
+
165
+ const bp = flowchartToBlueprint(layout, vertices, [])
166
+
167
+ const c = findNode(bp, 'C')!
168
+ expect(c.w).toBe(c.h)
169
+ expect(c.w).toBe(80)
170
+ })
171
+
172
+ it('creates subgraph frames with correct parent-child relationships', () => {
173
+ const layout = diagramLayout(
174
+ [node('a1', 50, 80, 60, 30), node('a2', 150, 80, 60, 30), node('b1', 300, 80, 60, 30)],
175
+ [cluster('sg1', 10, 40, 200, 100), cluster('sg2', 260, 40, 100, 100)]
176
+ )
177
+ const vertices = new Map([vertex('a1'), vertex('a2'), vertex('b1')])
178
+ const subGraphs = [
179
+ subGraph('sg1', 'Group One', ['a1', 'a2']),
180
+ subGraph('sg2', 'Group Two', ['b1']),
181
+ ]
182
+
183
+ const bp = flowchartToBlueprint(layout, vertices, [], subGraphs)
184
+
185
+ const sg1 = findNode(bp, 'sg1')!
186
+ expect(sg1.label).toBe('Group One')
187
+ expect(sg1.fill).toBe('semi')
188
+ expect(sg1.verticalAlign).toBe('start')
189
+
190
+ expect(findNode(bp, 'a1')!.parentId).toBe('sg1')
191
+ expect(findNode(bp, 'a2')!.parentId).toBe('sg1')
192
+ expect(findNode(bp, 'b1')!.parentId).toBe('sg2')
193
+ })
194
+
195
+ it('computes bend for curved edges', () => {
196
+ const layout = diagramLayout(
197
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
198
+ [],
199
+ [
200
+ edge('A', 'B', [
201
+ [20, 0],
202
+ [100, -50],
203
+ [180, 0],
204
+ ]),
205
+ ]
206
+ )
207
+ const vertices = new Map([vertex('A'), vertex('B')])
208
+ const edges = [flowEdge('A', 'B')]
209
+
210
+ const bp = flowchartToBlueprint(layout, vertices, edges)
211
+ expect(bp.edges[0].bend).not.toBe(0)
212
+ })
213
+
214
+ it('handles self-loop edges with non-zero bend', () => {
215
+ const layout = diagramLayout(
216
+ [node('D', 100, 100, 80, 40)],
217
+ [],
218
+ [
219
+ edge('D', 'D', [
220
+ [100, 80],
221
+ [140, 40],
222
+ [100, 120],
223
+ ]),
224
+ ]
225
+ )
226
+ const vertices = new Map([vertex('D')])
227
+ const edges = [flowEdge('D', 'D')]
228
+
229
+ const bp = flowchartToBlueprint(layout, vertices, edges)
230
+ expect(bp.edges[0].startNodeId).toBe('D')
231
+ expect(bp.edges[0].endNodeId).toBe('D')
232
+ expect(bp.edges[0].bend).not.toBe(0)
233
+ })
234
+
235
+ it('filters out edges referencing missing nodes', () => {
236
+ const layout = diagramLayout([node('A', 0, 0, 40, 40)])
237
+ const vertices = new Map([vertex('A')])
238
+ const edges = [flowEdge('A', 'MISSING')]
239
+
240
+ const bp = flowchartToBlueprint(layout, vertices, edges)
241
+ expect(bp.edges).toHaveLength(0)
242
+ })
243
+
244
+ it('maps classDef fill colors', () => {
245
+ const layout = diagramLayout([node('A', 50, 50, 80, 40), node('B', 200, 50, 80, 40)])
246
+ const vertices = new Map([
247
+ vertex('A', { text: 'Start', classes: ['green'] }),
248
+ vertex('B', { text: 'End', classes: ['blue'] }),
249
+ ])
250
+ const classDefs = new Map<string, { styles: string[] }>([
251
+ ['green', { styles: ['fill:#00ff00'] }],
252
+ ['blue', { styles: ['fill:#0000ff'] }],
253
+ ])
254
+
255
+ const bp = flowchartToBlueprint(layout, vertices, [], undefined, classDefs as any)
256
+
257
+ expect(findNodeByLabel(bp, 'Start')!.fill).toBe('solid')
258
+ expect(findNodeByLabel(bp, 'Start')!.color).toBe('light-green')
259
+ expect(findNodeByLabel(bp, 'End')!.fill).toBe('solid')
260
+ expect(findNodeByLabel(bp, 'End')!.color).toBe('blue')
261
+ })
262
+
263
+ it('maps inline style fill/stroke colors', () => {
264
+ const layout = diagramLayout([node('A', 50, 50, 80, 40)])
265
+ const vertices = new Map([
266
+ vertex('A', { text: 'Styled', styles: ['fill:#ffebee', 'stroke:#c62828'] }),
267
+ ])
268
+
269
+ const bp = flowchartToBlueprint(layout, vertices, [])
270
+
271
+ const a = findNodeByLabel(bp, 'Styled')!
272
+ expect(a.fill).toBe('solid')
273
+ expect(a.color).toBe('red')
274
+ })
275
+
276
+ it('maps edge dash styles from stroke-dasharray', () => {
277
+ const layout = diagramLayout(
278
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
279
+ [],
280
+ [
281
+ edge('A', 'B', [
282
+ [20, 0],
283
+ [180, 0],
284
+ ]),
285
+ ]
286
+ )
287
+ const vertices = new Map([vertex('A'), vertex('B')])
288
+ const edges = [flowEdge('A', 'B', { style: ['stroke-dasharray: 4 3'] })]
289
+
290
+ const bp = flowchartToBlueprint(layout, vertices, edges)
291
+ expect(bp.edges[0].dash).toBe('dashed')
292
+ })
293
+
294
+ it('maps dotted stroke to dotted dash', () => {
295
+ const layout = diagramLayout(
296
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
297
+ [],
298
+ [
299
+ edge('A', 'B', [
300
+ [20, 0],
301
+ [180, 0],
302
+ ]),
303
+ ]
304
+ )
305
+ const vertices = new Map([vertex('A'), vertex('B')])
306
+ const edges = [flowEdge('A', 'B', { stroke: 'dotted' })]
307
+
308
+ const bp = flowchartToBlueprint(layout, vertices, edges)
309
+ expect(bp.edges[0].dash).toBe('dotted')
310
+ })
311
+
312
+ it('maps double_arrow edge type to bidirectional arrowheads', () => {
313
+ const layout = diagramLayout(
314
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
315
+ [],
316
+ [
317
+ edge('A', 'B', [
318
+ [20, 0],
319
+ [180, 0],
320
+ ]),
321
+ ]
322
+ )
323
+ const vertices = new Map([vertex('A'), vertex('B')])
324
+ const edges = [flowEdge('A', 'B', { type: 'double_arrow_point' })]
325
+
326
+ const bp = flowchartToBlueprint(layout, vertices, edges)
327
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
328
+ expect(bp.edges[0].arrowheadStart).toBe('arrow')
329
+ })
330
+ })
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // State diagram tests
334
+ // ---------------------------------------------------------------------------
335
+
336
+ describe('stateToBlueprint', () => {
337
+ function stateStmt(id: string, opts: Partial<StateStmt> = {}): [string, StateStmt] {
338
+ return [
339
+ id,
340
+ {
341
+ id,
342
+ type: opts.type ?? 'default',
343
+ description: opts.description ?? '',
344
+ descriptions: opts.descriptions,
345
+ doc: opts.doc,
346
+ note: (opts as any).note,
347
+ classes: opts.classes,
348
+ stmt: 'state',
349
+ } as StateStmt,
350
+ ]
351
+ }
352
+
353
+ it('maps leaf states with correct geo and labels', () => {
354
+ const layout = diagramLayout([
355
+ node('Still', 100, 100, 80, 40),
356
+ node('Moving', 250, 100, 80, 40),
357
+ ])
358
+ const states = new Map([stateStmt('Still'), stateStmt('Moving')])
359
+ const relations = [{ id1: 'Still', id2: 'Moving', relationTitle: 'go' }]
360
+
361
+ const bp = stateToBlueprint(layout, states, relations)
362
+
363
+ expect(findNode(bp, 'Still')!.label).toBe('Still')
364
+ expect(findNode(bp, 'Still')!.geo).toBe('rectangle')
365
+ expect(findNode(bp, 'Moving')!.label).toBe('Moving')
366
+ expect(findEdge(bp, 'Still', 'Moving')!.label).toBe('go')
367
+ })
368
+
369
+ it('maps start/end pseudo-states to ellipses', () => {
370
+ const layout = diagramLayout([
371
+ node('root_start', 50, 50, 20, 20),
372
+ node('root_end', 300, 50, 20, 20),
373
+ node('Idle', 175, 50, 80, 40),
374
+ ])
375
+ const states = new Map([
376
+ stateStmt('root_start', { type: 'start' }),
377
+ stateStmt('root_end', { type: 'end' }),
378
+ stateStmt('Idle'),
379
+ ])
380
+ const relations = [
381
+ { id1: 'root_start', id2: 'Idle' },
382
+ { id1: 'Idle', id2: 'root_end' },
383
+ ]
384
+
385
+ const bp = stateToBlueprint(layout, states, relations)
386
+
387
+ const start = findNode(bp, 'root_start')!
388
+ expect(start.geo).toBe('ellipse')
389
+ expect(start.fill).toBe('solid')
390
+
391
+ const end = findNode(bp, 'root_end')!
392
+ expect(end.geo).toBe('ellipse')
393
+ expect(end.fill).toBe('none')
394
+
395
+ const endInner = findNode(bp, 'root_end__inner')!
396
+ expect(endInner.geo).toBe('ellipse')
397
+ expect(endInner.fill).toBe('solid')
398
+ })
399
+
400
+ it('maps choice states to diamonds', () => {
401
+ const layout = diagramLayout([node('D', 100, 100, 40, 40)])
402
+ const states = new Map([stateStmt('D', { type: 'choice' })])
403
+
404
+ const bp = stateToBlueprint(layout, states, [])
405
+ expect(findNode(bp, 'D')!.geo).toBe('diamond')
406
+ })
407
+
408
+ it('maps fork/join states to wide bars', () => {
409
+ const layout = diagramLayout([node('F', 100, 100, 20, 20)])
410
+ const states = new Map([stateStmt('F', { type: 'fork' })])
411
+
412
+ const bp = stateToBlueprint(layout, states, [])
413
+
414
+ const f = findNode(bp, 'F')!
415
+ expect(f.geo).toBe('rectangle')
416
+ expect(f.fill).toBe('solid')
417
+ expect(f.w).toBeGreaterThan(20)
418
+ })
419
+
420
+ it('creates compound state frames from clusters', () => {
421
+ const layout = diagramLayout(
422
+ [node('fA', 80, 100, 60, 30)],
423
+ [cluster('First', 20, 40, 200, 120)]
424
+ )
425
+ const states = new Map([
426
+ stateStmt('First', {
427
+ description: 'First',
428
+ doc: [
429
+ { stmt: 'state', id: 'fA', type: 'default', description: '' } as unknown as StateStmt,
430
+ ],
431
+ }),
432
+ stateStmt('fA'),
433
+ ])
434
+
435
+ const bp = stateToBlueprint(layout, states, [])
436
+
437
+ const first = findNode(bp, 'First')!
438
+ expect(first.fill).toBe('semi')
439
+ expect(first.label).toBe('First')
440
+ expect(findNode(bp, 'fA')!.parentId).toBe('First')
441
+ })
442
+
443
+ it('creates note nodes with yellow color', () => {
444
+ const layout = diagramLayout([
445
+ node('Idle', 100, 100, 80, 40),
446
+ node('Idle----note', 250, 100, 100, 40),
447
+ ])
448
+ const states = new Map([stateStmt('Idle', { note: { text: 'Important note' } } as any)])
449
+
450
+ const bp = stateToBlueprint(layout, states, [])
451
+
452
+ const noteNode = findNode(bp, 'Idle----note')!
453
+ expect(noteNode.label).toBe('Important note')
454
+ expect(noteNode.color).toBe('yellow')
455
+ expect(noteNode.fill).toBe('solid')
456
+
457
+ const noteEdge = findEdge(bp, 'Idle', 'Idle----note')!
458
+ expect(noteEdge.dash).toBe('dotted')
459
+ expect(noteEdge.arrowheadEnd).toBe('none')
460
+ })
461
+
462
+ it('maps classDef fill colors', () => {
463
+ const layout = diagramLayout([node('Idle', 100, 100, 80, 40)])
464
+ const states = new Map([stateStmt('Idle', { classes: ['green'] })])
465
+ const classDefs = new Map([['green', { styles: ['fill:#00ff00'] }]])
466
+
467
+ const bp = stateToBlueprint(layout, states, [], classDefs as any)
468
+
469
+ expect(findNode(bp, 'Idle')!.fill).toBe('solid')
470
+ expect(findNode(bp, 'Idle')!.color).toBe('light-green')
471
+ })
472
+
473
+ it('computes edge bend from SVG edge waypoints', () => {
474
+ const layout = diagramLayout(
475
+ [node('A', 0, 0, 40, 40), node('B', 200, 0, 40, 40)],
476
+ [],
477
+ [
478
+ edge('', '', [
479
+ [0, 0],
480
+ [100, -40],
481
+ [200, 0],
482
+ ]),
483
+ ]
484
+ )
485
+ const states = new Map([stateStmt('A'), stateStmt('B')])
486
+ const relations = [{ id1: 'A', id2: 'B' }]
487
+
488
+ const bp = stateToBlueprint(layout, states, relations)
489
+ expect(findEdge(bp, 'A', 'B')!.bend).not.toBe(0)
490
+ })
491
+
492
+ it('filters out edges referencing missing nodes', () => {
493
+ const layout = diagramLayout([node('A', 0, 0, 40, 40)])
494
+ const states = new Map([stateStmt('A')])
495
+ const relations = [{ id1: 'A', id2: 'MISSING' }]
496
+
497
+ const bp = stateToBlueprint(layout, states, relations)
498
+ expect(bp.edges).toHaveLength(0)
499
+ })
500
+
501
+ it('uses auto-detected start/end types from ID suffixes', () => {
502
+ const layout = diagramLayout([
503
+ node('root_start', 50, 50, 20, 20),
504
+ node('root_end2', 300, 50, 20, 20),
505
+ ])
506
+ const states = new Map([
507
+ stateStmt('root_start', { type: 'default' }),
508
+ stateStmt('root_end2', { type: 'default' }),
509
+ ])
510
+
511
+ const bp = stateToBlueprint(layout, states, [])
512
+
513
+ expect(findNode(bp, 'root_start')!.geo).toBe('ellipse')
514
+ expect(findNode(bp, 'root_end2')!.geo).toBe('ellipse')
515
+ })
516
+ })
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // Sequence diagram tests
520
+ // ---------------------------------------------------------------------------
521
+
522
+ describe('sequenceToBlueprint', () => {
523
+ const LINETYPE = {
524
+ SOLID: 0,
525
+ DOTTED: 1,
526
+ NOTE: 2,
527
+ SOLID_CROSS: 3,
528
+ DOTTED_CROSS: 4,
529
+ SOLID_OPEN: 5,
530
+ DOTTED_OPEN: 6,
531
+ LOOP_START: 10,
532
+ LOOP_END: 11,
533
+ ALT_START: 12,
534
+ ALT_ELSE: 13,
535
+ ALT_END: 14,
536
+ OPT_START: 15,
537
+ OPT_END: 16,
538
+ ACTIVE_START: 17,
539
+ ACTIVE_END: 18,
540
+ PAR_START: 19,
541
+ PAR_AND: 20,
542
+ PAR_END: 21,
543
+ AUTONUMBER: 26,
544
+ CRITICAL_START: 27,
545
+ CRITICAL_OPTION: 28,
546
+ CRITICAL_END: 29,
547
+ BREAK_START: 30,
548
+ BREAK_END: 31,
549
+ BIDIRECTIONAL_SOLID: 33,
550
+ BIDIRECTIONAL_DOTTED: 34,
551
+ } as const
552
+
553
+ const PLACEMENT = {
554
+ LEFTOF: 0,
555
+ RIGHTOF: 1,
556
+ OVER: 2,
557
+ } as const
558
+
559
+ function actor(key: string, opts: Partial<Actor> = {}): [string, Actor] {
560
+ return [
561
+ key,
562
+ {
563
+ name: opts.name ?? key,
564
+ description: opts.description ?? key,
565
+ type: opts.type ?? 'participant',
566
+ } as Actor,
567
+ ]
568
+ }
569
+
570
+ function msg(type: number, from: string, to: string, message = ''): Message {
571
+ return { type, from, to, message } as Message
572
+ }
573
+
574
+ function noteMsg(from: string, message: string, placement: number, to?: string): Message {
575
+ return { type: LINETYPE.NOTE, from, to: to ?? from, message, placement } as unknown as Message
576
+ }
577
+
578
+ function twoActorLayout(): ParsedSequenceLayout {
579
+ return {
580
+ actorLayouts: [
581
+ { x: -150, y: -200, w: 100, h: 50, bottomY: 200 },
582
+ { x: 150, y: -200, w: 100, h: 50, bottomY: 200 },
583
+ ],
584
+ noteRects: [],
585
+ }
586
+ }
587
+
588
+ function threeActorLayout(
589
+ noteRects: { x: number; y: number; w: number; h: number }[] = []
590
+ ): ParsedSequenceLayout {
591
+ return {
592
+ actorLayouts: [
593
+ { x: -300, y: -200, w: 100, h: 50, bottomY: 200 },
594
+ { x: 0, y: -200, w: 100, h: 50, bottomY: 200 },
595
+ { x: 300, y: -200, w: 100, h: 50, bottomY: 200 },
596
+ ],
597
+ noteRects,
598
+ }
599
+ }
600
+
601
+ it('creates top/bottom actor boxes and lifelines', () => {
602
+ const layout = twoActorLayout()
603
+ const actors = new Map([actor('Alice'), actor('Bob')])
604
+ const messages = [msg(LINETYPE.SOLID, 'Alice', 'Bob', 'Hello')]
605
+
606
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'Bob'], messages)
607
+
608
+ expect(findNode(bp, 'actor-top-Alice')).toBeDefined()
609
+ expect(findNode(bp, 'actor-top-Bob')).toBeDefined()
610
+ expect(findNode(bp, 'actor-bottom-Alice')).toBeDefined()
611
+ expect(findNode(bp, 'actor-bottom-Bob')).toBeDefined()
612
+
613
+ expect(bp.lines!.find((l) => l.id === 'lifeline-Alice')).toBeDefined()
614
+ expect(bp.lines!.find((l) => l.id === 'lifeline-Bob')).toBeDefined()
615
+ })
616
+
617
+ it('creates groups for actor elements', () => {
618
+ const layout = twoActorLayout()
619
+ const actors = new Map([actor('Alice'), actor('Bob')])
620
+ const messages = [msg(LINETYPE.SOLID, 'Alice', 'Bob', 'Hi')]
621
+
622
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'Bob'], messages)
623
+
624
+ expect(bp.groups).toEqual([
625
+ ['actor-top-Alice', 'lifeline-Alice', 'actor-bottom-Alice'],
626
+ ['actor-top-Bob', 'lifeline-Bob', 'actor-bottom-Bob'],
627
+ ])
628
+ })
629
+
630
+ it('creates signal edges with correct labels and dash styles', () => {
631
+ const layout = twoActorLayout()
632
+ const actors = new Map([actor('Alice'), actor('John')])
633
+ const messages = [
634
+ msg(LINETYPE.SOLID, 'Alice', 'John', 'Hello John, how are you?'),
635
+ msg(LINETYPE.DOTTED, 'John', 'Alice', 'Great!'),
636
+ msg(LINETYPE.SOLID_OPEN, 'Alice', 'John', 'See you later!'),
637
+ ]
638
+
639
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'John'], messages)
640
+
641
+ expect(bp.edges).toHaveLength(3)
642
+ expect(bp.edges[0].label).toBe('Hello John, how are you?')
643
+ expect(bp.edges[0].dash).toBe('solid')
644
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
645
+
646
+ expect(bp.edges[1].label).toBe('Great!')
647
+ expect(bp.edges[1].dash).toBe('dotted')
648
+
649
+ expect(bp.edges[2].label).toBe('See you later!')
650
+ expect(bp.edges[2].arrowheadEnd).toBe('none')
651
+ })
652
+
653
+ it('maps arrow type variations correctly', () => {
654
+ const layout = twoActorLayout()
655
+ const actors = new Map([actor('A'), actor('B')])
656
+ const messages = [
657
+ msg(LINETYPE.SOLID_CROSS, 'A', 'B', 'cross'),
658
+ msg(LINETYPE.DOTTED_CROSS, 'B', 'A', 'dotted cross'),
659
+ msg(LINETYPE.DOTTED_OPEN, 'A', 'B', 'dotted open'),
660
+ ]
661
+
662
+ const bp = sequenceToBlueprint(layout, actors, ['A', 'B'], messages)
663
+
664
+ expect(bp.edges[0].arrowheadEnd).toBe('bar')
665
+ expect(bp.edges[0].dash).toBe('solid')
666
+ expect(bp.edges[1].arrowheadEnd).toBe('bar')
667
+ expect(bp.edges[1].dash).toBe('dotted')
668
+ expect(bp.edges[2].arrowheadEnd).toBe('none')
669
+ expect(bp.edges[2].dash).toBe('dotted')
670
+ })
671
+
672
+ it('creates self-message with non-zero bend', () => {
673
+ const layout = twoActorLayout()
674
+ const actors = new Map([actor('W')])
675
+ const messages = [msg(LINETYPE.SOLID, 'W', 'W', 'execute()')]
676
+
677
+ const bp = sequenceToBlueprint(layout, actors, ['W'], messages)
678
+
679
+ const selfEdge = bp.edges.find(
680
+ (e) => e.startNodeId === 'lifeline-W' && e.endNodeId === 'lifeline-W'
681
+ )
682
+ expect(selfEdge).toBeDefined()
683
+ expect(selfEdge!.label).toBe('execute()')
684
+ expect(selfEdge!.bend).not.toBe(0)
685
+ })
686
+
687
+ it('creates loop fragments', () => {
688
+ const layout = threeActorLayout()
689
+ const actors = new Map([actor('P'), actor('Q'), actor('W')])
690
+ const messages = [
691
+ msg(LINETYPE.SOLID, 'P', 'Q', 'enqueue'),
692
+ { type: LINETYPE.LOOP_START, message: 'poll' } as unknown as Message,
693
+ msg(LINETYPE.SOLID, 'W', 'Q', 'dequeue'),
694
+ msg(LINETYPE.DOTTED, 'Q', 'W', 'job?'),
695
+ { type: LINETYPE.LOOP_END } as unknown as Message,
696
+ ]
697
+
698
+ const bp = sequenceToBlueprint(layout, actors, ['P', 'Q', 'W'], messages)
699
+
700
+ const fragment = findNode(bp, 'fragment-0')
701
+ expect(fragment).toBeDefined()
702
+ expect(fragment!.label).toContain('loop')
703
+ expect(fragment!.label).toContain('poll')
704
+ })
705
+
706
+ it('creates alt/else fragments with separator lines and section labels', () => {
707
+ const layout = twoActorLayout()
708
+ const actors = new Map([actor('User'), actor('App')])
709
+ const messages = [
710
+ msg(LINETYPE.SOLID, 'User', 'App', 'Sign in'),
711
+ { type: LINETYPE.ALT_START, message: 'Credentials valid' } as unknown as Message,
712
+ msg(LINETYPE.DOTTED, 'App', 'User', 'Redirect to dashboard'),
713
+ { type: LINETYPE.ALT_ELSE, message: 'Credentials invalid' } as unknown as Message,
714
+ msg(LINETYPE.DOTTED, 'App', 'User', 'Show error'),
715
+ { type: LINETYPE.ALT_END } as unknown as Message,
716
+ ]
717
+
718
+ const bp = sequenceToBlueprint(layout, actors, ['User', 'App'], messages)
719
+
720
+ const fragment = findNode(bp, 'fragment-0')
721
+ expect(fragment).toBeDefined()
722
+ expect(fragment!.label).toBe('alt [Credentials valid]')
723
+
724
+ const sepLine = bp.lines!.find((l) => l.id === 'fragment-0-sep-1')
725
+ expect(sepLine).toBeDefined()
726
+ expect(sepLine!.dash).toBe('dashed')
727
+ expect(sepLine!.endY).toBe(0)
728
+ expect(sepLine!.endX).toBeGreaterThan(0)
729
+
730
+ const sectionLabel = findNode(bp, 'fragment-0-section-1')
731
+ expect(sectionLabel).toBeDefined()
732
+ expect(sectionLabel!.label).toBe('[Credentials invalid]')
733
+ })
734
+
735
+ it('creates note nodes with yellow color and correct labels', () => {
736
+ const noteRect = { x: 10, y: 50, w: 120, h: 40 }
737
+ const layout: ParsedSequenceLayout = {
738
+ actorLayouts: [
739
+ { x: -150, y: -200, w: 100, h: 50, bottomY: 200 },
740
+ { x: 150, y: -200, w: 100, h: 50, bottomY: 200 },
741
+ ],
742
+ noteRects: [noteRect],
743
+ }
744
+ const actors = new Map([actor('Alice'), actor('John')])
745
+ const messages = [
746
+ msg(LINETYPE.SOLID, 'Alice', 'John', 'Hello'),
747
+ noteMsg('Alice', 'Alice thinks', PLACEMENT.RIGHTOF),
748
+ ]
749
+
750
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'John'], messages)
751
+
752
+ const note = bp.nodes.find((n) => n.id.startsWith('note-'))
753
+ expect(note).toBeDefined()
754
+ expect(note!.label).toBe('Alice thinks')
755
+ expect(note!.color).toBe('yellow')
756
+ expect(note!.fill).toBe('solid')
757
+ expect(note!.geo).toBe('rectangle')
758
+ })
759
+
760
+ it('creates activation boxes', () => {
761
+ const layout = twoActorLayout()
762
+ const actors = new Map([actor('Alice'), actor('John')])
763
+ const messages = [
764
+ msg(LINETYPE.SOLID, 'Alice', 'John', 'Hello'),
765
+ { type: LINETYPE.ACTIVE_START, from: 'John' } as unknown as Message,
766
+ msg(LINETYPE.DOTTED, 'John', 'Alice', 'Reply'),
767
+ { type: LINETYPE.ACTIVE_END, from: 'John' } as unknown as Message,
768
+ ]
769
+
770
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'John'], messages)
771
+
772
+ const activations = bp.nodes.filter((n) => n.id.startsWith('activation-'))
773
+ expect(activations).toHaveLength(1)
774
+ expect(activations[0].fill).toBe('solid')
775
+ expect(activations[0].color).toBe('light-violet')
776
+ expect(activations[0].w).toBe(20)
777
+ expect(activations[0].h).toBeGreaterThan(0)
778
+ })
779
+
780
+ it('adds autonumber decorations', () => {
781
+ const layout = twoActorLayout()
782
+ const actors = new Map([actor('Alice'), actor('Bob')])
783
+ const messages = [
784
+ { type: LINETYPE.AUTONUMBER } as unknown as Message,
785
+ msg(LINETYPE.SOLID, 'Alice', 'Bob', 'Hello'),
786
+ msg(LINETYPE.DOTTED, 'Bob', 'Alice', 'Hi'),
787
+ msg(LINETYPE.SOLID, 'Alice', 'Bob', 'How are you?'),
788
+ ]
789
+
790
+ const bp = sequenceToBlueprint(layout, actors, ['Alice', 'Bob'], messages)
791
+
792
+ expect(bp.edges).toHaveLength(3)
793
+ expect(bp.edges[0].decoration).toEqual({ type: 'autonumber', value: '1' })
794
+ expect(bp.edges[1].decoration).toEqual({ type: 'autonumber', value: '2' })
795
+ expect(bp.edges[2].decoration).toEqual({ type: 'autonumber', value: '3' })
796
+ })
797
+
798
+ it('maps bidirectional arrows', () => {
799
+ const layout = twoActorLayout()
800
+ const actors = new Map([actor('A'), actor('B')])
801
+ const messages = [msg(LINETYPE.BIDIRECTIONAL_SOLID, 'A', 'B', 'sync')]
802
+
803
+ const bp = sequenceToBlueprint(layout, actors, ['A', 'B'], messages)
804
+
805
+ expect(bp.edges[0].arrowheadEnd).toBe('arrow')
806
+ expect(bp.edges[0].arrowheadStart).toBe('arrow')
807
+ })
808
+
809
+ it('handles created actors with late-appearing top box', () => {
810
+ const layout = threeActorLayout()
811
+ const actors = new Map([actor('User'), actor('App'), actor('JobRunner')])
812
+ const createdActors = new Map([['JobRunner', 1]])
813
+ const messages = [
814
+ msg(LINETYPE.SOLID, 'User', 'App', 'Start report'),
815
+ msg(LINETYPE.SOLID, 'App', 'JobRunner', 'Spawn job'),
816
+ msg(LINETYPE.DOTTED, 'JobRunner', 'App', 'Job started'),
817
+ ]
818
+
819
+ const bp = sequenceToBlueprint(
820
+ layout,
821
+ actors,
822
+ ['User', 'App', 'JobRunner'],
823
+ messages,
824
+ createdActors
825
+ )
826
+
827
+ const jobRunnerTop = findNode(bp, 'actor-top-JobRunner')!
828
+ expect(jobRunnerTop).toBeDefined()
829
+ const userTop = findNode(bp, 'actor-top-User')!
830
+ expect(jobRunnerTop.y).toBeGreaterThan(userTop.y)
831
+
832
+ const creationEdge = bp.edges.find((e) => e.label === 'Spawn job')!
833
+ expect(creationEdge.endNodeId).toBe('actor-top-JobRunner')
834
+ })
835
+
836
+ it('maps actor types to correct geo', () => {
837
+ const layout: ParsedSequenceLayout = {
838
+ actorLayouts: [{ x: 0, y: -200, w: 100, h: 50, bottomY: 200 }],
839
+ noteRects: [],
840
+ }
841
+ const actors = new Map([actor('User', { type: 'actor' })])
842
+ const messages = [msg(LINETYPE.SOLID, 'User', 'User', 'think')]
843
+
844
+ const bp = sequenceToBlueprint(layout, actors, ['User'], messages)
845
+
846
+ expect(findNode(bp, 'actor-top-User')!.geo).toBe('ellipse')
847
+ })
848
+ })
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // countSequenceEvents tests
852
+ // ---------------------------------------------------------------------------
853
+
854
+ describe('countSequenceEvents', () => {
855
+ it('counts only signal and note messages', () => {
856
+ const messages = [
857
+ { type: 0, from: 'A', to: 'B', message: 'Hello' },
858
+ { type: 1, from: 'B', to: 'A', message: 'Hi' },
859
+ { type: 10, message: 'loop' },
860
+ { type: 11 },
861
+ { type: 2, from: 'A', to: 'A', message: 'note' },
862
+ ] as unknown as Message[]
863
+
864
+ expect(countSequenceEvents(messages)).toBe(3)
865
+ })
866
+
867
+ it('skips autonumber, fragment, and activation control messages', () => {
868
+ const messages = [
869
+ { type: 26 },
870
+ { type: 12, message: 'alt' },
871
+ { type: 13, message: 'else' },
872
+ { type: 14 },
873
+ { type: 17, from: 'A' },
874
+ { type: 18, from: 'A' },
875
+ { type: 0, from: 'A', to: 'B', message: 'real' },
876
+ ] as unknown as Message[]
877
+
878
+ expect(countSequenceEvents(messages)).toBe(1)
879
+ })
880
+ })