w-flow-vue 1.0.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.
Files changed (144) hide show
  1. package/.editorconfig +9 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc.js +55 -0
  4. package/.jsdoc +25 -0
  5. package/AGENT.md +223 -0
  6. package/LICENSE +21 -0
  7. package/README.md +37 -0
  8. package/SECURITY.md +5 -0
  9. package/babel.config.js +16 -0
  10. package/dist/w-flow-vue.umd.js +15 -0
  11. package/dist/w-flow-vue.umd.js.map +1 -0
  12. package/docs/components_WFlowVue.vue.html +1214 -0
  13. package/docs/examples/app.html +62 -0
  14. package/docs/examples/app.umd.js +20 -0
  15. package/docs/examples/app.umd.js.map +1 -0
  16. package/docs/examples/ex-AppBasic.html +440 -0
  17. package/docs/examples/ex-AppConnectivity.html +131 -0
  18. package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
  19. package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
  20. package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
  21. package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
  22. package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
  23. package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
  24. package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
  25. package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
  26. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
  27. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
  28. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
  29. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
  30. package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
  31. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
  32. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
  33. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
  34. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
  35. package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
  36. package/docs/global.html +1919 -0
  37. package/docs/index.html +84 -0
  38. package/docs/js_defaults.mjs.html +105 -0
  39. package/docs/js_edge-path.mjs.html +237 -0
  40. package/docs/js_geometry.mjs.html +298 -0
  41. package/docs/js_graph.mjs.html +103 -0
  42. package/docs/js_step-routing.mjs.html +346 -0
  43. package/docs/module-WFlowVue.html +2790 -0
  44. package/docs/scripts/collapse.js +39 -0
  45. package/docs/scripts/commonNav.js +28 -0
  46. package/docs/scripts/linenumber.js +25 -0
  47. package/docs/scripts/nav.js +12 -0
  48. package/docs/scripts/polyfill.js +4 -0
  49. package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
  50. package/docs/scripts/prettify/lang-css.js +2 -0
  51. package/docs/scripts/prettify/prettify.js +28 -0
  52. package/docs/scripts/search.js +99 -0
  53. package/docs/styles/jsdoc.css +776 -0
  54. package/docs/styles/prettify.css +80 -0
  55. package/jest.config.js +20 -0
  56. package/package.json +80 -0
  57. package/public/index.html +38 -0
  58. package/script.txt +22 -0
  59. package/src/App.vue +326 -0
  60. package/src/AppBasic.vue +125 -0
  61. package/src/AppConnectivity.vue +186 -0
  62. package/src/components/WFlowVue.vue +1142 -0
  63. package/src/components/canvas/BackgroundLayer.vue +78 -0
  64. package/src/components/canvas/FlowCanvas.vue +64 -0
  65. package/src/components/canvas/SelectionBox.vue +36 -0
  66. package/src/components/canvas/ViewportTransform.vue +35 -0
  67. package/src/components/edges/ConnectionLine.vue +65 -0
  68. package/src/components/edges/EdgeMarkerDefs.vue +76 -0
  69. package/src/components/edges/EdgeRenderer.vue +120 -0
  70. package/src/components/edges/EdgeWrapper.vue +379 -0
  71. package/src/components/nodes/DefaultNode.vue +276 -0
  72. package/src/components/nodes/Handle.vue +101 -0
  73. package/src/components/nodes/InputNode.vue +47 -0
  74. package/src/components/nodes/NodeBody.vue +103 -0
  75. package/src/components/nodes/NodeFace.vue +128 -0
  76. package/src/components/nodes/NodeRenderer.vue +95 -0
  77. package/src/components/nodes/NodeWrapper.vue +475 -0
  78. package/src/components/nodes/OutputNode.vue +47 -0
  79. package/src/components/ui/ConnSettingsForm.vue +158 -0
  80. package/src/components/ui/Controls.vue +83 -0
  81. package/src/components/ui/NodeSettingsForm.vue +185 -0
  82. package/src/js/defaults.mjs +33 -0
  83. package/src/js/edge-path.mjs +165 -0
  84. package/src/js/geometry.mjs +226 -0
  85. package/src/js/graph.mjs +31 -0
  86. package/src/js/step-routing.mjs +274 -0
  87. package/src/main.js +22 -0
  88. package/test/WFlowVue-features.test.mjs +760 -0
  89. package/test/WFlowVue.test.mjs +421 -0
  90. package/test/components-canvas.test.mjs +102 -0
  91. package/test/components-edge.test.mjs +147 -0
  92. package/test/components-node.test.mjs +174 -0
  93. package/test/components-ui.test.mjs +69 -0
  94. package/test/defaults.test.mjs +86 -0
  95. package/test/edge-path.test.mjs +102 -0
  96. package/test/generate-routing-snapshots.mjs +77 -0
  97. package/test/generate-visual-baselines.mjs +206 -0
  98. package/test/geometry.test.mjs +236 -0
  99. package/test/graph.test.mjs +72 -0
  100. package/test/jsons/routing-snapshots.json +24994 -0
  101. package/test/pics/_check2.png +0 -0
  102. package/test/pics/_check3.png +0 -0
  103. package/test/pics/_check4.png +0 -0
  104. package/test/pics/_check5.png +0 -0
  105. package/test/pics/_v1.png +0 -0
  106. package/test/pics/_v2.png +0 -0
  107. package/test/pics/_v3.png +0 -0
  108. package/test/pics/_v4.png +0 -0
  109. package/test/pics/_v5.png +0 -0
  110. package/test/pics/_v6.png +0 -0
  111. package/test/pics/_v7.png +0 -0
  112. package/test/pics/vb-edge-hovered.png +0 -0
  113. package/test/pics/vb-edges-normal.png +0 -0
  114. package/test/pics/vb-locked-edge-hovered.png +0 -0
  115. package/test/pics/vb-locked-node-hovered.png +0 -0
  116. package/test/pics/vb-locked-node-selected.png +0 -0
  117. package/test/pics/vb-locked-overview.png +0 -0
  118. package/test/pics/vb-node-1.png +0 -0
  119. package/test/pics/vb-node-10.png +0 -0
  120. package/test/pics/vb-node-11.png +0 -0
  121. package/test/pics/vb-node-12.png +0 -0
  122. package/test/pics/vb-node-2.png +0 -0
  123. package/test/pics/vb-node-3.png +0 -0
  124. package/test/pics/vb-node-4.png +0 -0
  125. package/test/pics/vb-node-5.png +0 -0
  126. package/test/pics/vb-node-6.png +0 -0
  127. package/test/pics/vb-node-7.png +0 -0
  128. package/test/pics/vb-node-8.png +0 -0
  129. package/test/pics/vb-node-9.png +0 -0
  130. package/test/pics/vb-node-hovered.png +0 -0
  131. package/test/pics/vb-node-selected.png +0 -0
  132. package/test/pics/vb-overview.png +0 -0
  133. package/test/step-routing-connectivity.test.mjs +78 -0
  134. package/test/step-routing.test.mjs +88 -0
  135. package/test/visual-regression.test.mjs +274 -0
  136. package/toolg/addVersion.mjs +4 -0
  137. package/toolg/cleanFolder.mjs +4 -0
  138. package/toolg/gDistApp.mjs +34 -0
  139. package/toolg/gDistRollupComps.mjs +22 -0
  140. package/toolg/gDocExams.mjs +47 -0
  141. package/toolg/gExtractHtml.mjs +179 -0
  142. package/toolg/modifyReadme.mjs +4 -0
  143. package/vue.config.js +9 -0
  144. package/vue2/344/271/213foreignObject/345/205/247/346/270/262/346/237/223/345/225/217/351/241/214/350/210/207/344/277/256/346/255/243.md +151 -0
@@ -0,0 +1,760 @@
1
+ /**
2
+ * Feature tests: Settings Forms, Node Resize, Box Selection, Pan.
3
+ */
4
+ import { mount } from '@vue/test-utils'
5
+ import WFlowVue from '../src/components/WFlowVue.vue'
6
+ import NodeSettingsForm from '../src/components/ui/NodeSettingsForm.vue'
7
+ import ConnSettingsForm from '../src/components/ui/ConnSettingsForm.vue'
8
+
9
+ const sampleNodes = [
10
+ { id: '1', type: 'input', name: 'Node 1', position: { x: 50, y: 50 }, width: 100, height: 40 },
11
+ { id: '2', type: 'output', name: 'Node 2', position: { x: 300, y: 300 }, width: 100, height: 40 },
12
+ { id: '3', type: 'basic', name: 'Node 3', position: { x: 200, y: 150 }, width: 100, height: 40 },
13
+ ]
14
+ const sampleConns = [
15
+ { id: 'e1-3', from: '1', to: '3', name: 'conn 1-3' },
16
+ { id: 'e3-2', from: '3', to: '2', name: 'conn 3-2', markerEnd: 'arrowclosed' },
17
+ ]
18
+ const defNode = {
19
+ type: 'basic', shape: 'rectangle', width: 100, height: 40,
20
+ fontSize: 12, fontSizeMin: 1, fontSizeMax: 72,
21
+ fontColor: '#333333', faceColor: '#ffffff', edgeColor: '#bbbbbb', edgeWidth: 1,
22
+ toPosition: 'bottom', fromPosition: 'top', popupDirection: 'right',
23
+ }
24
+ const defConn = {
25
+ type: 'bezier', fontSize: 10, fontSizeMin: 1, fontSizeMax: 72,
26
+ fontColor: '#333333', edgeColor: '#b1b1b7', edgeWidth: 1,
27
+ edgeDasharray: '', markerEnd: '', animated: false, defOffset: 24,
28
+ }
29
+
30
+ function createWrapper(optOverrides = {}) {
31
+ return mount(WFlowVue, {
32
+ propsData: {
33
+ opt: {
34
+ nodes: JSON.parse(JSON.stringify(sampleNodes)),
35
+ conns: JSON.parse(JSON.stringify(sampleConns)),
36
+ ...optOverrides,
37
+ },
38
+ },
39
+ attachTo: document.body,
40
+ })
41
+ }
42
+
43
+ // 1. NodeSettingsForm
44
+ describe('NodeSettingsForm', () => {
45
+ const node = { id: '1', type: 'basic', name: 'Test', description: 'desc', fontSize: 14, fontColor: '#000', faceColor: '#fff', edgeColor: '#ccc', edgeWidth: 2, shape: 'rectangle', popupDirection: 'right', fromPosition: 'top', toPosition: 'bottom' }
46
+ function mountForm(ov = {}) {
47
+ return mount(NodeSettingsForm, { propsData: { node: { ...node, ...ov }, defNode } })
48
+ }
49
+
50
+ test('renders text inputs', () => { const w = mountForm(); expect(w.findAll('input[type="text"]').length).toBe(3); w.destroy() })
51
+ test('emits update on name', async () => { const w = mountForm(); await w.findAll('input[type="text"]').at(0).setValue('X'); expect(w.emitted('update')[0]).toEqual(['name', 'X']); w.destroy() })
52
+ test('emits update on description', async () => { const w = mountForm(); await w.findAll('input[type="text"]').at(1).setValue('D'); expect(w.emitted('update')[0]).toEqual(['description', 'D']); w.destroy() })
53
+ test('emits update on type', async () => { const w = mountForm(); const s = w.findAll('select').at(0); s.element.value = 'output'; await s.trigger('input'); expect(w.emitted('update').some(e => e[0] === 'type')).toBe(true); w.destroy() })
54
+ test('emits update on shape', async () => { const w = mountForm(); const s = w.findAll('select').at(1); s.element.value = 'diamond'; await s.trigger('input'); expect(w.emitted('update').some(e => e[0] === 'shape')).toBe(true); w.destroy() })
55
+ test('fontSize ignores below min', () => { const w = mountForm(); w.vm.onFontSizeInput('0'); expect(w.emitted('update')).toBeFalsy(); w.destroy() })
56
+ test('fontSize clamps to max', () => { const w = mountForm(); w.vm.onFontSizeInput('100'); expect(w.emitted('update')[0]).toEqual(['fontSize', 72]); w.destroy() })
57
+ test('fontSize accepts valid', () => { const w = mountForm(); w.vm.onFontSizeInput('20'); expect(w.emitted('update')[0]).toEqual(['fontSize', 20]); w.destroy() })
58
+ test('edgeWidth ignores below 1', () => { const w = mountForm(); w.vm.onEdgeWidthInput('0'); expect(w.emitted('update')).toBeFalsy(); w.destroy() })
59
+ test('edgeWidth clamps to 24', () => { const w = mountForm(); w.vm.onEdgeWidthInput('30'); expect(w.emitted('update')[0]).toEqual(['edgeWidth', 24]); w.destroy() })
60
+ test('delete confirm then emit', async () => {
61
+ const w = mountForm()
62
+ expect(w.find('.vue-flow__delete-warn').exists()).toBe(false)
63
+ await w.find('.vue-flow__delete-btn').trigger('click')
64
+ expect(w.find('.vue-flow__delete-warn').exists()).toBe(true)
65
+ await w.find('.vue-flow__delete-btn--confirm').trigger('click')
66
+ expect(w.emitted('delete')).toBeTruthy()
67
+ w.destroy()
68
+ })
69
+ test('delete cancel', async () => {
70
+ const w = mountForm()
71
+ await w.find('.vue-flow__delete-btn').trigger('click')
72
+ await w.find('.vue-flow__delete-btn--cancel').trigger('click')
73
+ expect(w.find('.vue-flow__delete-warn').exists()).toBe(false)
74
+ w.destroy()
75
+ })
76
+ test('textFontSize prop', () => {
77
+ const w = mount(NodeSettingsForm, { propsData: { node, defNode, textFontSize: '16px' } })
78
+ expect(w.find('.vue-flow__settings-form').element.style.fontSize).toBe('16px')
79
+ w.destroy()
80
+ })
81
+ test('basic shows from/to handle', () => {
82
+ const w = mountForm({ type: 'basic' })
83
+ const t = w.findAll('label').wrappers.map(l => l.text())
84
+ expect(t.some(x => x.includes('From Handle'))).toBe(true)
85
+ expect(t.some(x => x.includes('To Handle'))).toBe(true)
86
+ w.destroy()
87
+ })
88
+ test('input hides from handle', () => {
89
+ const w = mountForm({ type: 'input' })
90
+ const t = w.findAll('label').wrappers.map(l => l.text())
91
+ expect(t.some(x => x.includes('From Handle'))).toBe(false)
92
+ w.destroy()
93
+ })
94
+ })
95
+
96
+ // 2. ConnSettingsForm
97
+ describe('ConnSettingsForm', () => {
98
+ const conn = { id: 'e1', from: '1', to: '2', name: 'C', type: 'bezier', fontSize: 10, fontColor: '#333', edgeColor: '#b1b1b7', edgeWidth: 1, markerEnd: '', animated: false }
99
+ function mountForm(ov = {}) {
100
+ return mount(ConnSettingsForm, { propsData: { conn: { ...conn, ...ov }, defConn } })
101
+ }
102
+
103
+ test('renders text inputs', () => { const w = mountForm(); expect(w.findAll('input[type="text"]').length).toBe(3); w.destroy() })
104
+ test('emits update on name', async () => { const w = mountForm(); await w.findAll('input[type="text"]').at(0).setValue('N'); expect(w.emitted('update')[0]).toEqual(['name', 'N']); w.destroy() })
105
+ test('emits update on type', async () => { const w = mountForm(); const s = w.findAll('select').at(0); s.element.value = 'step'; await s.trigger('input'); expect(w.emitted('update').some(e => e[0] === 'type')).toBe(true); w.destroy() })
106
+ test('emits update on animated', async () => { const w = mountForm(); await w.find('input[type="checkbox"]').setChecked(true); expect(w.emitted('update').some(e => e[0] === 'animated')).toBe(true); w.destroy() })
107
+ test('emits update on markerEnd', async () => { const w = mountForm(); const s = w.findAll('select').at(1); s.element.value = 'arrowclosed'; await s.trigger('input'); expect(w.emitted('update').some(e => e[0] === 'markerEnd')).toBe(true); w.destroy() })
108
+ test('fontSize clamps', () => { const w = mountForm(); w.vm.onFontSizeInput('100'); expect(w.emitted('update')[0]).toEqual(['fontSize', 72]); w.destroy() })
109
+ test('edgeWidth clamps', () => { const w = mountForm(); w.vm.onEdgeWidthInput('30'); expect(w.emitted('update')[0]).toEqual(['edgeWidth', 24]); w.destroy() })
110
+ test('delete confirm', async () => {
111
+ const w = mountForm()
112
+ await w.find('.vue-flow__delete-btn').trigger('click')
113
+ await w.find('.vue-flow__delete-btn--confirm').trigger('click')
114
+ expect(w.emitted('delete')).toBeTruthy()
115
+ w.destroy()
116
+ })
117
+ test('textFontSize prop', () => {
118
+ const w = mount(ConnSettingsForm, { propsData: { conn, defConn, textFontSize: '14px' } })
119
+ expect(w.find('.vue-flow__settings-form').element.style.fontSize).toBe('14px')
120
+ w.destroy()
121
+ })
122
+ })
123
+
124
+ // 3. Node Resize
125
+ describe('Node Resize', () => {
126
+ test('onNodeResize sets overlay', () => {
127
+ const w = createWrapper(); w.vm.onNodeResize({ nodeId: '1', width: 200, height: 80, x: 50, y: 50 })
128
+ expect(w.vm.resizeOverlay).toEqual({ id: '1', width: 200, height: 80, x: 50, y: 50 }); w.destroy()
129
+ })
130
+ test('onNodeResizeEnd updates node', () => {
131
+ const w = createWrapper()
132
+ w.vm.onNodeResize({ nodeId: '1', width: 200, height: 80, x: 60, y: 70 })
133
+ w.vm.onNodeResizeEnd({ nodeId: '1', width: 200, height: 80, x: 60, y: 70 })
134
+ const n = w.vm.nodeById('1')
135
+ expect(n.width).toBe(200); expect(n.height).toBe(80)
136
+ expect(n.position.x).toBe(60); expect(n.position.y).toBe(70)
137
+ expect(w.vm.resizeOverlay).toBeNull(); w.destroy()
138
+ })
139
+ test('blocked when locked', () => {
140
+ const w = createWrapper(); w.vm.toggleInteractive()
141
+ w.vm.onNodeResize({ nodeId: '1', width: 200, height: 80, x: 50, y: 50 })
142
+ expect(w.vm.resizeOverlay).toBeNull(); w.destroy()
143
+ })
144
+ test('emits nodes-change', () => {
145
+ const w = createWrapper()
146
+ w.vm.onNodeResize({ nodeId: '1', width: 200, height: 80, x: 50, y: 50 })
147
+ w.vm.onNodeResizeEnd({ nodeId: '1', width: 200, height: 80, x: 50, y: 50 })
148
+ expect(w.emitted('update:nodes')).toBeTruthy(); w.destroy()
149
+ })
150
+ test('nodesResizable=false hides handles', async () => {
151
+ const w = createWrapper({ nodesResizable: false }); await w.vm.$nextTick()
152
+ expect(w.findAll('.vue-flow__resize--top-left').length).toBe(0); w.destroy()
153
+ })
154
+ })
155
+
156
+ // 4. Box Selection
157
+ describe('Box Selection', () => {
158
+ test('startSelection sets state', () => {
159
+ const w = createWrapper()
160
+ w.vm.$refs.canvas = { getContainerRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) }
161
+ w.vm.startSelection({ clientX: 100, clientY: 100 })
162
+ expect(w.vm.isSelecting).toBe(true)
163
+ expect(w.vm.selectionBox).toEqual({ x: 100, y: 100, width: 0, height: 0 }); w.destroy()
164
+ })
165
+ test('doSelection updates box', () => {
166
+ const w = createWrapper()
167
+ w.vm.$refs.canvas = { getContainerRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) }
168
+ w.vm.startSelection({ clientX: 100, clientY: 100 })
169
+ w.vm.doSelection({ clientX: 300, clientY: 250 })
170
+ expect(w.vm.selectionBox).toEqual({ x: 100, y: 100, width: 200, height: 150 }); w.destroy()
171
+ })
172
+ test('doSelection reverse drag', () => {
173
+ const w = createWrapper()
174
+ w.vm.$refs.canvas = { getContainerRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) }
175
+ w.vm.startSelection({ clientX: 300, clientY: 300 })
176
+ w.vm.doSelection({ clientX: 100, clientY: 100 })
177
+ expect(w.vm.selectionBox).toEqual({ x: 100, y: 100, width: 200, height: 200 }); w.destroy()
178
+ })
179
+ test('endSelection selects nodes', () => {
180
+ const w = createWrapper()
181
+ w.vm.viewport = { x: 0, y: 0, zoom: 1 }
182
+ w.vm.selectionBox = { x: 40, y: 40, width: 270, height: 160 }
183
+ w.vm.isSelecting = true; w.vm.endSelection()
184
+ expect(w.vm.selectedNodes).toContain('1')
185
+ expect(w.vm.selectedNodes).toContain('3')
186
+ expect(w.vm.isSelecting).toBe(false); w.destroy()
187
+ })
188
+ test('endSelection auto-selects conns', () => {
189
+ const w = createWrapper()
190
+ w.vm.viewport = { x: 0, y: 0, zoom: 1 }
191
+ w.vm.selectionBox = { x: 40, y: 40, width: 270, height: 160 }
192
+ w.vm.isSelecting = true; w.vm.endSelection()
193
+ expect(w.vm.selectedConns).toContain('e1-3')
194
+ expect(w.vm.selectedConns).not.toContain('e3-2'); w.destroy()
195
+ })
196
+ test('endSelection clears state', () => {
197
+ const w = createWrapper()
198
+ w.vm.selectionBox = { x: 0, y: 0, width: 0, height: 0 }
199
+ w.vm.isSelecting = true; w.vm.selectionStartPos = { x: 0, y: 0 }
200
+ w.vm.endSelection()
201
+ expect(w.vm.isSelecting).toBe(false)
202
+ expect(w.vm.selectionStartPos).toBeNull()
203
+ expect(w.vm.selectionBox).toBeNull(); w.destroy()
204
+ })
205
+ })
206
+
207
+ // 5. Pan
208
+ describe('Pan', () => {
209
+ test('startPan sets state', () => {
210
+ const w = createWrapper(); w.vm.startPan({ clientX: 100, clientY: 200 })
211
+ expect(w.vm.isPanning).toBe(true)
212
+ expect(w.vm.panStartPos).toEqual({ x: 100, y: 200 }); w.destroy()
213
+ })
214
+ test('doPan updates viewport', () => {
215
+ const w = createWrapper(); w.vm.viewport = { x: 0, y: 0, zoom: 1 }
216
+ w.vm.startPan({ clientX: 100, clientY: 100 })
217
+ w.vm.doPan({ clientX: 150, clientY: 120 })
218
+ expect(w.vm.viewport.x).toBe(50); expect(w.vm.viewport.y).toBe(20); w.destroy()
219
+ })
220
+ test('doPan accumulates', () => {
221
+ const w = createWrapper(); w.vm.viewport = { x: 0, y: 0, zoom: 1 }
222
+ w.vm.startPan({ clientX: 100, clientY: 100 })
223
+ w.vm.doPan({ clientX: 150, clientY: 100 })
224
+ w.vm.doPan({ clientX: 200, clientY: 130 })
225
+ expect(w.vm.viewport.x).toBe(100); expect(w.vm.viewport.y).toBe(30); w.destroy()
226
+ })
227
+ test('doPan respects panLimits max', () => {
228
+ const w = createWrapper({ panLimits: [[0, 0], [200, 200]] })
229
+ w.vm.viewport = { x: 100, y: 100, zoom: 1 }
230
+ w.vm.startPan({ clientX: 0, clientY: 0 })
231
+ w.vm.doPan({ clientX: 500, clientY: 500 })
232
+ expect(w.vm.viewport.x).toBeLessThanOrEqual(200)
233
+ expect(w.vm.viewport.y).toBeLessThanOrEqual(200); w.destroy()
234
+ })
235
+ test('doPan respects panLimits min', () => {
236
+ const w = createWrapper({ panLimits: [[0, 0], [200, 200]] })
237
+ w.vm.viewport = { x: 100, y: 100, zoom: 1 }
238
+ w.vm.startPan({ clientX: 100, clientY: 100 })
239
+ w.vm.doPan({ clientX: -500, clientY: -500 })
240
+ expect(w.vm.viewport.x).toBeGreaterThanOrEqual(0)
241
+ expect(w.vm.viewport.y).toBeGreaterThanOrEqual(0); w.destroy()
242
+ })
243
+ test('endPan clears state', () => {
244
+ const w = createWrapper(); w.vm.startPan({ clientX: 100, clientY: 100 }); w.vm.endPan()
245
+ expect(w.vm.isPanning).toBe(false); expect(w.vm.panStartPos).toBeNull(); w.destroy()
246
+ })
247
+ test('endPan emits viewport-change', () => {
248
+ const w = createWrapper(); w.vm.startPan({ clientX: 100, clientY: 100 }); w.vm.endPan()
249
+ expect(w.emitted('viewport-change')).toBeTruthy(); w.destroy()
250
+ })
251
+ })
252
+
253
+ // 6. fitView
254
+ describe('fitView', () => {
255
+ test('adjusts viewport to fit all nodes', () => {
256
+ const w = createWrapper()
257
+ sampleNodes.forEach(n => w.vm.updateNodeInternals(n.id, { width: n.width, height: n.height }))
258
+ w.vm.viewport = { x: 999, y: 999, zoom: 0.1 }
259
+ w.vm.fitView()
260
+ // fitView recalculates viewport — x,y should change from 999
261
+ expect(w.vm.viewport.x).not.toBe(999)
262
+ expect(w.vm.viewport.y).not.toBe(999)
263
+ // zoom should be finite (may be 0 if container has no size in jsdom)
264
+ expect(isFinite(w.vm.viewport.zoom)).toBe(true)
265
+ w.destroy()
266
+ })
267
+ test('emits viewport-change', () => {
268
+ const w = createWrapper()
269
+ w.vm.fitView()
270
+ expect(w.emitted('viewport-change')).toBeTruthy()
271
+ w.destroy()
272
+ })
273
+ test('respects padding parameter', () => {
274
+ const w = createWrapper()
275
+ w.vm.fitView(50)
276
+ const z1 = w.vm.viewport.zoom
277
+ w.vm.fitView(200)
278
+ const z2 = w.vm.viewport.zoom
279
+ // Larger padding → smaller zoom (more margin)
280
+ expect(z2).toBeLessThanOrEqual(z1)
281
+ w.destroy()
282
+ })
283
+ test('does not exceed max zoom', () => {
284
+ const w = createWrapper({ zoomMax: 1.5 })
285
+ w.vm.fitView()
286
+ expect(w.vm.viewport.zoom).toBeLessThanOrEqual(1.5)
287
+ w.destroy()
288
+ })
289
+ })
290
+
291
+ // 8. Multi-select (Shift+Click)
292
+ describe('Multi-select', () => {
293
+ test('normal click selects single node', () => {
294
+ const w = createWrapper()
295
+ w.vm.onNodeClick({ node: { id: '1' }, event: { target: { closest: () => null } } })
296
+ expect(w.vm.selectedNodes).toEqual(['1'])
297
+ w.vm.onNodeClick({ node: { id: '3' }, event: { target: { closest: () => null } } })
298
+ expect(w.vm.selectedNodes).toEqual(['3'])
299
+ w.destroy()
300
+ })
301
+ test('shift+click adds node to selection', () => {
302
+ const w = createWrapper()
303
+ w.vm.onNodeClick({ node: { id: '1' }, event: { target: { closest: () => null } } })
304
+ expect(w.vm.selectedNodes).toEqual(['1'])
305
+ // Simulate shift pressed
306
+ w.vm.keysPressed = { Shift: true }
307
+ w.vm.onNodeClick({ node: { id: '3' }, event: { target: { closest: () => null } } })
308
+ expect(w.vm.selectedNodes).toContain('1')
309
+ expect(w.vm.selectedNodes).toContain('3')
310
+ w.destroy()
311
+ })
312
+ test('shift+click removes already selected node', () => {
313
+ const w = createWrapper()
314
+ w.vm.setSelectedNodes(['1', '3'])
315
+ w.vm.keysPressed = { Shift: true }
316
+ w.vm.onNodeClick({ node: { id: '1' }, event: { target: { closest: () => null } } })
317
+ expect(w.vm.selectedNodes).not.toContain('1')
318
+ expect(w.vm.selectedNodes).toContain('3')
319
+ w.destroy()
320
+ })
321
+ test('normal click on conn selects single conn', () => {
322
+ const w = createWrapper()
323
+ w.vm.onConnClick({ conn: { id: 'e1-3' }, event: { clientX: 100, clientY: 100 } })
324
+ expect(w.vm.selectedConns).toEqual(['e1-3'])
325
+ w.destroy()
326
+ })
327
+ test('shift+click adds conn to selection', () => {
328
+ const w = createWrapper()
329
+ w.vm.onConnClick({ conn: { id: 'e1-3' }, event: { clientX: 100, clientY: 100 } })
330
+ w.vm.keysPressed = { Shift: true }
331
+ w.vm.onConnClick({ conn: { id: 'e3-2' }, event: { clientX: 100, clientY: 100 } })
332
+ expect(w.vm.selectedConns).toContain('e1-3')
333
+ expect(w.vm.selectedConns).toContain('e3-2')
334
+ w.destroy()
335
+ })
336
+ test('multiSelectEnabled=false disables shift+click', () => {
337
+ const w = createWrapper({ multiSelectEnabled: false })
338
+ w.vm.onNodeClick({ node: { id: '1' }, event: { target: { closest: () => null } } })
339
+ w.vm.keysPressed = { Shift: true }
340
+ w.vm.onNodeClick({ node: { id: '3' }, event: { target: { closest: () => null } } })
341
+ // Should replace, not add
342
+ expect(w.vm.selectedNodes).toEqual(['3'])
343
+ w.destroy()
344
+ })
345
+ })
346
+
347
+ // 9. Snap-to-Grid integration
348
+ describe('Snap-to-Grid integration', () => {
349
+ test('snapToGrid=false: resize overlay not snapped', () => {
350
+ const w = createWrapper({ snapToGrid: false })
351
+ // Directly call onNodeResize with non-round values
352
+ w.vm.onNodeResize({ nodeId: '1', width: 137, height: 63, x: 51, y: 49 })
353
+ expect(w.vm.resizeOverlay.width).toBe(137)
354
+ expect(w.vm.resizeOverlay.height).toBe(63)
355
+ w.destroy()
356
+ })
357
+ test('snapGridSize passed to NodeRenderer', () => {
358
+ const w = createWrapper({ snapToGrid: true, snapGridSize: 25 })
359
+ const nr = w.findComponent({ name: 'NodeRenderer' })
360
+ expect(nr.props('snapGridSize')).toBe(25)
361
+ w.destroy()
362
+ })
363
+ test('snapToGrid=false passes null to NodeRenderer', () => {
364
+ const w = createWrapper({ snapToGrid: false })
365
+ const nr = w.findComponent({ name: 'NodeRenderer' })
366
+ expect(nr.props('snapGridSize')).toBeNull()
367
+ w.destroy()
368
+ })
369
+ })
370
+
371
+ // 10. EdgeMarkerDefs ID consistency
372
+ describe('EdgeMarkerDefs ID consistency', () => {
373
+ test('marker ID matches between EdgeMarkerDefs and EdgeWrapper', () => {
374
+ const w = createWrapper()
375
+ // Find EdgeWrapper that has markerEnd='arrowclosed' (conn e3-2)
376
+ const ews = w.findAllComponents({ name: 'EdgeWrapper' })
377
+ const ew = ews.wrappers.find(e => e.props('conn').id === 'e3-2')
378
+ const url = ew.vm.markerEndUrl
379
+ // Extract ID from url(#...)
380
+ const match = url && url.match(/url\(#(.+)\)/)
381
+ expect(match).toBeTruthy()
382
+ const refId = match[1]
383
+ // Check EdgeMarkerDefs has a marker with this ID
384
+ const defs = w.findComponent({ name: 'EdgeMarkerDefs' })
385
+ const markers = defs.vm.markers
386
+ expect(markers.some(m => m.id === refId)).toBe(true)
387
+ w.destroy()
388
+ })
389
+ test('string marker and object marker produce same ID', () => {
390
+ const w = createWrapper()
391
+ const defs = w.findComponent({ name: 'EdgeMarkerDefs' })
392
+ const id1 = defs.vm.getMarkerId('arrowclosed')
393
+ const id2 = defs.vm.getMarkerId({ type: 'arrowclosed' })
394
+ expect(id1).toBe(id2)
395
+ w.destroy()
396
+ })
397
+ })
398
+
399
+ // 12. EdgeWrapper.getMarkerUrl
400
+ describe('EdgeWrapper.getMarkerUrl', () => {
401
+ test('string marker returns correct URL', () => {
402
+ const w = createWrapper()
403
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
404
+ const url = ew.vm.getMarkerUrl('arrowclosed')
405
+ expect(url).toBe('url(#vue-flow__arrowclosed_b1b1b7)')
406
+ w.destroy()
407
+ })
408
+ test('object marker with color', () => {
409
+ const w = createWrapper()
410
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
411
+ const url = ew.vm.getMarkerUrl({ type: 'arrow', color: '#ff0000' })
412
+ expect(url).toBe('url(#vue-flow__arrow_ff0000)')
413
+ w.destroy()
414
+ })
415
+ test('object marker without color uses default', () => {
416
+ const w = createWrapper()
417
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
418
+ const url = ew.vm.getMarkerUrl({ type: 'arrow' })
419
+ expect(url).toBe('url(#vue-flow__arrow_b1b1b7)')
420
+ w.destroy()
421
+ })
422
+ test('null marker returns null', () => {
423
+ const w = createWrapper()
424
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
425
+ expect(ew.vm.getMarkerUrl(null)).toBeNull()
426
+ expect(ew.vm.getMarkerUrl('')).toBeNull()
427
+ w.destroy()
428
+ })
429
+ })
430
+
431
+ // 13. onDocMouseMove dispatching
432
+ describe('onDocMouseMove dispatching', () => {
433
+ test('dispatches to doPan when panning', () => {
434
+ const w = createWrapper()
435
+ w.vm.viewport = { x: 0, y: 0, zoom: 1 }
436
+ w.vm.startPan({ clientX: 100, clientY: 100 })
437
+ w.vm.onDocMouseMove({ clientX: 150, clientY: 120 })
438
+ expect(w.vm.viewport.x).toBe(50)
439
+ expect(w.vm.viewport.y).toBe(20)
440
+ w.destroy()
441
+ })
442
+ test('dispatches to doSelection when selecting', () => {
443
+ const w = createWrapper()
444
+ w.vm.$refs.canvas = { getContainerRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) }
445
+ w.vm.startSelection({ clientX: 100, clientY: 100 })
446
+ w.vm.onDocMouseMove({ clientX: 200, clientY: 200 })
447
+ expect(w.vm.selectionBox.width).toBe(100)
448
+ expect(w.vm.selectionBox.height).toBe(100)
449
+ w.destroy()
450
+ })
451
+ test('does nothing when idle', () => {
452
+ const w = createWrapper()
453
+ w.vm.viewport = { x: 0, y: 0, zoom: 1 }
454
+ w.vm.onDocMouseMove({ clientX: 200, clientY: 200 })
455
+ expect(w.vm.viewport.x).toBe(0)
456
+ w.destroy()
457
+ })
458
+ })
459
+
460
+ // 14. onDocMouseUp dispatching
461
+ describe('onDocMouseUp dispatching', () => {
462
+ test('ends pan', () => {
463
+ const w = createWrapper()
464
+ w.vm.startPan({ clientX: 100, clientY: 100 })
465
+ expect(w.vm.isPanning).toBe(true)
466
+ w.vm.onDocMouseUp({})
467
+ expect(w.vm.isPanning).toBe(false)
468
+ w.destroy()
469
+ })
470
+ test('ends selection', () => {
471
+ const w = createWrapper()
472
+ w.vm.$refs.canvas = { getContainerRect: () => ({ left: 0, top: 0, width: 800, height: 600 }) }
473
+ w.vm.startSelection({ clientX: 100, clientY: 100 })
474
+ expect(w.vm.isSelecting).toBe(true)
475
+ w.vm.selectionBox = { x: 0, y: 0, width: 10, height: 10 }
476
+ w.vm.onDocMouseUp({})
477
+ expect(w.vm.isSelecting).toBe(false)
478
+ w.destroy()
479
+ })
480
+ test('does nothing when idle', () => {
481
+ const w = createWrapper()
482
+ expect(() => w.vm.onDocMouseUp({})).not.toThrow()
483
+ w.destroy()
484
+ })
485
+ })
486
+
487
+ // 15. getSelectedElements
488
+ describe('getSelectedElements', () => {
489
+ test('returns selected nodes and conns', () => {
490
+ const w = createWrapper()
491
+ w.vm.setSelectedNodes(['1', '3'])
492
+ w.vm.setSelectedConns(['e1-3'])
493
+ const sel = w.vm.getSelectedElements()
494
+ expect(sel.nodes.map(n => n.id)).toEqual(['1', '3'])
495
+ expect(sel.conns.map(c => c.id)).toEqual(['e1-3'])
496
+ w.destroy()
497
+ })
498
+ test('returns empty when nothing selected', () => {
499
+ const w = createWrapper()
500
+ const sel = w.vm.getSelectedElements()
501
+ expect(sel.nodes).toEqual([])
502
+ expect(sel.conns).toEqual([])
503
+ w.destroy()
504
+ })
505
+ test('ignores non-existent IDs', () => {
506
+ const w = createWrapper()
507
+ w.vm.setSelectedNodes(['1', 'nonexistent'])
508
+ const sel = w.vm.getSelectedElements()
509
+ expect(sel.nodes.length).toBe(1)
510
+ expect(sel.nodes[0].id).toBe('1')
511
+ w.destroy()
512
+ })
513
+ })
514
+
515
+ // 16. updateNodeInternals
516
+ describe('updateNodeInternals', () => {
517
+ test('stores dimensions', () => {
518
+ const w = createWrapper()
519
+ w.vm.updateNodeInternals('1', { width: 200, height: 80 })
520
+ expect(w.vm.nodeInternals['1']).toEqual({ width: 200, height: 80 })
521
+ w.destroy()
522
+ })
523
+ test('skips update when same dimensions', () => {
524
+ const w = createWrapper()
525
+ w.vm.updateNodeInternals('1', { width: 200, height: 80 })
526
+ const ref1 = w.vm.nodeInternals['1']
527
+ w.vm.updateNodeInternals('1', { width: 200, height: 80 })
528
+ // Should be same reference (no $set called)
529
+ expect(w.vm.nodeInternals['1']).toBe(ref1)
530
+ w.destroy()
531
+ })
532
+ test('updates when dimensions change', () => {
533
+ const w = createWrapper()
534
+ w.vm.updateNodeInternals('1', { width: 200, height: 80 })
535
+ w.vm.updateNodeInternals('1', { width: 300, height: 90 })
536
+ expect(w.vm.nodeInternals['1']).toEqual({ width: 300, height: 90 })
537
+ w.destroy()
538
+ })
539
+ })
540
+
541
+ // 18. Settings icon visibility rules
542
+ describe('Settings icon visibility', () => {
543
+ describe('NodeWrapper', () => {
544
+ test('hidden when not hovered', () => {
545
+ const w = createWrapper()
546
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
547
+ expect(nw.vm.hovered).toBe(false)
548
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(false)
549
+ w.destroy()
550
+ })
551
+
552
+ test('shown when hovered', async () => {
553
+ const w = createWrapper()
554
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
555
+ nw.vm.hovered = true
556
+ await w.vm.$nextTick()
557
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(true)
558
+ w.destroy()
559
+ })
560
+
561
+ test('stays when popup open and mouse leaves', async () => {
562
+ const w = createWrapper()
563
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
564
+ nw.vm.hovered = true
565
+ nw.vm.settingsPopupShow = true
566
+ await w.vm.$nextTick()
567
+ nw.vm.hovered = false
568
+ await w.vm.$nextTick()
569
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(true)
570
+ w.destroy()
571
+ })
572
+
573
+ test('hides when popup closed and not hovered', async () => {
574
+ const w = createWrapper()
575
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
576
+ nw.vm.hovered = false
577
+ nw.vm.settingsPopupShow = false
578
+ await w.vm.$nextTick()
579
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(false)
580
+ w.destroy()
581
+ })
582
+
583
+ test('hidden when locked even if hovered', async () => {
584
+ const w = createWrapper()
585
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
586
+ nw.vm.hovered = true
587
+ await w.vm.$nextTick()
588
+ w.vm.toggleInteractive()
589
+ await w.vm.$nextTick()
590
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(false)
591
+ w.destroy()
592
+ })
593
+ })
594
+
595
+ describe('EdgeWrapper', () => {
596
+ test('hidden when not hovered', () => {
597
+ const w = createWrapper()
598
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
599
+ expect(ew.vm.hovered).toBe(false)
600
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(false)
601
+ w.destroy()
602
+ })
603
+
604
+ test('shown when hovered', async () => {
605
+ const w = createWrapper()
606
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
607
+ ew.vm.hovered = true
608
+ await w.vm.$nextTick()
609
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(true)
610
+ w.destroy()
611
+ })
612
+
613
+ test('stays when popup open and mouse leaves', async () => {
614
+ const w = createWrapper()
615
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
616
+ ew.vm.hovered = true
617
+ ew.vm.settingsPopupShow = true
618
+ await w.vm.$nextTick()
619
+ ew.vm.hovered = false
620
+ await w.vm.$nextTick()
621
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(true)
622
+ w.destroy()
623
+ })
624
+
625
+ test('hides when popup closed and not hovered', async () => {
626
+ const w = createWrapper()
627
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
628
+ ew.vm.hovered = false
629
+ ew.vm.settingsPopupShow = false
630
+ await w.vm.$nextTick()
631
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(false)
632
+ w.destroy()
633
+ })
634
+
635
+ test('hidden when locked even if hovered', async () => {
636
+ const w = createWrapper()
637
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
638
+ ew.vm.hovered = true
639
+ await w.vm.$nextTick()
640
+ w.vm.toggleInteractive()
641
+ await w.vm.$nextTick()
642
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(false)
643
+ w.destroy()
644
+ })
645
+ })
646
+ })
647
+
648
+ // 19. Settings popup open/close
649
+ describe('Settings popup open/close', () => {
650
+ describe('NodeWrapper', () => {
651
+ test('clicking settings icon opens popup', async () => {
652
+ const w = createWrapper()
653
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
654
+ nw.vm.hovered = true
655
+ await w.vm.$nextTick()
656
+ expect(nw.vm.settingsPopupShow).toBe(false)
657
+ // Simulate WPopup opening (v-model sets settingsPopupShow)
658
+ nw.vm.settingsPopupShow = true
659
+ await w.vm.$nextTick()
660
+ expect(nw.vm.settingsPopupShow).toBe(true)
661
+ w.destroy()
662
+ })
663
+
664
+ test('popup remains open when mouse leaves node', async () => {
665
+ const w = createWrapper()
666
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
667
+ nw.vm.hovered = true
668
+ nw.vm.settingsPopupShow = true
669
+ await w.vm.$nextTick()
670
+ // Mouse leaves
671
+ nw.vm.hovered = false
672
+ await w.vm.$nextTick()
673
+ // Popup still open
674
+ expect(nw.vm.settingsPopupShow).toBe(true)
675
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(true)
676
+ w.destroy()
677
+ })
678
+
679
+ test('popup closes and anchor hides after popup dismissed', async () => {
680
+ const w = createWrapper()
681
+ const nw = w.findAllComponents({ name: 'NodeWrapper' }).at(0)
682
+ nw.vm.hovered = false
683
+ nw.vm.settingsPopupShow = true
684
+ await w.vm.$nextTick()
685
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(true)
686
+ // Popup dismissed (WPopup sets v-model to false)
687
+ nw.vm.settingsPopupShow = false
688
+ await w.vm.$nextTick()
689
+ expect(nw.find('.vue-flow__node-settings-anchor').exists()).toBe(false)
690
+ w.destroy()
691
+ })
692
+ })
693
+
694
+ describe('EdgeWrapper', () => {
695
+ test('clicking settings icon opens popup', async () => {
696
+ const w = createWrapper()
697
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
698
+ ew.vm.hovered = true
699
+ await w.vm.$nextTick()
700
+ expect(ew.vm.settingsPopupShow).toBe(false)
701
+ ew.vm.settingsPopupShow = true
702
+ await w.vm.$nextTick()
703
+ expect(ew.vm.settingsPopupShow).toBe(true)
704
+ w.destroy()
705
+ })
706
+
707
+ test('popup remains open when mouse leaves edge', async () => {
708
+ const w = createWrapper()
709
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
710
+ ew.vm.hovered = true
711
+ ew.vm.settingsPopupShow = true
712
+ await w.vm.$nextTick()
713
+ ew.vm.hovered = false
714
+ await w.vm.$nextTick()
715
+ expect(ew.vm.settingsPopupShow).toBe(true)
716
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(true)
717
+ w.destroy()
718
+ })
719
+
720
+ test('popup closes and anchor hides after popup dismissed', async () => {
721
+ const w = createWrapper()
722
+ const ew = w.findAllComponents({ name: 'EdgeWrapper' }).at(0)
723
+ ew.vm.hovered = false
724
+ ew.vm.settingsPopupShow = true
725
+ await w.vm.$nextTick()
726
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(true)
727
+ ew.vm.settingsPopupShow = false
728
+ await w.vm.$nextTick()
729
+ expect(ew.find('.vue-flow__edge-settings-anchor').exists()).toBe(false)
730
+ w.destroy()
731
+ })
732
+ })
733
+ })
734
+
735
+ // 17. setViewport
736
+ describe('setViewport', () => {
737
+ test('sets all viewport properties', () => {
738
+ const w = createWrapper()
739
+ w.vm.setViewport({ x: 100, y: 200, zoom: 1.5 })
740
+ expect(w.vm.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 })
741
+ w.destroy()
742
+ })
743
+ test('partial update only changes specified', () => {
744
+ const w = createWrapper()
745
+ w.vm.setViewport({ x: 0, y: 0, zoom: 1 })
746
+ w.vm.setViewport({ x: 50 })
747
+ expect(w.vm.viewport.x).toBe(50)
748
+ expect(w.vm.viewport.y).toBe(0)
749
+ expect(w.vm.viewport.zoom).toBe(1)
750
+ w.destroy()
751
+ })
752
+ test('ignores undefined values', () => {
753
+ const w = createWrapper()
754
+ w.vm.setViewport({ x: 10, y: 20, zoom: 1.2 })
755
+ w.vm.setViewport({ x: undefined, y: 30 })
756
+ expect(w.vm.viewport.x).toBe(10) // unchanged
757
+ expect(w.vm.viewport.y).toBe(30)
758
+ w.destroy()
759
+ })
760
+ })