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.
- package/.editorconfig +9 -0
- package/.eslintignore +3 -0
- package/.eslintrc.js +55 -0
- package/.jsdoc +25 -0
- package/AGENT.md +223 -0
- package/LICENSE +21 -0
- package/README.md +37 -0
- package/SECURITY.md +5 -0
- package/babel.config.js +16 -0
- package/dist/w-flow-vue.umd.js +15 -0
- package/dist/w-flow-vue.umd.js.map +1 -0
- package/docs/components_WFlowVue.vue.html +1214 -0
- package/docs/examples/app.html +62 -0
- package/docs/examples/app.umd.js +20 -0
- package/docs/examples/app.umd.js.map +1 -0
- package/docs/examples/ex-AppBasic.html +440 -0
- package/docs/examples/ex-AppConnectivity.html +131 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Bold.woff2 +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.eot +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.ttf +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff +0 -0
- package/docs/fonts/Montserrat/Montserrat-Regular.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.svg +978 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-light-webfont.woff2 +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.eot +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.svg +1049 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.ttf +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff +0 -0
- package/docs/fonts/Source-Sans-Pro/sourcesanspro-regular-webfont.woff2 +0 -0
- package/docs/global.html +1919 -0
- package/docs/index.html +84 -0
- package/docs/js_defaults.mjs.html +105 -0
- package/docs/js_edge-path.mjs.html +237 -0
- package/docs/js_geometry.mjs.html +298 -0
- package/docs/js_graph.mjs.html +103 -0
- package/docs/js_step-routing.mjs.html +346 -0
- package/docs/module-WFlowVue.html +2790 -0
- package/docs/scripts/collapse.js +39 -0
- package/docs/scripts/commonNav.js +28 -0
- package/docs/scripts/linenumber.js +25 -0
- package/docs/scripts/nav.js +12 -0
- package/docs/scripts/polyfill.js +4 -0
- package/docs/scripts/prettify/Apache-License-2.0.txt +202 -0
- package/docs/scripts/prettify/lang-css.js +2 -0
- package/docs/scripts/prettify/prettify.js +28 -0
- package/docs/scripts/search.js +99 -0
- package/docs/styles/jsdoc.css +776 -0
- package/docs/styles/prettify.css +80 -0
- package/jest.config.js +20 -0
- package/package.json +80 -0
- package/public/index.html +38 -0
- package/script.txt +22 -0
- package/src/App.vue +326 -0
- package/src/AppBasic.vue +125 -0
- package/src/AppConnectivity.vue +186 -0
- package/src/components/WFlowVue.vue +1142 -0
- package/src/components/canvas/BackgroundLayer.vue +78 -0
- package/src/components/canvas/FlowCanvas.vue +64 -0
- package/src/components/canvas/SelectionBox.vue +36 -0
- package/src/components/canvas/ViewportTransform.vue +35 -0
- package/src/components/edges/ConnectionLine.vue +65 -0
- package/src/components/edges/EdgeMarkerDefs.vue +76 -0
- package/src/components/edges/EdgeRenderer.vue +120 -0
- package/src/components/edges/EdgeWrapper.vue +379 -0
- package/src/components/nodes/DefaultNode.vue +276 -0
- package/src/components/nodes/Handle.vue +101 -0
- package/src/components/nodes/InputNode.vue +47 -0
- package/src/components/nodes/NodeBody.vue +103 -0
- package/src/components/nodes/NodeFace.vue +128 -0
- package/src/components/nodes/NodeRenderer.vue +95 -0
- package/src/components/nodes/NodeWrapper.vue +475 -0
- package/src/components/nodes/OutputNode.vue +47 -0
- package/src/components/ui/ConnSettingsForm.vue +158 -0
- package/src/components/ui/Controls.vue +83 -0
- package/src/components/ui/NodeSettingsForm.vue +185 -0
- package/src/js/defaults.mjs +33 -0
- package/src/js/edge-path.mjs +165 -0
- package/src/js/geometry.mjs +226 -0
- package/src/js/graph.mjs +31 -0
- package/src/js/step-routing.mjs +274 -0
- package/src/main.js +22 -0
- package/test/WFlowVue-features.test.mjs +760 -0
- package/test/WFlowVue.test.mjs +421 -0
- package/test/components-canvas.test.mjs +102 -0
- package/test/components-edge.test.mjs +147 -0
- package/test/components-node.test.mjs +174 -0
- package/test/components-ui.test.mjs +69 -0
- package/test/defaults.test.mjs +86 -0
- package/test/edge-path.test.mjs +102 -0
- package/test/generate-routing-snapshots.mjs +77 -0
- package/test/generate-visual-baselines.mjs +206 -0
- package/test/geometry.test.mjs +236 -0
- package/test/graph.test.mjs +72 -0
- package/test/jsons/routing-snapshots.json +24994 -0
- package/test/pics/_check2.png +0 -0
- package/test/pics/_check3.png +0 -0
- package/test/pics/_check4.png +0 -0
- package/test/pics/_check5.png +0 -0
- package/test/pics/_v1.png +0 -0
- package/test/pics/_v2.png +0 -0
- package/test/pics/_v3.png +0 -0
- package/test/pics/_v4.png +0 -0
- package/test/pics/_v5.png +0 -0
- package/test/pics/_v6.png +0 -0
- package/test/pics/_v7.png +0 -0
- package/test/pics/vb-edge-hovered.png +0 -0
- package/test/pics/vb-edges-normal.png +0 -0
- package/test/pics/vb-locked-edge-hovered.png +0 -0
- package/test/pics/vb-locked-node-hovered.png +0 -0
- package/test/pics/vb-locked-node-selected.png +0 -0
- package/test/pics/vb-locked-overview.png +0 -0
- package/test/pics/vb-node-1.png +0 -0
- package/test/pics/vb-node-10.png +0 -0
- package/test/pics/vb-node-11.png +0 -0
- package/test/pics/vb-node-12.png +0 -0
- package/test/pics/vb-node-2.png +0 -0
- package/test/pics/vb-node-3.png +0 -0
- package/test/pics/vb-node-4.png +0 -0
- package/test/pics/vb-node-5.png +0 -0
- package/test/pics/vb-node-6.png +0 -0
- package/test/pics/vb-node-7.png +0 -0
- package/test/pics/vb-node-8.png +0 -0
- package/test/pics/vb-node-9.png +0 -0
- package/test/pics/vb-node-hovered.png +0 -0
- package/test/pics/vb-node-selected.png +0 -0
- package/test/pics/vb-overview.png +0 -0
- package/test/step-routing-connectivity.test.mjs +78 -0
- package/test/step-routing.test.mjs +88 -0
- package/test/visual-regression.test.mjs +274 -0
- package/toolg/addVersion.mjs +4 -0
- package/toolg/cleanFolder.mjs +4 -0
- package/toolg/gDistApp.mjs +34 -0
- package/toolg/gDistRollupComps.mjs +22 -0
- package/toolg/gDocExams.mjs +47 -0
- package/toolg/gExtractHtml.mjs +179 -0
- package/toolg/modifyReadme.mjs +4 -0
- package/vue.config.js +9 -0
- 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
|
+
})
|