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