@pyreon/flow 0.5.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/LICENSE +21 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/chunk-C8JhGJ3N.js +33 -0
- package/lib/elk.bundled-B9dPTHTZ.js +88591 -0
- package/lib/elk.bundled-B9dPTHTZ.js.map +1 -0
- package/lib/index.js +2526 -0
- package/lib/index.js.map +1 -0
- package/lib/types/chunk.d.ts +2 -0
- package/lib/types/elk.bundled.d.ts +7 -0
- package/lib/types/elk.bundled.d.ts.map +1 -0
- package/lib/types/index.d.ts +2342 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +708 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +55 -0
- package/src/components/background.tsx +128 -0
- package/src/components/controls.tsx +158 -0
- package/src/components/flow-component.tsx +918 -0
- package/src/components/handle.tsx +42 -0
- package/src/components/minimap.tsx +136 -0
- package/src/components/node-resizer.tsx +169 -0
- package/src/components/node-toolbar.tsx +74 -0
- package/src/components/panel.tsx +33 -0
- package/src/edges.ts +369 -0
- package/src/flow.ts +1141 -0
- package/src/index.ts +83 -0
- package/src/layout.ts +152 -0
- package/src/styles.ts +115 -0
- package/src/tests/flow-advanced.test.ts +802 -0
- package/src/tests/flow.test.ts +1284 -0
- package/src/types.ts +483 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
import { effect } from '@pyreon/reactivity'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
getBezierPath,
|
|
5
|
+
getEdgePath,
|
|
6
|
+
getHandlePosition,
|
|
7
|
+
getSmoothStepPath,
|
|
8
|
+
getStepPath,
|
|
9
|
+
getStraightPath,
|
|
10
|
+
getWaypointPath,
|
|
11
|
+
} from '../edges'
|
|
12
|
+
import { createFlow } from '../flow'
|
|
13
|
+
import { Position } from '../types'
|
|
14
|
+
|
|
15
|
+
// ─── Edge path math ──────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
describe('edge paths', () => {
|
|
18
|
+
describe('getBezierPath', () => {
|
|
19
|
+
it('generates a valid SVG path', () => {
|
|
20
|
+
const result = getBezierPath({
|
|
21
|
+
sourceX: 0,
|
|
22
|
+
sourceY: 0,
|
|
23
|
+
targetX: 200,
|
|
24
|
+
targetY: 100,
|
|
25
|
+
})
|
|
26
|
+
expect(result.path).toMatch(/^M/)
|
|
27
|
+
expect(result.path).toContain('C')
|
|
28
|
+
expect(result.labelX).toBe(100)
|
|
29
|
+
expect(result.labelY).toBe(50)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('respects source/target positions', () => {
|
|
33
|
+
const right = getBezierPath({
|
|
34
|
+
sourceX: 0,
|
|
35
|
+
sourceY: 0,
|
|
36
|
+
sourcePosition: Position.Right,
|
|
37
|
+
targetX: 200,
|
|
38
|
+
targetY: 0,
|
|
39
|
+
targetPosition: Position.Left,
|
|
40
|
+
})
|
|
41
|
+
const bottom = getBezierPath({
|
|
42
|
+
sourceX: 0,
|
|
43
|
+
sourceY: 0,
|
|
44
|
+
sourcePosition: Position.Bottom,
|
|
45
|
+
targetX: 0,
|
|
46
|
+
targetY: 200,
|
|
47
|
+
targetPosition: Position.Top,
|
|
48
|
+
})
|
|
49
|
+
// Different positions produce different paths
|
|
50
|
+
expect(right.path).not.toBe(bottom.path)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('handles custom curvature', () => {
|
|
54
|
+
const low = getBezierPath({
|
|
55
|
+
sourceX: 0,
|
|
56
|
+
sourceY: 0,
|
|
57
|
+
targetX: 200,
|
|
58
|
+
targetY: 100,
|
|
59
|
+
curvature: 0.1,
|
|
60
|
+
})
|
|
61
|
+
const high = getBezierPath({
|
|
62
|
+
sourceX: 0,
|
|
63
|
+
sourceY: 0,
|
|
64
|
+
targetX: 200,
|
|
65
|
+
targetY: 100,
|
|
66
|
+
curvature: 0.5,
|
|
67
|
+
})
|
|
68
|
+
expect(low.path).not.toBe(high.path)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('getStraightPath', () => {
|
|
73
|
+
it('generates a straight line', () => {
|
|
74
|
+
const result = getStraightPath({
|
|
75
|
+
sourceX: 0,
|
|
76
|
+
sourceY: 0,
|
|
77
|
+
targetX: 100,
|
|
78
|
+
targetY: 100,
|
|
79
|
+
})
|
|
80
|
+
expect(result.path).toBe('M0,0 L100,100')
|
|
81
|
+
expect(result.labelX).toBe(50)
|
|
82
|
+
expect(result.labelY).toBe(50)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('getSmoothStepPath', () => {
|
|
87
|
+
it('generates a valid SVG path', () => {
|
|
88
|
+
const result = getSmoothStepPath({
|
|
89
|
+
sourceX: 0,
|
|
90
|
+
sourceY: 0,
|
|
91
|
+
sourcePosition: Position.Right,
|
|
92
|
+
targetX: 200,
|
|
93
|
+
targetY: 100,
|
|
94
|
+
targetPosition: Position.Left,
|
|
95
|
+
})
|
|
96
|
+
expect(result.path).toMatch(/^M/)
|
|
97
|
+
expect(result.labelX).toBe(100)
|
|
98
|
+
expect(result.labelY).toBe(50)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('handles all position combinations', () => {
|
|
102
|
+
const combos = [
|
|
103
|
+
{ sourcePosition: Position.Right, targetPosition: Position.Top },
|
|
104
|
+
{ sourcePosition: Position.Bottom, targetPosition: Position.Left },
|
|
105
|
+
{ sourcePosition: Position.Right, targetPosition: Position.Left },
|
|
106
|
+
{ sourcePosition: Position.Bottom, targetPosition: Position.Top },
|
|
107
|
+
]
|
|
108
|
+
for (const combo of combos) {
|
|
109
|
+
const result = getSmoothStepPath({
|
|
110
|
+
sourceX: 0,
|
|
111
|
+
sourceY: 0,
|
|
112
|
+
...combo,
|
|
113
|
+
targetX: 200,
|
|
114
|
+
targetY: 100,
|
|
115
|
+
})
|
|
116
|
+
expect(result.path).toMatch(/^M/)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('getStepPath', () => {
|
|
122
|
+
it('is smoothstep with borderRadius 0', () => {
|
|
123
|
+
const step = getStepPath({
|
|
124
|
+
sourceX: 0,
|
|
125
|
+
sourceY: 0,
|
|
126
|
+
targetX: 200,
|
|
127
|
+
targetY: 100,
|
|
128
|
+
})
|
|
129
|
+
expect(step.path).toMatch(/^M/)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('getEdgePath', () => {
|
|
134
|
+
it('routes to correct path generator', () => {
|
|
135
|
+
const bezier = getEdgePath(
|
|
136
|
+
'bezier',
|
|
137
|
+
0,
|
|
138
|
+
0,
|
|
139
|
+
Position.Right,
|
|
140
|
+
200,
|
|
141
|
+
100,
|
|
142
|
+
Position.Left,
|
|
143
|
+
)
|
|
144
|
+
const straight = getEdgePath(
|
|
145
|
+
'straight',
|
|
146
|
+
0,
|
|
147
|
+
0,
|
|
148
|
+
Position.Right,
|
|
149
|
+
200,
|
|
150
|
+
100,
|
|
151
|
+
Position.Left,
|
|
152
|
+
)
|
|
153
|
+
const smooth = getEdgePath(
|
|
154
|
+
'smoothstep',
|
|
155
|
+
0,
|
|
156
|
+
0,
|
|
157
|
+
Position.Right,
|
|
158
|
+
200,
|
|
159
|
+
100,
|
|
160
|
+
Position.Left,
|
|
161
|
+
)
|
|
162
|
+
const step = getEdgePath(
|
|
163
|
+
'step',
|
|
164
|
+
0,
|
|
165
|
+
0,
|
|
166
|
+
Position.Right,
|
|
167
|
+
200,
|
|
168
|
+
100,
|
|
169
|
+
Position.Left,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
expect(bezier.path).toContain('C') // bezier has control points
|
|
173
|
+
expect(straight.path).toContain('L') // straight is a line
|
|
174
|
+
expect(smooth.path).toMatch(/^M/) // smoothstep is valid
|
|
175
|
+
expect(step.path).toMatch(/^M/) // step is valid
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('defaults to bezier for unknown type', () => {
|
|
179
|
+
const result = getEdgePath(
|
|
180
|
+
'unknown',
|
|
181
|
+
0,
|
|
182
|
+
0,
|
|
183
|
+
Position.Right,
|
|
184
|
+
200,
|
|
185
|
+
100,
|
|
186
|
+
Position.Left,
|
|
187
|
+
)
|
|
188
|
+
expect(result.path).toContain('C')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
describe('getHandlePosition', () => {
|
|
193
|
+
it('returns correct positions', () => {
|
|
194
|
+
const top = getHandlePosition(Position.Top, 0, 0, 100, 50)
|
|
195
|
+
expect(top).toEqual({ x: 50, y: 0 })
|
|
196
|
+
|
|
197
|
+
const right = getHandlePosition(Position.Right, 0, 0, 100, 50)
|
|
198
|
+
expect(right).toEqual({ x: 100, y: 25 })
|
|
199
|
+
|
|
200
|
+
const bottom = getHandlePosition(Position.Bottom, 0, 0, 100, 50)
|
|
201
|
+
expect(bottom).toEqual({ x: 50, y: 50 })
|
|
202
|
+
|
|
203
|
+
const left = getHandlePosition(Position.Left, 0, 0, 100, 50)
|
|
204
|
+
expect(left).toEqual({ x: 0, y: 25 })
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// ─── createFlow ──────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe('createFlow', () => {
|
|
212
|
+
describe('initialization', () => {
|
|
213
|
+
it('creates with default empty state', () => {
|
|
214
|
+
const flow = createFlow()
|
|
215
|
+
expect(flow.nodes()).toEqual([])
|
|
216
|
+
expect(flow.edges()).toEqual([])
|
|
217
|
+
expect(flow.viewport()).toEqual({ x: 0, y: 0, zoom: 1 })
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('creates with initial nodes and edges', () => {
|
|
221
|
+
const flow = createFlow({
|
|
222
|
+
nodes: [
|
|
223
|
+
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
|
|
224
|
+
{ id: '2', position: { x: 200, y: 0 }, data: { label: 'B' } },
|
|
225
|
+
],
|
|
226
|
+
edges: [{ source: '1', target: '2' }],
|
|
227
|
+
})
|
|
228
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
229
|
+
expect(flow.edges()).toHaveLength(1)
|
|
230
|
+
expect(flow.edges()[0]!.id).toBeDefined()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('auto-generates edge ids', () => {
|
|
234
|
+
const flow = createFlow({
|
|
235
|
+
nodes: [
|
|
236
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
237
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
238
|
+
],
|
|
239
|
+
edges: [{ source: '1', target: '2' }],
|
|
240
|
+
})
|
|
241
|
+
expect(flow.edges()[0]!.id).toBe('e-1-2')
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
describe('node operations', () => {
|
|
246
|
+
it('addNode adds a node', () => {
|
|
247
|
+
const flow = createFlow()
|
|
248
|
+
flow.addNode({
|
|
249
|
+
id: '1',
|
|
250
|
+
position: { x: 0, y: 0 },
|
|
251
|
+
data: { label: 'New' },
|
|
252
|
+
})
|
|
253
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
254
|
+
expect(flow.nodes()[0]!.id).toBe('1')
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('removeNode removes node and connected edges', () => {
|
|
258
|
+
const flow = createFlow({
|
|
259
|
+
nodes: [
|
|
260
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
261
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
262
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
263
|
+
],
|
|
264
|
+
edges: [
|
|
265
|
+
{ source: '1', target: '2' },
|
|
266
|
+
{ source: '2', target: '3' },
|
|
267
|
+
],
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
flow.removeNode('2')
|
|
271
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
272
|
+
expect(flow.edges()).toHaveLength(0) // both edges connected to '2'
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('updateNode updates node properties', () => {
|
|
276
|
+
const flow = createFlow({
|
|
277
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: { label: 'Old' } }],
|
|
278
|
+
})
|
|
279
|
+
flow.updateNode('1', { data: { label: 'New' } })
|
|
280
|
+
expect(flow.getNode('1')!.data.label).toBe('New')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('updateNodePosition updates position', () => {
|
|
284
|
+
const flow = createFlow({
|
|
285
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
286
|
+
})
|
|
287
|
+
flow.updateNodePosition('1', { x: 100, y: 200 })
|
|
288
|
+
expect(flow.getNode('1')!.position).toEqual({ x: 100, y: 200 })
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('updateNodePosition snaps to grid when enabled', () => {
|
|
292
|
+
const flow = createFlow({
|
|
293
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
294
|
+
snapToGrid: true,
|
|
295
|
+
snapGrid: 10,
|
|
296
|
+
})
|
|
297
|
+
flow.updateNodePosition('1', { x: 13, y: 27 })
|
|
298
|
+
expect(flow.getNode('1')!.position).toEqual({ x: 10, y: 30 })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('getNode returns undefined for missing id', () => {
|
|
302
|
+
const flow = createFlow()
|
|
303
|
+
expect(flow.getNode('missing')).toBeUndefined()
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe('edge operations', () => {
|
|
308
|
+
it('addEdge adds an edge', () => {
|
|
309
|
+
const flow = createFlow({
|
|
310
|
+
nodes: [
|
|
311
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
312
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
313
|
+
],
|
|
314
|
+
})
|
|
315
|
+
flow.addEdge({ source: '1', target: '2' })
|
|
316
|
+
expect(flow.edges()).toHaveLength(1)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('addEdge prevents duplicates', () => {
|
|
320
|
+
const flow = createFlow({
|
|
321
|
+
nodes: [
|
|
322
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
323
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
324
|
+
],
|
|
325
|
+
edges: [{ source: '1', target: '2' }],
|
|
326
|
+
})
|
|
327
|
+
flow.addEdge({ source: '1', target: '2' })
|
|
328
|
+
expect(flow.edges()).toHaveLength(1) // not duplicated
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('removeEdge removes an edge', () => {
|
|
332
|
+
const flow = createFlow({
|
|
333
|
+
nodes: [
|
|
334
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
335
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
336
|
+
],
|
|
337
|
+
edges: [{ source: '1', target: '2' }],
|
|
338
|
+
})
|
|
339
|
+
flow.removeEdge('e-1-2')
|
|
340
|
+
expect(flow.edges()).toHaveLength(0)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('getEdge returns edge by id', () => {
|
|
344
|
+
const flow = createFlow({
|
|
345
|
+
nodes: [
|
|
346
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
347
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
348
|
+
],
|
|
349
|
+
edges: [{ id: 'my-edge', source: '1', target: '2' }],
|
|
350
|
+
})
|
|
351
|
+
expect(flow.getEdge('my-edge')).toBeDefined()
|
|
352
|
+
expect(flow.getEdge('missing')).toBeUndefined()
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
describe('connection rules', () => {
|
|
357
|
+
it('validates connections based on rules', () => {
|
|
358
|
+
const flow = createFlow({
|
|
359
|
+
nodes: [
|
|
360
|
+
{ id: '1', type: 'input', position: { x: 0, y: 0 }, data: {} },
|
|
361
|
+
{ id: '2', type: 'process', position: { x: 100, y: 0 }, data: {} },
|
|
362
|
+
{ id: '3', type: 'output', position: { x: 200, y: 0 }, data: {} },
|
|
363
|
+
],
|
|
364
|
+
connectionRules: {
|
|
365
|
+
input: { outputs: ['process'] },
|
|
366
|
+
process: { outputs: ['process', 'output'] },
|
|
367
|
+
output: { outputs: [] },
|
|
368
|
+
},
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
expect(flow.isValidConnection({ source: '1', target: '2' })).toBe(true) // input → process
|
|
372
|
+
expect(flow.isValidConnection({ source: '2', target: '3' })).toBe(true) // process → output
|
|
373
|
+
expect(flow.isValidConnection({ source: '1', target: '3' })).toBe(false) // input → output (blocked)
|
|
374
|
+
expect(flow.isValidConnection({ source: '3', target: '1' })).toBe(false) // output → (no outputs)
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('allows all connections without rules', () => {
|
|
378
|
+
const flow = createFlow({
|
|
379
|
+
nodes: [
|
|
380
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
381
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
382
|
+
],
|
|
383
|
+
})
|
|
384
|
+
expect(flow.isValidConnection({ source: '1', target: '2' })).toBe(true)
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
describe('selection', () => {
|
|
389
|
+
it('selectNode selects a node', () => {
|
|
390
|
+
const flow = createFlow({
|
|
391
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
392
|
+
})
|
|
393
|
+
flow.selectNode('1')
|
|
394
|
+
expect(flow.selectedNodes()).toEqual(['1'])
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('selectNode replaces selection by default', () => {
|
|
398
|
+
const flow = createFlow({
|
|
399
|
+
nodes: [
|
|
400
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
401
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
402
|
+
],
|
|
403
|
+
})
|
|
404
|
+
flow.selectNode('1')
|
|
405
|
+
flow.selectNode('2')
|
|
406
|
+
expect(flow.selectedNodes()).toEqual(['2'])
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('selectNode with additive adds to selection', () => {
|
|
410
|
+
const flow = createFlow({
|
|
411
|
+
nodes: [
|
|
412
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
413
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
414
|
+
],
|
|
415
|
+
})
|
|
416
|
+
flow.selectNode('1')
|
|
417
|
+
flow.selectNode('2', true)
|
|
418
|
+
expect(flow.selectedNodes()).toEqual(expect.arrayContaining(['1', '2']))
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('clearSelection clears all', () => {
|
|
422
|
+
const flow = createFlow({
|
|
423
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
424
|
+
})
|
|
425
|
+
flow.selectNode('1')
|
|
426
|
+
flow.clearSelection()
|
|
427
|
+
expect(flow.selectedNodes()).toEqual([])
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it('selectAll selects all nodes', () => {
|
|
431
|
+
const flow = createFlow({
|
|
432
|
+
nodes: [
|
|
433
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
434
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
435
|
+
],
|
|
436
|
+
})
|
|
437
|
+
flow.selectAll()
|
|
438
|
+
expect(flow.selectedNodes()).toHaveLength(2)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('deleteSelected removes selected nodes and edges', () => {
|
|
442
|
+
const flow = createFlow({
|
|
443
|
+
nodes: [
|
|
444
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
445
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
446
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
447
|
+
],
|
|
448
|
+
edges: [
|
|
449
|
+
{ source: '1', target: '2' },
|
|
450
|
+
{ source: '2', target: '3' },
|
|
451
|
+
],
|
|
452
|
+
})
|
|
453
|
+
flow.selectNode('2')
|
|
454
|
+
flow.deleteSelected()
|
|
455
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
456
|
+
expect(flow.edges()).toHaveLength(0)
|
|
457
|
+
expect(flow.selectedNodes()).toEqual([])
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
describe('viewport', () => {
|
|
462
|
+
it('zoomIn increases zoom', () => {
|
|
463
|
+
const flow = createFlow()
|
|
464
|
+
const initial = flow.zoom()
|
|
465
|
+
flow.zoomIn()
|
|
466
|
+
expect(flow.zoom()).toBeGreaterThan(initial)
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
it('zoomOut decreases zoom', () => {
|
|
470
|
+
const flow = createFlow()
|
|
471
|
+
const initial = flow.zoom()
|
|
472
|
+
flow.zoomOut()
|
|
473
|
+
expect(flow.zoom()).toBeLessThan(initial)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('zoomTo clamps to min/max', () => {
|
|
477
|
+
const flow = createFlow({ minZoom: 0.5, maxZoom: 2 })
|
|
478
|
+
flow.zoomTo(0.1)
|
|
479
|
+
expect(flow.zoom()).toBe(0.5)
|
|
480
|
+
flow.zoomTo(10)
|
|
481
|
+
expect(flow.zoom()).toBe(2)
|
|
482
|
+
})
|
|
483
|
+
|
|
484
|
+
it('fitView adjusts viewport to show all nodes', () => {
|
|
485
|
+
const flow = createFlow({
|
|
486
|
+
nodes: [
|
|
487
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
488
|
+
{ id: '2', position: { x: 500, y: 300 }, data: {} },
|
|
489
|
+
],
|
|
490
|
+
})
|
|
491
|
+
flow.fitView()
|
|
492
|
+
expect(flow.viewport().zoom).toBeLessThanOrEqual(4)
|
|
493
|
+
expect(flow.viewport().zoom).toBeGreaterThan(0)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it('fitView with specific nodes', () => {
|
|
497
|
+
const flow = createFlow({
|
|
498
|
+
nodes: [
|
|
499
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
500
|
+
{ id: '2', position: { x: 1000, y: 1000 }, data: {} },
|
|
501
|
+
{ id: '3', position: { x: 50, y: 50 }, data: {} },
|
|
502
|
+
],
|
|
503
|
+
})
|
|
504
|
+
flow.fitView(['1', '3'])
|
|
505
|
+
// Should zoom in more since only close nodes are targeted
|
|
506
|
+
expect(flow.viewport().zoom).toBeGreaterThan(0)
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe('viewport — panTo and isNodeVisible', () => {
|
|
511
|
+
it('panTo updates viewport position', () => {
|
|
512
|
+
const flow = createFlow()
|
|
513
|
+
flow.panTo({ x: 100, y: 200 })
|
|
514
|
+
const vp = flow.viewport()
|
|
515
|
+
expect(vp.x).toBe(-100)
|
|
516
|
+
expect(vp.y).toBe(-200)
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('isNodeVisible checks viewport bounds', () => {
|
|
520
|
+
const flow = createFlow({
|
|
521
|
+
nodes: [
|
|
522
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
523
|
+
{ id: '2', position: { x: 5000, y: 5000 }, data: {} },
|
|
524
|
+
],
|
|
525
|
+
})
|
|
526
|
+
expect(flow.isNodeVisible('1')).toBe(true)
|
|
527
|
+
expect(flow.isNodeVisible('2')).toBe(false)
|
|
528
|
+
expect(flow.isNodeVisible('missing')).toBe(false)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('fitView with no nodes does nothing', () => {
|
|
532
|
+
const flow = createFlow()
|
|
533
|
+
const before = flow.viewport()
|
|
534
|
+
flow.fitView()
|
|
535
|
+
expect(flow.viewport()).toEqual(before)
|
|
536
|
+
})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
describe('edge selection', () => {
|
|
540
|
+
it('selectEdge selects an edge', () => {
|
|
541
|
+
const flow = createFlow({
|
|
542
|
+
nodes: [
|
|
543
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
544
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
545
|
+
],
|
|
546
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
547
|
+
})
|
|
548
|
+
flow.selectEdge('e1')
|
|
549
|
+
expect(flow.selectedEdges()).toEqual(['e1'])
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('selectEdge clears node selection by default', () => {
|
|
553
|
+
const flow = createFlow({
|
|
554
|
+
nodes: [
|
|
555
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
556
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
557
|
+
],
|
|
558
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
559
|
+
})
|
|
560
|
+
flow.selectNode('1')
|
|
561
|
+
flow.selectEdge('e1')
|
|
562
|
+
expect(flow.selectedNodes()).toEqual([])
|
|
563
|
+
expect(flow.selectedEdges()).toEqual(['e1'])
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('selectEdge additive mode', () => {
|
|
567
|
+
const flow = createFlow({
|
|
568
|
+
nodes: [
|
|
569
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
570
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
571
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
572
|
+
],
|
|
573
|
+
edges: [
|
|
574
|
+
{ id: 'e1', source: '1', target: '2' },
|
|
575
|
+
{ id: 'e2', source: '2', target: '3' },
|
|
576
|
+
],
|
|
577
|
+
})
|
|
578
|
+
flow.selectEdge('e1')
|
|
579
|
+
flow.selectEdge('e2', true)
|
|
580
|
+
expect(flow.selectedEdges()).toEqual(expect.arrayContaining(['e1', 'e2']))
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
it('deselectNode removes from selection', () => {
|
|
584
|
+
const flow = createFlow({
|
|
585
|
+
nodes: [
|
|
586
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
587
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
588
|
+
],
|
|
589
|
+
})
|
|
590
|
+
flow.selectNode('1')
|
|
591
|
+
flow.selectNode('2', true)
|
|
592
|
+
flow.deselectNode('1')
|
|
593
|
+
expect(flow.selectedNodes()).toEqual(['2'])
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('deleteSelected with selected edges only', () => {
|
|
597
|
+
const flow = createFlow({
|
|
598
|
+
nodes: [
|
|
599
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
600
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
601
|
+
],
|
|
602
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
603
|
+
})
|
|
604
|
+
flow.selectEdge('e1')
|
|
605
|
+
flow.deleteSelected()
|
|
606
|
+
expect(flow.edges()).toHaveLength(0)
|
|
607
|
+
expect(flow.nodes()).toHaveLength(2) // nodes untouched
|
|
608
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
describe('connection rules — edge cases', () => {
|
|
612
|
+
it('returns false for missing source node', () => {
|
|
613
|
+
const flow = createFlow({
|
|
614
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
615
|
+
connectionRules: { default: { outputs: ['default'] } },
|
|
616
|
+
})
|
|
617
|
+
expect(flow.isValidConnection({ source: 'missing', target: '1' })).toBe(
|
|
618
|
+
false,
|
|
619
|
+
)
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
it('returns false for missing target node', () => {
|
|
623
|
+
const flow = createFlow({
|
|
624
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
625
|
+
connectionRules: { default: { outputs: ['default'] } },
|
|
626
|
+
})
|
|
627
|
+
expect(flow.isValidConnection({ source: '1', target: 'missing' })).toBe(
|
|
628
|
+
false,
|
|
629
|
+
)
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
it('allows connection when no rule for source type', () => {
|
|
633
|
+
const flow = createFlow({
|
|
634
|
+
nodes: [
|
|
635
|
+
{ id: '1', type: 'custom', position: { x: 0, y: 0 }, data: {} },
|
|
636
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
637
|
+
],
|
|
638
|
+
connectionRules: { default: { outputs: [] } },
|
|
639
|
+
})
|
|
640
|
+
expect(flow.isValidConnection({ source: '1', target: '2' })).toBe(true) // no rule for 'custom'
|
|
641
|
+
})
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
describe('graph queries', () => {
|
|
645
|
+
it('getConnectedEdges returns edges for a node', () => {
|
|
646
|
+
const flow = createFlow({
|
|
647
|
+
nodes: [
|
|
648
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
649
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
650
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
651
|
+
],
|
|
652
|
+
edges: [
|
|
653
|
+
{ source: '1', target: '2' },
|
|
654
|
+
{ source: '2', target: '3' },
|
|
655
|
+
],
|
|
656
|
+
})
|
|
657
|
+
expect(flow.getConnectedEdges('2')).toHaveLength(2)
|
|
658
|
+
expect(flow.getConnectedEdges('1')).toHaveLength(1)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('getIncomers returns upstream nodes', () => {
|
|
662
|
+
const flow = createFlow({
|
|
663
|
+
nodes: [
|
|
664
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
665
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
666
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
667
|
+
],
|
|
668
|
+
edges: [
|
|
669
|
+
{ source: '1', target: '3' },
|
|
670
|
+
{ source: '2', target: '3' },
|
|
671
|
+
],
|
|
672
|
+
})
|
|
673
|
+
const incomers = flow.getIncomers('3')
|
|
674
|
+
expect(incomers).toHaveLength(2)
|
|
675
|
+
expect(incomers.map((n) => n.id)).toEqual(
|
|
676
|
+
expect.arrayContaining(['1', '2']),
|
|
677
|
+
)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
it('getOutgoers returns downstream nodes', () => {
|
|
681
|
+
const flow = createFlow({
|
|
682
|
+
nodes: [
|
|
683
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
684
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
685
|
+
{ id: '3', position: { x: 200, y: 0 }, data: {} },
|
|
686
|
+
],
|
|
687
|
+
edges: [
|
|
688
|
+
{ source: '1', target: '2' },
|
|
689
|
+
{ source: '1', target: '3' },
|
|
690
|
+
],
|
|
691
|
+
})
|
|
692
|
+
const outgoers = flow.getOutgoers('1')
|
|
693
|
+
expect(outgoers).toHaveLength(2)
|
|
694
|
+
})
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
describe('reactivity', () => {
|
|
698
|
+
it('nodes signal is reactive in effects', () => {
|
|
699
|
+
const flow = createFlow()
|
|
700
|
+
const counts: number[] = []
|
|
701
|
+
|
|
702
|
+
effect(() => {
|
|
703
|
+
counts.push(flow.nodes().length)
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
flow.addNode({ id: '1', position: { x: 0, y: 0 }, data: {} })
|
|
707
|
+
flow.addNode({ id: '2', position: { x: 100, y: 0 }, data: {} })
|
|
708
|
+
|
|
709
|
+
expect(counts).toEqual([0, 1, 2])
|
|
710
|
+
})
|
|
711
|
+
|
|
712
|
+
it('zoom is a reactive computed', () => {
|
|
713
|
+
const flow = createFlow()
|
|
714
|
+
const zooms: number[] = []
|
|
715
|
+
|
|
716
|
+
effect(() => {
|
|
717
|
+
zooms.push(flow.zoom())
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
flow.zoomIn()
|
|
721
|
+
flow.zoomOut()
|
|
722
|
+
|
|
723
|
+
expect(zooms).toHaveLength(3)
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('selectedNodes is reactive', () => {
|
|
727
|
+
const flow = createFlow({
|
|
728
|
+
nodes: [
|
|
729
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
730
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
731
|
+
],
|
|
732
|
+
})
|
|
733
|
+
const selections: string[][] = []
|
|
734
|
+
|
|
735
|
+
effect(() => {
|
|
736
|
+
selections.push([...flow.selectedNodes()])
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
flow.selectNode('1')
|
|
740
|
+
flow.selectNode('2', true)
|
|
741
|
+
|
|
742
|
+
expect(selections).toHaveLength(3)
|
|
743
|
+
expect(selections[2]).toEqual(expect.arrayContaining(['1', '2']))
|
|
744
|
+
})
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
describe('listeners', () => {
|
|
748
|
+
it('onConnect fires when edge is added', () => {
|
|
749
|
+
const flow = createFlow({
|
|
750
|
+
nodes: [
|
|
751
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
752
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
753
|
+
],
|
|
754
|
+
})
|
|
755
|
+
const fn = vi.fn()
|
|
756
|
+
flow.onConnect(fn)
|
|
757
|
+
|
|
758
|
+
flow.addEdge({ source: '1', target: '2' })
|
|
759
|
+
expect(fn).toHaveBeenCalledWith(
|
|
760
|
+
expect.objectContaining({ source: '1', target: '2' }),
|
|
761
|
+
)
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it('onNodesChange fires on position update', () => {
|
|
765
|
+
const flow = createFlow({
|
|
766
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
767
|
+
})
|
|
768
|
+
const fn = vi.fn()
|
|
769
|
+
flow.onNodesChange(fn)
|
|
770
|
+
|
|
771
|
+
flow.updateNodePosition('1', { x: 100, y: 200 })
|
|
772
|
+
expect(fn).toHaveBeenCalledWith([
|
|
773
|
+
{ type: 'position', id: '1', position: { x: 100, y: 200 } },
|
|
774
|
+
])
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('onNodesChange fires on remove', () => {
|
|
778
|
+
const flow = createFlow({
|
|
779
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
780
|
+
})
|
|
781
|
+
const fn = vi.fn()
|
|
782
|
+
flow.onNodesChange(fn)
|
|
783
|
+
|
|
784
|
+
flow.removeNode('1')
|
|
785
|
+
expect(fn).toHaveBeenCalledWith([{ type: 'remove', id: '1' }])
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it('listeners can be unsubscribed', () => {
|
|
789
|
+
const flow = createFlow({
|
|
790
|
+
nodes: [
|
|
791
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
792
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
793
|
+
],
|
|
794
|
+
})
|
|
795
|
+
const fn = vi.fn()
|
|
796
|
+
const unsub = flow.onConnect(fn)
|
|
797
|
+
|
|
798
|
+
flow.addEdge({ source: '1', target: '2' })
|
|
799
|
+
expect(fn).toHaveBeenCalledOnce()
|
|
800
|
+
|
|
801
|
+
unsub()
|
|
802
|
+
flow.removeEdge('e-1-2')
|
|
803
|
+
flow.addEdge({ source: '1', target: '2' })
|
|
804
|
+
expect(fn).toHaveBeenCalledOnce() // not called again
|
|
805
|
+
})
|
|
806
|
+
|
|
807
|
+
it('dispose clears all listeners', () => {
|
|
808
|
+
const flow = createFlow()
|
|
809
|
+
const fn1 = vi.fn()
|
|
810
|
+
const fn2 = vi.fn()
|
|
811
|
+
flow.onConnect(fn1)
|
|
812
|
+
flow.onNodesChange(fn2)
|
|
813
|
+
|
|
814
|
+
flow.dispose()
|
|
815
|
+
|
|
816
|
+
flow.addNode({ id: '1', position: { x: 0, y: 0 }, data: {} })
|
|
817
|
+
flow.addEdge({ source: '1', target: '1' })
|
|
818
|
+
expect(fn1).not.toHaveBeenCalled()
|
|
819
|
+
expect(fn2).not.toHaveBeenCalled()
|
|
820
|
+
})
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
describe('batch', () => {
|
|
824
|
+
it('batches multiple operations', () => {
|
|
825
|
+
const flow = createFlow()
|
|
826
|
+
const counts: number[] = []
|
|
827
|
+
|
|
828
|
+
effect(() => {
|
|
829
|
+
counts.push(flow.nodes().length)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
flow.batch(() => {
|
|
833
|
+
flow.addNode({ id: '1', position: { x: 0, y: 0 }, data: {} })
|
|
834
|
+
flow.addNode({ id: '2', position: { x: 100, y: 0 }, data: {} })
|
|
835
|
+
flow.addNode({ id: '3', position: { x: 200, y: 0 }, data: {} })
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
// Should batch into fewer updates (initial + batch result)
|
|
839
|
+
expect(counts[counts.length - 1]).toBe(3)
|
|
840
|
+
})
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
describe('real-world patterns', () => {
|
|
844
|
+
it('pipeline workflow', () => {
|
|
845
|
+
const flow = createFlow({
|
|
846
|
+
nodes: [
|
|
847
|
+
{
|
|
848
|
+
id: 'fetch',
|
|
849
|
+
type: 'input',
|
|
850
|
+
position: { x: 0, y: 0 },
|
|
851
|
+
data: { label: 'Fetch Data' },
|
|
852
|
+
},
|
|
853
|
+
{
|
|
854
|
+
id: 'transform',
|
|
855
|
+
type: 'process',
|
|
856
|
+
position: { x: 200, y: 0 },
|
|
857
|
+
data: { label: 'Transform' },
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
id: 'validate',
|
|
861
|
+
type: 'process',
|
|
862
|
+
position: { x: 400, y: 0 },
|
|
863
|
+
data: { label: 'Validate' },
|
|
864
|
+
},
|
|
865
|
+
{
|
|
866
|
+
id: 'store',
|
|
867
|
+
type: 'output',
|
|
868
|
+
position: { x: 600, y: 0 },
|
|
869
|
+
data: { label: 'Store' },
|
|
870
|
+
},
|
|
871
|
+
],
|
|
872
|
+
edges: [
|
|
873
|
+
{ source: 'fetch', target: 'transform' },
|
|
874
|
+
{ source: 'transform', target: 'validate' },
|
|
875
|
+
{ source: 'validate', target: 'store' },
|
|
876
|
+
],
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
expect(flow.nodes()).toHaveLength(4)
|
|
880
|
+
expect(flow.edges()).toHaveLength(3)
|
|
881
|
+
expect(flow.getOutgoers('fetch').map((n) => n.id)).toEqual(['transform'])
|
|
882
|
+
expect(flow.getIncomers('store').map((n) => n.id)).toEqual(['validate'])
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it('branching workflow', () => {
|
|
886
|
+
const flow = createFlow({
|
|
887
|
+
nodes: [
|
|
888
|
+
{ id: 'start', position: { x: 0, y: 100 }, data: {} },
|
|
889
|
+
{ id: 'branch-a', position: { x: 200, y: 0 }, data: {} },
|
|
890
|
+
{ id: 'branch-b', position: { x: 200, y: 200 }, data: {} },
|
|
891
|
+
{ id: 'merge', position: { x: 400, y: 100 }, data: {} },
|
|
892
|
+
],
|
|
893
|
+
edges: [
|
|
894
|
+
{ source: 'start', target: 'branch-a' },
|
|
895
|
+
{ source: 'start', target: 'branch-b' },
|
|
896
|
+
{ source: 'branch-a', target: 'merge' },
|
|
897
|
+
{ source: 'branch-b', target: 'merge' },
|
|
898
|
+
],
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
expect(flow.getOutgoers('start')).toHaveLength(2)
|
|
902
|
+
expect(flow.getIncomers('merge')).toHaveLength(2)
|
|
903
|
+
})
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
// ─── Waypoints ─────────────────────────────────────────────────────────
|
|
907
|
+
|
|
908
|
+
describe('edge waypoints', () => {
|
|
909
|
+
it('getWaypointPath generates path through waypoints', () => {
|
|
910
|
+
const result = getWaypointPath({
|
|
911
|
+
sourceX: 0,
|
|
912
|
+
sourceY: 0,
|
|
913
|
+
targetX: 300,
|
|
914
|
+
targetY: 0,
|
|
915
|
+
waypoints: [
|
|
916
|
+
{ x: 100, y: 50 },
|
|
917
|
+
{ x: 200, y: -50 },
|
|
918
|
+
],
|
|
919
|
+
})
|
|
920
|
+
expect(result.path).toBe('M0,0 L100,50 L200,-50 L300,0')
|
|
921
|
+
// Label at middle waypoint (index 1 of 2)
|
|
922
|
+
expect(result.labelX).toBe(200)
|
|
923
|
+
expect(result.labelY).toBe(-50)
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it('getWaypointPath with empty waypoints falls back to straight', () => {
|
|
927
|
+
const result = getWaypointPath({
|
|
928
|
+
sourceX: 0,
|
|
929
|
+
sourceY: 0,
|
|
930
|
+
targetX: 100,
|
|
931
|
+
targetY: 100,
|
|
932
|
+
waypoints: [],
|
|
933
|
+
})
|
|
934
|
+
expect(result.path).toBe('M0,0 L100,100')
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it('addEdgeWaypoint adds a bend point', () => {
|
|
938
|
+
const flow = createFlow({
|
|
939
|
+
nodes: [
|
|
940
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
941
|
+
{ id: '2', position: { x: 200, y: 0 }, data: {} },
|
|
942
|
+
],
|
|
943
|
+
edges: [{ id: 'e1', source: '1', target: '2' }],
|
|
944
|
+
})
|
|
945
|
+
|
|
946
|
+
flow.addEdgeWaypoint('e1', { x: 100, y: 50 })
|
|
947
|
+
expect(flow.getEdge('e1')!.waypoints).toEqual([{ x: 100, y: 50 }])
|
|
948
|
+
})
|
|
949
|
+
|
|
950
|
+
it('addEdgeWaypoint at specific index', () => {
|
|
951
|
+
const flow = createFlow({
|
|
952
|
+
nodes: [
|
|
953
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
954
|
+
{ id: '2', position: { x: 200, y: 0 }, data: {} },
|
|
955
|
+
],
|
|
956
|
+
edges: [
|
|
957
|
+
{
|
|
958
|
+
id: 'e1',
|
|
959
|
+
source: '1',
|
|
960
|
+
target: '2',
|
|
961
|
+
waypoints: [
|
|
962
|
+
{ x: 50, y: 0 },
|
|
963
|
+
{ x: 150, y: 0 },
|
|
964
|
+
],
|
|
965
|
+
},
|
|
966
|
+
],
|
|
967
|
+
})
|
|
968
|
+
|
|
969
|
+
flow.addEdgeWaypoint('e1', { x: 100, y: 50 }, 1)
|
|
970
|
+
expect(flow.getEdge('e1')!.waypoints).toHaveLength(3)
|
|
971
|
+
expect(flow.getEdge('e1')!.waypoints![1]).toEqual({ x: 100, y: 50 })
|
|
972
|
+
})
|
|
973
|
+
|
|
974
|
+
it('removeEdgeWaypoint removes a bend point', () => {
|
|
975
|
+
const flow = createFlow({
|
|
976
|
+
nodes: [
|
|
977
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
978
|
+
{ id: '2', position: { x: 200, y: 0 }, data: {} },
|
|
979
|
+
],
|
|
980
|
+
edges: [
|
|
981
|
+
{
|
|
982
|
+
id: 'e1',
|
|
983
|
+
source: '1',
|
|
984
|
+
target: '2',
|
|
985
|
+
waypoints: [{ x: 100, y: 50 }],
|
|
986
|
+
},
|
|
987
|
+
],
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
flow.removeEdgeWaypoint('e1', 0)
|
|
991
|
+
expect(flow.getEdge('e1')!.waypoints).toBeUndefined()
|
|
992
|
+
})
|
|
993
|
+
|
|
994
|
+
it('updateEdgeWaypoint moves a bend point', () => {
|
|
995
|
+
const flow = createFlow({
|
|
996
|
+
nodes: [
|
|
997
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
998
|
+
{ id: '2', position: { x: 200, y: 0 }, data: {} },
|
|
999
|
+
],
|
|
1000
|
+
edges: [
|
|
1001
|
+
{
|
|
1002
|
+
id: 'e1',
|
|
1003
|
+
source: '1',
|
|
1004
|
+
target: '2',
|
|
1005
|
+
waypoints: [{ x: 100, y: 50 }],
|
|
1006
|
+
},
|
|
1007
|
+
],
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
flow.updateEdgeWaypoint('e1', 0, { x: 100, y: -50 })
|
|
1011
|
+
expect(flow.getEdge('e1')!.waypoints![0]).toEqual({ x: 100, y: -50 })
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
1014
|
+
|
|
1015
|
+
// ─── Search / Filter ───────────────────────────────────────────────────
|
|
1016
|
+
|
|
1017
|
+
describe('search and filter', () => {
|
|
1018
|
+
it('findNodes with predicate', () => {
|
|
1019
|
+
const flow = createFlow({
|
|
1020
|
+
nodes: [
|
|
1021
|
+
{ id: '1', type: 'input', position: { x: 0, y: 0 }, data: {} },
|
|
1022
|
+
{ id: '2', type: 'process', position: { x: 100, y: 0 }, data: {} },
|
|
1023
|
+
{ id: '3', type: 'process', position: { x: 200, y: 0 }, data: {} },
|
|
1024
|
+
{ id: '4', type: 'output', position: { x: 300, y: 0 }, data: {} },
|
|
1025
|
+
],
|
|
1026
|
+
})
|
|
1027
|
+
|
|
1028
|
+
expect(flow.findNodes((n) => n.type === 'process')).toHaveLength(2)
|
|
1029
|
+
expect(flow.findNodes((n) => n.type === 'input')).toHaveLength(1)
|
|
1030
|
+
expect(flow.findNodes((n) => n.type === 'missing')).toHaveLength(0)
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('searchNodes by label', () => {
|
|
1034
|
+
const flow = createFlow({
|
|
1035
|
+
nodes: [
|
|
1036
|
+
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'Fetch Data' } },
|
|
1037
|
+
{ id: '2', position: { x: 100, y: 0 }, data: { label: 'Transform' } },
|
|
1038
|
+
{
|
|
1039
|
+
id: '3',
|
|
1040
|
+
position: { x: 200, y: 0 },
|
|
1041
|
+
data: { label: 'Fetch Users' },
|
|
1042
|
+
},
|
|
1043
|
+
],
|
|
1044
|
+
})
|
|
1045
|
+
|
|
1046
|
+
expect(flow.searchNodes('fetch')).toHaveLength(2)
|
|
1047
|
+
expect(flow.searchNodes('transform')).toHaveLength(1)
|
|
1048
|
+
expect(flow.searchNodes('FETCH')).toHaveLength(2) // case-insensitive
|
|
1049
|
+
expect(flow.searchNodes('missing')).toHaveLength(0)
|
|
1050
|
+
})
|
|
1051
|
+
|
|
1052
|
+
it('searchNodes falls back to node id', () => {
|
|
1053
|
+
const flow = createFlow({
|
|
1054
|
+
nodes: [{ id: 'api-gateway', position: { x: 0, y: 0 }, data: {} }],
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
expect(flow.searchNodes('gateway')).toHaveLength(1)
|
|
1058
|
+
})
|
|
1059
|
+
})
|
|
1060
|
+
|
|
1061
|
+
// ─── Export / Import ───────────────────────────────────────────────────
|
|
1062
|
+
|
|
1063
|
+
describe('toJSON / fromJSON', () => {
|
|
1064
|
+
it('exports and imports flow state', () => {
|
|
1065
|
+
const flow = createFlow({
|
|
1066
|
+
nodes: [
|
|
1067
|
+
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
|
|
1068
|
+
{ id: '2', position: { x: 200, y: 0 }, data: { label: 'B' } },
|
|
1069
|
+
],
|
|
1070
|
+
edges: [{ source: '1', target: '2' }],
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
flow.zoomTo(1.5)
|
|
1074
|
+
const json = flow.toJSON()
|
|
1075
|
+
|
|
1076
|
+
expect(json.nodes).toHaveLength(2)
|
|
1077
|
+
expect(json.edges).toHaveLength(1)
|
|
1078
|
+
expect(json.viewport.zoom).toBe(1.5)
|
|
1079
|
+
|
|
1080
|
+
// Create a new flow and import
|
|
1081
|
+
const flow2 = createFlow()
|
|
1082
|
+
flow2.fromJSON(json)
|
|
1083
|
+
|
|
1084
|
+
expect(flow2.nodes()).toHaveLength(2)
|
|
1085
|
+
expect(flow2.edges()).toHaveLength(1)
|
|
1086
|
+
expect(flow2.zoom()).toBe(1.5)
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
it('fromJSON without viewport keeps current', () => {
|
|
1090
|
+
const flow = createFlow()
|
|
1091
|
+
flow.zoomTo(2)
|
|
1092
|
+
|
|
1093
|
+
flow.fromJSON({
|
|
1094
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
1095
|
+
edges: [],
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
1099
|
+
expect(flow.zoom()).toBe(2) // unchanged
|
|
1100
|
+
})
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
// ─── Collision detection ───────────────────────────────────────────────
|
|
1104
|
+
|
|
1105
|
+
describe('collision detection', () => {
|
|
1106
|
+
it('getOverlappingNodes detects overlap', () => {
|
|
1107
|
+
const flow = createFlow({
|
|
1108
|
+
nodes: [
|
|
1109
|
+
{
|
|
1110
|
+
id: '1',
|
|
1111
|
+
position: { x: 0, y: 0 },
|
|
1112
|
+
width: 100,
|
|
1113
|
+
height: 50,
|
|
1114
|
+
data: {},
|
|
1115
|
+
},
|
|
1116
|
+
{
|
|
1117
|
+
id: '2',
|
|
1118
|
+
position: { x: 50, y: 25 },
|
|
1119
|
+
width: 100,
|
|
1120
|
+
height: 50,
|
|
1121
|
+
data: {},
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
id: '3',
|
|
1125
|
+
position: { x: 500, y: 500 },
|
|
1126
|
+
width: 100,
|
|
1127
|
+
height: 50,
|
|
1128
|
+
data: {},
|
|
1129
|
+
},
|
|
1130
|
+
],
|
|
1131
|
+
})
|
|
1132
|
+
|
|
1133
|
+
expect(flow.getOverlappingNodes('1')).toHaveLength(1)
|
|
1134
|
+
expect(flow.getOverlappingNodes('1')[0]!.id).toBe('2')
|
|
1135
|
+
expect(flow.getOverlappingNodes('3')).toHaveLength(0)
|
|
1136
|
+
})
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
// ─── Proximity connect ─────────────────────────────────────────────────
|
|
1140
|
+
|
|
1141
|
+
describe('proximity connect', () => {
|
|
1142
|
+
it('finds nearest unconnected node', () => {
|
|
1143
|
+
const flow = createFlow({
|
|
1144
|
+
nodes: [
|
|
1145
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
1146
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
1147
|
+
{ id: '3', position: { x: 500, y: 500 }, data: {} },
|
|
1148
|
+
],
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
const conn = flow.getProximityConnection('1', 200)
|
|
1152
|
+
expect(conn).not.toBeNull()
|
|
1153
|
+
expect(conn!.target).toBe('2')
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
it('returns null when no node is close', () => {
|
|
1157
|
+
const flow = createFlow({
|
|
1158
|
+
nodes: [
|
|
1159
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
1160
|
+
{ id: '2', position: { x: 500, y: 500 }, data: {} },
|
|
1161
|
+
],
|
|
1162
|
+
})
|
|
1163
|
+
|
|
1164
|
+
expect(flow.getProximityConnection('1', 50)).toBeNull()
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
it('skips already connected nodes', () => {
|
|
1168
|
+
const flow = createFlow({
|
|
1169
|
+
nodes: [
|
|
1170
|
+
{ id: '1', position: { x: 0, y: 0 }, data: {} },
|
|
1171
|
+
{ id: '2', position: { x: 100, y: 0 }, data: {} },
|
|
1172
|
+
],
|
|
1173
|
+
edges: [{ source: '1', target: '2' }],
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
expect(flow.getProximityConnection('1', 200)).toBeNull()
|
|
1177
|
+
})
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
// ─── Node extent ───────────────────────────────────────────────────────
|
|
1181
|
+
|
|
1182
|
+
describe('node extent', () => {
|
|
1183
|
+
it('clamps node position to extent', () => {
|
|
1184
|
+
const flow = createFlow({
|
|
1185
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
1186
|
+
nodeExtent: [
|
|
1187
|
+
[0, 0],
|
|
1188
|
+
[500, 500],
|
|
1189
|
+
],
|
|
1190
|
+
})
|
|
1191
|
+
|
|
1192
|
+
flow.updateNodePosition('1', { x: -100, y: -100 })
|
|
1193
|
+
expect(flow.getNode('1')!.position.x).toBe(0)
|
|
1194
|
+
expect(flow.getNode('1')!.position.y).toBe(0)
|
|
1195
|
+
|
|
1196
|
+
flow.updateNodePosition('1', { x: 600, y: 600 })
|
|
1197
|
+
expect(flow.getNode('1')!.position.x).toBeLessThanOrEqual(500)
|
|
1198
|
+
expect(flow.getNode('1')!.position.y).toBeLessThanOrEqual(500)
|
|
1199
|
+
})
|
|
1200
|
+
|
|
1201
|
+
it('setNodeExtent changes boundaries dynamically', () => {
|
|
1202
|
+
const flow = createFlow({
|
|
1203
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
1204
|
+
})
|
|
1205
|
+
|
|
1206
|
+
// No extent — no clamping
|
|
1207
|
+
flow.updateNodePosition('1', { x: -999, y: -999 })
|
|
1208
|
+
expect(flow.getNode('1')!.position.x).toBe(-999)
|
|
1209
|
+
|
|
1210
|
+
// Set extent — large enough for default node size (150x40)
|
|
1211
|
+
flow.setNodeExtent([
|
|
1212
|
+
[0, 0],
|
|
1213
|
+
[500, 500],
|
|
1214
|
+
])
|
|
1215
|
+
flow.updateNodePosition('1', { x: -999, y: -999 })
|
|
1216
|
+
expect(flow.getNode('1')!.position.x).toBe(0)
|
|
1217
|
+
})
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
// ─── Undo / Redo ───────────────────────────────────────────────────────
|
|
1221
|
+
|
|
1222
|
+
describe('undo / redo', () => {
|
|
1223
|
+
it('undo restores previous state', () => {
|
|
1224
|
+
const flow = createFlow({
|
|
1225
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
1226
|
+
})
|
|
1227
|
+
|
|
1228
|
+
flow.pushHistory()
|
|
1229
|
+
flow.addNode({ id: '2', position: { x: 100, y: 0 }, data: {} })
|
|
1230
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
1231
|
+
|
|
1232
|
+
flow.undo()
|
|
1233
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
it('redo restores undone state', () => {
|
|
1237
|
+
const flow = createFlow({
|
|
1238
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
1239
|
+
})
|
|
1240
|
+
|
|
1241
|
+
flow.pushHistory()
|
|
1242
|
+
flow.addNode({ id: '2', position: { x: 100, y: 0 }, data: {} })
|
|
1243
|
+
|
|
1244
|
+
flow.undo()
|
|
1245
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
1246
|
+
|
|
1247
|
+
flow.redo()
|
|
1248
|
+
expect(flow.nodes()).toHaveLength(2)
|
|
1249
|
+
})
|
|
1250
|
+
})
|
|
1251
|
+
|
|
1252
|
+
// ─── Copy / Paste ──────────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
describe('copy / paste', () => {
|
|
1255
|
+
it('copies and pastes selected nodes', () => {
|
|
1256
|
+
const flow = createFlow({
|
|
1257
|
+
nodes: [
|
|
1258
|
+
{ id: '1', position: { x: 0, y: 0 }, data: { label: 'A' } },
|
|
1259
|
+
{ id: '2', position: { x: 200, y: 0 }, data: { label: 'B' } },
|
|
1260
|
+
],
|
|
1261
|
+
edges: [{ source: '1', target: '2' }],
|
|
1262
|
+
})
|
|
1263
|
+
|
|
1264
|
+
flow.selectNode('1')
|
|
1265
|
+
flow.selectNode('2', true)
|
|
1266
|
+
flow.copySelected()
|
|
1267
|
+
flow.paste()
|
|
1268
|
+
|
|
1269
|
+
expect(flow.nodes()).toHaveLength(4)
|
|
1270
|
+
// Pasted nodes should have different IDs
|
|
1271
|
+
const ids = flow.nodes().map((n) => n.id)
|
|
1272
|
+
expect(new Set(ids).size).toBe(4)
|
|
1273
|
+
})
|
|
1274
|
+
|
|
1275
|
+
it('paste without copy does nothing', () => {
|
|
1276
|
+
const flow = createFlow({
|
|
1277
|
+
nodes: [{ id: '1', position: { x: 0, y: 0 }, data: {} }],
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
flow.paste()
|
|
1281
|
+
expect(flow.nodes()).toHaveLength(1)
|
|
1282
|
+
})
|
|
1283
|
+
})
|
|
1284
|
+
})
|