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,206 @@
1
+ /**
2
+ * Generate visual baseline screenshots for regression testing.
3
+ * Requires: npm run serve (http://localhost:8080) running
4
+ * Usage: node test/generate-visual-baselines.mjs
5
+ */
6
+ import { chromium } from 'playwright'
7
+ import path from 'path'
8
+ import { fileURLToPath } from 'url'
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
11
+ const outDir = path.join(__dirname, 'pics')
12
+
13
+ const VW = 1280
14
+ const VH = 900
15
+
16
+ /** Clip a region around a bounding box with padding, clamped to viewport */
17
+ function clipAround(box, pad) {
18
+ let x = Math.max(0, Math.floor(box.x - pad))
19
+ let y = Math.max(0, Math.floor(box.y - pad))
20
+ let w = Math.min(Math.ceil(box.width + pad * 2), VW - x)
21
+ let h = Math.min(Math.ceil(box.height + pad * 2), VH - y)
22
+ if (w < 1 || h < 1) return null
23
+ return { x, y, width: w, height: h }
24
+ }
25
+
26
+ /** Center viewport on a node by its data id */
27
+ function centerOnNode(page, nodeId) {
28
+ return page.evaluate((id) => {
29
+ let vm
30
+ for (let el of document.querySelectorAll('*')) {
31
+ if (el.__vue__ && el.__vue__.setViewport) { vm = el.__vue__; break }
32
+ }
33
+ if (!vm) return false
34
+ let n = vm.nodes.find(n => n.id === id)
35
+ if (!n) return false
36
+ let cw = vm.widthInp, ch = vm.heightInp
37
+ let w = n.width || 100, h = n.height || 40
38
+ vm.setViewport({ x: cw / 2 - (n.position.x + w / 2), y: ch / 2 - (n.position.y + h / 2), zoom: 1 })
39
+ return true
40
+ }, nodeId)
41
+ }
42
+
43
+ /** Reset viewport to origin */
44
+ function resetViewport(page) {
45
+ return page.evaluate(() => {
46
+ for (let el of document.querySelectorAll('*')) {
47
+ if (el.__vue__ && el.__vue__.setViewport) {
48
+ el.__vue__.setViewport({ x: 0, y: 0, zoom: 1 })
49
+ return
50
+ }
51
+ }
52
+ })
53
+ }
54
+
55
+ /** fitView to show all nodes */
56
+ function fitView(page) {
57
+ return page.evaluate(() => {
58
+ for (let el of document.querySelectorAll('*')) {
59
+ if (el.__vue__ && el.__vue__.fitView) { el.__vue__.fitView(30); return }
60
+ }
61
+ })
62
+ }
63
+
64
+ /** Get the canvas container clip region */
65
+ function getCanvasClip(page) {
66
+ return page.evaluate(() => {
67
+ let el = document.querySelector('.vue-flow__viewport')
68
+ if (!el) return null
69
+ let container = el.closest('[style*="height"]')
70
+ if (!container) return null
71
+ let rect = container.getBoundingClientRect()
72
+ return { x: Math.floor(rect.x), y: Math.floor(rect.y), width: Math.ceil(rect.width), height: Math.ceil(rect.height) }
73
+ })
74
+ }
75
+
76
+ async function run() {
77
+ const browser = await chromium.launch()
78
+ const page = await browser.newPage({ viewport: { width: VW, height: VH } })
79
+ await page.goto('http://localhost:8080')
80
+ await page.waitForTimeout(2000)
81
+
82
+ const pad = 60
83
+
84
+ // 1. Overview — fitView to show all nodes
85
+ await fitView(page)
86
+ await page.waitForTimeout(500)
87
+ let canvasClip = await getCanvasClip(page)
88
+ await page.screenshot({ path: path.join(outDir, 'vb-overview.png'), clip: canvasClip })
89
+
90
+ // 2. Each node — center on node, clip with padding (includes surrounding edges)
91
+ let nodes = await page.$$('.vue-flow__node')
92
+ for (const node of nodes) {
93
+ const id = await node.getAttribute('data-id')
94
+ if (!id) continue
95
+ await centerOnNode(page, id)
96
+ await page.waitForTimeout(300)
97
+ let box = await node.boundingBox()
98
+ if (!box) continue
99
+ let clip = clipAround(box, pad)
100
+ if (!clip) continue
101
+ await page.screenshot({ path: path.join(outDir, `vb-node-${id}.png`), clip })
102
+ }
103
+
104
+ // 3. Node hovered (first node) — resize handles + settings icon
105
+ let firstNode = nodes[0]
106
+ let firstId = firstNode ? await firstNode.getAttribute('data-id') : null
107
+ if (firstNode && firstId) {
108
+ await centerOnNode(page, firstId)
109
+ await page.waitForTimeout(300)
110
+ let box = await firstNode.boundingBox()
111
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
112
+ await page.waitForTimeout(500)
113
+ box = await firstNode.boundingBox()
114
+ await page.screenshot({ path: path.join(outDir, 'vb-node-hovered.png'), clip: clipAround(box, pad) })
115
+ await page.mouse.move(0, 0)
116
+ await page.waitForTimeout(300)
117
+ }
118
+
119
+ // 4. Node selected (click first node)
120
+ if (firstNode && firstId) {
121
+ await centerOnNode(page, firstId)
122
+ await page.waitForTimeout(300)
123
+ await firstNode.click()
124
+ await page.waitForTimeout(300)
125
+ let box = await firstNode.boundingBox()
126
+ await page.screenshot({ path: path.join(outDir, 'vb-node-selected.png'), clip: clipAround(box, pad) })
127
+ await page.mouse.click(10, 10)
128
+ await page.waitForTimeout(300)
129
+ }
130
+
131
+ // 5. Edge hovered — settings icon
132
+ await resetViewport(page)
133
+ await page.waitForTimeout(300)
134
+ let edges = await page.$$('.vue-flow__edge-interaction')
135
+ if (edges.length > 0) {
136
+ let box = await edges[0].boundingBox()
137
+ if (box) {
138
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
139
+ await page.waitForTimeout(500)
140
+ box = await edges[0].boundingBox()
141
+ await page.screenshot({ path: path.join(outDir, 'vb-edge-hovered.png'), clip: clipAround(box, 40) })
142
+ await page.mouse.move(0, 0)
143
+ await page.waitForTimeout(300)
144
+ }
145
+ }
146
+
147
+ // 6. Edges normal (overview, no hover)
148
+ await fitView(page)
149
+ await page.waitForTimeout(500)
150
+ await page.screenshot({ path: path.join(outDir, 'vb-edges-normal.png'), clip: canvasClip })
151
+
152
+ // ===== LOCKED STATE =====
153
+ let lockBtn = await page.$('.vue-flow__controls button[title="Lock"]')
154
+ if (lockBtn) {
155
+ await lockBtn.click()
156
+ await page.waitForTimeout(500)
157
+ }
158
+
159
+ // 7. Locked overview
160
+ await page.screenshot({ path: path.join(outDir, 'vb-locked-overview.png'), clip: canvasClip })
161
+
162
+ // 8. Locked node hovered — no resize handles, no settings icon, no handles
163
+ if (firstNode && firstId) {
164
+ await centerOnNode(page, firstId)
165
+ await page.waitForTimeout(300)
166
+ let box = await firstNode.boundingBox()
167
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
168
+ await page.waitForTimeout(500)
169
+ box = await firstNode.boundingBox()
170
+ await page.screenshot({ path: path.join(outDir, 'vb-locked-node-hovered.png'), clip: clipAround(box, pad) })
171
+ await page.mouse.move(0, 0)
172
+ await page.waitForTimeout(300)
173
+ }
174
+
175
+ // 9. Locked node selected
176
+ if (firstNode && firstId) {
177
+ await centerOnNode(page, firstId)
178
+ await page.waitForTimeout(300)
179
+ await firstNode.click()
180
+ await page.waitForTimeout(300)
181
+ let box = await firstNode.boundingBox()
182
+ await page.screenshot({ path: path.join(outDir, 'vb-locked-node-selected.png'), clip: clipAround(box, pad) })
183
+ await page.mouse.click(10, 10)
184
+ await page.waitForTimeout(300)
185
+ }
186
+
187
+ // 10. Locked edge hovered — no settings icon
188
+ await resetViewport(page)
189
+ await page.waitForTimeout(300)
190
+ if (edges.length > 0) {
191
+ let box = await edges[0].boundingBox()
192
+ if (box) {
193
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
194
+ await page.waitForTimeout(500)
195
+ box = await edges[0].boundingBox()
196
+ await page.screenshot({ path: path.join(outDir, 'vb-locked-edge-hovered.png'), clip: clipAround(box, 40) })
197
+ await page.mouse.move(0, 0)
198
+ await page.waitForTimeout(300)
199
+ }
200
+ }
201
+
202
+ console.log('Visual baselines generated in test/pics/')
203
+ await browser.close()
204
+ }
205
+
206
+ run().catch(e => { console.error(e); process.exit(1) })
@@ -0,0 +1,236 @@
1
+ import {
2
+ getHandlePosition,
3
+ getDiamondEdgePoint, getEllipseEdgePoint, getTriangleEdgePoint,
4
+ getOverlappingNodes,
5
+ clampPosition, snapPosition, rectsOverlap
6
+ } from '../src/js/geometry'
7
+
8
+ describe('geometry', () => {
9
+ const node = { id: '1', position: { x: 100, y: 50 }, width: 150, height: 40 }
10
+
11
+ describe('getHandlePosition', () => {
12
+ test('top handle is at top center', () => {
13
+ const pos = getHandlePosition(node, 'top', {})
14
+ expect(pos).toEqual({ x: 175, y: 50 })
15
+ })
16
+
17
+ test('bottom handle is at bottom center', () => {
18
+ const pos = getHandlePosition(node, 'bottom', {})
19
+ expect(pos).toEqual({ x: 175, y: 90 })
20
+ })
21
+
22
+ test('left handle is at left center', () => {
23
+ const pos = getHandlePosition(node, 'left', {})
24
+ expect(pos).toEqual({ x: 100, y: 70 })
25
+ })
26
+
27
+ test('right handle is at right center', () => {
28
+ const pos = getHandlePosition(node, 'right', {})
29
+ expect(pos).toEqual({ x: 250, y: 70 })
30
+ })
31
+
32
+ test('uses nodeInternals dimensions if provided', () => {
33
+ const pos = getHandlePosition(
34
+ { id: '1', position: { x: 0, y: 0 } },
35
+ 'bottom',
36
+ { width: 200, height: 60 }
37
+ )
38
+ expect(pos).toEqual({ x: 100, y: 60 })
39
+ })
40
+
41
+ test('defaults to 150x40 if no dimensions', () => {
42
+ const pos = getHandlePosition({ id: '1', position: { x: 0, y: 0 } }, 'bottom', {})
43
+ expect(pos).toEqual({ x: 75, y: 40 })
44
+ })
45
+ })
46
+
47
+ describe('getHandlePosition — diamond', () => {
48
+ const diamond = { id: 'd', position: { x: 0, y: 0 }, width: 100, height: 100, shape: 'diamond', type: 'input' }
49
+
50
+ test('top handle is at top vertex', () => {
51
+ const pos = getHandlePosition(diamond, 'top', {})
52
+ expect(pos.x).toBe(50)
53
+ expect(pos.y).toBe(0)
54
+ })
55
+
56
+ test('bottom handle is at bottom vertex', () => {
57
+ const pos = getHandlePosition(diamond, 'bottom', {})
58
+ expect(pos.x).toBe(50)
59
+ expect(pos.y).toBe(100)
60
+ })
61
+ })
62
+
63
+ describe('getHandlePosition — ellipse', () => {
64
+ const ellipse = { id: 'e', position: { x: 0, y: 0 }, width: 200, height: 100, shape: 'ellipse', type: 'input' }
65
+
66
+ test('right handle is at rightmost point', () => {
67
+ const pos = getHandlePosition(ellipse, 'right', {})
68
+ expect(pos.x).toBeCloseTo(200, 0)
69
+ expect(pos.y).toBeCloseTo(50, 0)
70
+ })
71
+
72
+ test('left handle is at leftmost point', () => {
73
+ const pos = getHandlePosition(ellipse, 'left', {})
74
+ expect(pos.x).toBeCloseTo(0, 0)
75
+ expect(pos.y).toBeCloseTo(50, 0)
76
+ })
77
+
78
+ test('top handle is at topmost point', () => {
79
+ const pos = getHandlePosition(ellipse, 'top', {})
80
+ expect(pos.x).toBeCloseTo(100, 0)
81
+ expect(pos.y).toBeCloseTo(0, 0)
82
+ })
83
+ })
84
+
85
+ describe('getHandlePosition — triangle', () => {
86
+ const tri = { id: 't', position: { x: 0, y: 0 }, width: 100, height: 100, shape: 'triangle', type: 'input' }
87
+
88
+ test('top handle (apex) is at top center', () => {
89
+ const pos = getHandlePosition(tri, 'top', {})
90
+ expect(pos.x).toBeCloseTo(50, 0)
91
+ expect(pos.y).toBeCloseTo(0, 0)
92
+ })
93
+
94
+ test('bottom handle (base) is at bottom midpoint', () => {
95
+ const pos = getHandlePosition(tri, 'bottom', {})
96
+ expect(pos.x).toBe(50)
97
+ expect(pos.y).toBe(100)
98
+ })
99
+ })
100
+
101
+ describe('getDiamondEdgePoint', () => {
102
+ test('ratio 0 returns left vertex for top side', () => {
103
+ const p = getDiamondEdgePoint(0, 0, 100, 100, 'top', 0)
104
+ expect(p.x).toBeCloseTo(0, 0)
105
+ expect(p.y).toBeCloseTo(50, 0)
106
+ })
107
+
108
+ test('ratio 0.5 returns top vertex for top side', () => {
109
+ const p = getDiamondEdgePoint(0, 0, 100, 100, 'top', 0.5)
110
+ expect(p.x).toBeCloseTo(50, 0)
111
+ expect(p.y).toBeCloseTo(0, 0)
112
+ })
113
+
114
+ test('ratio 1 returns right vertex for top side', () => {
115
+ const p = getDiamondEdgePoint(0, 0, 100, 100, 'top', 1)
116
+ expect(p.x).toBeCloseTo(100, 0)
117
+ expect(p.y).toBeCloseTo(50, 0)
118
+ })
119
+ })
120
+
121
+ describe('getEllipseEdgePoint', () => {
122
+ test('top side ratio 0.5 returns top vertex', () => {
123
+ const p = getEllipseEdgePoint(0, 0, 200, 100, 'top', 0.5)
124
+ expect(p.x).toBeCloseTo(100, 0)
125
+ expect(p.y).toBeCloseTo(0, 0)
126
+ })
127
+
128
+ test('right side ratio 0.5 returns right vertex', () => {
129
+ const p = getEllipseEdgePoint(0, 0, 200, 100, 'right', 0.5)
130
+ expect(p.x).toBeCloseTo(200, 0)
131
+ expect(p.y).toBeCloseTo(50, 0)
132
+ })
133
+
134
+ test('returns point on ellipse boundary', () => {
135
+ const cx = 100; const cy = 50; const rx = 100; const ry = 50
136
+ const p = getEllipseEdgePoint(0, 0, 200, 100, 'top', 0.3)
137
+ const dx = (p.x - cx) / rx
138
+ const dy = (p.y - cy) / ry
139
+ expect(dx * dx + dy * dy).toBeCloseTo(1, 1)
140
+ })
141
+ })
142
+
143
+ describe('getTriangleEdgePoint', () => {
144
+ test('apex side ratio 0.5 returns apex for triangle-up', () => {
145
+ const p = getTriangleEdgePoint(0, 0, 100, 100, 'top', 0.5, 'triangle')
146
+ expect(p.x).toBeCloseTo(50, 0)
147
+ expect(p.y).toBeCloseTo(0, 0)
148
+ })
149
+
150
+ test('base side returns linear interpolation', () => {
151
+ const p0 = getTriangleEdgePoint(0, 0, 100, 100, 'bottom', 0, 'triangle')
152
+ const p1 = getTriangleEdgePoint(0, 0, 100, 100, 'bottom', 1, 'triangle')
153
+ expect(p0.x).toBeCloseTo(0, 0)
154
+ expect(p1.x).toBeCloseTo(100, 0)
155
+ expect(p0.y).toBe(p1.y)
156
+ })
157
+
158
+ test('triangle-right apex is at right edge', () => {
159
+ const p = getTriangleEdgePoint(0, 0, 100, 100, 'right', 0.5, 'triangle-right')
160
+ expect(p.x).toBeCloseTo(100, 0)
161
+ expect(p.y).toBeCloseTo(50, 0)
162
+ })
163
+
164
+ test('triangle-down apex is at bottom center', () => {
165
+ const p = getTriangleEdgePoint(0, 0, 100, 100, 'bottom', 0.5, 'triangle-down')
166
+ expect(p.x).toBeCloseTo(50, 0)
167
+ expect(p.y).toBeCloseTo(100, 0)
168
+ })
169
+
170
+ test('triangle-left apex is at left edge', () => {
171
+ const p = getTriangleEdgePoint(0, 0, 100, 100, 'left', 0.5, 'triangle-left')
172
+ expect(p.x).toBeCloseTo(0, 0)
173
+ expect(p.y).toBeCloseTo(50, 0)
174
+ })
175
+ })
176
+
177
+ describe('rectsOverlap', () => {
178
+ test('overlapping rects return true', () => {
179
+ const a = { x: 0, y: 0, width: 100, height: 100 }
180
+ const b = { x: 50, y: 50, width: 100, height: 100 }
181
+ expect(rectsOverlap(a, b)).toBe(true)
182
+ })
183
+
184
+ test('non-overlapping rects return false', () => {
185
+ const a = { x: 0, y: 0, width: 50, height: 50 }
186
+ const b = { x: 100, y: 100, width: 50, height: 50 }
187
+ expect(rectsOverlap(a, b)).toBe(false)
188
+ })
189
+
190
+ test('touching rects (edge) return false', () => {
191
+ const a = { x: 0, y: 0, width: 50, height: 50 }
192
+ const b = { x: 50, y: 0, width: 50, height: 50 }
193
+ expect(rectsOverlap(a, b)).toBe(false)
194
+ })
195
+ })
196
+
197
+ describe('getOverlappingNodes', () => {
198
+ const nodes = [
199
+ { id: '1', position: { x: 0, y: 0 }, width: 100, height: 50 },
200
+ { id: '2', position: { x: 200, y: 200 }, width: 100, height: 50 },
201
+ { id: '3', position: { x: 50, y: 20 }, width: 100, height: 50 },
202
+ ]
203
+
204
+ test('returns overlapping nodes', () => {
205
+ const rect = { x: 10, y: 10, width: 80, height: 80 }
206
+ const result = getOverlappingNodes(rect, nodes, {})
207
+ expect(result.map(n => n.id)).toEqual(['1', '3'])
208
+ })
209
+ })
210
+
211
+ describe('clampPosition', () => {
212
+ test('clamps to extent', () => {
213
+ const extent = [[0, 0], [100, 100]]
214
+ expect(clampPosition({ x: -10, y: 50 }, extent)).toEqual({ x: 0, y: 50 })
215
+ expect(clampPosition({ x: 50, y: 150 }, extent)).toEqual({ x: 50, y: 100 })
216
+ })
217
+
218
+ test('returns original if no extent', () => {
219
+ expect(clampPosition({ x: -10, y: 50 }, null)).toEqual({ x: -10, y: 50 })
220
+ })
221
+ })
222
+
223
+ describe('snapPosition', () => {
224
+ test('snaps to grid', () => {
225
+ expect(snapPosition({ x: 17, y: 23 }, 15)).toEqual({ x: 15, y: 30 })
226
+ })
227
+
228
+ test('returns original if no grid', () => {
229
+ expect(snapPosition({ x: 17, y: 23 }, null)).toEqual({ x: 17, y: 23 })
230
+ })
231
+
232
+ test('snaps negative positions', () => {
233
+ expect(snapPosition({ x: -7, y: -22 }, 10)).toEqual({ x: -10, y: -20 })
234
+ })
235
+ })
236
+ })
@@ -0,0 +1,72 @@
1
+ import { isValidConnection, generateId, resetIdCounter } from '../src/js/graph'
2
+
3
+ describe('graph', () => {
4
+ const conns = [
5
+ { id: 'e1-2', from: '1', to: '2' },
6
+ { id: 'e2-3', from: '2', to: '3' },
7
+ { id: 'e1-3', from: '1', to: '3' },
8
+ ]
9
+
10
+ const nodes = [
11
+ { id: '1', position: { x: 0, y: 0 } },
12
+ { id: '2', position: { x: 100, y: 0 } },
13
+ { id: '3', position: { x: 200, y: 0 } },
14
+ ]
15
+
16
+ describe('isValidConnection', () => {
17
+ test('valid connection returns true', () => {
18
+ const connection = { from: '1', to: '2' }
19
+ expect(isValidConnection(connection, nodes, [], null)).toBe(true)
20
+ })
21
+
22
+ test('self-connection returns false', () => {
23
+ const connection = { from: '1', to: '1' }
24
+ expect(isValidConnection(connection, nodes, [], null)).toBe(false)
25
+ })
26
+
27
+ test('duplicate connection returns false', () => {
28
+ const connection = { from: '1', to: '2' }
29
+ expect(isValidConnection(connection, nodes, conns, null)).toBe(false)
30
+ })
31
+
32
+ test('missing from returns false', () => {
33
+ expect(isValidConnection({ to: '1' }, nodes, [], null)).toBe(false)
34
+ })
35
+
36
+ test('missing to returns false', () => {
37
+ expect(isValidConnection({ from: '1' }, nodes, [], null)).toBe(false)
38
+ })
39
+
40
+ test('custom validator rejection', () => {
41
+ const connection = { from: '1', to: '3' }
42
+ const validator = () => false
43
+ expect(isValidConnection(connection, nodes, [], validator)).toBe(false)
44
+ })
45
+
46
+ test('custom validator approval', () => {
47
+ const connection = { from: '1', to: '3' }
48
+ const validator = () => true
49
+ expect(isValidConnection(connection, nodes, [], validator)).toBe(true)
50
+ })
51
+
52
+ test('same from-to pair is always duplicate', () => {
53
+ const existing = [{ id: 'e1-2', from: '1', to: '2' }]
54
+ const connection = { from: '1', to: '2' }
55
+ expect(isValidConnection(connection, nodes, existing, null)).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('generateId', () => {
60
+ beforeEach(() => resetIdCounter())
61
+
62
+ test('generates unique ids', () => {
63
+ const id1 = generateId()
64
+ const id2 = generateId()
65
+ expect(id1).not.toBe(id2)
66
+ })
67
+
68
+ test('returns string', () => {
69
+ expect(typeof generateId()).toBe('string')
70
+ })
71
+ })
72
+ })