Compare commits
105 Commits
feat/monit
...
feat/edito
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85dbeaca6 | ||
|
|
53568476d5 | ||
|
|
58bd57818e | ||
|
|
ee79660276 | ||
|
|
85b84c2281 | ||
|
|
f9e0eb95f8 | ||
|
|
47c5e94b8f | ||
|
|
820884f8aa | ||
|
|
b17d1e7618 | ||
|
|
641d794cf0 | ||
|
|
ec211ccbc3 | ||
|
|
7757a04694 | ||
|
|
2a6ead352d | ||
|
|
274ffb0238 | ||
|
|
a00fd02634 | ||
|
|
f6b692e420 | ||
|
|
2cbd905f0b | ||
|
|
84d9cbb19d | ||
|
|
e5b438c17e | ||
|
|
64dcdc49b3 | ||
|
|
9c64455a19 | ||
|
|
9f359de953 | ||
|
|
9d2f5127c1 | ||
|
|
bb053fda21 | ||
|
|
f92467b409 | ||
|
|
c9c7f55aa0 | ||
|
|
d6d74d4c6b | ||
|
|
e86c06c3e5 | ||
|
|
363054afda | ||
|
|
327d1de621 | ||
|
|
3f6d95683d | ||
|
|
6f4471ce6f | ||
|
|
5d55ebaaa2 | ||
|
|
23a02b2b4a | ||
|
|
487ee30923 | ||
|
|
8c28dd6c1c | ||
|
|
5a9b78fdda | ||
|
|
a6f24b677f | ||
|
|
022a6708ea | ||
|
|
f62f416af3 | ||
|
|
385ec250cc | ||
|
|
35bf3ad9e5 | ||
|
|
66daafe1f0 | ||
|
|
5d650b36ce | ||
|
|
e9acab456e | ||
|
|
1a8670ba13 | ||
|
|
f174623a4c | ||
|
|
b3b77b94ad | ||
|
|
67558a7ac7 | ||
|
|
e1257bdf48 | ||
|
|
3f7e196bb7 | ||
|
|
566c4c18cc | ||
|
|
8ffc919e7e | ||
|
|
a5a345b9a9 | ||
|
|
96afba2a1d | ||
|
|
c7ed3c8ef2 | ||
|
|
e6f29a0f6b | ||
|
|
a4428c0d67 | ||
| 5385bd72b1 | |||
|
|
e805c882fe | ||
|
|
35ab95bd35 | ||
|
|
ad8111d6c2 | ||
|
|
4e07b95722 | ||
|
|
442df423d1 | ||
|
|
bd079a4121 | ||
|
|
9e7c192804 | ||
|
|
d2d4dc1242 | ||
|
|
e6b0d7564d | ||
|
|
6d1c17e77b | ||
|
|
4e9a048c90 | ||
|
|
c13fb7d33d | ||
|
|
0ad2d5935f | ||
|
|
9b3414ba98 | ||
|
|
381cb0c822 | ||
|
|
0b74763e24 | ||
|
|
08374ac2c2 | ||
|
|
46c2e0ede6 | ||
|
|
9c80391fea | ||
|
|
f4745c736f | ||
|
|
508fa48be6 | ||
|
|
9dae45e398 | ||
|
|
bd93b04bfd | ||
|
|
216b136a75 | ||
|
|
111400bd82 | ||
|
|
01d73b777a | ||
|
|
9f26edb6ec | ||
|
|
8f1367ed83 | ||
|
|
bd2ffe622f | ||
|
|
b4df868e26 | ||
|
|
149b82cb66 | ||
|
|
c5f44536b7 | ||
|
|
444e8b0289 | ||
|
|
c1ef924be1 | ||
|
|
0b29cb5858 | ||
|
|
fcc279fb31 | ||
|
|
709dd28959 | ||
|
|
099afebe98 | ||
|
|
8d4c3fc64b | ||
|
|
7925023f25 | ||
|
|
2faa42bd4c | ||
|
|
ae8ef317a4 | ||
|
|
757435e9f8 | ||
|
|
f22fe38e22 | ||
|
|
9d4f10213e | ||
|
|
10d5a15c88 |
528
package-lock.json
generated
528
package-lock.json
generated
@@ -10,9 +10,11 @@
|
||||
"dependencies": {
|
||||
"@neodrag/react": "^2.3.1",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.3",
|
||||
"reactflow": "^11.11.4",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -24,6 +26,7 @@
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
@@ -2052,6 +2055,276 @@
|
||||
"url": "https://opencollective.com/pkgr"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/background/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
|
||||
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.35",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
|
||||
@@ -2646,12 +2919,102 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/d3-axis": "*",
|
||||
"@types/d3-brush": "*",
|
||||
"@types/d3-chord": "*",
|
||||
"@types/d3-color": "*",
|
||||
"@types/d3-contour": "*",
|
||||
"@types/d3-delaunay": "*",
|
||||
"@types/d3-dispatch": "*",
|
||||
"@types/d3-drag": "*",
|
||||
"@types/d3-dsv": "*",
|
||||
"@types/d3-ease": "*",
|
||||
"@types/d3-fetch": "*",
|
||||
"@types/d3-force": "*",
|
||||
"@types/d3-format": "*",
|
||||
"@types/d3-geo": "*",
|
||||
"@types/d3-hierarchy": "*",
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-path": "*",
|
||||
"@types/d3-polygon": "*",
|
||||
"@types/d3-quadtree": "*",
|
||||
"@types/d3-random": "*",
|
||||
"@types/d3-scale": "*",
|
||||
"@types/d3-scale-chromatic": "*",
|
||||
"@types/d3-selection": "*",
|
||||
"@types/d3-shape": "*",
|
||||
"@types/d3-time": "*",
|
||||
"@types/d3-time-format": "*",
|
||||
"@types/d3-timer": "*",
|
||||
"@types/d3-transition": "*",
|
||||
"@types/d3-zoom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-axis": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
|
||||
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-brush": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
|
||||
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-chord": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
|
||||
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-contour": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
|
||||
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "*",
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-delaunay": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-dispatch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
|
||||
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
@@ -2661,6 +3024,54 @@
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-dsv": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
|
||||
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-fetch": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
|
||||
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-dsv": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-force": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
|
||||
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-format": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
|
||||
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-hierarchy": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
|
||||
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
@@ -2670,12 +3081,78 @@
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-polygon": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
|
||||
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-quadtree": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
|
||||
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-random": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
|
||||
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-time-format": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
|
||||
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
@@ -2702,6 +3179,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -3698,9 +4181,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
|
||||
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==",
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -3970,6 +4453,15 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
@@ -4869,9 +5361,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6944,9 +7436,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
||||
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
|
||||
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -6965,6 +7457,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.14",
|
||||
"@reactflow/controls": "11.2.14",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@reactflow/minimap": "11.7.14",
|
||||
"@reactflow/node-resizer": "2.2.14",
|
||||
"@reactflow/node-toolbar": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
"dependencies": {
|
||||
"@neodrag/react": "^2.3.1",
|
||||
"@xyflow/react": "^12.8.6",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.9.3",
|
||||
"reactflow": "^11.11.4",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -28,6 +30,7 @@
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.3",
|
||||
"baseline-browser-mapping": "^2.9.11",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
|
||||
@@ -248,3 +248,11 @@ button.no-button {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.flex-center-x {
|
||||
display: flex;
|
||||
justify-content: center; /* horizontal centering */
|
||||
text-align: center; /* center multi-line text */
|
||||
width: 100%; /* allow it to stretch */
|
||||
flex-wrap: wrap; /* optional: let text wrap naturally */
|
||||
}
|
||||
75
src/components/MultilineTextField.tsx
Normal file
75
src/components/MultilineTextField.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import styles from "./TextField.module.css";
|
||||
|
||||
export function MultilineTextField({
|
||||
value = "",
|
||||
setValue,
|
||||
placeholder,
|
||||
className,
|
||||
id,
|
||||
ariaLabel,
|
||||
invalid = false,
|
||||
minRows = 3,
|
||||
}: {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
ariaLabel?: string;
|
||||
invalid?: boolean;
|
||||
minRows?: number;
|
||||
}) {
|
||||
const [readOnly, setReadOnly] = useState(true);
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
// Auto-grow logic
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [inputValue]);
|
||||
|
||||
const onCommit = () => {
|
||||
setReadOnly(true);
|
||||
setValue(inputValue);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLTextAreaElement).blur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={minRows}
|
||||
placeholder={placeholder}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onFocus={() => setReadOnly(false)}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={onKeyDown}
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
aria-label={ariaLabel}
|
||||
className={`
|
||||
${readOnly ? "drag" : "nodrag"}
|
||||
flex-1
|
||||
${styles.textField}
|
||||
${styles.multiline}
|
||||
${invalid ? styles.invalid : ""}
|
||||
${className ?? ""}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5pt;
|
||||
padding: 4px 8px;
|
||||
max-width: 50vw;
|
||||
min-width: 10vw;
|
||||
outline: none;
|
||||
background-color: canvas;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
@@ -25,3 +27,13 @@
|
||||
.text-field:read-only:hover:not(.invalid) {
|
||||
border-color: color-mix(in srgb, canvas, #777 10%);
|
||||
}
|
||||
|
||||
.multiline {
|
||||
resize: none; /* no manual resizing */
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
overflow: hidden; /* needed for auto-grow */
|
||||
max-width: 100%;
|
||||
width: 95%;
|
||||
min-width: 95%;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function RealtimeTextField({
|
||||
readOnly={readOnly}
|
||||
id={id}
|
||||
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
|
||||
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
|
||||
className={`${readOnly ? "drag" : "nodrag"} flex-1 ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
|
||||
aria-label={ariaLabel}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -59,8 +59,21 @@ button:focus-visible {
|
||||
background-color: #ffffff;
|
||||
|
||||
--accent-color: #00AAAA;
|
||||
--select-color: rgba(gray);
|
||||
|
||||
--dropdown-menu-background-color: rgb(247, 247, 247);
|
||||
--dropdown-menu-border: rgba(207, 207, 207, 0.986);
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color: #ffffff;
|
||||
--select-color: rgba(gray);
|
||||
--dropdown-menu-background-color: rgba(39, 39, 39, 0.986);
|
||||
--dropdown-menu-border: rgba(65, 65, 65, 0.986);
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,12 @@
|
||||
|
||||
/* Node Styles */
|
||||
|
||||
:global(.react-flow__node.selected) {
|
||||
outline: 1px dashed blue !important;
|
||||
border-radius: 5pt;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.default-node {
|
||||
padding: 10px 15px;
|
||||
background-color: canvas;
|
||||
@@ -41,6 +47,8 @@
|
||||
filter: drop-shadow(0 0 0.75rem black);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.node-norm {
|
||||
outline: rgb(0, 149, 25) solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
@@ -71,10 +79,21 @@
|
||||
filter: drop-shadow(0 0 0.25rem red);
|
||||
}
|
||||
|
||||
.node-basic_belief {
|
||||
outline: plum solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem plum);
|
||||
}
|
||||
|
||||
.node-inferred_belief {
|
||||
outline: mediumpurple solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem mediumpurple);
|
||||
}
|
||||
|
||||
.draggable-node {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: black solid 2pt;
|
||||
filter: drop-shadow(0 0 0.75rem black);
|
||||
}
|
||||
@@ -83,6 +102,7 @@
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: forestgreen solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem forestgreen);
|
||||
}
|
||||
@@ -91,6 +111,7 @@
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: yellow solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem yellow);
|
||||
}
|
||||
@@ -99,6 +120,7 @@
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: teal solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem teal);
|
||||
}
|
||||
@@ -107,6 +129,7 @@
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: dodgerblue solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem dodgerblue);
|
||||
}
|
||||
@@ -115,6 +138,7 @@
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: orange solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem orange);
|
||||
}
|
||||
@@ -123,6 +147,87 @@
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: red solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem red);
|
||||
}
|
||||
|
||||
.draggable-node-basic_belief {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
cursor: move;
|
||||
outline: plum solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem plum);
|
||||
}
|
||||
|
||||
.draggable-node-inferred_belief {
|
||||
padding: 3px 10px;
|
||||
background-color: canvas;
|
||||
border-radius: 5pt;
|
||||
outline: mediumpurple solid 2pt;
|
||||
filter: drop-shadow(0 0 0.25rem mediumpurple);
|
||||
}
|
||||
|
||||
.planNoIterate {
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.bottomLeftHandle {
|
||||
left: 40% !important;
|
||||
}
|
||||
|
||||
.bottomRightHandle {
|
||||
left: 60% !important;
|
||||
}
|
||||
|
||||
.node-toolbar-tooltip {
|
||||
background-color: darkgray;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: sans-serif;
|
||||
font-weight: bold;
|
||||
cursor: help;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-tooltip {
|
||||
pointer-events: none;
|
||||
background-color: Canvas;
|
||||
color: CanvasText;
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
outline: CanvasText solid 2px;
|
||||
font-size: 14px;
|
||||
filter: drop-shadow(0 0 0.25rem CanvasText);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.custom-tooltip-header {
|
||||
pointer-events: none;
|
||||
background-color: CanvasText;
|
||||
color: Canvas;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
outline: CanvasText solid 2px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
font-variant-caps: small-caps;
|
||||
filter: drop-shadow(0 0 0.25rem CanvasText);
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -4,16 +4,21 @@ import {
|
||||
Panel,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
MarkerType,
|
||||
MarkerType, getOutgoers
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {useEffect} from "react";
|
||||
import {graphReducer, runProgram} from "./VisProgLogic.tsx";
|
||||
import warningStyles from './visualProgrammingUI/components/WarningSidebar.module.css'
|
||||
import {type CSSProperties, useEffect, useState} from "react";
|
||||
import {useShallow} from 'zustand/react/shallow';
|
||||
import useProgramStore from "../../utils/programStore.ts";
|
||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||
import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||
import styles from './VisProg.module.css'
|
||||
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||
import { NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||
|
||||
// --| config starting params for flow |--
|
||||
@@ -39,6 +44,7 @@ const selector = (state: FlowState) => ({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
onNodesChange: state.onNodesChange,
|
||||
onNodesDelete: state.onNodesDelete,
|
||||
onEdgesDelete: state.onEdgesDelete,
|
||||
onEdgesChange: state.onEdgesChange,
|
||||
onConnect: state.onConnect,
|
||||
@@ -48,7 +54,8 @@ const selector = (state: FlowState) => ({
|
||||
undo: state.undo,
|
||||
redo: state.redo,
|
||||
beginBatchAction: state.beginBatchAction,
|
||||
endBatchAction: state.endBatchAction
|
||||
endBatchAction: state.endBatchAction,
|
||||
scrollable: state.scrollable
|
||||
});
|
||||
|
||||
// --| define ReactFlow editor |--
|
||||
@@ -63,6 +70,7 @@ const VisProgUI = () => {
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange,
|
||||
onNodesDelete,
|
||||
onEdgesDelete,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
@@ -72,9 +80,10 @@ const VisProgUI = () => {
|
||||
undo,
|
||||
redo,
|
||||
beginBatchAction,
|
||||
endBatchAction
|
||||
endBatchAction,
|
||||
scrollable
|
||||
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
|
||||
|
||||
const [zoom, setZoom] = useState(1);
|
||||
// adds ctrl+z and ctrl+y support to respectively undo and redo actions
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -84,15 +93,36 @@ const VisProgUI = () => {
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
});
|
||||
const {unregisterWarning, registerWarning} = useFlowStore();
|
||||
useEffect(() => {
|
||||
|
||||
if (checkPhaseChain()) {
|
||||
unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM');
|
||||
} else {
|
||||
// create global warning for incomplete program chain
|
||||
const incompleteProgramWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: globalWarning,
|
||||
handleId: undefined
|
||||
},
|
||||
type: 'INCOMPLETE_PROGRAM',
|
||||
severity: "ERROR",
|
||||
description: "there is no complete phase chain from the startNode to the EndNode"
|
||||
}
|
||||
|
||||
registerWarning(incompleteProgramWarning);
|
||||
}
|
||||
},[edges, registerWarning, unregisterWarning])
|
||||
|
||||
return (
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
@@ -101,9 +131,13 @@ const VisProgUI = () => {
|
||||
onConnect={onConnect}
|
||||
onNodeDragStart={beginBatchAction}
|
||||
onNodeDragStop={endBatchAction}
|
||||
preventScrolling={scrollable}
|
||||
onMove={(_, viewport) => setZoom(viewport.zoom)}
|
||||
reconnectRadius={15}
|
||||
snapToGrid
|
||||
fitView
|
||||
proOptions={{hideAttribution: true}}
|
||||
style={{flexGrow: 3}}
|
||||
>
|
||||
<Panel position="top-center" className={styles.dndPanel}>
|
||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||
@@ -112,12 +146,16 @@ const VisProgUI = () => {
|
||||
<SaveLoadPanel></SaveLoadPanel>
|
||||
</Panel>
|
||||
<Panel position="bottom-center">
|
||||
<button onClick={() => undo()}>undo</button>
|
||||
<button onClick={() => undo()}>Undo</button>
|
||||
<button onClick={() => redo()}>Redo</button>
|
||||
</Panel>
|
||||
<Panel position="center-right" className={warningStyles.warningsSidebar}>
|
||||
<WarningsSidebar/>
|
||||
</Panel>
|
||||
<Controls/>
|
||||
<Background/>
|
||||
</ReactFlow>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -137,36 +175,23 @@ function VisualProgrammingUI() {
|
||||
);
|
||||
}
|
||||
|
||||
// currently outputs the prepared program to the console
|
||||
function runProgram() {
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
fetch(
|
||||
"http://localhost:8000/program",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(program),
|
||||
}
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
}
|
||||
const checkPhaseChain = (): boolean => {
|
||||
const {nodes, edges} = useFlowStore.getState();
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return nodes
|
||||
.filter((n) => n.type == 'phase')
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
function checkForCompleteChain(currentNodeId: string): boolean {
|
||||
const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
|
||||
.filter(node => ["end", "phase"].includes(node.type!));
|
||||
|
||||
if (outgoingPhases.length === 0) return false;
|
||||
if (outgoingPhases.some(node => node.type === "end" )) return true;
|
||||
|
||||
const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
|
||||
.find(result => result);
|
||||
return !!next;
|
||||
}
|
||||
|
||||
return checkForCompleteChain('start');
|
||||
};
|
||||
|
||||
/**
|
||||
* houses the entire page, so also UI elements
|
||||
@@ -174,10 +199,29 @@ function graphReducer() {
|
||||
* @constructor
|
||||
*/
|
||||
function VisProgPage() {
|
||||
const [programValidity, setProgramValidity] = useState<boolean>(true);
|
||||
const {isProgramValid, severityIndex} = useFlowStore();
|
||||
|
||||
const validity = () => {return isProgramValid();}
|
||||
|
||||
useEffect(() => {
|
||||
setProgramValidity(validity);
|
||||
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
|
||||
// however this would cause unneeded updates
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [severityIndex]);
|
||||
|
||||
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||
|
||||
const processProgram = () => {
|
||||
const phases = graphReducer(); // reduce graph
|
||||
setProgramState({ phases }); // <-- save to store
|
||||
runProgram(); // send to backend if needed
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<VisualProgrammingUI/>
|
||||
<button onClick={runProgram}>run program</button>
|
||||
<button onClick={processProgram} disabled={!programValidity}>Run Program</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import useProgramStore from "../../utils/programStore";
|
||||
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores';
|
||||
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
|
||||
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
export function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Outputs the prepared program to the console and sends it to the backend
|
||||
*/
|
||||
export function runProgram() {
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
fetch(
|
||||
"http://localhost:8000/program",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(program),
|
||||
}
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
|
||||
// store reduced program in global program store for further use in the UI
|
||||
// when the program was sent to the backend successfully:
|
||||
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
console.log(program);
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import type {Edge, Node} from "@xyflow/react";
|
||||
import type {StateCreator, StoreApi } from 'zustand/vanilla';
|
||||
import type {
|
||||
SeverityIndex,
|
||||
WarningRegistry
|
||||
} from "./components/EditorWarnings.tsx";
|
||||
import type {FlowState} from "./VisProgTypes.tsx";
|
||||
|
||||
export type FlowSnapshot = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
warnings: {
|
||||
warningRegistry: WarningRegistry;
|
||||
severityIndex: SeverityIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,10 +47,14 @@ export const UndoRedo = (
|
||||
* @param {BaseFlowState} state - the current state of the editor
|
||||
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
|
||||
*/
|
||||
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({
|
||||
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
|
||||
nodes: state.nodes,
|
||||
edges: state.edges
|
||||
});
|
||||
edges: state.edges,
|
||||
warnings: {
|
||||
warningRegistry: state.editorWarningRegistry,
|
||||
severityIndex: state.severityIndex,
|
||||
}
|
||||
}));
|
||||
|
||||
const initialState = config(set, get, api);
|
||||
|
||||
@@ -78,6 +90,8 @@ export const UndoRedo = (
|
||||
set({
|
||||
nodes: snapshot.nodes,
|
||||
edges: snapshot.edges,
|
||||
editorWarningRegistry: snapshot.warnings.warningRegistry,
|
||||
severityIndex: snapshot.warnings.severityIndex,
|
||||
});
|
||||
|
||||
state.future.push(currentSnapshot); // push current to redo
|
||||
@@ -97,6 +111,8 @@ export const UndoRedo = (
|
||||
set({
|
||||
nodes: snapshot.nodes,
|
||||
edges: snapshot.edges,
|
||||
editorWarningRegistry: snapshot.warnings.warningRegistry,
|
||||
severityIndex: snapshot.warnings.severityIndex,
|
||||
});
|
||||
|
||||
state.past.push(currentSnapshot); // push current to undo
|
||||
|
||||
122
src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
Normal file
122
src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {type Connection} from "@xyflow/react";
|
||||
import {useEffect} from "react";
|
||||
import useFlowStore from "./VisProgStores.tsx";
|
||||
|
||||
export type ConnectionContext = {
|
||||
connectionCount: number;
|
||||
source: {
|
||||
id: string;
|
||||
handleId: string;
|
||||
}
|
||||
target: {
|
||||
id: string;
|
||||
handleId: string;
|
||||
}
|
||||
}
|
||||
|
||||
export type HandleRule = (
|
||||
connection: Connection,
|
||||
context: ConnectionContext
|
||||
) => RuleResult;
|
||||
|
||||
/**
|
||||
* A RuleResult describes the outcome of validating a HandleRule
|
||||
*
|
||||
* if a rule is not satisfied, the RuleResult includes a message that is used inside a tooltip
|
||||
* that tells the user why their attempted connection is not possible
|
||||
*/
|
||||
export type RuleResult =
|
||||
| { isSatisfied: true }
|
||||
| { isSatisfied: false, message: string };
|
||||
|
||||
/**
|
||||
* default RuleResults, can be used to create more readable handleRule definitions
|
||||
*/
|
||||
export const ruleResult = {
|
||||
satisfied: { isSatisfied: true } as RuleResult,
|
||||
unknownError: {isSatisfied: false, message: "Unknown Error" } as RuleResult,
|
||||
notSatisfied: (message: string) : RuleResult => { return {isSatisfied: false, message: message } }
|
||||
}
|
||||
|
||||
|
||||
const evaluateRules = (
|
||||
rules: HandleRule[],
|
||||
connection: Connection,
|
||||
context: ConnectionContext
|
||||
) : RuleResult => {
|
||||
// evaluate the rules and check if there is at least one unsatisfied rule
|
||||
const failedRule = rules
|
||||
.map(rule => rule(connection, context))
|
||||
.find(result => !result.isSatisfied);
|
||||
|
||||
return failedRule ? ruleResult.notSatisfied(failedRule.message) : ruleResult.satisfied;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* !DOCUMENTATION NOT FINISHED!
|
||||
*
|
||||
* - The output is a single RuleResult, meaning we only show one error message.
|
||||
* Error messages are prioritised by listOrder; Thus, if multiple HandleRules evaluate to false,
|
||||
* we only send the error message of the first failed rule in the target's registered list of rules.
|
||||
*
|
||||
* @param {string} nodeId
|
||||
* @param {string} handleId
|
||||
* @param type
|
||||
* @param {HandleRule[]} rules
|
||||
* @returns {(c: Connection) => RuleResult} a function that validates an attempted connection
|
||||
*/
|
||||
export function useHandleRules(
|
||||
nodeId: string,
|
||||
handleId: string,
|
||||
type: "source" | "target",
|
||||
rules: HandleRule[],
|
||||
) : (c: Connection) => RuleResult {
|
||||
const edges = useFlowStore.getState().edges;
|
||||
const registerRules = useFlowStore((state) => state.registerRules);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
registerRules(nodeId, handleId, rules);
|
||||
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
|
||||
// however this would result in an infinite loop because it would change one of its own dependencies
|
||||
// so we only use those dependencies that we don't change ourselves
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [handleId, nodeId, registerRules]);
|
||||
|
||||
return (connection: Connection) => {
|
||||
// inside this function we consider the target to be the target of the isValidConnection event
|
||||
// and not the target in the actual connection
|
||||
const { target, targetHandle } = type === "source"
|
||||
? connection
|
||||
: { target: connection.source, targetHandle: connection.sourceHandle };
|
||||
|
||||
if (!targetHandle) {throw new Error("No target handle was provided");}
|
||||
|
||||
const targetConnections = edges.filter(edge => edge.target === target && edge.targetHandle === targetHandle);
|
||||
|
||||
|
||||
// we construct the connectionContext
|
||||
const context: ConnectionContext = {
|
||||
connectionCount: targetConnections.length,
|
||||
source: {id: nodeId, handleId: handleId},
|
||||
target: {id: target, handleId: targetHandle},
|
||||
};
|
||||
const targetRules = useFlowStore.getState().getTargetRules(target, targetHandle);
|
||||
|
||||
// finally we return a function that evaluates all rules using the created context
|
||||
return evaluateRules(targetRules, connection, context);
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConnectionWithRules(
|
||||
connection: Connection,
|
||||
context: ConnectionContext
|
||||
): RuleResult {
|
||||
const rules = useFlowStore.getState().getTargetRules(
|
||||
connection.target!,
|
||||
connection.targetHandle!
|
||||
);
|
||||
|
||||
return evaluateRules(rules,connection, context);
|
||||
}
|
||||
46
src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts
Normal file
46
src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
type HandleRule,
|
||||
ruleResult
|
||||
} from "./HandleRuleLogic.ts";
|
||||
import useFlowStore from "./VisProgStores.tsx";
|
||||
|
||||
|
||||
/**
|
||||
* this specifies what types of nodes can make a connection to a handle that uses this rule
|
||||
*/
|
||||
export function allowOnlyConnectionsFromType(nodeTypes: string[]) : HandleRule {
|
||||
return ((_, {source}) => {
|
||||
const sourceType = useFlowStore.getState().nodes.find(node => node.id === source.id)!.type!;
|
||||
return nodeTypes.find(type => sourceType === type)
|
||||
? ruleResult.satisfied
|
||||
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceType}`);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* similar to allowOnlyConnectionsFromType,
|
||||
* this is a more specific variant that allows you to restrict connections to specific handles on each nodeType
|
||||
*/
|
||||
//
|
||||
export function allowOnlyConnectionsFromHandle(handles: {nodeType: string, handleId: string}[]) : HandleRule {
|
||||
return ((_, {source}) => {
|
||||
const sourceNode = useFlowStore.getState().nodes.find(node => node.id === source.id)!;
|
||||
return handles.find(handle => sourceNode.type === handle.nodeType && source.handleId === handle.handleId)
|
||||
? ruleResult.satisfied
|
||||
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceNode.type}`);
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* This rule prevents a node from making a connection between its own handles
|
||||
*/
|
||||
export const noSelfConnections : HandleRule =
|
||||
(connection, _) => {
|
||||
return connection.source !== connection.target
|
||||
? ruleResult.satisfied
|
||||
: ruleResult.notSatisfied("nodes are not allowed to connect to themselves");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import EndNode, {
|
||||
EndConnectionSource,
|
||||
EndDisconnectionTarget,
|
||||
EndDisconnectionSource,
|
||||
EndReduce
|
||||
EndReduce,
|
||||
EndTooltip
|
||||
} from "./nodes/EndNode";
|
||||
import { EndNodeDefaults } from "./nodes/EndNode.default";
|
||||
import StartNode, {
|
||||
@@ -11,7 +12,8 @@ import StartNode, {
|
||||
StartConnectionSource,
|
||||
StartDisconnectionTarget,
|
||||
StartDisconnectionSource,
|
||||
StartReduce
|
||||
StartReduce,
|
||||
StartTooltip
|
||||
} from "./nodes/StartNode";
|
||||
import { StartNodeDefaults } from "./nodes/StartNode.default";
|
||||
import PhaseNode, {
|
||||
@@ -19,7 +21,8 @@ import PhaseNode, {
|
||||
PhaseConnectionSource,
|
||||
PhaseDisconnectionTarget,
|
||||
PhaseDisconnectionSource,
|
||||
PhaseReduce
|
||||
PhaseReduce,
|
||||
PhaseTooltip
|
||||
} from "./nodes/PhaseNode";
|
||||
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
|
||||
import NormNode, {
|
||||
@@ -27,7 +30,8 @@ import NormNode, {
|
||||
NormConnectionSource,
|
||||
NormDisconnectionTarget,
|
||||
NormDisconnectionSource,
|
||||
NormReduce
|
||||
NormReduce,
|
||||
NormTooltip
|
||||
} from "./nodes/NormNode";
|
||||
import { NormNodeDefaults } from "./nodes/NormNode.default";
|
||||
import GoalNode, {
|
||||
@@ -35,7 +39,8 @@ import GoalNode, {
|
||||
GoalConnectionSource,
|
||||
GoalDisconnectionTarget,
|
||||
GoalDisconnectionSource,
|
||||
GoalReduce
|
||||
GoalReduce,
|
||||
GoalTooltip
|
||||
} from "./nodes/GoalNode";
|
||||
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
|
||||
import TriggerNode, {
|
||||
@@ -43,9 +48,28 @@ import TriggerNode, {
|
||||
TriggerConnectionSource,
|
||||
TriggerDisconnectionTarget,
|
||||
TriggerDisconnectionSource,
|
||||
TriggerReduce
|
||||
TriggerReduce,
|
||||
TriggerTooltip
|
||||
} from "./nodes/TriggerNode";
|
||||
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
||||
import InferredBeliefNode, {
|
||||
InferredBeliefConnectionTarget,
|
||||
InferredBeliefConnectionSource,
|
||||
InferredBeliefDisconnectionTarget,
|
||||
InferredBeliefDisconnectionSource,
|
||||
InferredBeliefReduce, InferredBeliefTooltip
|
||||
} from "./nodes/InferredBeliefNode";
|
||||
import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default";
|
||||
import BasicBeliefNode, {
|
||||
BasicBeliefConnectionSource,
|
||||
BasicBeliefConnectionTarget,
|
||||
BasicBeliefDisconnectionSource,
|
||||
BasicBeliefDisconnectionTarget,
|
||||
BasicBeliefReduce
|
||||
,
|
||||
BasicBeliefTooltip
|
||||
} from "./nodes/BasicBeliefNode.tsx";
|
||||
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
|
||||
|
||||
/**
|
||||
* Registered node types in the visual programming system.
|
||||
@@ -60,6 +84,8 @@ export const NodeTypes = {
|
||||
norm: NormNode,
|
||||
goal: GoalNode,
|
||||
trigger: TriggerNode,
|
||||
basic_belief: BasicBeliefNode,
|
||||
inferred_belief: InferredBeliefNode,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -74,6 +100,8 @@ export const NodeDefaults = {
|
||||
norm: NormNodeDefaults,
|
||||
goal: GoalNodeDefaults,
|
||||
trigger: TriggerNodeDefaults,
|
||||
basic_belief: BasicBeliefNodeDefaults,
|
||||
inferred_belief: InferredBeliefNodeDefaults,
|
||||
};
|
||||
|
||||
|
||||
@@ -90,6 +118,8 @@ export const NodeReduces = {
|
||||
norm: NormReduce,
|
||||
goal: GoalReduce,
|
||||
trigger: TriggerReduce,
|
||||
basic_belief: BasicBeliefReduce,
|
||||
inferred_belief: InferredBeliefReduce,
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +137,8 @@ export const NodeConnections = {
|
||||
norm: NormConnectionTarget,
|
||||
goal: GoalConnectionTarget,
|
||||
trigger: TriggerConnectionTarget,
|
||||
basic_belief: BasicBeliefConnectionTarget,
|
||||
inferred_belief: InferredBeliefConnectionTarget,
|
||||
},
|
||||
Sources: {
|
||||
start: StartConnectionSource,
|
||||
@@ -115,6 +147,8 @@ export const NodeConnections = {
|
||||
norm: NormConnectionSource,
|
||||
goal: GoalConnectionSource,
|
||||
trigger: TriggerConnectionSource,
|
||||
basic_belief: BasicBeliefConnectionSource,
|
||||
inferred_belief: InferredBeliefConnectionSource,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +166,8 @@ export const NodeDisconnections = {
|
||||
norm: NormDisconnectionTarget,
|
||||
goal: GoalDisconnectionTarget,
|
||||
trigger: TriggerDisconnectionTarget,
|
||||
basic_belief: BasicBeliefDisconnectionTarget,
|
||||
inferred_belief: InferredBeliefDisconnectionTarget,
|
||||
},
|
||||
Sources: {
|
||||
start: StartDisconnectionSource,
|
||||
@@ -140,6 +176,8 @@ export const NodeDisconnections = {
|
||||
norm: NormDisconnectionSource,
|
||||
goal: GoalDisconnectionSource,
|
||||
trigger: TriggerDisconnectionSource,
|
||||
basic_belief: BasicBeliefDisconnectionSource,
|
||||
inferred_belief: InferredBeliefDisconnectionSource,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,7 +189,6 @@ export const NodeDisconnections = {
|
||||
export const NodeDeletes = {
|
||||
start: () => false,
|
||||
end: () => false,
|
||||
test: () => false, // Used for coverage of universal/ undefined nodes
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,5 +201,20 @@ export const NodesInPhase = {
|
||||
start: () => false,
|
||||
end: () => false,
|
||||
phase: () => false,
|
||||
test: () => false, // Used for coverage of universal/ undefined nodes
|
||||
basic_belief: () => false,
|
||||
inferred_belief: () => false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the tooltips for all nodeTypes so they can be accessed by the tooltip component
|
||||
*/
|
||||
export const NodeTooltips = {
|
||||
start: StartTooltip,
|
||||
end: EndTooltip,
|
||||
phase: PhaseTooltip,
|
||||
norm: NormTooltip,
|
||||
goal: GoalTooltip,
|
||||
trigger: TriggerTooltip,
|
||||
basic_belief: BasicBeliefTooltip,
|
||||
inferred_belief: InferredBeliefTooltip,
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type Edge,
|
||||
type XYPosition,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
|
||||
import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
|
||||
import type { FlowState } from './VisProgTypes';
|
||||
import {
|
||||
NodeDefaults,
|
||||
@@ -28,32 +31,31 @@ import { UndoRedo } from "./EditorUndoRedo.ts";
|
||||
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
|
||||
* @returns A fully initialized Node object ready to be added to the flow.
|
||||
*/
|
||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||
const newData = {
|
||||
id: id,
|
||||
type: type,
|
||||
position: position,
|
||||
data: data,
|
||||
deletable: deletable,
|
||||
}
|
||||
return {...defaultData, ...newData}
|
||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
|
||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
deletable,
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(defaultData)),
|
||||
...data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//* Initial nodes, created by using createNode. */
|
||||
const initialNodes : Node[] = [
|
||||
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
||||
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
||||
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}),
|
||||
];
|
||||
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
|
||||
const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
|
||||
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
|
||||
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
|
||||
|
||||
// * Initial edges * /
|
||||
const initialEdges: Edge[] = [
|
||||
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
|
||||
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
|
||||
];
|
||||
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
|
||||
|
||||
// Initial edges, leave empty as setting initial edges...
|
||||
// ...breaks logic that is dependent on connection events
|
||||
const initialEdges: Edge[] = [];
|
||||
|
||||
/**
|
||||
* useFlowStore contains the implementation for all editor functionality
|
||||
@@ -70,14 +72,26 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||
nodes: initialNodes,
|
||||
edges: initialEdges,
|
||||
edgeReconnectSuccessful: true,
|
||||
scrollable: true,
|
||||
|
||||
/**
|
||||
* handles changing the scrollable state of the editor,
|
||||
* this is used to control if scrolling is captured by the editor
|
||||
* or if it's available to other components within the reactFlowProvider
|
||||
* @param {boolean} val - the desired state
|
||||
*/
|
||||
setScrollable: (val) => set({scrollable: val}),
|
||||
|
||||
/**
|
||||
* Handles changes to nodes triggered by ReactFlow.
|
||||
*/
|
||||
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
|
||||
|
||||
onEdgesDelete: (edges) => {
|
||||
onNodesDelete: (nodes) => nodes.forEach((_node) => {
|
||||
return;
|
||||
}),
|
||||
|
||||
onEdgesDelete: (edges) => {
|
||||
// we make sure any affected nodes get updated to reflect removal of edges
|
||||
edges.forEach((edge) => {
|
||||
const nodes = get().nodes;
|
||||
@@ -118,7 +132,41 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||
* Handles reconnecting an edge between nodes.
|
||||
*/
|
||||
onReconnect: (oldEdge, newConnection) => {
|
||||
get().edgeReconnectSuccessful = true;
|
||||
|
||||
function createContext(
|
||||
source: {id: string, handleId: string},
|
||||
target: {id: string, handleId: string}
|
||||
) : ConnectionContext {
|
||||
const edges = get().edges;
|
||||
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
|
||||
return {
|
||||
connectionCount: targetConnections,
|
||||
source: source,
|
||||
target: target
|
||||
}
|
||||
}
|
||||
|
||||
// connection validation
|
||||
const context: ConnectionContext = oldEdge.source === newConnection.source
|
||||
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
|
||||
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
|
||||
|
||||
const result = validateConnectionWithRules(
|
||||
newConnection,
|
||||
context
|
||||
);
|
||||
|
||||
if (!result.isSatisfied) {
|
||||
set({
|
||||
edges: get().edges.map(e =>
|
||||
e.id === oldEdge.id ? oldEdge : e
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// further reconnect logic
|
||||
set({ edgeReconnectSuccessful: true });
|
||||
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
|
||||
|
||||
// We make sure to perform any required data updates on the newly reconnected nodes
|
||||
@@ -171,19 +219,32 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||
* Deletes a node by ID, respecting NodeDeletes rules.
|
||||
* Also removes all edges connected to that node.
|
||||
*/
|
||||
deleteNode: (nodeId) => {
|
||||
deleteNode: (nodeId, deleteElements) => {
|
||||
get().pushSnapshot();
|
||||
|
||||
// Let's find our node to check if they have a special deletion function
|
||||
const ourNode = get().nodes.find((n)=>n.id==nodeId);
|
||||
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
|
||||
|
||||
|
||||
|
||||
|
||||
// If there's no function, OR, our function tells us we can delete it, let's do so...
|
||||
if (ourFunction == undefined || ourFunction()) {
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||
})}
|
||||
if (deleteElements){
|
||||
deleteElements({
|
||||
nodes: get().nodes.filter((n) => n.id === nodeId),
|
||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target === nodeId)}
|
||||
).then(() => {
|
||||
get().unregisterNodeRules(nodeId);
|
||||
get().unregisterWarningsForId(nodeId);
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
nodes: get().nodes.filter((n) => n.id !== nodeId),
|
||||
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -223,7 +284,84 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
|
||||
past: [],
|
||||
future: [],
|
||||
isBatchAction: false,
|
||||
|
||||
// handleRuleRegistry definitions
|
||||
/**
|
||||
* stores registered rules for handle connection validation
|
||||
*/
|
||||
ruleRegistry: new Map(),
|
||||
|
||||
/**
|
||||
* gets the rules registered by that handle described by the given node and handle ids
|
||||
*
|
||||
* @param {string} targetNodeId
|
||||
* @param {string} targetHandleId
|
||||
* @returns {HandleRule[]}
|
||||
*/
|
||||
getTargetRules: (targetNodeId, targetHandleId) => {
|
||||
const key = `${targetNodeId}:${targetHandleId}`;
|
||||
const rules = get().ruleRegistry.get(key);
|
||||
|
||||
// helper function that handles a situation where no rules were registered
|
||||
const missingRulesResponse = () => {
|
||||
console.warn(
|
||||
`No rules were registered for the following handle "${key}"!
|
||||
returning and empty handleRule[] to avoid crashing`);
|
||||
return []
|
||||
}
|
||||
|
||||
return rules
|
||||
? rules
|
||||
: missingRulesResponse()
|
||||
},
|
||||
|
||||
/**
|
||||
* registers a handle's connection rules
|
||||
*
|
||||
* @param {string} nodeId
|
||||
* @param {string} handleId
|
||||
* @param {HandleRule[]} rules
|
||||
*/
|
||||
registerRules: (nodeId, handleId, rules) => {
|
||||
const registry = get().ruleRegistry;
|
||||
registry.set(`${nodeId}:${handleId}`, rules);
|
||||
set({ ruleRegistry: registry }) ;
|
||||
},
|
||||
|
||||
/**
|
||||
* unregisters a handles connection rules
|
||||
*
|
||||
* @param {string} nodeId
|
||||
* @param {string} handleId
|
||||
*/
|
||||
unregisterHandleRules: (nodeId, handleId) => {
|
||||
set( () => {
|
||||
const registry = get().ruleRegistry;
|
||||
registry.delete(`${nodeId}:${handleId}`);
|
||||
return { ruleRegistry: registry };
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* unregisters connection rules for all handles on the given node
|
||||
* used for cleaning up rules on node deletion
|
||||
*
|
||||
* @param {string} nodeId
|
||||
*/
|
||||
unregisterNodeRules: (nodeId) => {
|
||||
set(() => {
|
||||
const registry = get().ruleRegistry;
|
||||
registry.forEach((_,key) => {
|
||||
if (key.startsWith(`${nodeId}:`)) registry.delete(key)
|
||||
})
|
||||
return { ruleRegistry: registry };
|
||||
})
|
||||
},
|
||||
...editorWarningRegistry(get, set),
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
|
||||
export default useFlowStore;
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// VisProgTypes.ts
|
||||
import type {Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node, OnEdgesDelete} from '@xyflow/react';
|
||||
import type {
|
||||
Edge,
|
||||
OnNodesChange,
|
||||
OnEdgesChange,
|
||||
OnConnect,
|
||||
OnReconnect,
|
||||
Node,
|
||||
OnEdgesDelete,
|
||||
OnNodesDelete, DeleteElementsOptions
|
||||
} from '@xyflow/react';
|
||||
import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
|
||||
import type {HandleRule} from "./HandleRuleLogic.ts";
|
||||
import type { NodeTypes } from './NodeRegistry';
|
||||
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
|
||||
|
||||
@@ -23,10 +34,16 @@ export type FlowState = {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
edgeReconnectSuccessful: boolean;
|
||||
scrollable: boolean;
|
||||
|
||||
/** Handler for managing scrollable state */
|
||||
setScrollable: (value: boolean) => void;
|
||||
|
||||
/** Handler for changes to nodes triggered by ReactFlow */
|
||||
onNodesChange: OnNodesChange;
|
||||
|
||||
onNodesDelete: OnNodesDelete;
|
||||
|
||||
onEdgesDelete: OnEdgesDelete;
|
||||
|
||||
/** Handler for changes to edges triggered by ReactFlow */
|
||||
@@ -52,7 +69,10 @@ export type FlowState = {
|
||||
* Deletes a node and any connected edges.
|
||||
* @param nodeId - the ID of the node to delete
|
||||
*/
|
||||
deleteNode: (nodeId: string) => void;
|
||||
deleteNode: (nodeId: string, deleteElements?: (params: DeleteElementsOptions) => Promise<{
|
||||
deletedNodes: Node[]
|
||||
deletedEdges: Edge[]
|
||||
}>) => void;
|
||||
|
||||
/**
|
||||
* Replaces the current nodes array in the store.
|
||||
@@ -78,7 +98,9 @@ export type FlowState = {
|
||||
* @param node - the Node object to add
|
||||
*/
|
||||
addNode: (node: Node) => void;
|
||||
} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
|
||||
|
||||
export type UndoRedoState = {
|
||||
// UndoRedo Types
|
||||
past: FlowSnapshot[];
|
||||
future: FlowSnapshot[];
|
||||
@@ -88,4 +110,30 @@ export type FlowState = {
|
||||
endBatchAction: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export type HandleRuleRegistry = {
|
||||
ruleRegistry: Map<string, HandleRule[]>;
|
||||
|
||||
getTargetRules: (
|
||||
targetNodeId: string,
|
||||
targetHandleId: string
|
||||
) => HandleRule[];
|
||||
|
||||
registerRules: (
|
||||
nodeId: string,
|
||||
handleId: string,
|
||||
rules: HandleRule[]
|
||||
) => void;
|
||||
|
||||
unregisterHandleRules: (
|
||||
nodeId: string,
|
||||
handleId: string
|
||||
) => void;
|
||||
|
||||
// cleans up all registered rules of all handles of the provided node
|
||||
unregisterNodeRules: (nodeId: string) => void
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useReactFlow, type XYPosition } from '@xyflow/react';
|
||||
import { type ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
|
||||
import { NodeDefaults, type NodeTypes} from '../NodeRegistry'
|
||||
import {Tooltip} from "./NodeComponents.tsx";
|
||||
|
||||
/**
|
||||
* Props for a draggable node within the drag-and-drop toolbar.
|
||||
@@ -47,14 +48,17 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}
|
||||
ref={draggableRef}
|
||||
id={`draggable-${nodeType}`}
|
||||
data-testid={`draggable-${nodeType}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
<Tooltip nodeType={nodeType}>
|
||||
<div>
|
||||
<div className={className}
|
||||
ref={draggableRef}
|
||||
id={`draggable-${nodeType}`}
|
||||
data-testid={`draggable-${nodeType}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,23 +73,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
|
||||
* @param position - The XY position in the flow canvas where the node will appear.
|
||||
*/
|
||||
function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) {
|
||||
const { nodes, addNode } = useFlowStore.getState();
|
||||
const { addNode } = useFlowStore.getState();
|
||||
|
||||
// Load any predefined data for this node type.
|
||||
const defaultData = NodeDefaults[nodeType] ?? {}
|
||||
|
||||
// Currently, we find out what the Id is by checking the last node and adding one.
|
||||
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
|
||||
const nextNumber =
|
||||
sameTypeNodes.length > 0
|
||||
? (() => {
|
||||
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
||||
const parts = lastNode.id.split('-');
|
||||
const lastNum = Number(parts[1]);
|
||||
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
|
||||
})()
|
||||
: 1;
|
||||
const id = `${nodeType}-${nextNumber}`;
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Create new node
|
||||
const newNode = {
|
||||
@@ -145,7 +137,7 @@ export function DndToolbar() {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
|
||||
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`} id={"draggable-sidebar"}>
|
||||
<div className="description">
|
||||
You can drag these nodes to the pane to create new nodes.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/* contains all logic for the VisProgEditor warning system
|
||||
*
|
||||
* Missing but desirable features:
|
||||
* - Warning filtering:
|
||||
* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
|
||||
* then hide any startNode, phaseNode, or endNode specific warnings
|
||||
*/
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import type {FlowState} from "../VisProgTypes.tsx";
|
||||
|
||||
// --| Type definitions |--
|
||||
|
||||
export type WarningId = NodeId | "GLOBAL_WARNINGS";
|
||||
export type NodeId = string;
|
||||
|
||||
|
||||
export type WarningType =
|
||||
| 'MISSING_INPUT'
|
||||
| 'MISSING_OUTPUT'
|
||||
| 'PLAN_IS_UNDEFINED'
|
||||
| 'INCOMPLETE_PROGRAM'
|
||||
| 'NOT_CONNECTED_TO_PROGRAM'
|
||||
| string
|
||||
|
||||
export type WarningSeverity =
|
||||
| 'INFO' // Acceptable, but important to be aware of
|
||||
| 'WARNING' // Acceptable, but probably undesirable behavior
|
||||
| 'ERROR' // Prevents running program, should be fixed before running program is allowed
|
||||
|
||||
/**
|
||||
* warning scope, include a handleId if the warning is handle specific
|
||||
*/
|
||||
export type WarningScope = {
|
||||
id: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export type EditorWarning = {
|
||||
scope: WarningScope;
|
||||
type: WarningType;
|
||||
severity: WarningSeverity;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* a scoped WarningKey,
|
||||
* the handleId scoping is only needed for handle specific errors
|
||||
*
|
||||
* "`WarningType`:`handleId`"
|
||||
*/
|
||||
export type WarningKey = string; // for warnings that can occur on a per-handle basis
|
||||
|
||||
/**
|
||||
* a composite key used in the severityIndex
|
||||
*
|
||||
* "`WarningId`|`WarningKey`"
|
||||
*/
|
||||
export type CompositeWarningKey = string;
|
||||
|
||||
export type WarningRegistry = Map<WarningId , Map<WarningKey, EditorWarning>>;
|
||||
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
|
||||
|
||||
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => void;
|
||||
type ZustandGet = () => FlowState;
|
||||
|
||||
export type EditorWarningRegistry = {
|
||||
/**
|
||||
* stores all editor warnings
|
||||
*/
|
||||
editorWarningRegistry: WarningRegistry;
|
||||
/**
|
||||
* index of warnings by severity
|
||||
*/
|
||||
severityIndex: SeverityIndex;
|
||||
|
||||
/**
|
||||
* gets all warnings and returns them as a list of warnings
|
||||
* @returns {EditorWarning[]}
|
||||
*/
|
||||
getWarnings: () => EditorWarning[];
|
||||
|
||||
/**
|
||||
* gets all warnings with the current severity
|
||||
* @param {WarningSeverity} warningSeverity
|
||||
* @returns {EditorWarning[]}
|
||||
*/
|
||||
getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
|
||||
|
||||
/**
|
||||
* checks if there are no warnings of breaking severity
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isProgramValid: () => boolean;
|
||||
|
||||
/**
|
||||
* registers a warning to the warningRegistry and the SeverityIndex
|
||||
* @param {EditorWarning} warning
|
||||
*/
|
||||
registerWarning: (warning: EditorWarning) => void;
|
||||
|
||||
/**
|
||||
* unregisters a warning from the warningRegistry and the SeverityIndex
|
||||
* @param {EditorWarning} warning
|
||||
*/
|
||||
unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
|
||||
|
||||
/**
|
||||
* unregisters warnings from the warningRegistry and the SeverityIndex
|
||||
* @param {WarningId} warning
|
||||
*/
|
||||
unregisterWarningsForId: (id: WarningId) => void;
|
||||
}
|
||||
|
||||
// --| implemented logic |--
|
||||
|
||||
/**
|
||||
* the id to use for global editor warnings
|
||||
* @type {string}
|
||||
*/
|
||||
export const globalWarning = "GLOBAL_WARNINGS";
|
||||
|
||||
export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
|
||||
editorWarningRegistry: new Map<NodeId, Map<WarningKey, EditorWarning>>(),
|
||||
severityIndex: new Map([
|
||||
['INFO', new Set<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]),
|
||||
|
||||
getWarningsBySeverity: (warningSeverity) => {
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
const warningKeys = sIndex.get(warningSeverity);
|
||||
const warnings: EditorWarning[] = [];
|
||||
|
||||
warningKeys?.forEach(
|
||||
(compositeKey) => {
|
||||
const [id, warningKey] = compositeKey.split('|');
|
||||
const warning = wRegistry.get(id)?.get(warningKey);
|
||||
|
||||
if (warning) {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return warnings;
|
||||
},
|
||||
|
||||
isProgramValid: () => {
|
||||
const sIndex = get().severityIndex;
|
||||
return (sIndex.get("ERROR")!.size === 0);
|
||||
},
|
||||
|
||||
getWarnings: () => Array.from(get().editorWarningRegistry.values())
|
||||
.flatMap(innerMap => Array.from(innerMap.values())),
|
||||
|
||||
|
||||
registerWarning: (warning) => {
|
||||
const { scope: {id, handleId}, type, severity } = warning;
|
||||
const warningKey = handleId ? `${type}:${handleId}` : type;
|
||||
const compositeKey = `${id}|${warningKey}`;
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
// add to warning registry
|
||||
if (!wRegistry.has(id)) {
|
||||
wRegistry.set(id, new Map());
|
||||
}
|
||||
wRegistry.get(id)!.set(warningKey, warning);
|
||||
|
||||
|
||||
// add to severityIndex
|
||||
if (!sIndex.get(severity)!.has(compositeKey)) {
|
||||
sIndex.get(severity)!.add(compositeKey);
|
||||
}
|
||||
|
||||
set({
|
||||
editorWarningRegistry: wRegistry,
|
||||
severityIndex: sIndex
|
||||
})
|
||||
},
|
||||
|
||||
unregisterWarning: (id, warningKey) => {
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
// verify if the warning was created already
|
||||
const warning = wRegistry.get(id)?.get(warningKey);
|
||||
if (!warning) return;
|
||||
|
||||
// remove from warning registry
|
||||
wRegistry.get(id)!.delete(warningKey);
|
||||
|
||||
|
||||
// remove from severityIndex
|
||||
sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
|
||||
|
||||
set({
|
||||
editorWarningRegistry: wRegistry,
|
||||
severityIndex: sIndex
|
||||
})
|
||||
},
|
||||
|
||||
unregisterWarningsForId: (id) => {
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
|
||||
const nodeWarnings = wRegistry.get(id);
|
||||
|
||||
// remove from severity index
|
||||
if (nodeWarnings) {
|
||||
nodeWarnings.forEach((warning) => {
|
||||
const warningKey = warning.scope.handleId
|
||||
? `${warning.type}:${warning.scope.handleId}`
|
||||
: warning.type;
|
||||
sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
|
||||
});
|
||||
}
|
||||
|
||||
// remove from warning registry
|
||||
wRegistry.delete(id);
|
||||
|
||||
set({
|
||||
editorWarningRegistry: wRegistry,
|
||||
severityIndex: sIndex
|
||||
})
|
||||
},
|
||||
}}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* returns a summary of the warningRegistry
|
||||
* @returns {{info: number, warning: number, error: number, isValid: boolean}}
|
||||
*/
|
||||
export function warningSummary() {
|
||||
const {severityIndex, isProgramValid} = useFlowStore.getState();
|
||||
return {
|
||||
info: severityIndex.get('INFO')!.size,
|
||||
warning: severityIndex.get('WARNING')!.size,
|
||||
error: severityIndex.get('ERROR')!.size,
|
||||
isValid: isProgramValid(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
|
||||
.gestureEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modeSelector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modeLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggleContainer {
|
||||
display: flex;
|
||||
background: rgba(78, 78, 78, 0.411);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toggleButton {
|
||||
padding: 6px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggleButton:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.toggleButton.active {
|
||||
box-shadow: 0 0 1px 0 rgba(9, 255, 0, 0.733);
|
||||
}
|
||||
|
||||
.valueEditor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.textInput {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.textInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.tagSelector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tagSelect {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: rgba(135, 135, 135, 0.296);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tagSelect:focus {
|
||||
outline: none;
|
||||
border-color: rgb(0, 149, 25);
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid rgba(var(--primary-rgb), 0.1);
|
||||
border-radius: 4px;
|
||||
background: var(--primary-color);
|
||||
}
|
||||
|
||||
.tagButton {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid gray;
|
||||
border-radius: 4px;
|
||||
background: var(--primary-rgb);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagButton:hover {
|
||||
background: gray;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
.tagButton.selected {
|
||||
background: rgba(var(--primary-rgb), 0.5);
|
||||
color: var(--primary-rgb);
|
||||
border-color: rgb(27, 223, 60);
|
||||
}
|
||||
|
||||
.suggestionsDropdownLeft {
|
||||
position: absolute;
|
||||
left: -220px;
|
||||
top: 120px;
|
||||
|
||||
width: 200px;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
|
||||
background: var(--dropdown-menu-background-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px var(--dropdown-menu-border);
|
||||
}
|
||||
|
||||
.suggestionsDropdownLeft::before {
|
||||
content: "Gesture Suggestions";
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.suggestionItem {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.suggestionItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.suggestionItem:hover {
|
||||
background-color: var(--background-hover);
|
||||
}
|
||||
|
||||
.suggestionItem:active {
|
||||
background-color: var(--primary-color-light);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import { useState, useRef } from "react";
|
||||
import styles from './GestureValueEditor.module.css'
|
||||
|
||||
/**
|
||||
* Props for the GestureValueEditor component.
|
||||
* - value: current gesture value (controlled by parent)
|
||||
* - setValue: callback to update the gesture value in parent state
|
||||
* - placeholder: optional placeholder text for the input field
|
||||
*/
|
||||
type GestureValueEditorProps = {
|
||||
value: string;
|
||||
setValue: (value: string) => void;
|
||||
setType: (value: boolean) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* List of high-level gesture "tags".
|
||||
* These are human-readable categories or semantic labels.
|
||||
* In a real app, these would likely be loaded from an external source.
|
||||
*/
|
||||
const GESTURE_TAGS = ["above", "affirmative", "afford", "agitated", "all", "allright", "alright", "any",
|
||||
"assuage", "attemper", "back", "bashful", "beg", "beseech", "blank",
|
||||
"body language", "bored", "bow", "but", "call", "calm", "choose", "choice", "cloud",
|
||||
"cogitate", "cool", "crazy", "disappointed", "down", "earth", "empty", "embarrassed",
|
||||
"enthusiastic", "entire", "estimate", "except", "exalted", "excited", "explain", "far",
|
||||
"field", "floor", "forlorn", "friendly", "front", "frustrated", "gentle", "gift",
|
||||
"give", "ground", "happy", "hello", "her", "here", "hey", "hi", "him", "hopeless",
|
||||
"hysterical", "I", "implore", "indicate", "joyful", "me", "meditate", "modest",
|
||||
"negative", "nervous", "no", "not know", "nothing", "offer", "ok", "once upon a time",
|
||||
"oppose", "or", "pacify", "pick", "placate", "please", "present", "proffer", "quiet",
|
||||
"reason", "refute", "reject", "rousing", "sad", "select", "shamefaced", "show",
|
||||
"show sky", "sky", "soothe", "sun", "supplicate", "tablet", "tall", "them", "there",
|
||||
"think", "timid", "top", "unless", "up", "upstairs", "void", "warm", "winner", "yeah",
|
||||
"yes", "yoo-hoo", "you", "your", "zero", "zestful"];
|
||||
|
||||
/**
|
||||
* List of concrete gesture animation paths.
|
||||
* These represent specific animation assets and are used in "single" mode
|
||||
* with autocomplete-style selection, also would be loaded from an external source.
|
||||
*/
|
||||
const GESTURE_SINGLES = [
|
||||
"animations/Stand/BodyTalk/Listening/Listening_1",
|
||||
"animations/Stand/BodyTalk/Listening/Listening_2",
|
||||
"animations/Stand/BodyTalk/Listening/Listening_3",
|
||||
"animations/Stand/BodyTalk/Listening/Listening_4",
|
||||
"animations/Stand/BodyTalk/Listening/Listening_5",
|
||||
"animations/Stand/BodyTalk/Listening/Listening_6",
|
||||
"animations/Stand/BodyTalk/Listening/Listening_7",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_1",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_10",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_11",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_12",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_13",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_14",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_15",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_16",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_2",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_3",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_4",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_5",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_6",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_7",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_8",
|
||||
"animations/Stand/BodyTalk/Speaking/BodyTalk_9",
|
||||
"animations/Stand/BodyTalk/Thinking/Remember_1",
|
||||
"animations/Stand/BodyTalk/Thinking/Remember_2",
|
||||
"animations/Stand/BodyTalk/Thinking/Remember_3",
|
||||
"animations/Stand/BodyTalk/Thinking/ThinkingLoop_1",
|
||||
"animations/Stand/BodyTalk/Thinking/ThinkingLoop_2",
|
||||
"animations/Stand/Emotions/Negative/Angry_1",
|
||||
"animations/Stand/Emotions/Negative/Angry_2",
|
||||
"animations/Stand/Emotions/Negative/Angry_3",
|
||||
"animations/Stand/Emotions/Negative/Angry_4",
|
||||
"animations/Stand/Emotions/Negative/Anxious_1",
|
||||
"animations/Stand/Emotions/Negative/Bored_1",
|
||||
"animations/Stand/Emotions/Negative/Bored_2",
|
||||
"animations/Stand/Emotions/Negative/Disappointed_1",
|
||||
"animations/Stand/Emotions/Negative/Exhausted_1",
|
||||
"animations/Stand/Emotions/Negative/Exhausted_2",
|
||||
"animations/Stand/Emotions/Negative/Fear_1",
|
||||
"animations/Stand/Emotions/Negative/Fear_2",
|
||||
"animations/Stand/Emotions/Negative/Fearful_1",
|
||||
"animations/Stand/Emotions/Negative/Frustrated_1",
|
||||
"animations/Stand/Emotions/Negative/Humiliated_1",
|
||||
"animations/Stand/Emotions/Negative/Hurt_1",
|
||||
"animations/Stand/Emotions/Negative/Hurt_2",
|
||||
"animations/Stand/Emotions/Negative/Late_1",
|
||||
"animations/Stand/Emotions/Negative/Sad_1",
|
||||
"animations/Stand/Emotions/Negative/Sad_2",
|
||||
"animations/Stand/Emotions/Negative/Shocked_1",
|
||||
"animations/Stand/Emotions/Negative/Sorry_1",
|
||||
"animations/Stand/Emotions/Negative/Surprise_1",
|
||||
"animations/Stand/Emotions/Negative/Surprise_2",
|
||||
"animations/Stand/Emotions/Negative/Surprise_3",
|
||||
"animations/Stand/Emotions/Neutral/Alienated_1",
|
||||
"animations/Stand/Emotions/Neutral/AskForAttention_1",
|
||||
"animations/Stand/Emotions/Neutral/AskForAttention_2",
|
||||
"animations/Stand/Emotions/Neutral/AskForAttention_3",
|
||||
"animations/Stand/Emotions/Neutral/Cautious_1",
|
||||
"animations/Stand/Emotions/Neutral/Confused_1",
|
||||
"animations/Stand/Emotions/Neutral/Determined_1",
|
||||
"animations/Stand/Emotions/Neutral/Embarrassed_1",
|
||||
"animations/Stand/Emotions/Neutral/Hesitation_1",
|
||||
"animations/Stand/Emotions/Neutral/Innocent_1",
|
||||
"animations/Stand/Emotions/Neutral/Lonely_1",
|
||||
"animations/Stand/Emotions/Neutral/Mischievous_1",
|
||||
"animations/Stand/Emotions/Neutral/Puzzled_1",
|
||||
"animations/Stand/Emotions/Neutral/Sneeze",
|
||||
"animations/Stand/Emotions/Neutral/Stubborn_1",
|
||||
"animations/Stand/Emotions/Neutral/Suspicious_1",
|
||||
"animations/Stand/Emotions/Positive/Amused_1",
|
||||
"animations/Stand/Emotions/Positive/Confident_1",
|
||||
"animations/Stand/Emotions/Positive/Ecstatic_1",
|
||||
"animations/Stand/Emotions/Positive/Enthusiastic_1",
|
||||
"animations/Stand/Emotions/Positive/Excited_1",
|
||||
"animations/Stand/Emotions/Positive/Excited_2",
|
||||
"animations/Stand/Emotions/Positive/Excited_3",
|
||||
"animations/Stand/Emotions/Positive/Happy_1",
|
||||
"animations/Stand/Emotions/Positive/Happy_2",
|
||||
"animations/Stand/Emotions/Positive/Happy_3",
|
||||
"animations/Stand/Emotions/Positive/Happy_4",
|
||||
"animations/Stand/Emotions/Positive/Hungry_1",
|
||||
"animations/Stand/Emotions/Positive/Hysterical_1",
|
||||
"animations/Stand/Emotions/Positive/Interested_1",
|
||||
"animations/Stand/Emotions/Positive/Interested_2",
|
||||
"animations/Stand/Emotions/Positive/Laugh_1",
|
||||
"animations/Stand/Emotions/Positive/Laugh_2",
|
||||
"animations/Stand/Emotions/Positive/Laugh_3",
|
||||
"animations/Stand/Emotions/Positive/Mocker_1",
|
||||
"animations/Stand/Emotions/Positive/Optimistic_1",
|
||||
"animations/Stand/Emotions/Positive/Peaceful_1",
|
||||
"animations/Stand/Emotions/Positive/Proud_1",
|
||||
"animations/Stand/Emotions/Positive/Proud_2",
|
||||
"animations/Stand/Emotions/Positive/Proud_3",
|
||||
"animations/Stand/Emotions/Positive/Relieved_1",
|
||||
"animations/Stand/Emotions/Positive/Shy_1",
|
||||
"animations/Stand/Emotions/Positive/Shy_2",
|
||||
"animations/Stand/Emotions/Positive/Sure_1",
|
||||
"animations/Stand/Emotions/Positive/Winner_1",
|
||||
"animations/Stand/Emotions/Positive/Winner_2",
|
||||
"animations/Stand/Gestures/Angry_1",
|
||||
"animations/Stand/Gestures/Angry_2",
|
||||
"animations/Stand/Gestures/Angry_3",
|
||||
"animations/Stand/Gestures/BowShort_1",
|
||||
"animations/Stand/Gestures/BowShort_2",
|
||||
"animations/Stand/Gestures/BowShort_3",
|
||||
"animations/Stand/Gestures/But_1",
|
||||
"animations/Stand/Gestures/CalmDown_1",
|
||||
"animations/Stand/Gestures/CalmDown_2",
|
||||
"animations/Stand/Gestures/CalmDown_3",
|
||||
"animations/Stand/Gestures/CalmDown_4",
|
||||
"animations/Stand/Gestures/CalmDown_5",
|
||||
"animations/Stand/Gestures/CalmDown_6",
|
||||
"animations/Stand/Gestures/Choice_1",
|
||||
"animations/Stand/Gestures/ComeOn_1",
|
||||
"animations/Stand/Gestures/Confused_1",
|
||||
"animations/Stand/Gestures/Confused_2",
|
||||
"animations/Stand/Gestures/CountFive_1",
|
||||
"animations/Stand/Gestures/CountFour_1",
|
||||
"animations/Stand/Gestures/CountMore_1",
|
||||
"animations/Stand/Gestures/CountOne_1",
|
||||
"animations/Stand/Gestures/CountThree_1",
|
||||
"animations/Stand/Gestures/CountTwo_1",
|
||||
"animations/Stand/Gestures/Desperate_1",
|
||||
"animations/Stand/Gestures/Desperate_2",
|
||||
"animations/Stand/Gestures/Desperate_3",
|
||||
"animations/Stand/Gestures/Desperate_4",
|
||||
"animations/Stand/Gestures/Desperate_5",
|
||||
"animations/Stand/Gestures/DontUnderstand_1",
|
||||
"animations/Stand/Gestures/Enthusiastic_3",
|
||||
"animations/Stand/Gestures/Enthusiastic_4",
|
||||
"animations/Stand/Gestures/Enthusiastic_5",
|
||||
"animations/Stand/Gestures/Everything_1",
|
||||
"animations/Stand/Gestures/Everything_2",
|
||||
"animations/Stand/Gestures/Everything_3",
|
||||
"animations/Stand/Gestures/Everything_4",
|
||||
"animations/Stand/Gestures/Everything_6",
|
||||
"animations/Stand/Gestures/Excited_1",
|
||||
"animations/Stand/Gestures/Explain_1",
|
||||
"animations/Stand/Gestures/Explain_10",
|
||||
"animations/Stand/Gestures/Explain_11",
|
||||
"animations/Stand/Gestures/Explain_2",
|
||||
"animations/Stand/Gestures/Explain_3",
|
||||
"animations/Stand/Gestures/Explain_4",
|
||||
"animations/Stand/Gestures/Explain_5",
|
||||
"animations/Stand/Gestures/Explain_6",
|
||||
"animations/Stand/Gestures/Explain_7",
|
||||
"animations/Stand/Gestures/Explain_8",
|
||||
"animations/Stand/Gestures/Far_1",
|
||||
"animations/Stand/Gestures/Far_2",
|
||||
"animations/Stand/Gestures/Far_3",
|
||||
"animations/Stand/Gestures/Follow_1",
|
||||
"animations/Stand/Gestures/Give_1",
|
||||
"animations/Stand/Gestures/Give_2",
|
||||
"animations/Stand/Gestures/Give_3",
|
||||
"animations/Stand/Gestures/Give_4",
|
||||
"animations/Stand/Gestures/Give_5",
|
||||
"animations/Stand/Gestures/Give_6",
|
||||
"animations/Stand/Gestures/Great_1",
|
||||
"animations/Stand/Gestures/HeSays_1",
|
||||
"animations/Stand/Gestures/HeSays_2",
|
||||
"animations/Stand/Gestures/HeSays_3",
|
||||
"animations/Stand/Gestures/Hey_1",
|
||||
"animations/Stand/Gestures/Hey_10",
|
||||
"animations/Stand/Gestures/Hey_2",
|
||||
"animations/Stand/Gestures/Hey_3",
|
||||
"animations/Stand/Gestures/Hey_4",
|
||||
"animations/Stand/Gestures/Hey_6",
|
||||
"animations/Stand/Gestures/Hey_7",
|
||||
"animations/Stand/Gestures/Hey_8",
|
||||
"animations/Stand/Gestures/Hey_9",
|
||||
"animations/Stand/Gestures/Hide_1",
|
||||
"animations/Stand/Gestures/Hot_1",
|
||||
"animations/Stand/Gestures/Hot_2",
|
||||
"animations/Stand/Gestures/IDontKnow_1",
|
||||
"animations/Stand/Gestures/IDontKnow_2",
|
||||
"animations/Stand/Gestures/IDontKnow_3",
|
||||
"animations/Stand/Gestures/IDontKnow_4",
|
||||
"animations/Stand/Gestures/IDontKnow_5",
|
||||
"animations/Stand/Gestures/IDontKnow_6",
|
||||
"animations/Stand/Gestures/Joy_1",
|
||||
"animations/Stand/Gestures/Kisses_1",
|
||||
"animations/Stand/Gestures/Look_1",
|
||||
"animations/Stand/Gestures/Look_2",
|
||||
"animations/Stand/Gestures/Maybe_1",
|
||||
"animations/Stand/Gestures/Me_1",
|
||||
"animations/Stand/Gestures/Me_2",
|
||||
"animations/Stand/Gestures/Me_4",
|
||||
"animations/Stand/Gestures/Me_7",
|
||||
"animations/Stand/Gestures/Me_8",
|
||||
"animations/Stand/Gestures/Mime_1",
|
||||
"animations/Stand/Gestures/Mime_2",
|
||||
"animations/Stand/Gestures/Next_1",
|
||||
"animations/Stand/Gestures/No_1",
|
||||
"animations/Stand/Gestures/No_2",
|
||||
"animations/Stand/Gestures/No_3",
|
||||
"animations/Stand/Gestures/No_4",
|
||||
"animations/Stand/Gestures/No_5",
|
||||
"animations/Stand/Gestures/No_6",
|
||||
"animations/Stand/Gestures/No_7",
|
||||
"animations/Stand/Gestures/No_8",
|
||||
"animations/Stand/Gestures/No_9",
|
||||
"animations/Stand/Gestures/Nothing_1",
|
||||
"animations/Stand/Gestures/Nothing_2",
|
||||
"animations/Stand/Gestures/OnTheEvening_1",
|
||||
"animations/Stand/Gestures/OnTheEvening_2",
|
||||
"animations/Stand/Gestures/OnTheEvening_3",
|
||||
"animations/Stand/Gestures/OnTheEvening_4",
|
||||
"animations/Stand/Gestures/OnTheEvening_5",
|
||||
"animations/Stand/Gestures/Please_1",
|
||||
"animations/Stand/Gestures/Please_2",
|
||||
"animations/Stand/Gestures/Please_3",
|
||||
"animations/Stand/Gestures/Reject_1",
|
||||
"animations/Stand/Gestures/Reject_2",
|
||||
"animations/Stand/Gestures/Reject_3",
|
||||
"animations/Stand/Gestures/Reject_4",
|
||||
"animations/Stand/Gestures/Reject_5",
|
||||
"animations/Stand/Gestures/Reject_6",
|
||||
"animations/Stand/Gestures/Salute_1",
|
||||
"animations/Stand/Gestures/Salute_2",
|
||||
"animations/Stand/Gestures/Salute_3",
|
||||
"animations/Stand/Gestures/ShowFloor_1",
|
||||
"animations/Stand/Gestures/ShowFloor_2",
|
||||
"animations/Stand/Gestures/ShowFloor_3",
|
||||
"animations/Stand/Gestures/ShowFloor_4",
|
||||
"animations/Stand/Gestures/ShowFloor_5",
|
||||
"animations/Stand/Gestures/ShowSky_1",
|
||||
"animations/Stand/Gestures/ShowSky_10",
|
||||
"animations/Stand/Gestures/ShowSky_11",
|
||||
"animations/Stand/Gestures/ShowSky_12",
|
||||
"animations/Stand/Gestures/ShowSky_2",
|
||||
"animations/Stand/Gestures/ShowSky_3",
|
||||
"animations/Stand/Gestures/ShowSky_4",
|
||||
"animations/Stand/Gestures/ShowSky_5",
|
||||
"animations/Stand/Gestures/ShowSky_6",
|
||||
"animations/Stand/Gestures/ShowSky_7",
|
||||
"animations/Stand/Gestures/ShowSky_8",
|
||||
"animations/Stand/Gestures/ShowSky_9",
|
||||
"animations/Stand/Gestures/ShowTablet_1",
|
||||
"animations/Stand/Gestures/ShowTablet_2",
|
||||
"animations/Stand/Gestures/ShowTablet_3",
|
||||
"animations/Stand/Gestures/Shy_1",
|
||||
"animations/Stand/Gestures/Stretch_1",
|
||||
"animations/Stand/Gestures/Stretch_2",
|
||||
"animations/Stand/Gestures/Surprised_1",
|
||||
"animations/Stand/Gestures/TakePlace_1",
|
||||
"animations/Stand/Gestures/TakePlace_2",
|
||||
"animations/Stand/Gestures/Take_1",
|
||||
"animations/Stand/Gestures/Thinking_1",
|
||||
"animations/Stand/Gestures/Thinking_2",
|
||||
"animations/Stand/Gestures/Thinking_3",
|
||||
"animations/Stand/Gestures/Thinking_4",
|
||||
"animations/Stand/Gestures/Thinking_5",
|
||||
"animations/Stand/Gestures/Thinking_6",
|
||||
"animations/Stand/Gestures/Thinking_7",
|
||||
"animations/Stand/Gestures/Thinking_8",
|
||||
"animations/Stand/Gestures/This_1",
|
||||
"animations/Stand/Gestures/This_10",
|
||||
"animations/Stand/Gestures/This_11",
|
||||
"animations/Stand/Gestures/This_12",
|
||||
"animations/Stand/Gestures/This_13",
|
||||
"animations/Stand/Gestures/This_14",
|
||||
"animations/Stand/Gestures/This_15",
|
||||
"animations/Stand/Gestures/This_2",
|
||||
"animations/Stand/Gestures/This_3",
|
||||
"animations/Stand/Gestures/This_4",
|
||||
"animations/Stand/Gestures/This_5",
|
||||
"animations/Stand/Gestures/This_6",
|
||||
"animations/Stand/Gestures/This_7",
|
||||
"animations/Stand/Gestures/This_8",
|
||||
"animations/Stand/Gestures/This_9",
|
||||
"animations/Stand/Gestures/WhatSThis_1",
|
||||
"animations/Stand/Gestures/WhatSThis_10",
|
||||
"animations/Stand/Gestures/WhatSThis_11",
|
||||
"animations/Stand/Gestures/WhatSThis_12",
|
||||
"animations/Stand/Gestures/WhatSThis_13",
|
||||
"animations/Stand/Gestures/WhatSThis_14",
|
||||
"animations/Stand/Gestures/WhatSThis_15",
|
||||
"animations/Stand/Gestures/WhatSThis_16",
|
||||
"animations/Stand/Gestures/WhatSThis_2",
|
||||
"animations/Stand/Gestures/WhatSThis_3",
|
||||
"animations/Stand/Gestures/WhatSThis_4",
|
||||
"animations/Stand/Gestures/WhatSThis_5",
|
||||
"animations/Stand/Gestures/WhatSThis_6",
|
||||
"animations/Stand/Gestures/WhatSThis_7",
|
||||
"animations/Stand/Gestures/WhatSThis_8",
|
||||
"animations/Stand/Gestures/WhatSThis_9",
|
||||
"animations/Stand/Gestures/Whisper_1",
|
||||
"animations/Stand/Gestures/Wings_1",
|
||||
"animations/Stand/Gestures/Wings_2",
|
||||
"animations/Stand/Gestures/Wings_3",
|
||||
"animations/Stand/Gestures/Wings_4",
|
||||
"animations/Stand/Gestures/Wings_5",
|
||||
"animations/Stand/Gestures/Yes_1",
|
||||
"animations/Stand/Gestures/Yes_2",
|
||||
"animations/Stand/Gestures/Yes_3",
|
||||
"animations/Stand/Gestures/YouKnowWhat_1",
|
||||
"animations/Stand/Gestures/YouKnowWhat_2",
|
||||
"animations/Stand/Gestures/YouKnowWhat_3",
|
||||
"animations/Stand/Gestures/YouKnowWhat_4",
|
||||
"animations/Stand/Gestures/YouKnowWhat_5",
|
||||
"animations/Stand/Gestures/YouKnowWhat_6",
|
||||
"animations/Stand/Gestures/You_1",
|
||||
"animations/Stand/Gestures/You_2",
|
||||
"animations/Stand/Gestures/You_3",
|
||||
"animations/Stand/Gestures/You_4",
|
||||
"animations/Stand/Gestures/You_5",
|
||||
"animations/Stand/Gestures/Yum_1",
|
||||
"animations/Stand/Reactions/EthernetOff_1",
|
||||
"animations/Stand/Reactions/EthernetOn_1",
|
||||
"animations/Stand/Reactions/Heat_1",
|
||||
"animations/Stand/Reactions/Heat_2",
|
||||
"animations/Stand/Reactions/LightShine_1",
|
||||
"animations/Stand/Reactions/LightShine_2",
|
||||
"animations/Stand/Reactions/LightShine_3",
|
||||
"animations/Stand/Reactions/LightShine_4",
|
||||
"animations/Stand/Reactions/SeeColor_1",
|
||||
"animations/Stand/Reactions/SeeColor_2",
|
||||
"animations/Stand/Reactions/SeeColor_3",
|
||||
"animations/Stand/Reactions/SeeSomething_1",
|
||||
"animations/Stand/Reactions/SeeSomething_3",
|
||||
"animations/Stand/Reactions/SeeSomething_4",
|
||||
"animations/Stand/Reactions/SeeSomething_5",
|
||||
"animations/Stand/Reactions/SeeSomething_6",
|
||||
"animations/Stand/Reactions/SeeSomething_7",
|
||||
"animations/Stand/Reactions/SeeSomething_8",
|
||||
"animations/Stand/Reactions/ShakeBody_1",
|
||||
"animations/Stand/Reactions/ShakeBody_2",
|
||||
"animations/Stand/Reactions/ShakeBody_3",
|
||||
"animations/Stand/Reactions/TouchHead_1",
|
||||
"animations/Stand/Reactions/TouchHead_2",
|
||||
"animations/Stand/Reactions/TouchHead_3",
|
||||
"animations/Stand/Reactions/TouchHead_4",
|
||||
"animations/Stand/Waiting/AirGuitar_1",
|
||||
"animations/Stand/Waiting/BackRubs_1",
|
||||
"animations/Stand/Waiting/Bandmaster_1",
|
||||
"animations/Stand/Waiting/Binoculars_1",
|
||||
"animations/Stand/Waiting/BreathLoop_1",
|
||||
"animations/Stand/Waiting/BreathLoop_2",
|
||||
"animations/Stand/Waiting/BreathLoop_3",
|
||||
"animations/Stand/Waiting/CallSomeone_1",
|
||||
"animations/Stand/Waiting/Drink_1",
|
||||
"animations/Stand/Waiting/DriveCar_1",
|
||||
"animations/Stand/Waiting/Fitness_1",
|
||||
"animations/Stand/Waiting/Fitness_2",
|
||||
"animations/Stand/Waiting/Fitness_3",
|
||||
"animations/Stand/Waiting/FunnyDancer_1",
|
||||
"animations/Stand/Waiting/HappyBirthday_1",
|
||||
"animations/Stand/Waiting/Helicopter_1",
|
||||
"animations/Stand/Waiting/HideEyes_1",
|
||||
"animations/Stand/Waiting/HideHands_1",
|
||||
"animations/Stand/Waiting/Innocent_1",
|
||||
"animations/Stand/Waiting/Knight_1",
|
||||
"animations/Stand/Waiting/KnockEye_1",
|
||||
"animations/Stand/Waiting/KungFu_1",
|
||||
"animations/Stand/Waiting/LookHand_1",
|
||||
"animations/Stand/Waiting/LookHand_2",
|
||||
"animations/Stand/Waiting/LoveYou_1",
|
||||
"animations/Stand/Waiting/Monster_1",
|
||||
"animations/Stand/Waiting/MysticalPower_1",
|
||||
"animations/Stand/Waiting/PlayHands_1",
|
||||
"animations/Stand/Waiting/PlayHands_2",
|
||||
"animations/Stand/Waiting/PlayHands_3",
|
||||
"animations/Stand/Waiting/Relaxation_1",
|
||||
"animations/Stand/Waiting/Relaxation_2",
|
||||
"animations/Stand/Waiting/Relaxation_3",
|
||||
"animations/Stand/Waiting/Relaxation_4",
|
||||
"animations/Stand/Waiting/Rest_1",
|
||||
"animations/Stand/Waiting/Robot_1",
|
||||
"animations/Stand/Waiting/ScratchBack_1",
|
||||
"animations/Stand/Waiting/ScratchBottom_1",
|
||||
"animations/Stand/Waiting/ScratchEye_1",
|
||||
"animations/Stand/Waiting/ScratchHand_1",
|
||||
"animations/Stand/Waiting/ScratchHead_1",
|
||||
"animations/Stand/Waiting/ScratchLeg_1",
|
||||
"animations/Stand/Waiting/ScratchTorso_1",
|
||||
"animations/Stand/Waiting/ShowMuscles_1",
|
||||
"animations/Stand/Waiting/ShowMuscles_2",
|
||||
"animations/Stand/Waiting/ShowMuscles_3",
|
||||
"animations/Stand/Waiting/ShowMuscles_4",
|
||||
"animations/Stand/Waiting/ShowMuscles_5",
|
||||
"animations/Stand/Waiting/ShowSky_1",
|
||||
"animations/Stand/Waiting/ShowSky_2",
|
||||
"animations/Stand/Waiting/SpaceShuttle_1",
|
||||
"animations/Stand/Waiting/Stretch_1",
|
||||
"animations/Stand/Waiting/Stretch_2",
|
||||
"animations/Stand/Waiting/TakePicture_1",
|
||||
"animations/Stand/Waiting/Taxi_1",
|
||||
"animations/Stand/Waiting/Think_1",
|
||||
"animations/Stand/Waiting/Think_2",
|
||||
"animations/Stand/Waiting/Think_3",
|
||||
"animations/Stand/Waiting/Think_4",
|
||||
"animations/Stand/Waiting/Waddle_1",
|
||||
"animations/Stand/Waiting/Waddle_2",
|
||||
"animations/Stand/Waiting/WakeUp_1",
|
||||
"animations/Stand/Waiting/Zombie_1"]
|
||||
|
||||
|
||||
/**
|
||||
* Returns a gesture value editor component.
|
||||
* @returns JSX.Element
|
||||
*/
|
||||
export default function GestureValueEditor({
|
||||
value,
|
||||
setValue,
|
||||
setType,
|
||||
placeholder = "Gesture name",
|
||||
}: GestureValueEditorProps) {
|
||||
|
||||
/** Input mode: semantic tag vs concrete animation path */
|
||||
const [mode, setMode] = useState<"single" | "tag">("tag");
|
||||
|
||||
/** Raw text value for single-gesture input */
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
/** Autocomplete dropdown state */
|
||||
const [showSuggestions, setShowSuggestions] = useState(true);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
|
||||
/** Reserved for future click-outside / positioning logic */
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/** Switch between tag and single input modes */
|
||||
const handleModeChange = (newMode: "single" | "tag") => {
|
||||
setMode(newMode);
|
||||
|
||||
if (newMode === "single") {
|
||||
setValue(customValue || value);
|
||||
setType(false);
|
||||
setFilteredSuggestions(GESTURE_SINGLES);
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
// Clear value if it does not match a valid tag
|
||||
setType(true);
|
||||
const isValidTag = GESTURE_TAGS.some(
|
||||
tag => tag.toLowerCase() === value.toLowerCase()
|
||||
);
|
||||
if (!isValidTag) setValue("");
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Select a semantic gesture tag */
|
||||
const handleTagSelect = (tag: string) => {
|
||||
setValue(tag);
|
||||
};
|
||||
|
||||
/** Update single-gesture input and filter suggestions */
|
||||
const handleCustomChange = (newValue: string) => {
|
||||
setCustomValue(newValue);
|
||||
setValue(newValue);
|
||||
|
||||
if (newValue.trim() === "") {
|
||||
setFilteredSuggestions(GESTURE_SINGLES);
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
const filtered = GESTURE_SINGLES.filter(single =>
|
||||
single.toLowerCase().includes(newValue.toLowerCase())
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
}
|
||||
};
|
||||
|
||||
/** Commit autocomplete selection */
|
||||
const handleSuggestionSelect = (suggestion: string) => {
|
||||
setCustomValue(suggestion);
|
||||
setValue(suggestion);
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
/** Refresh suggestions on refocus */
|
||||
const handleInputFocus = () => {
|
||||
if (!customValue.trim()) return;
|
||||
|
||||
const filtered = GESTURE_SINGLES.filter(single =>
|
||||
single.toLowerCase().includes(customValue.toLowerCase())
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
};
|
||||
|
||||
/** Exists to allow delayed blur handling if needed */
|
||||
const handleInputBlur = (_e: React.FocusEvent) => {};
|
||||
|
||||
|
||||
/** Build the JSX component */
|
||||
return (
|
||||
<div className={styles.gestureEditor} ref={containerRef}>
|
||||
{/* Mode toggle */}
|
||||
<div className={styles.modeSelector}>
|
||||
<label className={styles.modeLabel}>Input Mode:</label>
|
||||
<div className={styles.toggleContainer}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggleButton} ${mode === "single" ? styles.active : ""}`}
|
||||
onClick={() => handleModeChange("single")}
|
||||
>
|
||||
Single
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.toggleButton} ${mode === "tag" ? styles.active : ""}`}
|
||||
onClick={() => handleModeChange("tag")}
|
||||
>
|
||||
Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.valueEditor} data-testid={"valueEditorTestID"}>
|
||||
{mode === "single" ? (
|
||||
<div className={styles.autocompleteContainer}>
|
||||
{showSuggestions && (
|
||||
<div className={styles.suggestionsDropdownLeft}>
|
||||
{filteredSuggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion}
|
||||
className={styles.suggestionItem}
|
||||
onClick={() => handleSuggestionSelect(suggestion)}
|
||||
onMouseDown={(e) => e.preventDefault()} // prevent blur before click
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
onFocus={handleInputFocus}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={placeholder}
|
||||
className={`${styles.textInput} ${showSuggestions ? styles.textInputWithSuggestions : ''}`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tagSelector}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleTagSelect(e.target.value)}
|
||||
className={styles.tagSelect}
|
||||
data-testid={"tagSelectorTestID"}
|
||||
>
|
||||
<option value="" >Select a gesture tag...</option>
|
||||
{GESTURE_TAGS.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className={styles.tagList}>
|
||||
{GESTURE_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className={`${styles.tagButton} ${value === tag ? styles.selected : ""}`}
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { NodeToolbar } from '@xyflow/react';
|
||||
import {NodeToolbar, useReactFlow} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {type JSX, useState} from "react";
|
||||
import {createPortal} from "react-dom";
|
||||
import styles from "../../VisProg.module.css";
|
||||
import {NodeTooltips} from "../NodeRegistry.ts";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
|
||||
|
||||
/**
|
||||
* Props for the Toolbar component.
|
||||
*
|
||||
@@ -24,14 +29,95 @@ type ToolbarProps = {
|
||||
* @constructor
|
||||
*/
|
||||
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
|
||||
const {deleteNode} = useFlowStore();
|
||||
const {nodes, deleteNode} = useFlowStore();
|
||||
const { deleteElements } = useReactFlow();
|
||||
|
||||
const deleteParentNode = ()=> {
|
||||
deleteNode(nodeId);
|
||||
}
|
||||
const deleteParentNode = () => {
|
||||
|
||||
deleteNode(nodeId, deleteElements);
|
||||
};
|
||||
|
||||
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
|
||||
return (
|
||||
<NodeToolbar>
|
||||
<NodeToolbar className={"flex-row align-center"}>
|
||||
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
|
||||
<Tooltip nodeType={nodeType}>
|
||||
<div className={styles.nodeToolbarTooltip}>i</div>
|
||||
</Tooltip>
|
||||
</NodeToolbar>);
|
||||
}
|
||||
|
||||
|
||||
type TooltipProps = {
|
||||
nodeType?: keyof typeof NodeTooltips;
|
||||
children: JSX.Element;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A general tooltip component, that can be used as a wrapper for any component
|
||||
* that has a nodeType and a corresponding nodeTooltip.
|
||||
*
|
||||
* currently used to show tooltips for draggable-nodes and nodes inside the editor
|
||||
*
|
||||
* @param {"start" | "end" | "phase" | "norm" | "goal" | "trigger" | "basic_belief" | undefined} nodeType
|
||||
* @param {React.JSX.Element} children
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export function Tooltip({ nodeType, children }: TooltipProps) {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [disabled , setDisabled] = useState(false);
|
||||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||||
|
||||
const updateTooltipPos = () => {
|
||||
const rect = document.getElementById("draggable-sidebar")!.getBoundingClientRect();
|
||||
setCoords({
|
||||
// Position exactly below the bottom edge of the draggable sidebar (plus a small gap)
|
||||
top: rect.bottom + 10,
|
||||
left: rect.left + rect.width / 2, // Keep it horizontally centered
|
||||
});
|
||||
};
|
||||
|
||||
return nodeType ?
|
||||
(<div>
|
||||
<div
|
||||
onMouseDown={() => {
|
||||
updateTooltipPos();
|
||||
setShowTooltip(false);
|
||||
setDisabled(true);
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
setDisabled(false);
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (!disabled) {
|
||||
updateTooltipPos();
|
||||
setShowTooltip(true);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={ () => setShowTooltip(false)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{showTooltip && createPortal(
|
||||
<div
|
||||
className={"flex-row"}
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
position: 'fixed',
|
||||
top: `${coords.top}px`,
|
||||
left: `${coords.left}px`,
|
||||
transform: 'translateX(-50%)', // Center based on the midpoint
|
||||
}}
|
||||
>
|
||||
<span className={styles.customTooltipHeader}>{nodeType}</span>
|
||||
<span className={styles.customTooltip}>
|
||||
{NodeTooltips[nodeType] || "Available for drag"}
|
||||
</span>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
) : children
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Plan, PlanElement } from "./Plan";
|
||||
|
||||
export const defaultPlan: Plan = {
|
||||
name: "Default Plan",
|
||||
id: "-1",
|
||||
steps: [] as PlanElement[],
|
||||
}
|
||||
124
src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx
Normal file
124
src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type Node } from "@xyflow/react"
|
||||
import { GoalReduce } from "../nodes/GoalNode"
|
||||
|
||||
|
||||
export type Plan = {
|
||||
name: string,
|
||||
id: string,
|
||||
steps: PlanElement[],
|
||||
}
|
||||
|
||||
export type PlanElement = Goal | Action
|
||||
|
||||
export type Goal = {
|
||||
id: string // we let the reducer figure out the rest dynamically
|
||||
type: "goal"
|
||||
}
|
||||
|
||||
// Actions
|
||||
export type Action = SpeechAction | GestureAction | LLMAction
|
||||
export type SpeechAction = { id: string, text: string, type:"speech" }
|
||||
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
|
||||
export type LLMAction = { id: string, goal: string, type:"llm" }
|
||||
export type ActionTypes = "speech" | "gesture" | "llm";
|
||||
|
||||
|
||||
// Extract the wanted information from a plan within the reducing of nodes
|
||||
export function PlanReduce(_nodes: Node[], plan?: Plan, ) {
|
||||
if (!plan) return ""
|
||||
return {
|
||||
id: plan.id,
|
||||
steps: plan.steps.map((x) => StepReduce(x, _nodes))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Extract the wanted information from a plan element.
|
||||
function StepReduce(planElement: PlanElement, _nodes: Node[]) : Record<string, unknown> {
|
||||
// We have different types of plan elements, requiring differnt types of output
|
||||
const nodes = _nodes
|
||||
const thisNode = _nodes.find((x) => x.id === planElement.id)
|
||||
switch (planElement.type) {
|
||||
case ("speech"):
|
||||
return {
|
||||
id: planElement.id,
|
||||
text: planElement.text,
|
||||
}
|
||||
case ("gesture"):
|
||||
return {
|
||||
id: planElement.id,
|
||||
gesture: {
|
||||
type: planElement.isTag ? "tag" : "single",
|
||||
name: planElement.gesture
|
||||
},
|
||||
}
|
||||
case ("llm"):
|
||||
return {
|
||||
id: planElement.id,
|
||||
goal: planElement.goal,
|
||||
}
|
||||
case ("goal"):
|
||||
return thisNode ? GoalReduce(thisNode, nodes) : {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds out whether the plan can iterate multiple times, or always stops after one action.
|
||||
* This comes down to checking if the plan only has speech/ gesture actions, or others as well.
|
||||
* @param plan: the plan to check
|
||||
* @returns: a boolean
|
||||
*/
|
||||
export function DoesPlanIterate( _nodes: Node[], plan?: Plan,) : boolean {
|
||||
// TODO: should recursively check plans that have goals (and thus more plans) in them.
|
||||
if (!plan) return false
|
||||
return plan.steps.filter((step) => step.type == "llm").length > 0 ||
|
||||
(
|
||||
// Find the goal node of this step
|
||||
plan.steps.filter((step) => step.type == "goal").map((goalStep) => {
|
||||
const goalId = goalStep.id;
|
||||
const goalNode = _nodes.find((x) => x.id === goalId);
|
||||
// In case we don't find any valid plan, this node doesn't iterate
|
||||
if (!goalNode || !goalNode.data.plan) return false;
|
||||
// Otherwise, check if this node can fail - if so, we should have the option to iterate
|
||||
return (goalNode && goalNode.data.plan && goalNode.data.can_fail)
|
||||
})
|
||||
).includes(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if any of the plan's goal steps has its can_fail value set to true.
|
||||
* @param plan: plan to check
|
||||
* @param _nodes: nodes in flow store.
|
||||
*/
|
||||
export function HasCheckingSubGoal(plan: Plan, _nodes: Node[]) {
|
||||
const goalSteps = plan.steps.filter((x) => x.type == "goal");
|
||||
return goalSteps.map((goalStep) => {
|
||||
// Find the goal node and check its can_fail data boolean.
|
||||
const goalId = goalStep.id;
|
||||
const goalNode = _nodes.find((x) => x.id === goalId);
|
||||
return (goalNode && goalNode.data.can_fail)
|
||||
}).includes(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the action.
|
||||
* Since typescript can't polymorphicly access the value field,
|
||||
* we need to switch over the types and return the correct field.
|
||||
* @param action: action to retrieve the value from
|
||||
* @returns string | undefined
|
||||
*/
|
||||
export function GetActionValue(action: Action) {
|
||||
let returnAction;
|
||||
switch (action.type) {
|
||||
case "gesture":
|
||||
returnAction = action as GestureAction
|
||||
return returnAction.gesture;
|
||||
case "speech":
|
||||
returnAction = action as SpeechAction
|
||||
return returnAction.text;
|
||||
case "llm":
|
||||
returnAction = action as LLMAction
|
||||
return returnAction.goal;
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// This file is to avoid sharing both functions and components which eslint dislikes. :)
|
||||
import type { GoalNode } from "../nodes/GoalNode"
|
||||
import type { Goal, Plan } from "./Plan"
|
||||
|
||||
/**
|
||||
* Inserts a goal into a plan
|
||||
* @param plan: plan to insert goal into
|
||||
* @param goalNode: the goal node to insert into the plan.
|
||||
* @returns: a new plan with the goal inside.
|
||||
*/
|
||||
export function insertGoalInPlan(plan: Plan, goalNode: GoalNode): Plan {
|
||||
const planElement : Goal = {
|
||||
id: goalNode.id,
|
||||
type: "goal",
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
steps: [...plan.steps, planElement],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deletes a goal from a plan
|
||||
* @param plan: plan to delete goal from
|
||||
* @param goalID: the goal node to delete.
|
||||
* @returns: a new plan with the goal removed.
|
||||
*/
|
||||
export function deleteGoalInPlanByID(plan: Plan, goalID: string) {
|
||||
const updatedPlan = {...plan,
|
||||
steps: plan.steps.filter((x) => x.id !== goalID)
|
||||
}
|
||||
return updatedPlan.steps.length == 0 ? undefined : updatedPlan
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
.planDialog {
|
||||
overflow:visible;
|
||||
width: 80vw;
|
||||
max-width: 900px;
|
||||
transition: width 0.25s ease;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
|
||||
.planDialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.planEditor {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.planEditorLeft {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.planEditorRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border-left: 1px solid var(--border-color, #ccc);
|
||||
padding-left: 1rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.planStep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: text-decoration 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.planStep:hover {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.stepType {
|
||||
opacity: 0.7;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
|
||||
.stepIndex {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.emptySteps {
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.stepSuggestion {
|
||||
opacity: 0.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import {useRef, useState} from "react";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import styles from './PlanEditor.module.css';
|
||||
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
|
||||
import { defaultPlan } from "../components/Plan.default";
|
||||
import { TextField } from "../../../../components/TextField";
|
||||
import GestureValueEditor from "./GestureValueEditor";
|
||||
|
||||
type PlanEditorDialogProps = {
|
||||
plan?: Plan;
|
||||
onSave: (plan: Plan | undefined) => void;
|
||||
description? : string;
|
||||
};
|
||||
|
||||
export default function PlanEditorDialog({
|
||||
plan,
|
||||
onSave,
|
||||
description,
|
||||
}: PlanEditorDialogProps) {
|
||||
// UseStates and references
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
|
||||
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
|
||||
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
|
||||
const [newActionValue, setNewActionValue] = useState("");
|
||||
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
|
||||
const { setScrollable } = useFlowStore();
|
||||
const nodes = useFlowStore().nodes;
|
||||
|
||||
//Button Actions
|
||||
const openCreate = () => {
|
||||
setScrollable(false);
|
||||
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
|
||||
dialogRef.current?.showModal();
|
||||
};
|
||||
|
||||
const openCreateWithDescription = () => {
|
||||
setScrollable(false);
|
||||
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!});
|
||||
setNewActionType("llm")
|
||||
setNewActionValue(description!)
|
||||
dialogRef.current?.showModal();
|
||||
}
|
||||
|
||||
const openEdit = () => {
|
||||
setScrollable(false);
|
||||
if (!plan) return;
|
||||
setDraftPlan(structuredClone(plan));
|
||||
dialogRef.current?.showModal();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setScrollable(true);
|
||||
dialogRef.current?.close();
|
||||
setDraftPlan(null);
|
||||
};
|
||||
|
||||
const buildAction = (): Action => {
|
||||
const id = crypto.randomUUID();
|
||||
setHasInteractedWithPlan(true)
|
||||
switch (newActionType) {
|
||||
case "speech":
|
||||
return { id, text: newActionValue, type: "speech" };
|
||||
case "gesture":
|
||||
return { id, gesture: newActionValue, isTag: newActionGestureType, type: "gesture" };
|
||||
case "llm":
|
||||
return { id, goal: newActionValue, type: "llm" };
|
||||
}
|
||||
};
|
||||
|
||||
return (<>
|
||||
{/* Create and edit buttons */}
|
||||
{!plan && (
|
||||
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
|
||||
Create Plan
|
||||
</button>
|
||||
)}
|
||||
{plan && (
|
||||
<button className={styles.nodeButton} onClick={openEdit}>
|
||||
Edit Plan
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Start of dialog (plan editor) */}
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={`${styles.planDialog}`}
|
||||
//onWheel={(e) => e.stopPropagation()}
|
||||
data-testid={"PlanEditorDialogTestID"}
|
||||
>
|
||||
<form method="dialog" className="flex-col gap-md">
|
||||
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
||||
{/* Plan name text field */}
|
||||
{draftPlan && (
|
||||
<TextField
|
||||
value={draftPlan.name}
|
||||
setValue={(name) =>
|
||||
setDraftPlan({ ...draftPlan, name })}
|
||||
placeholder="Plan name"
|
||||
data-testid="name_text_field"/>
|
||||
)}
|
||||
|
||||
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
|
||||
{draftPlan && (<div className={styles.planEditor}>
|
||||
<div className={styles.planEditorLeft}>
|
||||
{/* Left Side (Action Adder) */}
|
||||
<h4>Add Action</h4>
|
||||
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
|
||||
<label> Filled in as a suggestion! </label>
|
||||
<label> Feel free to change! </label>
|
||||
</div>)}
|
||||
<label>
|
||||
Action Type <wbr />
|
||||
{/* Type selection */}
|
||||
<select
|
||||
value={newActionType}
|
||||
onChange={(e) => {
|
||||
setNewActionType(e.target.value as ActionTypes);
|
||||
// Reset value when action type changes
|
||||
setNewActionValue("");
|
||||
}}>
|
||||
<option value="speech">Speech Action</option>
|
||||
<option value="gesture">Gesture Action</option>
|
||||
<option value="llm">LLM Action</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Action value editor*/}
|
||||
{newActionType === "gesture" ? (
|
||||
// Gesture get their own editor component
|
||||
<GestureValueEditor
|
||||
value={newActionValue}
|
||||
setValue={setNewActionValue}
|
||||
setType={setNewActionGestureType}
|
||||
placeholder="Gesture name"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
value={newActionValue}
|
||||
setValue={setNewActionValue}
|
||||
placeholder={
|
||||
newActionType === "speech" ? "Speech text"
|
||||
: "LLM goal"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Adding steps */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!newActionValue}
|
||||
onClick={() => {
|
||||
if (!draftPlan) return;
|
||||
// Add action to steps
|
||||
const action = buildAction();
|
||||
setDraftPlan({
|
||||
...draftPlan,
|
||||
steps: [...draftPlan.steps, action],});
|
||||
|
||||
// Reset current action building
|
||||
setNewActionValue("");
|
||||
setNewActionType("speech");
|
||||
}}>
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Side (Steps shown) */}
|
||||
<div className={styles.planEditorRight}>
|
||||
<h4>Steps</h4>
|
||||
|
||||
{/* Show if there are no steps yet */}
|
||||
{draftPlan.steps.length === 0 && (
|
||||
<div className={styles.emptySteps}>
|
||||
No steps yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Map over all steps */}
|
||||
{draftPlan.steps.map((step, index) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
key={step.id}
|
||||
className={styles.planStep}
|
||||
// Extra logic for screen readers to access using keyboard
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
setDraftPlan({
|
||||
...draftPlan,
|
||||
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
||||
}}}
|
||||
onClick={() => {
|
||||
setDraftPlan({
|
||||
...draftPlan,
|
||||
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
||||
}}>
|
||||
|
||||
<span className={styles.stepIndex}>{index + 1}.</span>
|
||||
<span className={styles.stepType}>{step.type}:</span>
|
||||
<span className={styles.stepName}>
|
||||
{
|
||||
// This just tries to find the goals name, i know it looks ugly:(
|
||||
step.type === "goal"
|
||||
? ((nodes.find(x => x.id === step.id)?.data.name as string) == "" ?
|
||||
"unnamed goal": (nodes.find(x => x.id === step.id)?.data.name as string))
|
||||
: (GetActionValue(step) ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex-row gap-md">
|
||||
{/* Close button */}
|
||||
<button type="button" onClick={close}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{/* Confirm/ Create button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draftPlan}
|
||||
onClick={() => {
|
||||
if (!draftPlan) return;
|
||||
onSave(draftPlan);
|
||||
close();
|
||||
}}>
|
||||
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
|
||||
</button>
|
||||
|
||||
{/* Reset button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!draftPlan}
|
||||
onClick={() => {
|
||||
onSave(undefined);
|
||||
close();
|
||||
}}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
:global(.react-flow__handle.source){
|
||||
border-radius: 100%;
|
||||
}
|
||||
:global(.react-flow__handle.target){
|
||||
border-radius: 15%;
|
||||
}
|
||||
|
||||
|
||||
|
||||
:global(.react-flow__handle.connected) {
|
||||
background: lightgray;
|
||||
border-color: green;
|
||||
filter: drop-shadow(0 0 0.15rem green);
|
||||
}
|
||||
|
||||
:global(.singleConnectionHandle.connected) {
|
||||
background: #55dd99;
|
||||
}
|
||||
|
||||
:global(.react-flow__handle.unconnected){
|
||||
background: lightgray;
|
||||
border-color: gray;
|
||||
}
|
||||
|
||||
:global(.singleConnectionHandle.unconnected){
|
||||
background: lightsalmon;
|
||||
border-color: #ff6060;
|
||||
filter: drop-shadow(0 0 0.15rem #ff6060);
|
||||
}
|
||||
|
||||
:global(.react-flow__handle.connectingto) {
|
||||
background: #ff6060;
|
||||
border-color: coral;
|
||||
filter: drop-shadow(0 0 0.15rem coral);
|
||||
}
|
||||
|
||||
:global(.react-flow__handle.valid) {
|
||||
background: #55dd99;
|
||||
border-color: green;
|
||||
filter: drop-shadow(0 0 0.15rem green);
|
||||
}
|
||||
|
||||
:global(.react-flow__handle) {
|
||||
width: calc(8px / var(--flow-zoom, 1));
|
||||
height: calc(8px / var(--flow-zoom, 1));
|
||||
transition: width 0.1s ease, height 0.1s ease;
|
||||
min-width: 8px;
|
||||
min-height: 8px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Handle,
|
||||
type HandleProps,
|
||||
type Connection,
|
||||
useNodeId, useNodeConnections
|
||||
} from '@xyflow/react';
|
||||
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
|
||||
import "./RuleBasedHandle.module.css";
|
||||
|
||||
|
||||
|
||||
export function MultiConnectionHandle({
|
||||
id,
|
||||
type,
|
||||
rules = [],
|
||||
...otherProps
|
||||
} : HandleProps & { rules?: HandleRule[]}) {
|
||||
let nodeId = useNodeId();
|
||||
// this check is used to make sure that the handle code doesn't break when used inside a test,
|
||||
// since useNodeId would be undefined if the handle is not used inside a node
|
||||
nodeId = nodeId ? nodeId : "mockId";
|
||||
const validate = useHandleRules(nodeId, id!, type!, rules);
|
||||
|
||||
|
||||
const connections = useNodeConnections({
|
||||
id: nodeId,
|
||||
handleType: type,
|
||||
handleId: id!
|
||||
})
|
||||
|
||||
return (
|
||||
<Handle
|
||||
{...otherProps}
|
||||
id={id}
|
||||
type={type}
|
||||
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||
isValidConnection={(connection) => {
|
||||
const result = validate(connection as Connection);
|
||||
return result.isSatisfied;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleConnectionHandle({
|
||||
id,
|
||||
type,
|
||||
rules = [],
|
||||
...otherProps
|
||||
} : HandleProps & { rules?: HandleRule[]}) {
|
||||
let nodeId = useNodeId();
|
||||
// this check is used to make sure that the handle code doesn't break when used inside a test,
|
||||
// since useNodeId would be undefined if the handle is not used inside a node
|
||||
nodeId = nodeId ? nodeId : "mockId";
|
||||
const validate = useHandleRules(nodeId, id!, type!, rules);
|
||||
|
||||
const connections = useNodeConnections({
|
||||
id: nodeId,
|
||||
handleType: type,
|
||||
handleId: id!
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<Handle
|
||||
{...otherProps}
|
||||
id={id}
|
||||
type={type}
|
||||
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||
isConnectable={connections.length === 0}
|
||||
isValidConnection={(connection) => {
|
||||
const result = validate(connection as Connection);
|
||||
return result.isSatisfied;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
.warnings-sidebar {
|
||||
min-width: auto;
|
||||
max-width: 340px;
|
||||
margin-right: 0;
|
||||
height: 100%;
|
||||
background: canvas;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.warnings-toggle-bar {
|
||||
background-color: ButtonFace;
|
||||
justify-items: center;
|
||||
align-content: center;
|
||||
width: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.warnings-toggle-bar.error:first-child:has(.arrow-right){
|
||||
background-color: hsl(from red h s 75%);
|
||||
}
|
||||
.warnings-toggle-bar.warning:first-child:has(.arrow-right) {
|
||||
background-color: hsl(from orange h s 75%);
|
||||
}
|
||||
.warnings-toggle-bar.info:first-child:has(.arrow-right) {
|
||||
background-color: hsl(from steelblue h s 75%);
|
||||
}
|
||||
|
||||
.warnings-toggle-bar:hover {
|
||||
background-color: GrayText !important ;
|
||||
.arrow-left {
|
||||
border-right-color: ButtonFace;
|
||||
transition: transform 0.15s ease-in-out;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
.arrow-right {
|
||||
border-left-color: ButtonFace;
|
||||
transition: transform 0.15s ease-in-out;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.warnings-content {
|
||||
width: 320px;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
border-left: 2px solid CanvasText;
|
||||
}
|
||||
|
||||
.warnings-header {
|
||||
padding: 12px;
|
||||
border-bottom: 2px solid CanvasText;
|
||||
}
|
||||
|
||||
.severity-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.severity-tab {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
background: ButtonFace;
|
||||
color: GrayText;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.count {
|
||||
padding: 4px;
|
||||
color: GrayText;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.severity-tab.active {
|
||||
color: ButtonText;
|
||||
border: 2px solid currentColor;
|
||||
.count {
|
||||
color: ButtonText;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-group-header {
|
||||
background: ButtonFace;
|
||||
padding: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.warnings-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.warnings-empty {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.warning-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 5px;
|
||||
gap: 2px;
|
||||
padding: 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
color: GrayText;
|
||||
}
|
||||
|
||||
.warning-item:hover {
|
||||
background: ButtonFace;
|
||||
}
|
||||
|
||||
.warning-item--error {
|
||||
border: 2px solid red;
|
||||
background-color: hsl(from red h s 96%);
|
||||
.item-header{
|
||||
background-color: red;
|
||||
.type{
|
||||
color: hsl(from red h s 96%);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.warning-item--error:hover {
|
||||
background-color: hsl(from red h s 75%);
|
||||
}
|
||||
|
||||
.warning-item--warning {
|
||||
border: 2px solid orange;
|
||||
background-color: hsl(from orange h s 96%);
|
||||
.item-header{
|
||||
background-color: orange;
|
||||
.type{
|
||||
color: hsl(from orange h s 96%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning-item--warning:hover {
|
||||
background-color: hsl(from orange h s 75%);
|
||||
}
|
||||
|
||||
.warning-item--info {
|
||||
border: 2px solid steelblue;
|
||||
background-color: hsl(from steelblue h s 96%);
|
||||
.item-header{
|
||||
background-color: steelblue;
|
||||
.type{
|
||||
color: hsl(from steelblue h s 96%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning-item--info:hover {
|
||||
background-color: hsl(from steelblue h s 75%);
|
||||
}
|
||||
|
||||
.warning-item .item-header {
|
||||
padding: 8px 8px;
|
||||
opacity: 1;
|
||||
font-weight: bolder;
|
||||
}
|
||||
.warning-item .item-header .type{
|
||||
padding: 2px 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.warning-item .description {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.auto-hide {
|
||||
background-color: Canvas;
|
||||
border-top: 2px solid CanvasText;
|
||||
margin-top: auto;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* arrows for toggleBar */
|
||||
.arrow-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 0.5rem solid transparent;
|
||||
border-bottom: 0.5rem solid transparent;
|
||||
border-left: 0.6rem solid GrayText;
|
||||
}
|
||||
|
||||
.arrow-left {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 0.5rem solid transparent;
|
||||
border-bottom: 0.5rem solid transparent;
|
||||
border-right: 0.6rem solid GrayText;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import {useReactFlow, useStoreApi} from "@xyflow/react";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import {
|
||||
warningSummary,
|
||||
type WarningSeverity,
|
||||
type EditorWarning, globalWarning
|
||||
} from "./EditorWarnings.tsx";
|
||||
import styles from "./WarningSidebar.module.css";
|
||||
|
||||
/**
|
||||
* the warning sidebar, shows all warnings
|
||||
*
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export function WarningsSidebar() {
|
||||
const warnings = useFlowStore.getState().getWarnings();
|
||||
const [hide, setHide] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
|
||||
const [autoHide, setAutoHide] = useState(false);
|
||||
|
||||
// let autohide change hide status only when autohide is toggled
|
||||
// and allow for user to change the hide state even if autohide is enabled
|
||||
const hasWarnings = warnings.length > 0;
|
||||
useEffect(() => {
|
||||
if (autoHide) {
|
||||
setHide(!hasWarnings);
|
||||
}
|
||||
}, [autoHide, hasWarnings]);
|
||||
|
||||
const filtered = severityFilter === 'ALL'
|
||||
? warnings
|
||||
: warnings.filter(w => w.severity === severityFilter);
|
||||
|
||||
|
||||
const summary = warningSummary();
|
||||
// Finds the first key where the count > 0
|
||||
const getHighestSeverity = () => {
|
||||
if (summary.error > 0) return styles.error;
|
||||
if (summary.warning > 0) return styles.warning;
|
||||
if (summary.info > 0) return styles.info;
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`flex-row`} >
|
||||
<div
|
||||
className={`${styles.warningsToggleBar} ${getHighestSeverity()}`}
|
||||
onClick={() => setHide(!hide)}
|
||||
title={"toggle warnings"}
|
||||
>
|
||||
<div className={`${hide ? styles.arrowRight : styles.arrowLeft}`}></div>
|
||||
</div>
|
||||
<div
|
||||
id="warningSidebar"
|
||||
className={styles.warningsContent}
|
||||
style={hide ? {display: "none"} : {display: "flex"}}
|
||||
>
|
||||
<WarningsHeader
|
||||
severityFilter={severityFilter}
|
||||
onChange={setSeverityFilter}
|
||||
/>
|
||||
|
||||
<WarningsList warnings={filtered} />
|
||||
<div className={styles.autoHide}>
|
||||
<input
|
||||
id="autoHideSwitch"
|
||||
type={"checkbox"}
|
||||
checked={autoHide}
|
||||
onChange={(e) => setAutoHide(e.target.checked)}
|
||||
/><label>Hide if there are no warnings</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* the header of the warning sidebar, contains severity filtering buttons
|
||||
*
|
||||
* @param {WarningSeverity | "ALL"} severityFilter
|
||||
* @param {(severity: (WarningSeverity | "ALL")) => void} onChange
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function WarningsHeader({
|
||||
severityFilter,
|
||||
onChange,
|
||||
}: {
|
||||
severityFilter: WarningSeverity | 'ALL';
|
||||
onChange: (severity: WarningSeverity | 'ALL') => void;
|
||||
}) {
|
||||
const summary = warningSummary();
|
||||
|
||||
return (
|
||||
<div className={styles.warningsHeader}>
|
||||
<h3>Warnings</h3>
|
||||
<div className={styles.severityTabs}>
|
||||
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
|
||||
<button
|
||||
key={severity}
|
||||
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
|
||||
onClick={() => onChange(severity)}
|
||||
>
|
||||
{severity}
|
||||
{severity !== 'ALL' && (
|
||||
<span className={styles.count}>
|
||||
{summary[severity.toLowerCase() as keyof typeof summary]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* the list of warnings in the warning sidebar
|
||||
*
|
||||
* @param {{warnings: EditorWarning[]}} props
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function WarningsList(props: { warnings: EditorWarning[] }) {
|
||||
const splitWarnings = {
|
||||
global: props.warnings.filter(w => w.scope.id === globalWarning),
|
||||
other: props.warnings.filter(w => w.scope.id !== globalWarning),
|
||||
}
|
||||
if (props.warnings.length === 0) {
|
||||
return (
|
||||
<div className={styles.warningsEmpty}>
|
||||
No warnings!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={styles.warningsList}>
|
||||
<div className={styles.warningGroupHeader}>global:</div>
|
||||
<div className={styles.warningsGroup}>
|
||||
{splitWarnings.global.map((warning) => (
|
||||
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||
? `:${warning.scope.handleId}`
|
||||
: "")}
|
||||
/>
|
||||
))}
|
||||
{splitWarnings.global.length === 0 && "No global warnings!"}
|
||||
</div>
|
||||
<div className={styles.warningGroupHeader}>other:</div>
|
||||
<div className={styles.warningsGroup}>
|
||||
{splitWarnings.other.map((warning) => (
|
||||
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||
? `:${warning.scope.handleId}`
|
||||
: "")}
|
||||
/>
|
||||
))}
|
||||
{splitWarnings.other.length === 0 && "No other warnings!"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* a single warning in the warning sidebar
|
||||
*
|
||||
* @param {{warning: EditorWarning, key: string}} props
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function WarningListItem(props: { warning: EditorWarning, key: string}) {
|
||||
const jumpToNode = useJumpToNode();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
|
||||
onClick={() => jumpToNode(props.warning.scope.id)}
|
||||
>
|
||||
<div className={styles.itemHeader}>
|
||||
<span className={styles.type}>{props.warning.type}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.description}>
|
||||
{props.warning.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* moves the editor to the provided node
|
||||
* @returns {(nodeId: string) => void}
|
||||
*/
|
||||
function useJumpToNode() {
|
||||
const { getNode, setCenter, getViewport } = useReactFlow();
|
||||
const { addSelectedNodes } = useStoreApi().getState();
|
||||
|
||||
|
||||
return (nodeId: string) => {
|
||||
// user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
|
||||
if (nodeId === globalWarning) return;
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
|
||||
const { position } = node;
|
||||
const viewport = getViewport();
|
||||
const { width, height } = nodeElement.getBoundingClientRect();
|
||||
|
||||
//move to node
|
||||
setCenter(
|
||||
position!.x + ((width / viewport.zoom) / 2),
|
||||
position!.y + ((height / viewport.zoom) / 2),
|
||||
{duration: 300, interpolate: "smooth" }
|
||||
).then(() => {
|
||||
addSelectedNodes([nodeId]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx";
|
||||
|
||||
|
||||
/**
|
||||
* Default data for this node
|
||||
*/
|
||||
export const BasicBeliefNodeDefaults: BasicBeliefNodeData = {
|
||||
label: "Belief",
|
||||
droppable: true,
|
||||
belief: {type: "keyword", id: "", value: "", label: "Keyword said:"},
|
||||
hasReduce: true,
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
} from '@xyflow/react';
|
||||
import { Toolbar } from '../components/NodeComponents.tsx';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||
import useFlowStore from '../VisProgStores.tsx';
|
||||
import { TextField } from '../../../../components/TextField.tsx';
|
||||
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
|
||||
import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
|
||||
|
||||
/**
|
||||
* The default data structure for a BasicBelief node
|
||||
*
|
||||
* Represents configuration for a node that activates when a specific condition is met,
|
||||
* such as keywords being spoken or emotions detected.
|
||||
*
|
||||
* @property label: the display label of this BasicBelief node.
|
||||
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
||||
* @property BasicBeliefType - The type of BasicBelief ("keywords" or a custom string).
|
||||
* @property BasicBeliefs - The list of keyword BasicBeliefs (if applicable).
|
||||
* @property hasReduce - Whether this node supports reduction logic.
|
||||
*/
|
||||
export type BasicBeliefNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
belief: BasicBeliefType;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
// These are all the types a basic belief could be.
|
||||
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
|
||||
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
|
||||
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
|
||||
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
|
||||
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
|
||||
|
||||
export type BasicBeliefNode = Node<BasicBeliefNodeData>
|
||||
|
||||
// update the tooltip to reflect newly added connection options for a belief
|
||||
export const BasicBeliefTooltip = `
|
||||
A belief describes a condition that must be met
|
||||
in order for a connected norm to be activated`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the received connection
|
||||
*/
|
||||
export function BasicBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the source
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _targetNodeId the target of the created connection
|
||||
*/
|
||||
export function BasicBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is disconnected with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the disconnected connection
|
||||
*/
|
||||
export function BasicBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is disconnected with this node type as the source
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _targetNodeId the target of the diconnected connection
|
||||
*/
|
||||
export function BasicBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines how a BasicBelief node should be rendered
|
||||
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||
* @returns The rendered BasicBeliefNode React element (React.JSX.Element).
|
||||
*/
|
||||
export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const updateValue = (value: string) => updateNodeData(props.id, {...data, belief: {...data.belief, value: value}});
|
||||
const label_input_id = `basic_belief_${props.id}_label_input`;
|
||||
|
||||
type BeliefString = BasicBeliefType["type"];
|
||||
|
||||
function updateBeliefType(newType: BeliefString) {
|
||||
updateNodeData(props.id, {
|
||||
...data,
|
||||
belief: {
|
||||
...data.belief,
|
||||
type: newType,
|
||||
value:
|
||||
newType === "emotion"
|
||||
? emotionOptions[0]
|
||||
: data.belief.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const setBeliefDescription = (value: string) => {
|
||||
updateNodeData(props.id, {...data, belief: {...data.belief, description: value}});
|
||||
}
|
||||
|
||||
// Use this
|
||||
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
|
||||
|
||||
|
||||
let placeholder = ""
|
||||
let wrapping = ""
|
||||
switch (props.data.belief.type) {
|
||||
case ("keyword"):
|
||||
placeholder = "keyword..."
|
||||
wrapping = '"'
|
||||
break;
|
||||
case ("semantic"):
|
||||
placeholder = "short description..."
|
||||
wrapping = '"'
|
||||
break;
|
||||
case ("object"):
|
||||
placeholder = "object..."
|
||||
break;
|
||||
case ("emotion"):
|
||||
// TODO: emotion should probably be a drop-down menu rather than a string
|
||||
// So this placeholder won't hold for always
|
||||
placeholder = "emotion..."
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeBasicBelief /*TODO: Change this*/}`}>
|
||||
<div className={"flex-center-x gap-sm"}>
|
||||
<label htmlFor={label_input_id}>Belief:</label>
|
||||
</div>
|
||||
<div className={"flex-row gap-sm"}>
|
||||
<select
|
||||
value={data.belief.type}
|
||||
onChange={(e) => updateBeliefType(e.target.value as BeliefString)}
|
||||
>
|
||||
<option value="keyword">Keyword said:</option>
|
||||
<option value="semantic">Detected with LLM:</option>
|
||||
<option value="object">Object found:</option>
|
||||
<option value="emotion">Emotion recognised:</option>
|
||||
</select>
|
||||
{wrapping}
|
||||
{data.belief.type === "emotion" && (
|
||||
<select
|
||||
value={data.belief.value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
>
|
||||
{emotionOptions.map((emotion) => (
|
||||
<option key={emotion} value={emotion.toLowerCase()}>
|
||||
{emotion}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{data.belief.type !== "emotion" &&
|
||||
(<TextField
|
||||
id={label_input_id}
|
||||
value={data.belief.value}
|
||||
setValue={updateValue}
|
||||
placeholder={placeholder}
|
||||
/>)}
|
||||
{wrapping}
|
||||
</div>
|
||||
{data.belief.type === "semantic" && (
|
||||
<div className={"flex-wrap padding-sm"}>
|
||||
<MultilineTextField
|
||||
value={data.belief.description}
|
||||
setValue={setBeliefDescription}
|
||||
placeholder={"Describe a detailed desciption of this LLM belief..."}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
noMatchingLeftRightBelief,
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
|
||||
]} title="Connect to any number of trigger and/or normNode(-s)"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces each BasicBelief, including its children down into its core data.
|
||||
* @param node - The BasicBelief node to reduce.
|
||||
* @param _nodes - The list of all nodes in the current flow graph.
|
||||
* @returns A simplified object containing the node label and its list of BasicBeliefs.
|
||||
*/
|
||||
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
|
||||
const data = node.data as BasicBeliefNodeData;
|
||||
const result: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
};
|
||||
|
||||
switch (data.belief.type) {
|
||||
case "emotion":
|
||||
result["emotion"] = data.belief.value;
|
||||
break;
|
||||
case "keyword":
|
||||
result["keyword"] = data.belief.value;
|
||||
break;
|
||||
case "object":
|
||||
result["object"] = data.belief.value;
|
||||
break;
|
||||
case "semantic":
|
||||
result["name"] = data.belief.value;
|
||||
result["description"] = data.belief.description;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {getOutgoers, type Node} from '@xyflow/react';
|
||||
import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import {BasicBeliefReduce} from "./BasicBeliefNode.tsx";
|
||||
import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx";
|
||||
|
||||
export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) {
|
||||
switch (beliefNode.type) {
|
||||
case 'basic_belief':
|
||||
return BasicBeliefReduce(beliefNode, nodes);
|
||||
case 'inferred_belief':
|
||||
return InferredBeliefReduce(beliefNode, nodes);
|
||||
}
|
||||
}
|
||||
|
||||
export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief');
|
||||
if (!thisNode) return ruleResult.satisfied;
|
||||
|
||||
const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief;
|
||||
return (iBelief.left === connection.source || iBelief.right === connection.source)
|
||||
? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed")
|
||||
: ruleResult.satisfied;
|
||||
}
|
||||
/**
|
||||
* makes it impossible to connect Inferred belief nodes
|
||||
* if the connection would create a cyclical connection between inferred beliefs
|
||||
*/
|
||||
export const noBeliefCycles: HandleRule = (connection, _): RuleResult => {
|
||||
const {nodes, edges} = useFlowStore.getState();
|
||||
const defaultErrorMessage = "Cyclical connection exists between inferred beliefs";
|
||||
|
||||
/**
|
||||
* recursively checks for cyclical connections between InferredBelief nodes
|
||||
*
|
||||
* to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check,
|
||||
* the currentNodeId should be initialised with the id of the targetNode of the attempted connection.
|
||||
*
|
||||
* @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection
|
||||
* @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node
|
||||
* @returns {RuleResult}
|
||||
*/
|
||||
function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult {
|
||||
const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges)
|
||||
.filter(node => node.type === 'inferred_belief');
|
||||
|
||||
if (outgoingBeliefs.length === 0) return ruleResult.satisfied;
|
||||
if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult
|
||||
.notSatisfied(defaultErrorMessage);
|
||||
|
||||
const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id))
|
||||
.find(result => !result.isSatisfied);
|
||||
|
||||
return next
|
||||
? next
|
||||
: ruleResult.satisfied;
|
||||
}
|
||||
|
||||
return connection.source === connection.target
|
||||
? ruleResult.notSatisfied(defaultErrorMessage)
|
||||
: checkForCycle(connection.source, connection.target);
|
||||
};
|
||||
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
type Node, useNodeConnections
|
||||
} from '@xyflow/react';
|
||||
import {useEffect} from "react";
|
||||
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@@ -25,6 +30,27 @@ export type EndNode = Node<EndNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function EndNode(props: NodeProps<EndNode>) {
|
||||
const {registerWarning, unregisterWarning} = useFlowStore.getState();
|
||||
const connections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleId: 'target'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'target'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: "ERROR",
|
||||
description: "the endNode does not have an incoming connection from a phaseNode"
|
||||
}
|
||||
|
||||
if (connections.length === 0) { registerWarning(noConnectionWarning); }
|
||||
else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); }
|
||||
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
@@ -32,7 +58,9 @@ export default function EndNode(props: NodeProps<EndNode>) {
|
||||
<div className={"flex-row gap-sm"}>
|
||||
End
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||
allowOnlyConnectionsFromType(["phase"])
|
||||
]} title="Connect to a phaseNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -51,6 +79,10 @@ export function EndReduce(node: Node, _nodes: Node[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export const EndTooltip = `
|
||||
The end node signifies the endpoint of your program;
|
||||
the output of the final phase of your program should connect to the end node`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { GoalNodeData } from "./GoalNode";
|
||||
*/
|
||||
export const GoalNodeDefaults: GoalNodeData = {
|
||||
label: "Goal Node",
|
||||
name: "",
|
||||
droppable: true,
|
||||
description: "The robot will strive towards this goal",
|
||||
description: "",
|
||||
achieved: false,
|
||||
hasReduce: true,
|
||||
can_fail: false,
|
||||
};
|
||||
@@ -1,27 +1,40 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
type Node
|
||||
} from '@xyflow/react';
|
||||
import {useEffect} from "react";
|
||||
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { TextField } from '../../../../components/TextField';
|
||||
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan';
|
||||
import PlanEditorDialog from '../components/PlanEditor';
|
||||
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
||||
import { defaultPlan } from '../components/Plan.default.ts';
|
||||
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||
|
||||
/**
|
||||
* The default data dot a phase node
|
||||
* @param label: the label of this phase
|
||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||
* @param desciption: description of the goal
|
||||
* @param desciption: description of the goal - this will be checked for completion
|
||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||
* @param can_fail: whether this plan should be checked- this plan could possible fail
|
||||
* @param plan: The (possible) attached plan to this goal
|
||||
*/
|
||||
export type GoalNodeData = {
|
||||
label: string;
|
||||
name: string;
|
||||
description: string;
|
||||
droppable: boolean;
|
||||
achieved: boolean;
|
||||
hasReduce: boolean;
|
||||
can_fail: boolean;
|
||||
plan?: Plan;
|
||||
};
|
||||
|
||||
export type GoalNode = Node<GoalNodeData>
|
||||
@@ -33,19 +46,44 @@ export type GoalNode = Node<GoalNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||
const _nodes = useFlowStore().nodes;
|
||||
|
||||
const text_input_id = `goal_${id}_text_input`;
|
||||
const checkbox_id = `goal_${id}_checkbox`;
|
||||
const planIterate = DoesPlanIterate(_nodes, data.plan);
|
||||
const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes)
|
||||
|
||||
const setDescription = (value: string) => {
|
||||
updateNodeData(id, {...data, description: value});
|
||||
}
|
||||
|
||||
const setAchieved = (value: boolean) => {
|
||||
updateNodeData(id, {...data, achieved: value});
|
||||
const setName= (value: string) => {
|
||||
updateNodeData(id, {...data, name: value})
|
||||
}
|
||||
|
||||
const setFailable = (value: boolean) => {
|
||||
updateNodeData(id, {...data, can_fail: value});
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const noPlanWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: id,
|
||||
handleId: undefined
|
||||
},
|
||||
type: 'PLAN_IS_UNDEFINED',
|
||||
severity: 'ERROR',
|
||||
description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button"
|
||||
};
|
||||
|
||||
if (!data.plan){
|
||||
registerWarning(noPlanWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(id, noPlanWarning.type);
|
||||
},[data.plan, id, registerWarning, unregisterWarning])
|
||||
return <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||
@@ -53,21 +91,60 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
<label htmlFor={text_input_id}>Goal:</label>
|
||||
<TextField
|
||||
id={text_input_id}
|
||||
value={data.description}
|
||||
setValue={(val) => setDescription(val)}
|
||||
value={data.name}
|
||||
setValue={(val) => setName(val)}
|
||||
placeholder={"To ..."}
|
||||
/>
|
||||
</div>
|
||||
<div className={"flex-row gap-md align-center"}>
|
||||
<label htmlFor={checkbox_id}>Achieved:</label>
|
||||
|
||||
{(data.can_fail || hasCheckSubGoal) && (<div>
|
||||
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
|
||||
<div className={"flex-wrap"}>
|
||||
<MultilineTextField
|
||||
id={text_input_id}
|
||||
value={data.description}
|
||||
setValue={setDescription}
|
||||
placeholder={"Describe the condition of this goal..."}
|
||||
/>
|
||||
</div>
|
||||
</div>)}
|
||||
<div>
|
||||
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until all steps complete. 🟢"} </label>
|
||||
</div>
|
||||
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
|
||||
{planIterate ? "" : <s></s>}
|
||||
<label htmlFor={checkbox_id}>{!planIterate ? "This plan always succeeds!" : "Check if this plan fails"}:</label>
|
||||
<input
|
||||
id={checkbox_id}
|
||||
type={"checkbox"}
|
||||
checked={data.achieved || false}
|
||||
onChange={(e) => setAchieved(e.target.checked)}
|
||||
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
||||
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
|
||||
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<PlanEditorDialog
|
||||
plan={data.plan}
|
||||
onSave={(plan) => {
|
||||
updateNodeData(id, {
|
||||
...data,
|
||||
plan,
|
||||
});
|
||||
}}
|
||||
description={data.name}
|
||||
/>
|
||||
</div>
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||
]} title="Connect to any number of phase and/or goalNode(-s)"/>
|
||||
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[
|
||||
allowOnlyConnectionsFromType(["goal"])]
|
||||
} title="Connect to any number of goalNode(-s)"/>
|
||||
|
||||
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -80,21 +157,42 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
*/
|
||||
export function GoalReduce(node: Node, _nodes: Node[]) {
|
||||
const data = node.data as GoalNodeData;
|
||||
return {
|
||||
return {
|
||||
id: node.id,
|
||||
label: data.label,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
achieved: data.achieved,
|
||||
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
|
||||
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const GoalTooltip = `
|
||||
The goal node allows you to set goals that Pepper has to achieve
|
||||
before moving to the next phase of your program`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the received connection
|
||||
*/
|
||||
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
// Goals should only be targeted by other goals, for them to be part of our plan.
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
||||
if (!otherNode || otherNode.type !== "goal") return;
|
||||
|
||||
const data = _thisNode.data as GoalNodeData
|
||||
|
||||
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
|
||||
if (!data.plan) {
|
||||
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
|
||||
}
|
||||
|
||||
// Else, lets just insert this goal into our current plan.
|
||||
else {
|
||||
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +210,9 @@ export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
* @param _sourceNodeId the source of the disconnected connection
|
||||
*/
|
||||
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
// We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list.
|
||||
const data = _thisNode.data as GoalNodeData
|
||||
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
|
||||
|
||||
|
||||
/**
|
||||
* Default data for this node
|
||||
*/
|
||||
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
|
||||
label: "AND/OR",
|
||||
droppable: true,
|
||||
inferredBelief: {
|
||||
left: undefined,
|
||||
operator: true,
|
||||
right: undefined
|
||||
},
|
||||
hasReduce: true,
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
.operator-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
cursor: pointer;
|
||||
font-family: sans-serif;
|
||||
/* Change this font-size to scale the whole component */
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* hide the default checkbox */
|
||||
.operator-switch input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The Track */
|
||||
.switch-visual {
|
||||
position: relative;
|
||||
/* height is now 3x the font size */
|
||||
height: 3em;
|
||||
aspect-ratio: 1 / 2;
|
||||
background-color: ButtonFace;
|
||||
border-radius: 2em;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
/* The Knob */
|
||||
.switch-visual::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.1em;
|
||||
left: 0.1em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background: Canvas;
|
||||
border: 0.175em solid mediumpurple;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease-in-out, border-color 0.2s;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.switch-labels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 3em; /* Matches the track height */
|
||||
font-weight: 800;
|
||||
color: Canvas;
|
||||
line-height: 1.4;
|
||||
padding: 0.2em 0;
|
||||
}
|
||||
|
||||
.operator-switch input:checked + .switch-visual::after {
|
||||
/* Moves the slider down */
|
||||
transform: translateY(1.4em);
|
||||
}
|
||||
|
||||
/*change the colours to highlight the selected operator*/
|
||||
.operator-switch input:checked ~ .switch-labels{
|
||||
:first-child {
|
||||
transition: ease-in-out color 0.2s;
|
||||
color: ButtonFace;
|
||||
}
|
||||
:last-child {
|
||||
transition: ease-in-out color 0.2s;
|
||||
color: mediumpurple;
|
||||
}
|
||||
}
|
||||
|
||||
.operator-switch input:not(:checked) ~ .switch-labels{
|
||||
:first-child {
|
||||
transition: ease-in-out color 0.2s;
|
||||
color: mediumpurple;
|
||||
}
|
||||
:last-child {
|
||||
transition: ease-in-out color 0.2s;
|
||||
color: ButtonFace;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {getConnectedEdges, type Node, type NodeProps, Position, useNodeConnections} from '@xyflow/react';
|
||||
import {useEffect, useState} from "react";
|
||||
import styles from '../../VisProg.module.css';
|
||||
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||
import {Toolbar} from '../components/NodeComponents.tsx';
|
||||
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
|
||||
import switchStyles from './InferredBeliefNode.module.css';
|
||||
|
||||
|
||||
/**
|
||||
* The default data structure for an InferredBelief node
|
||||
*/
|
||||
export type InferredBeliefNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
inferredBelief: InferredBelief;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* stores a boolean to represent the operator
|
||||
* and a left and right BeliefNode (can be both an inferred and a basic belief)
|
||||
* in the form of their corresponding id's
|
||||
*/
|
||||
export type InferredBelief = {
|
||||
left: string | undefined,
|
||||
operator: boolean,
|
||||
right: string | undefined,
|
||||
}
|
||||
|
||||
export type InferredBeliefNode = Node<InferredBeliefNodeData>;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the received connection
|
||||
*/
|
||||
export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
const data = _thisNode.data as InferredBeliefNodeData;
|
||||
|
||||
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId
|
||||
&& ['basic_belief', 'inferred_belief'].includes(node.type!)))
|
||||
) {
|
||||
const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges);
|
||||
switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){
|
||||
case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break;
|
||||
case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the source
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _targetNodeId the target of the created connection
|
||||
*/
|
||||
export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is disconnected with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the disconnected connection
|
||||
*/
|
||||
export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
const data = _thisNode.data as InferredBeliefNodeData;
|
||||
|
||||
if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined;
|
||||
if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is disconnected with this node type as the source
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _targetNodeId the target of the diconnected connection
|
||||
*/
|
||||
export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
}
|
||||
|
||||
export const InferredBeliefTooltip = `
|
||||
Combines two beliefs into a single belief using logical inference,
|
||||
the node can be toggled between using "AND" and "OR" mode for inference`;
|
||||
/**
|
||||
* Defines how an InferredBelief node should be rendered
|
||||
* @param {NodeProps<InferredBeliefNode>} props - Node properties provided by React Flow, including `id` and `data`.
|
||||
* @returns The rendered InferredBeliefNode React element. (React.JSX.Element)
|
||||
*/
|
||||
export default function InferredBeliefNode(props: NodeProps<InferredBeliefNode>) {
|
||||
const data = props.data;
|
||||
const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore();
|
||||
// start of as an AND operator, true: "AND", false: "OR"
|
||||
const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true);
|
||||
|
||||
// used to toggle operator
|
||||
function onToggle() {
|
||||
const newOperator = !enforceAllBeliefs; // compute the new value
|
||||
setEnforceAllBeliefs(newOperator);
|
||||
|
||||
updateNodeData(props.id, {
|
||||
...data,
|
||||
inferredBelief: {
|
||||
...data.inferredBelief,
|
||||
operator: enforceAllBeliefs,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const beliefConnections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noBeliefsWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: undefined
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: `This AND/OR node is missing one or more beliefs,
|
||||
please make sure to use both inputs of an AND/OR node`
|
||||
};
|
||||
|
||||
if (beliefConnections.length < 2){
|
||||
registerWarning(noBeliefsWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(props.id, noBeliefsWarning.type);
|
||||
},[beliefConnections.length, props.id, registerWarning, unregisterWarning])
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeInferredBelief}`}>
|
||||
{/* The checkbox used to toggle the operator between 'AND' and 'OR' */}
|
||||
<label className={switchStyles.operatorSwitch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.inferredBelief.operator}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<div className={switchStyles.switchVisual}></div>
|
||||
<div className={switchStyles.switchLabels}>
|
||||
<span title={"Belief is fulfilled if either of the supplied beliefs is true"}>OR</span>
|
||||
<span title={"Belief is fulfilled if all of the supplied beliefs are true"}>AND</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
|
||||
{/* outgoing connections */}
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
allowOnlyConnectionsFromType(["norm", "trigger"]),
|
||||
noBeliefCycles,
|
||||
noMatchingLeftRightBelief
|
||||
]}/>
|
||||
|
||||
{/* incoming connections */}
|
||||
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '30%'}} id="beliefLeft" rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
|
||||
noBeliefCycles,
|
||||
noMatchingLeftRightBelief
|
||||
]}/>
|
||||
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '70%'}} id="beliefRight" rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
|
||||
noBeliefCycles,
|
||||
noMatchingLeftRightBelief
|
||||
]}/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces each BasicBelief, including its children down into its core data.
|
||||
* @param {Node} node - The BasicBelief node to reduce.
|
||||
* @param {Node[]} nodes - The list of all nodes in the current flow graph.
|
||||
* @returns A simplified object containing the node label and its list of BasicBeliefs.
|
||||
*/
|
||||
export function InferredBeliefReduce(node: Node, nodes: Node[]) {
|
||||
const data = node.data as InferredBeliefNodeData;
|
||||
const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left);
|
||||
const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right);
|
||||
|
||||
if (!leftBelief) { throw new Error("No Left belief found")}
|
||||
if (!rightBelief) { throw new Error("No Right Belief found")}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
left: BeliefGlobalReduce(leftBelief, nodes),
|
||||
operator: data.inferredBelief.operator ? "AND" : "OR",
|
||||
right: BeliefGlobalReduce(rightBelief, nodes),
|
||||
};
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode";
|
||||
export const NormNodeDefaults: NormNodeData = {
|
||||
label: "Norm Node",
|
||||
droppable: true,
|
||||
condition: undefined,
|
||||
norm: "",
|
||||
hasReduce: true,
|
||||
critical: false,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
@@ -7,7 +6,10 @@ import {
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import { TextField } from '../../../../components/TextField';
|
||||
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
|
||||
|
||||
/**
|
||||
* The default data dot a phase node
|
||||
@@ -19,6 +21,7 @@ import useFlowStore from '../VisProgStores';
|
||||
export type NormNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
condition?: string; // id of this node's belief.
|
||||
norm: string;
|
||||
hasReduce: boolean;
|
||||
critical: boolean;
|
||||
@@ -67,7 +70,19 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
||||
onChange={(e) => setCritical(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="norms"/>
|
||||
|
||||
|
||||
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
|
||||
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
|
||||
</div>)}
|
||||
|
||||
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
||||
]} title="Connect to any number of phaseNode(-s)"/>
|
||||
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
|
||||
]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
@@ -76,25 +91,43 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
||||
/**
|
||||
* Reduces each Norm, including its children down into its relevant data.
|
||||
* @param node The Node Properties of this node.
|
||||
* @param _nodes all the nodes in the graph
|
||||
* @param nodes all the nodes in the graph
|
||||
*/
|
||||
export function NormReduce(node: Node, _nodes: Node[]) {
|
||||
export function NormReduce(node: Node, nodes: Node[]) {
|
||||
const data = node.data as NormNodeData;
|
||||
return {
|
||||
id: node.id,
|
||||
label: data.label,
|
||||
norm: data.norm,
|
||||
critical: data.critical,
|
||||
}
|
||||
|
||||
// conditions nodes - make sure to check for empty arrays
|
||||
const result: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
label: data.label,
|
||||
norm: data.norm,
|
||||
critical: data.critical,
|
||||
};
|
||||
|
||||
if (data.condition) {
|
||||
const conditionNode = nodes.find((node) => node.id === data.condition);
|
||||
// In case something went wrong, and our condition doesn't actually exist;
|
||||
if (conditionNode == undefined) return result;
|
||||
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const NormTooltip = `
|
||||
A norm describes a behavioral rule Pepper must follow during the connected phase(-s),
|
||||
for example: "respond using formal language"`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the received connection
|
||||
*/
|
||||
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
const data = _thisNode.data as NormNodeData;
|
||||
// If we got a belief connected, this is the condition for the norm.
|
||||
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
|
||||
data.condition = _sourceNodeId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +145,9 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
* @param _sourceNodeId the source of the disconnected connection
|
||||
*/
|
||||
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
const data = _thisNode.data as NormNodeData;
|
||||
// remove if the target of disconnection was our condition
|
||||
if (_sourceNodeId == data.condition) data.condition = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,4 +8,6 @@ export const PhaseNodeDefaults: PhaseNodeData = {
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
nextPhaseId: null,
|
||||
isFirstPhase: false,
|
||||
};
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node
|
||||
type Node, useNodeConnections
|
||||
} from '@xyflow/react';
|
||||
import {useEffect, useRef} from "react";
|
||||
import {type EditorWarning} from "../components/EditorWarnings.tsx";
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
|
||||
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import { TextField } from '../../../../components/TextField';
|
||||
@@ -16,12 +19,15 @@ import { TextField } from '../../../../components/TextField';
|
||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||
* @param children: ID's of children of this node
|
||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||
* @param nextPhaseId:
|
||||
*/
|
||||
export type PhaseNodeData = {
|
||||
label: string;
|
||||
droppable: boolean;
|
||||
children: string[];
|
||||
hasReduce: boolean;
|
||||
nextPhaseId: string | "end" | null;
|
||||
isFirstPhase: boolean;
|
||||
};
|
||||
|
||||
export type PhaseNode = Node<PhaseNodeData>
|
||||
@@ -33,10 +39,107 @@ export type PhaseNode = Node<PhaseNodeData>
|
||||
*/
|
||||
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
||||
const label_input_id = `phase_${props.id}_label_input`;
|
||||
|
||||
const connections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
handleId: 'data'
|
||||
})
|
||||
|
||||
const phaseOutCons = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "source",
|
||||
handleId: 'source',
|
||||
})
|
||||
|
||||
const phaseInCons = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
handleId: 'target',
|
||||
})
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const noConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'data'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: "WARNING",
|
||||
description: "the phaseNode has no incoming goals, norms, and/or triggers"
|
||||
}
|
||||
|
||||
if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
|
||||
unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
|
||||
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||
|
||||
useEffect(() => {
|
||||
const notConnectedInfo : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: undefined,
|
||||
},
|
||||
type: 'NOT_CONNECTED_TO_PROGRAM',
|
||||
severity: "INFO",
|
||||
description: "The PhaseNode is not connected to other nodes"
|
||||
};
|
||||
const noIncomingPhaseWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'target'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: "WARNING",
|
||||
description: "the phaseNode has no incoming connection from a phase or the startNode"
|
||||
}
|
||||
const noOutgoingPhaseWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'source'
|
||||
},
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: "WARNING",
|
||||
description: "the phaseNode has no outgoing connection to a phase or the endNode"
|
||||
}
|
||||
|
||||
// register relevant warning and unregister others
|
||||
if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
|
||||
registerWarning(notConnectedInfo);
|
||||
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||
return;
|
||||
}
|
||||
if (phaseOutCons.length === 0) {
|
||||
registerWarning(noOutgoingPhaseWarning);
|
||||
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||
return;
|
||||
}
|
||||
if (phaseInCons.length === 0) {
|
||||
registerWarning(noIncomingPhaseWarning);
|
||||
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||
return;
|
||||
}
|
||||
// unregister all warnings if none should be present
|
||||
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||
}, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const { width, height } = ref.current.getBoundingClientRect();
|
||||
|
||||
console.log('Node width:', width, 'height:', height);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
@@ -50,9 +153,17 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
placeholder={"Phase ..."}
|
||||
/>
|
||||
</div>
|
||||
<Handle type="target" position={Position.Left} id="target"/>
|
||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||
noSelfConnections,
|
||||
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||
]} title="Connect to a phase or the startNode"/>
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
|
||||
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
|
||||
]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
|
||||
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
noSelfConnections,
|
||||
allowOnlyConnectionsFromType(["phase", "end"]),
|
||||
]} title="Connect to a phase or the endNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -65,8 +176,8 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
||||
*/
|
||||
export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||
const thisnode = node as PhaseNode;
|
||||
const data = thisnode.data as PhaseNodeData;
|
||||
const thisNode = node as PhaseNode;
|
||||
const data = thisNode.data as PhaseNodeData;
|
||||
|
||||
// node typings that are not in phase
|
||||
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
||||
@@ -85,8 +196,8 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||
|
||||
// Build the result object
|
||||
const result: Record<string, unknown> = {
|
||||
id: thisnode.id,
|
||||
label: data.label,
|
||||
id: thisNode.id,
|
||||
name: data.label,
|
||||
};
|
||||
|
||||
nodesInPhase.forEach((type) => {
|
||||
@@ -96,26 +207,39 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||
console.warn(`No reducer found for node type ${type}`);
|
||||
result[type + "s"] = [];
|
||||
} else {
|
||||
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
|
||||
result[type + "s"] = [];
|
||||
for (const typedChild of typedChildren) {
|
||||
(result[type + "s"] as object[]).push(reducer(typedChild, nodes))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export const PhaseTooltip = `
|
||||
A phase is a single stage of the program, during a phase Pepper will behave
|
||||
in accordance with any connected norms, goals and triggers`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target (phase)
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
* @param _sourceNodeId the source of the received connection
|
||||
*/
|
||||
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
const node = _thisNode as PhaseNode
|
||||
const data = node.data as PhaseNodeData
|
||||
// we only add none phase nodes to the children
|
||||
if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) {
|
||||
data.children.push(_sourceNodeId)
|
||||
}
|
||||
const data = _thisNode.data as PhaseNodeData
|
||||
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
||||
switch (sourceNode.type) {
|
||||
case "phase": break;
|
||||
case "start": data.isFirstPhase = true; break;
|
||||
// we only add none phase or start nodes to the children
|
||||
// endNodes cannot be the source of an outgoing connection
|
||||
// so we don't need to cover them with a special case
|
||||
// before handling the default behavior
|
||||
default: data.children.push(_sourceNodeId); break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +248,19 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
* @param _targetNodeId the target of the created connection
|
||||
*/
|
||||
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
const data = _thisNode.data as PhaseNodeData
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
|
||||
const targetNode = nodes.find((node) => node.id === _targetNodeId)
|
||||
if (!targetNode) {throw new Error("Source node not found")}
|
||||
|
||||
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
|
||||
// or "end" if the target node is the end node
|
||||
switch (targetNode.type) {
|
||||
case 'phase': data.nextPhaseId = _targetNodeId; break;
|
||||
case 'end': data.nextPhaseId = "end"; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,9 +269,23 @@ export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
* @param _sourceNodeId the source of the disconnected connection
|
||||
*/
|
||||
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
const node = _thisNode as PhaseNode
|
||||
const data = node.data as PhaseNodeData
|
||||
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
||||
const data = _thisNode.data as PhaseNodeData
|
||||
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
|
||||
const sourceType = sourceNode ? sourceNode.type : "deleted";
|
||||
switch (sourceType) {
|
||||
case "phase": break;
|
||||
case "start": data.isFirstPhase = false; break;
|
||||
// we only add none phase or start nodes to the children
|
||||
// endNodes cannot be the source of an outgoing connection
|
||||
// so we don't need to cover them with a special case
|
||||
// before handling the default behavior
|
||||
default:
|
||||
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,5 +294,12 @@ export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string)
|
||||
* @param _targetNodeId the target of the diconnected connection
|
||||
*/
|
||||
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
const data = _thisNode.data as PhaseNodeData
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
|
||||
// if the target is a phase or end node set the nextPhaseId to null,
|
||||
// as we are no longer connected to a subsequent phaseNode or to the endNode
|
||||
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
|
||||
data.nextPhaseId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Node,
|
||||
type Node, useNodeConnections
|
||||
} from '@xyflow/react';
|
||||
import {useEffect} from "react";
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {type EditorWarning} from "../components/EditorWarnings.tsx";
|
||||
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
|
||||
|
||||
export type StartNodeData = {
|
||||
@@ -24,6 +28,27 @@ export type StartNode = Node<StartNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function StartNode(props: NodeProps<StartNode>) {
|
||||
const {registerWarning, unregisterWarning} = useFlowStore.getState();
|
||||
const connections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleId: 'source'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'source'
|
||||
},
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: "ERROR",
|
||||
description: "the startNode does not have an outgoing connection to a phaseNode"
|
||||
}
|
||||
|
||||
if (connections.length === 0) { registerWarning(noConnectionWarning); }
|
||||
else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); }
|
||||
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
@@ -31,7 +56,9 @@ export default function StartNode(props: NodeProps<StartNode>) {
|
||||
<div className={"flex-row gap-sm"}>
|
||||
Start
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} id="source"/>
|
||||
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
|
||||
]} title="Connect to a phaseNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -50,6 +77,10 @@ export function StartReduce(node: Node, _nodes: Node[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export const StartTooltip = `
|
||||
The start node acts as the starting point for a program,
|
||||
it should be connected to the left handle of the first phase of your program`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
|
||||
@@ -5,8 +5,7 @@ import type { TriggerNodeData } from "./TriggerNode";
|
||||
*/
|
||||
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||
label: "Trigger Node",
|
||||
name: "",
|
||||
droppable: true,
|
||||
triggers: [],
|
||||
triggerType: "keywords",
|
||||
hasReduce: true,
|
||||
};
|
||||
@@ -1,17 +1,22 @@
|
||||
import {
|
||||
Handle,
|
||||
type NodeProps,
|
||||
Position,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type Node, useNodeConnections
|
||||
} from '@xyflow/react';
|
||||
import {useEffect} from "react";
|
||||
import type {EditorWarning} from "../components/EditorWarnings.tsx";
|
||||
import { Toolbar } from '../components/NodeComponents';
|
||||
import styles from '../../VisProg.module.css';
|
||||
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||
import useFlowStore from '../VisProgStores';
|
||||
import { useState } from 'react';
|
||||
import { RealtimeTextField, TextField } from '../../../../components/TextField';
|
||||
import duplicateIndices from '../../../../utils/duplicateIndices';
|
||||
import {PlanReduce, type Plan } from '../components/Plan';
|
||||
import PlanEditorDialog from '../components/PlanEditor';
|
||||
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
|
||||
import type { GoalNode } from './GoalNode.tsx';
|
||||
import { defaultPlan } from '../components/Plan.default.ts';
|
||||
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||
import { TextField } from '../../../../components/TextField.tsx';
|
||||
|
||||
/**
|
||||
* The default data structure for a Trigger node
|
||||
@@ -21,32 +26,20 @@ import duplicateIndices from '../../../../utils/duplicateIndices';
|
||||
*
|
||||
* @property label: the display label of this Trigger node.
|
||||
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
||||
* @property triggerType - The type of trigger ("keywords" or a custom string).
|
||||
* @property triggers - The list of keyword triggers (if applicable).
|
||||
* @property hasReduce - Whether this node supports reduction logic.
|
||||
*/
|
||||
export type TriggerNodeData = {
|
||||
label: string;
|
||||
name: string;
|
||||
droppable: boolean;
|
||||
triggerType: "keywords" | string;
|
||||
triggers: Keyword[] | never;
|
||||
condition?: string; // id of the belief
|
||||
plan?: Plan;
|
||||
hasReduce: boolean;
|
||||
};
|
||||
|
||||
|
||||
export type TriggerNode = Node<TriggerNodeData>
|
||||
|
||||
|
||||
/**
|
||||
* Determines whether a Trigger node can connect to another node or edge.
|
||||
*
|
||||
* @param connection - The connection or edge being attempted to connect towards.
|
||||
* @returns `true` if the connection is defined; otherwise, `false`.
|
||||
*/
|
||||
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
||||
return (connection != undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines how a Trigger node should be rendered
|
||||
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||
@@ -54,25 +47,123 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
||||
*/
|
||||
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||
|
||||
const setKeywords = (keywords: Keyword[]) => {
|
||||
updateNodeData(props.id, {...data, triggers: keywords});
|
||||
const setName= (value: string) => {
|
||||
updateNodeData(props.id, {...data, name: value})
|
||||
}
|
||||
|
||||
const beliefInput = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
handleId: "TriggerBeliefs"
|
||||
})
|
||||
|
||||
const outputCons = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "source",
|
||||
handleId: "TriggerSource"
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noPhaseConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'TriggerSource'
|
||||
},
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: 'INFO',
|
||||
description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
|
||||
};
|
||||
|
||||
if (outputCons.length === 0){
|
||||
registerWarning(noPhaseConnectionWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(props.id, `${noPhaseConnectionWarning.type}:${noPhaseConnectionWarning.scope.handleId}`);
|
||||
},[outputCons.length, props.id, registerWarning, unregisterWarning])
|
||||
|
||||
useEffect(() => {
|
||||
const noBeliefWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'TriggerBeliefs'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
|
||||
};
|
||||
|
||||
if (beliefInput.length === 0 && outputCons.length !== 0){
|
||||
registerWarning(noBeliefWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(props.id, `${noBeliefWarning.type}:${noBeliefWarning.scope.handleId}`);
|
||||
},[beliefInput.length, outputCons.length, props.id, registerWarning, unregisterWarning])
|
||||
|
||||
useEffect(() => {
|
||||
const noPlanWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: undefined
|
||||
},
|
||||
type: 'PLAN_IS_UNDEFINED',
|
||||
severity: 'ERROR',
|
||||
description: "This triggerNode is missing a plan, please make sure to create a plan by using the create plan button"
|
||||
};
|
||||
|
||||
if (!data.plan && outputCons.length !== 0){
|
||||
registerWarning(noPlanWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(props.id, noPlanWarning.type);
|
||||
},[data.plan, outputCons.length, props.id, registerWarning, unregisterWarning])
|
||||
return <>
|
||||
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
|
||||
{data.triggerType === "emotion" && (
|
||||
<div className={"flex-row gap-md"}>Emotion?</div>
|
||||
)}
|
||||
{data.triggerType === "keywords" && (
|
||||
<Keywords
|
||||
keywords={data.triggers}
|
||||
setKeywords={setKeywords}
|
||||
/>
|
||||
)}
|
||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||
<TextField
|
||||
value={props.data.name}
|
||||
setValue={(val) => setName(val)}
|
||||
placeholder={"Name of this trigger..."}
|
||||
/>
|
||||
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
|
||||
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
|
||||
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||
]} title="Connect to any number of phaseNodes"/>
|
||||
<SingleConnectionHandle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id="TriggerBeliefs"
|
||||
style={{ left: '40%' }}
|
||||
rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
|
||||
]}
|
||||
title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"
|
||||
/>
|
||||
|
||||
<MultiConnectionHandle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id="GoalTarget"
|
||||
style={{ left: '60%' }}
|
||||
rules={[
|
||||
allowOnlyConnectionsFromType(['goal']),
|
||||
]}
|
||||
title="Connect to any number of goalNodes"
|
||||
/>
|
||||
|
||||
<PlanEditorDialog
|
||||
plan={data.plan}
|
||||
onSave={(plan) => {
|
||||
updateNodeData(props.id, {
|
||||
...data,
|
||||
plan,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -80,27 +171,27 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
/**
|
||||
* Reduces each Trigger, including its children down into its core data.
|
||||
* @param node - The Trigger node to reduce.
|
||||
* @param _nodes - The list of all nodes in the current flow graph.
|
||||
* @param nodes - The list of all nodes in the current flow graph.
|
||||
* @returns A simplified object containing the node label and its list of triggers.
|
||||
*/
|
||||
export function TriggerReduce(node: Node, _nodes: Node[]) {
|
||||
const data = node.data;
|
||||
switch (data.triggerType) {
|
||||
case "keywords":
|
||||
return {
|
||||
id: node.id,
|
||||
type: "keywords",
|
||||
label: data.label,
|
||||
keywords: data.triggers,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
...data,
|
||||
id: node.id,
|
||||
};
|
||||
export function TriggerReduce(node: Node, nodes: Node[]) {
|
||||
const data = node.data as TriggerNodeData;
|
||||
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
|
||||
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.name,
|
||||
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
|
||||
plan: !data.plan ? "" : PlanReduce(nodes, data.plan), // Make sure we have a plan when reducing, or default to ""
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const TriggerTooltip = `
|
||||
A trigger node is used to make Pepper execute a predefined plan -
|
||||
consisting of one or more actions - when the connected beliefs are met`;
|
||||
|
||||
/**
|
||||
* This function is called whenever a connection is made with this node type as the target
|
||||
* @param _thisNode the node of this node type which function is called
|
||||
@@ -108,6 +199,27 @@ export function TriggerReduce(node: Node, _nodes: Node[]) {
|
||||
*/
|
||||
export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
const data = _thisNode.data as TriggerNodeData;
|
||||
// If we got a belief connected, this is the condition for the norm.
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
||||
if (!otherNode) return;
|
||||
|
||||
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
|
||||
data.condition = _sourceNodeId;
|
||||
}
|
||||
|
||||
else if (otherNode.type === 'goal') {
|
||||
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
|
||||
if (!data.plan) {
|
||||
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
|
||||
}
|
||||
|
||||
// Else, lets just insert this goal into our current plan.
|
||||
else {
|
||||
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +238,11 @@ export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string)
|
||||
*/
|
||||
export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||
// no additional connection logic exists yet
|
||||
const data = _thisNode.data as TriggerNodeData;
|
||||
// remove if the target of disconnection was our condition
|
||||
if (_sourceNodeId == data.condition) data.condition = undefined
|
||||
|
||||
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,92 +272,4 @@ export type KeywordTriggerNodeProps = {
|
||||
}
|
||||
|
||||
/** Union type for all possible Trigger node configurations. */
|
||||
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||
|
||||
/**
|
||||
* Renders an input element that allows users to add new keyword triggers.
|
||||
*
|
||||
* When the input is committed, the `addKeyword` callback is called with the new keyword.
|
||||
*
|
||||
* @param param0 - An object containing the `addKeyword` function.
|
||||
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
|
||||
*/
|
||||
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const text_input_id = "keyword_adder_input";
|
||||
|
||||
return <div className={"flex-row gap-md"}>
|
||||
<label htmlFor={text_input_id}>New Keyword:</label>
|
||||
<RealtimeTextField
|
||||
id={text_input_id}
|
||||
value={input}
|
||||
setValue={setInput}
|
||||
onCommit={() => {
|
||||
if (!input) return;
|
||||
addKeyword(input);
|
||||
setInput("");
|
||||
}}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays and manages a list of keyword triggers for a Trigger node.
|
||||
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
|
||||
*
|
||||
* @param keywords - The current list of keyword triggers.
|
||||
* @param setKeywords - A callback to update the keyword list in the parent node.
|
||||
* @returns A React element(React.JSX.Element) for editing keyword triggers.
|
||||
*/
|
||||
function Keywords({
|
||||
keywords,
|
||||
setKeywords,
|
||||
}: {
|
||||
keywords: Keyword[];
|
||||
setKeywords: (keywords: Keyword[]) => void;
|
||||
}) {
|
||||
type Interpolatable = string | number | boolean | bigint | null | undefined;
|
||||
|
||||
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
|
||||
|
||||
/** Indices of duplicates in the keyword array. */
|
||||
const [duplicates, setDuplicates] = useState<number[]>([]);
|
||||
|
||||
function replace(id: string, value: string) {
|
||||
value = value.trim();
|
||||
const newKeywords = value === ""
|
||||
? keywords.filter((kw) => kw.id != id)
|
||||
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
function add(value: string) {
|
||||
value = value.trim();
|
||||
if (value === "") return;
|
||||
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
|
||||
setKeywords(newKeywords);
|
||||
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
|
||||
}
|
||||
|
||||
return <>
|
||||
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
||||
{[...keywords].map(({id, keyword}, index) => {
|
||||
return <div key={id} className={"flex-row gap-md"}>
|
||||
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
||||
<TextField
|
||||
id={inputElementId(id)}
|
||||
value={keyword}
|
||||
setValue={(val) => replace(id, val)}
|
||||
placeholder={"..."}
|
||||
className={"flex-1"}
|
||||
invalid={duplicates.includes(index)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
<KeywordAdder addKeyword={add} />
|
||||
</>;
|
||||
}
|
||||
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
||||
40
src/utils/orderPhaseNodes.ts
Normal file
40
src/utils/orderPhaseNodes.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||
|
||||
/**
|
||||
* takes an array of phaseNodes and orders them according to their nextPhaseId attributes,
|
||||
* starting with the phase that has isFirstPhase = true
|
||||
*
|
||||
* @param {PhaseNode[]} nodes an unordered phaseNode array
|
||||
* @returns {PhaseNode[]} the ordered phaseNode array
|
||||
*/
|
||||
export default function orderPhaseNodeArray(nodes: PhaseNode[]) : PhaseNode[] {
|
||||
// find the first phaseNode of the sequence
|
||||
const start = nodes.find(node => node.data.isFirstPhase);
|
||||
if (!start) {
|
||||
throw new Error('No phaseNode with isFirstObject = true found');
|
||||
}
|
||||
|
||||
// prepare for ordering of phaseNodes
|
||||
const orderedPhaseNodes: PhaseNode[] = [];
|
||||
const IdMap = new Map(nodes.map(node => [node.id, node]));
|
||||
let currentNode: PhaseNode | undefined = start;
|
||||
|
||||
// populate orderedPhaseNodes array with the phaseNodes in the correct order
|
||||
while (currentNode) {
|
||||
orderedPhaseNodes.push(currentNode);
|
||||
|
||||
if (!currentNode.data.nextPhaseId) {
|
||||
throw new Error("Incomplete phase sequence, program does not reach the end node");
|
||||
}
|
||||
|
||||
if (currentNode.data.nextPhaseId === "end") break;
|
||||
|
||||
currentNode = IdMap.get(currentNode.data.nextPhaseId);
|
||||
|
||||
if (!currentNode) {
|
||||
throw new Error(`Incomplete phase sequence, phaseNode with id "${orderedPhaseNodes.at(-1)?.data.nextPhaseId}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedPhaseNodes;
|
||||
}
|
||||
81
src/utils/programStore.ts
Normal file
81
src/utils/programStore.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {create} from "zustand";
|
||||
|
||||
// the type of a reduced program
|
||||
export type ReducedProgram = { phases: Record<string, unknown>[] };
|
||||
|
||||
/**
|
||||
* the type definition of the programStore
|
||||
*/
|
||||
export type ProgramState = {
|
||||
// Basic store functionality:
|
||||
currentProgram: ReducedProgram;
|
||||
setProgramState: (state: ReducedProgram) => void;
|
||||
getProgramState: () => ReducedProgram;
|
||||
|
||||
// Utility functions:
|
||||
// to avoid having to manually go through the entire state for every instance where data is required
|
||||
getPhaseIds: () => string[];
|
||||
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
// if more specific utility functions are needed they can be added here:
|
||||
}
|
||||
|
||||
/**
|
||||
* the ProgramStore can be used to access all information of the most recently sent program,
|
||||
* it contains basic functions to set and get the current program.
|
||||
* And it contains some utility functions that allow you to easily gain access
|
||||
* to the norms, triggers and goals of a specific phase.
|
||||
*/
|
||||
const useProgramStore = create<ProgramState>((set, get) => ({
|
||||
currentProgram: { phases: [] as Record<string, unknown>[]},
|
||||
/**
|
||||
* sets the current program by cloning the provided program using a structuredClone
|
||||
*/
|
||||
setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}),
|
||||
/**
|
||||
* gets the current program
|
||||
*/
|
||||
getProgramState: () => get().currentProgram,
|
||||
|
||||
// utility functions:
|
||||
/**
|
||||
* gets the ids of all phases in the program
|
||||
*/
|
||||
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
|
||||
/**
|
||||
* gets the norms for the provided phase
|
||||
*/
|
||||
getNormsInPhase: (currentPhaseId) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
if (phase) {
|
||||
return phase["norms"] as Record<string, unknown>[];
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
},
|
||||
/**
|
||||
* gets the goals for the provided phase
|
||||
*/
|
||||
getGoalsInPhase: (currentPhaseId) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
if (phase) {
|
||||
return phase["goals"] as Record<string, unknown>[];
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
},
|
||||
/**
|
||||
* gets the triggers for the provided phase
|
||||
*/
|
||||
getTriggersInPhase: (currentPhaseId) => {
|
||||
const program = get().currentProgram;
|
||||
const phase = program.phases.find(val => val["id"] === currentPhaseId);
|
||||
if (phase) {
|
||||
return phase["triggers"] as Record<string, unknown>[];
|
||||
}
|
||||
throw new Error(`phase with id:"${currentPhaseId}" not found`)
|
||||
}
|
||||
}));
|
||||
|
||||
export default useProgramStore;
|
||||
@@ -3,6 +3,9 @@ import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/
|
||||
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
mockReactFlow();
|
||||
});
|
||||
@@ -31,10 +34,17 @@ describe("UndoRedo Middleware", () => {
|
||||
type: 'default',
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
},
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
edges: [],
|
||||
warnings: {
|
||||
warningRegistry: new Map(),
|
||||
severityIndex: new Map()
|
||||
}
|
||||
}],
|
||||
ruleRegistry: new Map(),
|
||||
editorWarningRegistry: new Map(),
|
||||
severityIndex: new Map()
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -50,7 +60,11 @@ describe("UndoRedo Middleware", () => {
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
}],
|
||||
edges: []
|
||||
edges: [],
|
||||
warnings: {
|
||||
warningRegistry: {},
|
||||
severityIndex: {}
|
||||
}
|
||||
});
|
||||
expect(state.future).toEqual([]);
|
||||
});
|
||||
@@ -77,7 +91,9 @@ describe("UndoRedo Middleware", () => {
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
}],
|
||||
edges: []
|
||||
edges: [],
|
||||
editorWarningRegistry: new Map(),
|
||||
severityIndex: new Map()
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -111,7 +127,11 @@ describe("UndoRedo Middleware", () => {
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'B'}
|
||||
}],
|
||||
edges: []
|
||||
edges: [],
|
||||
warnings: {
|
||||
warningRegistry: {},
|
||||
severityIndex: {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +157,9 @@ describe("UndoRedo Middleware", () => {
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
}],
|
||||
edges: []
|
||||
edges: [],
|
||||
editorWarningRegistry: new Map(),
|
||||
severityIndex: new Map()
|
||||
});
|
||||
|
||||
act(() => {
|
||||
@@ -173,7 +195,11 @@ describe("UndoRedo Middleware", () => {
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
}],
|
||||
edges: []
|
||||
edges: [],
|
||||
warnings: {
|
||||
warningRegistry: {},
|
||||
severityIndex: {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +222,9 @@ describe("UndoRedo Middleware", () => {
|
||||
position: {x: 0, y: 0},
|
||||
data: {label: 'A'}
|
||||
}],
|
||||
edges: []
|
||||
edges: [],
|
||||
editorWarningRegistry: new Map(),
|
||||
severityIndex: new Map()
|
||||
});
|
||||
|
||||
act(() => { store.getState().beginBatchAction(); });
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import {renderHook} from "@testing-library/react";
|
||||
import type {Connection} from "@xyflow/react";
|
||||
import {
|
||||
ruleResult,
|
||||
type RuleResult,
|
||||
useHandleRules
|
||||
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
|
||||
describe('useHandleRules', () => {
|
||||
it('should register rules on mount and validate connection', () => {
|
||||
const rules = [() => ({ isSatisfied: true } as RuleResult)];
|
||||
|
||||
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
|
||||
|
||||
// Confirm rules registered
|
||||
const storedRules = useFlowStore.getState().getTargetRules('node1', 'h1');
|
||||
expect(storedRules).toEqual(rules);
|
||||
|
||||
// Validate a connection
|
||||
const connection = { source: 'node2', sourceHandle: 'h2', target: 'node1', targetHandle: 'h1' };
|
||||
const validation = result.current(connection);
|
||||
expect(validation).toEqual(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('should throw error if targetHandle missing', () => {
|
||||
const rules: any[] = [];
|
||||
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
|
||||
|
||||
expect(() =>
|
||||
result.current({ source: 'a', target: 'b', targetHandle: null, sourceHandle: null })
|
||||
).toThrow('No target handle was provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHandleRules with multiple failed rules', () => {
|
||||
it('should return the first failed rule message and consider connectionCount', () => {
|
||||
// Mock rules for the target handle
|
||||
const failingRules = [
|
||||
(_conn: any, ctx: any) => {
|
||||
if (ctx.connectionCount >= 1) {
|
||||
return { isSatisfied: false, message: 'Max connections reached' } as RuleResult;
|
||||
}
|
||||
return { isSatisfied: true } as RuleResult;
|
||||
},
|
||||
() => ({ isSatisfied: false, message: 'Other rule failed' } as RuleResult),
|
||||
() => ({ isSatisfied: true } as RuleResult),
|
||||
];
|
||||
|
||||
// Register rules for the target handle
|
||||
useFlowStore.getState().registerRules('targetNode', 'targetHandle', failingRules);
|
||||
|
||||
// Add one existing edge to simulate connectionCount
|
||||
useFlowStore.setState({
|
||||
edges: [
|
||||
{
|
||||
id: 'edge-1',
|
||||
source: 'sourceNode',
|
||||
sourceHandle: 'sourceHandle',
|
||||
target: 'targetNode',
|
||||
targetHandle: 'targetHandle',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Create hook for a source node handle
|
||||
const rulesForSource = [
|
||||
(_c: Connection) => ({ isSatisfied: true } as RuleResult)
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useHandleRules('sourceNode', 'sourceHandle', 'source', rulesForSource)
|
||||
);
|
||||
|
||||
const connection = {
|
||||
source: 'sourceNode',
|
||||
sourceHandle: 'sourceHandle',
|
||||
target: 'targetNode',
|
||||
targetHandle: 'targetHandle',
|
||||
};
|
||||
|
||||
const validation = result.current(connection);
|
||||
|
||||
// Should fail with first failing rule message
|
||||
expect(validation).toEqual(ruleResult.notSatisfied('Max connections reached'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import {ruleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||
import {
|
||||
allowOnlyConnectionsFromType,
|
||||
allowOnlyConnectionsFromHandle, noSelfConnections
|
||||
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts";
|
||||
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
|
||||
beforeEach(() => {
|
||||
useFlowStore.setState({
|
||||
nodes: [
|
||||
{ id: 'nodeA', type: 'typeA', position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: 'nodeB', type: 'typeB', position: { x: 0, y: 0 }, data: {} },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowOnlyConnectionsFromType', () => {
|
||||
it('should allow connection from allowed node type', () => {
|
||||
const rule = allowOnlyConnectionsFromType(['typeA']);
|
||||
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('should not allow connection from disallowed node type', () => {
|
||||
const rule = allowOnlyConnectionsFromType(['typeA']);
|
||||
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
|
||||
});
|
||||
});
|
||||
describe('allowOnlyConnectionsFromHandle', () => {
|
||||
it('should allow connection from node with correct type and handle', () => {
|
||||
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('should not allow connection from node with wrong handle', () => {
|
||||
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||
const connection = { source: 'nodeA', sourceHandle: 'wrongHandle', target: 'nodeB', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'wrongHandle' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeA"));
|
||||
});
|
||||
|
||||
it('should not allow connection from node with wrong type', () => {
|
||||
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
|
||||
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
|
||||
});
|
||||
});
|
||||
|
||||
describe('noSelfConnections', () => {
|
||||
it('should allow connection from node with other type and handle', () => {
|
||||
const rule = noSelfConnections;
|
||||
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('should not allow connection from other handle on same node', () => {
|
||||
const rule = noSelfConnections;
|
||||
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
|
||||
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
|
||||
|
||||
const result = rule(connection, context);
|
||||
expect(result).toEqual(ruleResult.notSatisfied("nodes are not allowed to connect to themselves"));
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import {act} from '@testing-library/react';
|
||||
import type {Connection, Edge, Node} from "@xyflow/react";
|
||||
import {
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
} from "@xyflow/react";
|
||||
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
|
||||
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
@@ -397,6 +402,7 @@ describe('FlowStore Functionality', () => {
|
||||
}]
|
||||
});
|
||||
|
||||
|
||||
act(()=> {
|
||||
deleteNode(nodeId);
|
||||
});
|
||||
@@ -594,5 +600,48 @@ describe('FlowStore Functionality', () => {
|
||||
expect(updatedState.nodes).toHaveLength(1);
|
||||
expect(updatedState.nodes[0]).toMatchObject(expected.node);
|
||||
})
|
||||
})
|
||||
describe('Handle Rule Registry', () => {
|
||||
it('should register and retrieve rules', () => {
|
||||
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||
|
||||
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
||||
|
||||
expect(rules).toEqual(mockRules);
|
||||
});
|
||||
|
||||
it('should warn and return empty array if rules are missing', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
const rules = useFlowStore.getState().getTargetRules('missingNode', 'missingHandle');
|
||||
expect(rules).toEqual([]);
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No rules were registered'));
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should unregister a specific handle rule', () => {
|
||||
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||
|
||||
useFlowStore.getState().unregisterHandleRules('node1', 'handleA');
|
||||
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
||||
|
||||
expect(rules).toEqual([]);
|
||||
});
|
||||
|
||||
it('should unregister all rules for a node', () => {
|
||||
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
||||
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
||||
useFlowStore.getState().registerRules('node1', 'handleB', mockRules);
|
||||
useFlowStore.getState().registerRules('node2', 'handleC', mockRules);
|
||||
|
||||
useFlowStore.getState().unregisterNodeRules('node1');
|
||||
|
||||
expect(useFlowStore.getState().getTargetRules('node1', 'handleA')).toEqual([]);
|
||||
expect(useFlowStore.getState().getTargetRules('node1', 'handleB')).toEqual([]);
|
||||
expect(useFlowStore.getState().getTargetRules('node2', 'handleC')).toEqual(mockRules);
|
||||
});
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
@@ -95,7 +95,11 @@ describe("Drag & drop node creation", () => {
|
||||
const node = nodes[0];
|
||||
|
||||
expect(node.type).toBe("phase");
|
||||
expect(node.id).toBe("phase-1");
|
||||
|
||||
// UUID Expression
|
||||
expect(node.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
|
||||
// screenToFlowPosition was mocked to subtract 100
|
||||
expect(node.position).toEqual({
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect} from '@jest/globals';
|
||||
import {
|
||||
type EditorWarning, warningSummary
|
||||
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
|
||||
|
||||
function makeWarning(
|
||||
overrides?: Partial<EditorWarning>
|
||||
): EditorWarning {
|
||||
return {
|
||||
scope: { id: 'node-1' },
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: 'Missing input',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("editorWarnings", () => {
|
||||
describe('registerWarning', () => {
|
||||
it('registers a node-level warning', () => {
|
||||
const warning = makeWarning();
|
||||
const {registerWarning, getWarnings} = useFlowStore.getState()
|
||||
registerWarning(warning);
|
||||
|
||||
const warnings = getWarnings();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toEqual(warning);
|
||||
});
|
||||
|
||||
it('registers a handle-level warning with scoped key', () => {
|
||||
const warning = makeWarning({
|
||||
scope: { id: 'node-1', handleId: 'input-1' },
|
||||
});
|
||||
const {registerWarning} = useFlowStore.getState()
|
||||
registerWarning(warning);
|
||||
const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1');
|
||||
expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true);
|
||||
});
|
||||
|
||||
it('updates severityIndex correctly', () => {
|
||||
const {registerWarning, severityIndex} = useFlowStore.getState()
|
||||
registerWarning(makeWarning());
|
||||
expect(severityIndex.get('ERROR')!.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWarningsBySeverity', () => {
|
||||
it('returns only warnings of requested severity', () => {
|
||||
const {registerWarning, getWarningsBySeverity} = useFlowStore.getState()
|
||||
registerWarning(
|
||||
makeWarning({ severity: 'ERROR' })
|
||||
);
|
||||
|
||||
registerWarning(
|
||||
makeWarning({
|
||||
severity: 'WARNING',
|
||||
type: 'MISSING_OUTPUT',
|
||||
})
|
||||
);
|
||||
|
||||
const errors = getWarningsBySeverity('ERROR');
|
||||
const warnings = getWarningsBySeverity('WARNING');
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProgramValid', () => {
|
||||
it('returns true when no ERROR warnings exist', () => {
|
||||
expect(useFlowStore.getState().isProgramValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when ERROR warnings exist', () => {
|
||||
const {registerWarning, isProgramValid} = useFlowStore.getState()
|
||||
registerWarning(makeWarning());
|
||||
expect(isProgramValid()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterWarning', () => {
|
||||
it('removes warning from registry and severityIndex', () => {
|
||||
const warning = makeWarning();
|
||||
const {
|
||||
registerWarning,
|
||||
getWarnings,
|
||||
unregisterWarning,
|
||||
severityIndex
|
||||
} = useFlowStore.getState()
|
||||
|
||||
registerWarning(warning);
|
||||
|
||||
unregisterWarning('node-1', 'MISSING_INPUT');
|
||||
|
||||
expect(getWarnings()).toHaveLength(0);
|
||||
expect(severityIndex.get('ERROR')!.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing if warning does not exist', () => {
|
||||
expect(() =>
|
||||
useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterWarningsForId', () => {
|
||||
it('removes all warnings for a node', () => {
|
||||
const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState()
|
||||
registerWarning(
|
||||
makeWarning({
|
||||
scope: { id: 'node-1', handleId: 'h1' },
|
||||
})
|
||||
);
|
||||
|
||||
registerWarning(
|
||||
makeWarning({
|
||||
scope: { id: 'node-1' },
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: 'WARNING',
|
||||
})
|
||||
);
|
||||
|
||||
unregisterWarningsForId('node-1');
|
||||
|
||||
expect(getWarnings()).toHaveLength(0);
|
||||
expect(
|
||||
severityIndex.get('ERROR')!.size
|
||||
).toBe(0);
|
||||
expect(
|
||||
severityIndex.get('WARNING')!.size
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningSummary', () => {
|
||||
it('returns correct counts and validity', () => {
|
||||
const {registerWarning} = useFlowStore.getState()
|
||||
registerWarning(
|
||||
makeWarning({ severity: 'ERROR' })
|
||||
);
|
||||
|
||||
const summary = warningSummary();
|
||||
|
||||
expect(summary.error).toBe(1);
|
||||
expect(summary.warning).toBe(0);
|
||||
expect(summary.info).toBe(0);
|
||||
expect(summary.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders, screen } from '../../../../test-utils/test-utils.tsx';
|
||||
import GestureValueEditor from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor';
|
||||
|
||||
function TestHarness({ initialValue = '', initialType=true, placeholder = 'Gesture name' } : { initialValue?: string, initialType?: boolean, placeholder?: string }) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [_, setType] = useState(initialType)
|
||||
return (
|
||||
<GestureValueEditor value={value} setValue={setValue} setType={setType} placeholder={placeholder} />
|
||||
);
|
||||
}
|
||||
|
||||
describe('GestureValueEditor', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
});
|
||||
|
||||
test('renders in tag mode by default and allows selecting a tag via button and select', async () => {
|
||||
renderWithProviders(<TestHarness />);
|
||||
|
||||
// Tag selector should be present
|
||||
const select = screen.getByTestId('tagSelectorTestID') as HTMLSelectElement;
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(select.value).toBe('');
|
||||
|
||||
// Choose a tag via select
|
||||
await user.selectOptions(select, 'happy');
|
||||
expect(select.value).toBe('happy');
|
||||
|
||||
// The corresponding tag button should reflect the selection (have the selected class)
|
||||
const happyButton = screen.getByRole('button', { name: /happy/i });
|
||||
expect(happyButton).toBeInTheDocument();
|
||||
expect(happyButton.className).toMatch(/selected/);
|
||||
});
|
||||
|
||||
test('switches to single mode and shows suggestions list', async () => {
|
||||
renderWithProviders(<TestHarness initialValue={'happy'} />);
|
||||
|
||||
const singleButton = screen.getByRole('button', { name: /^single$/i });
|
||||
await user.click(singleButton);
|
||||
|
||||
// Input should be present with placeholder
|
||||
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
// Because switching to single populates suggestions, we expect at least one suggestion item
|
||||
const suggestion = await screen.findByText(/Listening_1/);
|
||||
expect(suggestion).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('typing filters suggestions and selecting a suggestion commits the value and hides the list', async () => {
|
||||
renderWithProviders(<TestHarness />);
|
||||
|
||||
// Switch to single mode
|
||||
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||
|
||||
// Type a substring that matches some suggestions
|
||||
await user.type(input, 'Listening_2');
|
||||
|
||||
// The suggestion should appear and include the text we typed
|
||||
const matching = await screen.findByText(/Listening_2/);
|
||||
expect(matching).toBeInTheDocument();
|
||||
|
||||
// Click the suggestion
|
||||
await user.click(matching);
|
||||
|
||||
// After selecting, input should contain that suggestion and suggestions should be hidden
|
||||
expect(input.value).toContain('Listening_2');
|
||||
expect(screen.queryByText(/Listening_1/)).toBeNull();
|
||||
});
|
||||
|
||||
test('typing a non-matching string hides the suggestions list', async () => {
|
||||
renderWithProviders(<TestHarness />);
|
||||
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||
|
||||
await user.type(input, 'no-match-zzz');
|
||||
|
||||
// There should be no suggestion that includes that gibberish
|
||||
expect(screen.queryByText(/no-match-zzz/)).toBeNull();
|
||||
});
|
||||
|
||||
test('switching back to tag mode clears value when it is not a valid tag and preserves it when it is', async () => {
|
||||
renderWithProviders(<TestHarness />);
|
||||
|
||||
// Switch to single mode and pick a suggestion (which is not a semantic tag)
|
||||
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||
await user.type(input, 'Listening_3');
|
||||
const suggestion = await screen.findByText(/Listening_3/);
|
||||
await user.click(suggestion);
|
||||
|
||||
// Switch back to tag mode -> value should be cleared (not in tag list)
|
||||
await user.click(screen.getByRole('button', { name: /^tag$/i }));
|
||||
const select = screen.getByTestId('tagSelectorTestID') as HTMLSelectElement;
|
||||
expect(select.value).toBe('');
|
||||
|
||||
// Now pick a valid tag and switch to single then back to tag
|
||||
await user.selectOptions(select, 'happy');
|
||||
expect(select.value).toBe('happy');
|
||||
|
||||
// Switch to single and then back to tag; since 'happy' is a valid tag, it should remain
|
||||
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||
await user.click(screen.getByRole('button', { name: /^tag$/i }));
|
||||
expect(select.value).toBe('happy');
|
||||
});
|
||||
|
||||
test('focus on input re-shows filtered suggestions when customValue is present', async () => {
|
||||
renderWithProviders(<TestHarness />);
|
||||
|
||||
// Switch to single mode and type to filter
|
||||
await user.click(screen.getByRole('button', { name: /^single$/i }));
|
||||
const input = screen.getByPlaceholderText('Gesture name') as HTMLInputElement;
|
||||
|
||||
await user.type(input, 'Listening_4');
|
||||
const found = await screen.findByText(/Listening_4/);
|
||||
expect(found).toBeInTheDocument();
|
||||
|
||||
// Blur the input
|
||||
input.blur();
|
||||
expect(found).toBeInTheDocument();
|
||||
|
||||
// Focus the input again and ensure the suggestions remain or reappear
|
||||
await user.click(input);
|
||||
const foundAgain = await screen.findByText(/Listening_4/);
|
||||
expect(foundAgain).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {Tooltip} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx";
|
||||
import {renderWithSidebar} from "../../../../test-utils/test-utils.tsx";
|
||||
|
||||
describe('Tooltip component test', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders and shows tooltip content on hover', () => {
|
||||
renderWithSidebar(
|
||||
<Tooltip nodeType="phase">
|
||||
<div>?</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const trigger = screen.getByText('?');
|
||||
|
||||
// initially hidden
|
||||
expect(
|
||||
screen.queryByText('Phase tooltip text')
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// hover shows tooltip
|
||||
fireEvent.mouseOver(trigger);
|
||||
|
||||
expect(screen.getByText('phase')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('A phase is a single stage of the program, during a phase Pepper will behave in accordance with any connected norms, goals and triggers')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// rendered via portal
|
||||
expect(
|
||||
document.body.contains(
|
||||
screen.getByText('A phase is a single stage of the program, during a phase Pepper will behave in accordance with any connected norms, goals and triggers')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, it, beforeEach, jest } from '@jest/globals';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor';
|
||||
import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan';
|
||||
import '@testing-library/jest-dom';
|
||||
import { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx';
|
||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||
import { insertGoalInPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx';
|
||||
|
||||
|
||||
// Mock structuredClone
|
||||
(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val)));
|
||||
|
||||
// UUID Regex for checking ID's
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
describe('PlanEditorDialog', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
const mockOnSave = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultPlan: Plan = {
|
||||
id: 'plan-1',
|
||||
name: 'Test Plan',
|
||||
steps: [],
|
||||
};
|
||||
|
||||
const extendedPlan: Plan = {
|
||||
id: 'extended-plan-1',
|
||||
name: 'extended test plan',
|
||||
steps: [
|
||||
// Step 1: A wave tag gesture
|
||||
{
|
||||
id: 'firststep',
|
||||
type: 'gesture',
|
||||
isTag: true,
|
||||
gesture: "hello"
|
||||
},
|
||||
|
||||
// Step 2: A single tag gesture
|
||||
{
|
||||
id: 'secondstep',
|
||||
type: 'gesture',
|
||||
isTag: false,
|
||||
gesture: "somefolder/somegesture"
|
||||
},
|
||||
|
||||
// Step 3: A LLM action
|
||||
{
|
||||
id: 'thirdstep',
|
||||
type: 'llm',
|
||||
goal: 'ask the user something or whatever'
|
||||
},
|
||||
|
||||
// Step 4: A speech action
|
||||
{
|
||||
id: 'fourthstep',
|
||||
type: 'speech',
|
||||
text: "I'm a cyborg ninja :>"
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const planWithSteps: Plan = {
|
||||
id: 'plan-2',
|
||||
name: 'Existing Plan',
|
||||
steps: [
|
||||
{ id: 'step-1', text: 'Hello world', type: 'speech' as const },
|
||||
{ id: 'step-2', gesture: 'Wave', isTag:true, type: 'gesture' as const },
|
||||
],
|
||||
};
|
||||
|
||||
const renderDialog = (props: Partial<React.ComponentProps<typeof PlanEditorDialog>> = {}) => {
|
||||
const defaultProps = {
|
||||
plan: undefined,
|
||||
onSave: mockOnSave,
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
return renderWithProviders(<PlanEditorDialog {...defaultProps} {...props} />);
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show "Create Plan" button when no plan is provided', () => {
|
||||
renderDialog();
|
||||
// The button should be visible
|
||||
expect(screen.getByRole('button', { name: 'Create Plan' })).toBeInTheDocument();
|
||||
// The dialog content should NOT be visible initially
|
||||
expect(screen.queryByText(/Add Action/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "Edit Plan" button when a plan is provided', () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
expect(screen.getByRole('button', { name: 'Edit Plan' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "Create Plan" button when a plan exists', () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
// Query for the button text specifically, not dialog title
|
||||
expect(screen.queryByRole('button', { name: 'Create Plan' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dialog Interactions', () => {
|
||||
it('should open dialog with "Create Plan" title when creating new plan', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
|
||||
// One for button, one for dialog.
|
||||
expect(screen.getAllByText('Create Plan').length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should open dialog with "Edit Plan" title when editing existing plan', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
// One for button, one for dialog
|
||||
expect(screen.getAllByText('Edit Plan').length).toEqual(2);
|
||||
});
|
||||
|
||||
it('should pre-fill plan name when editing', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||
expect(nameInput.value).toBe(defaultPlan.name);
|
||||
});
|
||||
|
||||
it('should close dialog when cancel button is clicked', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan Creation', () => {
|
||||
it('should create a new plan with default values', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
// One for the button, one for the dialog
|
||||
expect(screen.getAllByText('Create Plan').length).toEqual(2);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Plan name');
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should auto-fill with description when provided', async () => {
|
||||
const description = 'Achieve world peace';
|
||||
renderDialog({ description });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
// Check if plan name is pre-filled with description
|
||||
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||
expect(nameInput.value).toBe(description);
|
||||
|
||||
// Check if action type is set to LLM
|
||||
const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement;
|
||||
expect(actionTypeSelect.value).toBe('llm');
|
||||
|
||||
// Check if suggestion text is shown
|
||||
expect(screen.getByText('Filled in as a suggestion!')).toBeInTheDocument();
|
||||
expect(screen.getByText('Feel free to change!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow changing plan name', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||
const newName = 'My Custom Plan';
|
||||
|
||||
// Instead of clear(), select all text and type new value
|
||||
await user.click(nameInput);
|
||||
await user.keyboard('{Control>}a{/Control}'); // Select all (Ctrl+A)
|
||||
await user.keyboard(newName);
|
||||
|
||||
expect(nameInput.value).toBe(newName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action Management', () => {
|
||||
it('should add a speech action to the plan', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||
const actionValueInput = screen.getByPlaceholderText("Speech text")
|
||||
const addButton = screen.getByText('Add Step');
|
||||
|
||||
// Set up a speech action
|
||||
await user.selectOptions(actionTypeSelect, 'speech');
|
||||
await user.type(actionValueInput, 'Hello there!');
|
||||
|
||||
await user.click(addButton);
|
||||
|
||||
// Check if step was added
|
||||
expect(screen.getByText('speech:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello there!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add a gesture action to the plan', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /edit plan/i }));
|
||||
|
||||
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||
const addButton = screen.getByText('Add Step');
|
||||
|
||||
// Set up a gesture action
|
||||
await user.selectOptions(actionTypeSelect, 'gesture');
|
||||
|
||||
// Find the input field after type change
|
||||
const select = screen.getByTestId("tagSelectorTestID")
|
||||
const options = within(select).getAllByRole('option')
|
||||
|
||||
await user.selectOptions(select, options[1])
|
||||
await user.click(addButton);
|
||||
|
||||
// Check if step was added
|
||||
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should add an LLM action to the plan', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||
const addButton = screen.getByText('Add Step');
|
||||
|
||||
// Set up an LLM action
|
||||
await user.selectOptions(actionTypeSelect, 'llm');
|
||||
|
||||
// Find the input field after type change
|
||||
const llmInput = screen.getByPlaceholderText(/LLM goal|text/i);
|
||||
await user.type(llmInput, 'Generate a story');
|
||||
|
||||
await user.click(addButton);
|
||||
|
||||
// Check if step was added
|
||||
expect(screen.getByText('llm:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Generate a story')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable "Add Step" button when action value is empty', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
const addButton = screen.getByText('Add Step');
|
||||
expect(addButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should reset action form after adding a step', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
|
||||
const actionValueInput = screen.getByPlaceholderText("Speech text")
|
||||
const addButton = screen.getByText('Add Step');
|
||||
|
||||
await user.type(actionValueInput, 'Test speech');
|
||||
await user.click(addButton);
|
||||
|
||||
// Action value should be cleared
|
||||
expect(actionValueInput).toHaveValue('');
|
||||
// Action type should be reset to speech (default)
|
||||
const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement;
|
||||
expect(actionTypeSelect.value).toBe('speech');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step Management', () => {
|
||||
it('should show existing steps when editing a plan', async () => {
|
||||
renderDialog({ plan: planWithSteps });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
// Check if existing steps are shown
|
||||
expect(screen.getByText('speech:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wave')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show "No steps yet" message when plan has no steps', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
expect(screen.getByText('No steps yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should remove a step when clicked', async () => {
|
||||
renderDialog({ plan: planWithSteps });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
// Initially have 2 steps
|
||||
expect(screen.getByText('speech:')).toBeInTheDocument();
|
||||
expect(screen.getByText('gesture:')).toBeInTheDocument();
|
||||
|
||||
// Click on the first step to remove it
|
||||
await user.click(screen.getByText('Hello world'));
|
||||
|
||||
// First step should be removed
|
||||
expect(screen.queryByText('Hello world')).not.toBeInTheDocument();
|
||||
// Second step should still exist
|
||||
expect(screen.getByText('Wave')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Save Functionality', () => {
|
||||
it('should call onSave with new plan when creating', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
// Set plan name
|
||||
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||
await user.click(nameInput);
|
||||
await user.keyboard('{Control>}a{/Control}');
|
||||
await user.keyboard('My New Plan');
|
||||
|
||||
// Add a step
|
||||
const actionValueInput = screen.getByPlaceholderText(/text/i);
|
||||
await user.type(actionValueInput, 'First step');
|
||||
await user.click(screen.getByText('Add Step'));
|
||||
|
||||
// Save the plan
|
||||
await user.click(screen.getByText('Create'));
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith({
|
||||
id: expect.stringMatching(uuidRegex),
|
||||
name: 'My New Plan',
|
||||
steps: [
|
||||
{
|
||||
id: expect.stringMatching(uuidRegex),
|
||||
text: 'First step',
|
||||
type: 'speech',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onSave with updated plan when editing', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
// Change plan name
|
||||
const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement;
|
||||
await user.click(nameInput);
|
||||
await user.keyboard('{Control>}a{/Control}');
|
||||
await user.keyboard('Updated Plan Name');
|
||||
|
||||
// Add a step
|
||||
const actionValueInput = screen.getByPlaceholderText(/text/i);
|
||||
await user.type(actionValueInput, 'New speech action');
|
||||
await user.click(screen.getByText('Add Step'));
|
||||
|
||||
// Save the plan
|
||||
await user.click(screen.getByText('Confirm'));
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith({
|
||||
id: defaultPlan.id,
|
||||
name: 'Updated Plan Name',
|
||||
steps: [
|
||||
{
|
||||
id: expect.stringMatching(uuidRegex),
|
||||
text: 'New speech action',
|
||||
type: 'speech',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onSave with undefined when reset button is clicked', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
await user.click(screen.getByText('Reset'));
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith(undefined);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable save button when no draft plan exists', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
// The save button should be enabled since draftPlan exists after clicking Create Plan
|
||||
const saveButton = screen.getByText('Create');
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step Indexing', () => {
|
||||
it('should show correct step numbers', async () => {
|
||||
renderDialog({ plan: defaultPlan });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Edit Plan' }));
|
||||
|
||||
// Add multiple steps
|
||||
const actionValueInput = screen.getByPlaceholderText(/text/i);
|
||||
const addButton = screen.getByText('Add Step');
|
||||
|
||||
await user.type(actionValueInput, 'First');
|
||||
await user.click(addButton);
|
||||
|
||||
await user.type(actionValueInput, 'Second');
|
||||
await user.click(addButton);
|
||||
|
||||
await user.type(actionValueInput, 'Third');
|
||||
await user.click(addButton);
|
||||
|
||||
// Check step numbers
|
||||
expect(screen.getByText('1.')).toBeInTheDocument();
|
||||
expect(screen.getByText('2.')).toBeInTheDocument();
|
||||
expect(screen.getByText('3.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action Type Switching', () => {
|
||||
it('should update placeholder text when action type changes', async () => {
|
||||
renderDialog();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create Plan' }));
|
||||
|
||||
const actionTypeSelect = screen.getByLabelText(/Action Type/i);
|
||||
|
||||
// Check speech placeholder
|
||||
await user.selectOptions(actionTypeSelect, 'speech');
|
||||
// The placeholder might be set dynamically, so we need to check the input
|
||||
const speechInput = screen.getByPlaceholderText(/text/i);
|
||||
expect(speechInput).toBeInTheDocument();
|
||||
|
||||
// Check gesture placeholder
|
||||
await user.selectOptions(actionTypeSelect, 'gesture');
|
||||
const gestureInput = screen.getByTestId("valueEditorTestID")
|
||||
expect(gestureInput).toBeInTheDocument();
|
||||
|
||||
// Check LLM placeholder
|
||||
await user.selectOptions(actionTypeSelect, 'llm');
|
||||
const llmInput = screen.getByPlaceholderText(/LLM|text/i);
|
||||
expect(llmInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan reducing', () => {
|
||||
it('should correctly reduce the plan given the elements of the plan', () => {
|
||||
// Create a plan for testing
|
||||
const testplan = extendedPlan
|
||||
const mockGoalNode: Node<GoalNodeData> = {
|
||||
id: 'goal-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'mock goal', plan: defaultPlan },
|
||||
};
|
||||
|
||||
// Insert the goal and retrieve its expected data
|
||||
const newTestPlan = insertGoalInPlan(testplan, mockGoalNode)
|
||||
const goalReduced = GoalReduce(mockGoalNode, [mockGoalNode])
|
||||
const expectedResult = {
|
||||
id: "extended-plan-1",
|
||||
steps: [
|
||||
{
|
||||
id: "firststep",
|
||||
gesture: {
|
||||
type: "tag",
|
||||
name: "hello"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "secondstep",
|
||||
gesture: {
|
||||
type: "single",
|
||||
name: "somefolder/somegesture"
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "thirdstep",
|
||||
goal: "ask the user something or whatever"
|
||||
},
|
||||
{
|
||||
id: "fourthstep",
|
||||
text: "I'm a cyborg ninja :>"
|
||||
},
|
||||
goalReduced,
|
||||
]
|
||||
}
|
||||
|
||||
// Check to see it the goal got added, and its reduced data was added to the goals'
|
||||
const actualResult = PlanReduce([mockGoalNode], newTestPlan)
|
||||
expect(actualResult).toEqual(expectedResult)
|
||||
});
|
||||
})
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import {fireEvent, render, screen} from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {useReactFlow, useStoreApi} from "@xyflow/react";
|
||||
import {
|
||||
type EditorWarning,
|
||||
globalWarning
|
||||
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
|
||||
|
||||
jest.mock('@xyflow/react', () => ({
|
||||
useReactFlow: jest.fn(),
|
||||
useStoreApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx');
|
||||
|
||||
function makeWarning(
|
||||
overrides?: Partial<EditorWarning>
|
||||
): EditorWarning {
|
||||
return {
|
||||
scope: { id: 'node-1' },
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: 'Missing input',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WarningsSidebar', () => {
|
||||
let getStateSpy: jest.SpyInstance;
|
||||
|
||||
const setCenter = jest.fn(() => Promise.resolve());
|
||||
const getNode = jest.fn();
|
||||
const addSelectedNodes = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// React Flow hooks
|
||||
(useReactFlow as jest.Mock).mockReturnValue({
|
||||
getNode,
|
||||
setCenter,
|
||||
});
|
||||
(useStoreApi as jest.Mock).mockReturnValue({
|
||||
getState: () => ({ addSelectedNodes }),
|
||||
});
|
||||
|
||||
// Use spyOn to override store
|
||||
const mockWarnings = [
|
||||
makeWarning({ description: 'Node warning', scope: { id: 'node-1' } }),
|
||||
makeWarning({
|
||||
description: 'Global warning',
|
||||
scope: { id: globalWarning },
|
||||
type: 'INCOMPLETE_PROGRAM',
|
||||
severity: 'WARNING',
|
||||
}),
|
||||
makeWarning({
|
||||
description: 'Info warning',
|
||||
scope: { id: 'node-2' },
|
||||
severity: 'INFO',
|
||||
}),
|
||||
];
|
||||
|
||||
getStateSpy = jest
|
||||
.spyOn(useFlowStore, 'getState')
|
||||
.mockReturnValue({
|
||||
getWarnings: () => mockWarnings,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders warnings header', () => {
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('Warnings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all warning descriptions', () => {
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('Node warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Global warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Info warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('splits global and other warnings correctly', () => {
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('global:')).toBeInTheDocument();
|
||||
expect(screen.getByText('other:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no warnings exist', () => {
|
||||
getStateSpy.mockReturnValueOnce({
|
||||
getWarnings: () => [],
|
||||
} as any);
|
||||
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('No warnings!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by severity', () => {
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('ERROR'));
|
||||
|
||||
expect(screen.getByText('Node warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Info warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters INFO severity correctly', () => {
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('INFO'));
|
||||
|
||||
expect(screen.getByText('Info warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Node warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking global warning does NOT jump', () => {
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('Global warning'));
|
||||
|
||||
expect(setCenter).not.toHaveBeenCalled();
|
||||
expect(addSelectedNodes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if node does not exist', () => {
|
||||
getNode.mockReturnValue(undefined);
|
||||
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('Node warning'));
|
||||
|
||||
expect(setCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import {type Connection, getOutgoers, type Node} from '@xyflow/react';
|
||||
import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
||||
import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx";
|
||||
import {
|
||||
BeliefGlobalReduce, noBeliefCycles,
|
||||
noMatchingLeftRightBelief
|
||||
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts";
|
||||
import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
|
||||
import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
|
||||
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
|
||||
|
||||
describe('BeliefGlobalReduce', () => {
|
||||
const nodes: Node[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('delegates to BasicBeliefReduce for basic_belief nodes', () => {
|
||||
const spy = jest
|
||||
.spyOn(BasicModule, 'BasicBeliefReduce')
|
||||
.mockReturnValue('basic-result' as any);
|
||||
|
||||
const node = { id: '1', type: 'basic_belief' } as Node;
|
||||
|
||||
const result = BeliefGlobalReduce(node, nodes);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(node, nodes);
|
||||
expect(result).toBe('basic-result');
|
||||
});
|
||||
|
||||
it('delegates to InferredBeliefReduce for inferred_belief nodes', () => {
|
||||
const spy = jest
|
||||
.spyOn(InferredModule, 'InferredBeliefReduce')
|
||||
.mockReturnValue('inferred-result' as any);
|
||||
|
||||
const node = { id: '2', type: 'inferred_belief' } as Node;
|
||||
|
||||
const result = BeliefGlobalReduce(node, nodes);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(node, nodes);
|
||||
expect(result).toBe('inferred-result');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown node types', () => {
|
||||
const node = { id: '3', type: 'other' } as Node;
|
||||
|
||||
const result = BeliefGlobalReduce(node, nodes);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(BasicBeliefReduce).not.toHaveBeenCalled();
|
||||
expect(InferredBeliefReduce).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('noMatchingLeftRightBelief rule', () => {
|
||||
let getStateSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
|
||||
});
|
||||
|
||||
it('is satisfied when target node is not an inferred belief', () => {
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [{ id: 't1', type: 'basic_belief' }],
|
||||
} as any);
|
||||
|
||||
const result = noMatchingLeftRightBelief(
|
||||
{ source: 's1', target: 't1' } as Connection,
|
||||
null as any
|
||||
);
|
||||
|
||||
expect(result).toBe(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('is satisfied when inferred belief has no matching left/right', () => {
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: 't1',
|
||||
type: 'inferred_belief',
|
||||
data: {
|
||||
inferredBelief: {
|
||||
left: 'a',
|
||||
right: 'b',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = noMatchingLeftRightBelief(
|
||||
{ source: 'c', target: 't1' } as Connection,
|
||||
null as any
|
||||
);
|
||||
|
||||
expect(result).toBe(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('is NOT satisfied when source matches left input', () => {
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: 't1',
|
||||
type: 'inferred_belief',
|
||||
data: {
|
||||
inferredBelief: {
|
||||
left: 's1',
|
||||
right: 's2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = noMatchingLeftRightBelief(
|
||||
{ source: 's1', target: 't1' } as Connection,
|
||||
null as any
|
||||
);
|
||||
|
||||
expect(result.isSatisfied).toBe(false);
|
||||
if (!(result.isSatisfied)) {
|
||||
expect(result.message).toContain(
|
||||
'Connecting one belief to both input handles of an inferred belief node is not allowed'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('is NOT satisfied when source matches right input', () => {
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [
|
||||
{
|
||||
id: 't1',
|
||||
type: 'inferred_belief',
|
||||
data: {
|
||||
inferredBelief: {
|
||||
left: 's1',
|
||||
right: 's2',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
||||
const result = noMatchingLeftRightBelief(
|
||||
{ source: 's2', target: 't1' } as Connection,
|
||||
null as any
|
||||
);
|
||||
|
||||
expect(result.isSatisfied).toBe(false);
|
||||
if (!(result.isSatisfied)) {
|
||||
expect(result.message).toContain(
|
||||
'Connecting one belief to both input handles of an inferred belief node is not allowed'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
jest.mock('@xyflow/react', () => ({
|
||||
getOutgoers: jest.fn(),
|
||||
getConnectedEdges: jest.fn(), // include if some tests require it
|
||||
}));
|
||||
|
||||
describe('noBeliefCycles rule', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns notSatisfied when source === target', () => {
|
||||
const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any);
|
||||
expect(result.isSatisfied).toBe(false);
|
||||
if (!(result.isSatisfied)) {
|
||||
expect(result.message).toContain('Cyclical connection exists');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns satisfied when there are no outgoing inferred beliefs', () => {
|
||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
||||
nodes: [{ id: 'n1', type: 'inferred_belief' }],
|
||||
edges: [],
|
||||
} as any);
|
||||
|
||||
(getOutgoers as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
|
||||
expect(result).toBe(ruleResult.satisfied);
|
||||
});
|
||||
|
||||
it('returns notSatisfied for direct cycle', () => {
|
||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
||||
nodes: [
|
||||
{ id: 'n1', type: 'inferred_belief' },
|
||||
{ id: 'n2', type: 'inferred_belief' },
|
||||
],
|
||||
edges: [{ source: 'n2', target: 'n1' }],
|
||||
} as any);
|
||||
|
||||
// @ts-expect-error is acting up
|
||||
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
|
||||
if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
|
||||
expect(result.isSatisfied).toBe(false);
|
||||
if (!(result.isSatisfied)) {
|
||||
expect(result.message).toContain('Cyclical connection exists');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns notSatisfied for indirect cycle', () => {
|
||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
||||
nodes: [
|
||||
{ id: 'A', type: 'inferred_belief' },
|
||||
{ id: 'B', type: 'inferred_belief' },
|
||||
{ id: 'C', type: 'inferred_belief' },
|
||||
],
|
||||
edges: [
|
||||
{ source: 'A', target: 'B' },
|
||||
{ source: 'B', target: 'C' },
|
||||
{ source: 'C', target: 'A' },
|
||||
],
|
||||
} as any);
|
||||
|
||||
// @ts-expect-error is acting up
|
||||
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
|
||||
const mapping: Record<string, any[]> = {
|
||||
A: [{ id: 'B', type: 'inferred_belief' }],
|
||||
B: [{ id: 'C', type: 'inferred_belief' }],
|
||||
C: [{ id: 'A', type: 'inferred_belief' }],
|
||||
};
|
||||
return mapping[id] || [];
|
||||
});
|
||||
|
||||
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
|
||||
expect(result.isSatisfied).toBe(false);
|
||||
if (!(result.isSatisfied)) {
|
||||
expect(result.message).toContain('Cyclical connection exists');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns satisfied when no cycle exists in a multi-node graph', () => {
|
||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
||||
nodes: [
|
||||
{ id: 'A', type: 'inferred_belief' },
|
||||
{ id: 'B', type: 'inferred_belief' },
|
||||
{ id: 'C', type: 'inferred_belief' },
|
||||
],
|
||||
edges: [
|
||||
{ source: 'A', target: 'B' },
|
||||
{ source: 'B', target: 'C' },
|
||||
],
|
||||
} as any);
|
||||
|
||||
// @ts-expect-error is acting up
|
||||
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
|
||||
const mapping: Record<string, any[]> = {
|
||||
A: [{ id: 'B', type: 'inferred_belief' }],
|
||||
B: [{ id: 'C', type: 'inferred_belief' }],
|
||||
C: [],
|
||||
};
|
||||
return mapping[id] || [];
|
||||
});
|
||||
|
||||
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
|
||||
expect(result).toBe(ruleResult.satisfied);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,742 @@
|
||||
// BasicBeliefNode.test.tsx
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
|
||||
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
describe('BasicBeliefNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
});
|
||||
describe('Rendering', () => {
|
||||
it('should render the basic belief node with keyword type by default', () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'help', value: 'help', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Belief:')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Keyword said:')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('help')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with semantic belief type', () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-2',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'semantic', id: 'test', value: 'test value', description: "test description", label: 'Detected with LLM:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Detected with LLM:')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('test value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with object belief type', () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-3',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'object', id: 'obj1', value: 'cup', label: 'Object found:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Object found:')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('cup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with emotion belief type and select dropdown', () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-4',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument();
|
||||
// For emotion type, we should check that the select has the correct value selected
|
||||
const selectElement = screen.getByDisplayValue('Happy');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
expect((selectElement as HTMLSelectElement).value).toBe('happy');
|
||||
});
|
||||
|
||||
it('should render emotion dropdown with all emotion options', () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-5',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectElement = screen.getByDisplayValue('Happy');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Check that all emotion options are present
|
||||
expect(screen.getByText('Happy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Angry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sad')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cheerful')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without wrapping quotes for object type', () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-6',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
// Object type should not have wrapping quotes
|
||||
const inputs = screen.getAllByDisplayValue('chair');
|
||||
expect(inputs.length).toBe(1); // Only the text input, no extra quote elements
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should update belief type when select is changed', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByDisplayValue('Keyword said:');
|
||||
await user.selectOptions(select, 'semantic');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.type).toBe('semantic');
|
||||
// Note: The component doesn't update the label when changing type
|
||||
// So we can't test for label change
|
||||
});
|
||||
});
|
||||
|
||||
it('should update text value when typing for keyword type', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('keyword...');
|
||||
await user.type(input, 'help me{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.value).toBe('help me');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update text value when typing for semantic type', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'semantic', id: 'test', value: 'test value', description: "test description", label: 'Detected with LLM:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('test value') as HTMLInputElement;
|
||||
|
||||
// Clear the input
|
||||
for (let i = 0; i < 'test value'.length; i++) {
|
||||
await user.type(input, '{backspace}');
|
||||
}
|
||||
await user.type(input, 'new semantic value{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.value).toBe('new semantic value');
|
||||
});
|
||||
});
|
||||
|
||||
it('should update emotion value when selecting from dropdown', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByDisplayValue('Happy');
|
||||
await user.selectOptions(select, 'sad');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.value).toBe('sad');
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve value when switching between text-based belief types', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: 'test value', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
// Switch from keyword to semantic
|
||||
const typeSelect = screen.getByDisplayValue('Keyword said:');
|
||||
await user.selectOptions(typeSelect, 'semantic');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.type).toBe('semantic');
|
||||
expect(updatedNode?.data.belief.value).toBe('test value'); // Value should be preserved
|
||||
});
|
||||
});
|
||||
|
||||
it('should automatically choose the first option when switching to emotion type, and carry on to the text values', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: 'some text', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
// Switch from keyword to emotion
|
||||
const typeSelect = screen.getByDisplayValue('Keyword said:');
|
||||
await user.selectOptions(typeSelect, 'emotion');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
expect(updatedNode?.data.belief.type).toBe('emotion');
|
||||
// The component doesn't reset the value when changing types
|
||||
// So it keeps the old value even though it doesn't make sense for emotion type
|
||||
expect(updatedNode?.data.belief.value).toBe('Happy');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ... rest of the tests remain the same, just fixing the Integration with Store section ...
|
||||
|
||||
describe('Integration with Store', () => {
|
||||
it('should properly update the store when changing belief value', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('keyword...');
|
||||
await user.type(input, 'emergency{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
expect(state.nodes).toHaveLength(1);
|
||||
expect(state.nodes[0].id).toBe('belief-1');
|
||||
const beliefData = state.nodes[0].data as BasicBeliefNodeData;
|
||||
expect(beliefData.belief.value).toBe('emergency');
|
||||
expect(beliefData.belief.type).toBe('keyword');
|
||||
});
|
||||
});
|
||||
|
||||
it('should properly update the store when changing belief type', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: 'test', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const select = screen.getByDisplayValue('Keyword said:');
|
||||
await user.selectOptions(select, 'object');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const beliefData = state.nodes[0].data as BasicBeliefNodeData;
|
||||
expect(beliefData.belief.type).toBe('object');
|
||||
// Note: The component doesn't update the label when changing type
|
||||
expect(beliefData.belief.value).toBe('test'); // Value should be preserved
|
||||
});
|
||||
});
|
||||
|
||||
it('should not affect other nodes when updating one belief node', async () => {
|
||||
const belief1: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief 1',
|
||||
droppable: true,
|
||||
belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
const belief2: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-2',
|
||||
type: 'basic_belief',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
label: 'Belief 2',
|
||||
droppable: true,
|
||||
belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [belief1, belief2],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={belief1.id}
|
||||
type={belief1.type as string}
|
||||
data={belief1.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('hello') as HTMLInputElement;
|
||||
|
||||
// Clear the input
|
||||
for (let i = 0; i < 'hello'.length; i++) {
|
||||
await user.type(input, '{backspace}');
|
||||
}
|
||||
await user.type(input, 'goodbye{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updatedBelief1 = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||
const unchangedBelief2 = state.nodes.find(n => n.id === 'belief-2') as Node<BasicBeliefNodeData>;
|
||||
|
||||
expect(updatedBelief1.data.belief.value).toBe('goodbye');
|
||||
expect(unchangedBelief2.data.belief.value).toBe('chair');
|
||||
expect(unchangedBelief2.data.belief.type).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple rapid updates to belief value', async () => {
|
||||
const mockNode: Node<BasicBeliefNodeData> = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Belief',
|
||||
droppable: true,
|
||||
belief: { type: 'semantic', id: 'test', value: 'test value', description: "test description", label: 'Detected with LLM:' },
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
<BasicBeliefNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('test value') as HTMLInputElement;
|
||||
|
||||
await user.type(input, '1');
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
|
||||
expect(nodeData.belief.value).toBe('test value');
|
||||
});
|
||||
|
||||
await user.type(input, '2');
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
|
||||
expect(nodeData.belief.value).toBe('test value');
|
||||
});
|
||||
|
||||
await user.type(input, '{enter}');
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
|
||||
expect(nodeData.belief.value).toBe('test value12');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import GoalNode, { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
|
||||
|
||||
describe('GoalNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the Goal node with default data', () => {
|
||||
const mockNode: Node = {
|
||||
id: 'goal-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)) },
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('To ...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates goal name when user types and commits', async () => {
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-2',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: '' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('To ...');
|
||||
|
||||
await user.type(input, 'Save the world{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updated = state.nodes.find(n => n.id === 'goal-2');
|
||||
expect(updated?.data.name).toBe('Save the world');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows plan message and disabled checked checkbox when plan does not iterate', () => {
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-3',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: defaultPlan, name: 'G' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Will follow plan 'Default Plan' until all steps complete./i)).toBeInTheDocument();
|
||||
|
||||
const checkbox = screen.getByLabelText(/This plan always succeeds!/i) as HTMLInputElement;
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('allows toggling can_fail when plan iterates', async () => {
|
||||
// plan with an llm-step will make DoesPlanIterate return true
|
||||
const iterPlan = { ...defaultPlan, id: 'p-iter', steps: [{ id: 'a-1', type: 'llm', goal: 'do' }] } as any;
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-4',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: iterPlan, name: 'Iterating' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByLabelText(/Check if this plan fails/i) as HTMLInputElement;
|
||||
expect(checkbox).not.toBeDisabled();
|
||||
expect(checkbox.checked).toBe(false);
|
||||
|
||||
await user.click(checkbox);
|
||||
|
||||
await waitFor(() => {
|
||||
const state = useFlowStore.getState();
|
||||
const updated = state.nodes.find(n => n.id === 'goal-4');
|
||||
expect(updated?.data.can_fail).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the checkbox and shows description when plan includes a checking sub-goal', () => {
|
||||
const childGoal: Node = {
|
||||
id: 'child-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true },
|
||||
};
|
||||
|
||||
const p = { ...defaultPlan, id: 'p-2', steps: [{ id: 'child-1', type: 'goal' } as any] } as any;
|
||||
|
||||
const mockNode: Node<GoalNodeData> = {
|
||||
id: 'goal-5',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'HasCheck' },
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode, childGoal], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<GoalNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
|
||||
expect(checkbox).toBeDisabled();
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
// description box should be visible because there's a checking subgoal
|
||||
expect(screen.getByPlaceholderText('Describe the condition of this goal...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reduces its data correctly (GoalReduce)', () => {
|
||||
const childGoal: Node = {
|
||||
id: 'child-2',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true },
|
||||
};
|
||||
|
||||
const p = { ...defaultPlan, id: 'p-3', steps: [{ id: 'child-2', type: 'goal' } as any] } as any;
|
||||
|
||||
const mockNode: Node = {
|
||||
id: 'goal-6',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'ReduceMe', description: 'desc', can_fail: false },
|
||||
};
|
||||
|
||||
const reduced = GoalReduce(mockNode, [mockNode, childGoal]);
|
||||
expect(reduced).toEqual({
|
||||
id: 'goal-6',
|
||||
name: 'ReduceMe',
|
||||
description: 'desc',
|
||||
can_fail: true,
|
||||
plan: {
|
||||
id: expect.anything(),
|
||||
steps: expect.any(Array),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a goal into a plan when a goal is connected to another', () => {
|
||||
const source: Node = { id: 'g-src', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Source' } };
|
||||
const target: Node = { id: 'g-target', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Target' } };
|
||||
|
||||
useFlowStore.setState({ nodes: [source, target], edges: [] });
|
||||
|
||||
// Simulate react-flow connect
|
||||
useFlowStore.getState().onConnect({ source: 'g-src', target: 'g-target', sourceHandle: null, targetHandle: null });
|
||||
|
||||
const state = useFlowStore.getState();
|
||||
const updatedTarget = state.nodes.find(n => n.id === 'g-target');
|
||||
|
||||
expect(updatedTarget?.data.plan).toBeDefined();
|
||||
const plan = updatedTarget?.data.plan as any;
|
||||
expect(plan.steps.length).toBe(1);
|
||||
expect(plan.steps[0].id).toBe('g-src');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import type {Node, Edge} from '@xyflow/react';
|
||||
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
import {
|
||||
type InferredBelief,
|
||||
InferredBeliefConnectionTarget,
|
||||
InferredBeliefDisconnectionTarget,
|
||||
InferredBeliefReduce,
|
||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
|
||||
|
||||
// helper functions
|
||||
function inferredNode(overrides = {}): Node {
|
||||
return {
|
||||
id: 'i1',
|
||||
type: 'inferred_belief',
|
||||
position: {x: 0, y: 0},
|
||||
data: {
|
||||
inferredBelief: {
|
||||
left: undefined,
|
||||
operator: true,
|
||||
right: undefined,
|
||||
},
|
||||
...overrides,
|
||||
},
|
||||
} as Node;
|
||||
}
|
||||
|
||||
describe('InferredBelief connection logic', () => {
|
||||
let getStateSpy: ReturnType<typeof jest.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
|
||||
});
|
||||
|
||||
it('sets left belief when connected on beliefLeft handle', () => {
|
||||
const node = inferredNode();
|
||||
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [{ id: 'b1', type: 'basic_belief' }],
|
||||
edges: [
|
||||
{
|
||||
source: 'b1',
|
||||
target: 'i1',
|
||||
targetHandle: 'beliefLeft',
|
||||
} as Edge,
|
||||
],
|
||||
} as any);
|
||||
|
||||
InferredBeliefConnectionTarget(node, 'b1');
|
||||
|
||||
expect((node.data.inferredBelief as InferredBelief).left).toBe('b1');
|
||||
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets right belief when connected on beliefRight handle', () => {
|
||||
const node = inferredNode();
|
||||
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [{ id: 'b2', type: 'basic_belief' }],
|
||||
edges: [
|
||||
{
|
||||
source: 'b2',
|
||||
target: 'i1',
|
||||
targetHandle: 'beliefRight',
|
||||
} as Edge,
|
||||
],
|
||||
} as any);
|
||||
|
||||
InferredBeliefConnectionTarget(node, 'b2');
|
||||
|
||||
expect((node.data.inferredBelief as InferredBelief).right).toBe('b2');
|
||||
});
|
||||
|
||||
it('ignores connections from unsupported node types', () => {
|
||||
const node = inferredNode();
|
||||
|
||||
getStateSpy.mockReturnValue({
|
||||
nodes: [{ id: 'x', type: 'norm' }],
|
||||
edges: [],
|
||||
} as any);
|
||||
|
||||
InferredBeliefConnectionTarget(node, 'x');
|
||||
|
||||
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
|
||||
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears left or right belief on disconnection', () => {
|
||||
const node = inferredNode({
|
||||
inferredBelief: { left: 'a', right: 'b', operator: true },
|
||||
});
|
||||
|
||||
InferredBeliefDisconnectionTarget(node, 'a');
|
||||
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
|
||||
|
||||
InferredBeliefDisconnectionTarget(node, 'b');
|
||||
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InferredBeliefReduce', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('throws if left belief is missing', () => {
|
||||
const node = inferredNode({
|
||||
inferredBelief: { left: 'l', right: 'r', operator: true },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
InferredBeliefReduce(node, [{ id: 'r' } as Node])
|
||||
).toThrow('No Left belief found');
|
||||
});
|
||||
|
||||
it('throws if right belief is missing', () => {
|
||||
const node = inferredNode({
|
||||
inferredBelief: { left: 'l', right: 'r', operator: true },
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
InferredBeliefReduce(node, [{ id: 'l' } as Node])
|
||||
).toThrow('No Right Belief found');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -10,8 +10,9 @@ import NormNode, {
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
|
||||
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
|
||||
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
|
||||
import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
|
||||
|
||||
describe('NormNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
@@ -26,12 +27,7 @@ describe('NormNode', () => {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
},
|
||||
data: {...JSON.parse(JSON.stringify(NormNodeDefaults))},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
@@ -60,6 +56,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Be respectful to humans',
|
||||
@@ -94,8 +91,10 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
conditions: [],
|
||||
norm: '',
|
||||
hasReduce: true,
|
||||
critical: false
|
||||
@@ -129,6 +128,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Dragged norm',
|
||||
@@ -165,6 +165,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -210,6 +211,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Initial norm text',
|
||||
@@ -261,6 +263,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -314,6 +317,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -358,6 +362,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -399,25 +404,41 @@ describe('NormNode', () => {
|
||||
|
||||
describe('NormReduce Function', () => {
|
||||
it('should reduce a norm node to its essential data', () => {
|
||||
|
||||
const condition: Node = {
|
||||
id: "belief-1",
|
||||
type: 'basic_belief',
|
||||
position: {x: 10, y: 10},
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
|
||||
}
|
||||
}
|
||||
|
||||
const normNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Safety Norm',
|
||||
droppable: true,
|
||||
norm: 'Never harm humans',
|
||||
hasReduce: true,
|
||||
condition: "belief-1"
|
||||
},
|
||||
};
|
||||
|
||||
const allNodes: Node[] = [normNode];
|
||||
const allNodes: Node[] = [normNode, condition];
|
||||
const result = NormReduce(normNode, allNodes);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'norm-1',
|
||||
label: 'Safety Norm',
|
||||
norm: 'Never harm humans',
|
||||
critical: false,
|
||||
condition: {
|
||||
id: "belief-1",
|
||||
keyword: ""
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -427,6 +448,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Norm 1',
|
||||
droppable: true,
|
||||
norm: 'Be helpful',
|
||||
@@ -439,6 +461,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Norm 2',
|
||||
droppable: true,
|
||||
norm: 'Be honest',
|
||||
@@ -463,6 +486,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Empty Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -482,6 +506,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Custom Label',
|
||||
droppable: false,
|
||||
norm: 'Test norm',
|
||||
@@ -502,6 +527,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
@@ -514,6 +540,7 @@ describe('NormNode', () => {
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: [],
|
||||
@@ -532,6 +559,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
@@ -544,6 +572,7 @@ describe('NormNode', () => {
|
||||
type: 'phase',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Phase 1',
|
||||
droppable: true,
|
||||
children: [],
|
||||
@@ -562,6 +591,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...NormNodeDefaults,
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'Test',
|
||||
@@ -583,6 +613,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -634,6 +665,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: '',
|
||||
@@ -682,6 +714,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Norm 1',
|
||||
droppable: true,
|
||||
norm: 'Original norm 1',
|
||||
@@ -694,6 +727,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Norm 2',
|
||||
droppable: true,
|
||||
norm: 'Original norm 2',
|
||||
@@ -748,6 +782,7 @@ describe('NormNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...NormNodeDefaults,
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'haa haa fuyaaah - link',
|
||||
@@ -778,21 +813,140 @@ describe('NormNode', () => {
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||
expect(input).toBeDefined()
|
||||
|
||||
await user.type(input, 'a');
|
||||
await user.type(input, 'a{enter}');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka');
|
||||
});
|
||||
|
||||
await user.type(input, 'b');
|
||||
await user.type(input, 'b{enter}');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab');
|
||||
});
|
||||
|
||||
await user.type(input, 'c');
|
||||
await user.type(input, 'c{enter}');
|
||||
await waitFor(() => {
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
||||
}, { timeout: 3000 });
|
||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration beliefs', () => {
|
||||
it('should update visually when adding beliefs', async () => {
|
||||
// Setup state
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'haa haa fuyaaah - link',
|
||||
hasReduce: true,
|
||||
}
|
||||
};
|
||||
|
||||
const mockBelief: Node = {
|
||||
id: 'basic_belief-1',
|
||||
type: 'basic_belief',
|
||||
position: {x:100, y:100},
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
|
||||
}
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode, mockBelief],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
// Simulate connecting
|
||||
NormConnectionTarget(mockNode, mockBelief.id);
|
||||
BasicBeliefConnectionSource(mockBelief, mockNode.id)
|
||||
|
||||
renderWithProviders(
|
||||
<div>
|
||||
<NormNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
<BasicBeliefNode
|
||||
id={mockBelief.id}
|
||||
type={mockBelief.type as string}
|
||||
data={mockBelief.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
it('should update the data when adding beliefs', async () => {
|
||||
// Setup state
|
||||
const mockNode: Node = {
|
||||
id: 'norm-1',
|
||||
type: 'norm',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Test Norm',
|
||||
droppable: true,
|
||||
norm: 'haa haa fuyaaah - link',
|
||||
hasReduce: true,
|
||||
}
|
||||
};
|
||||
|
||||
const mockBelief1: Node = {
|
||||
id: 'basic_belief-1',
|
||||
type: 'basic_belief',
|
||||
position: {x:100, y:100},
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
|
||||
}
|
||||
};
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [mockNode, mockBelief1],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
// Simulate connecting
|
||||
useFlowStore.getState().onConnect({
|
||||
source: 'basic_belief-1',
|
||||
target: 'norm-1',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
});
|
||||
|
||||
|
||||
const state = useFlowStore.getState();
|
||||
const updatedNorm = state.nodes.find(n => n.id === 'norm-1');
|
||||
expect(updatedNorm?.data.condition).toEqual("basic_belief-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Node, Edge, Connection } from '@xyflow/react'
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
||||
import { getByTestId, render } from '@testing-library/react';
|
||||
import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
||||
import {act, getByTestId, render} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
||||
import {mockReactFlow} from "../../../../setupFlowTests.ts";
|
||||
|
||||
|
||||
class ResizeObserver {
|
||||
@@ -76,8 +78,10 @@ describe('PhaseNode', () => {
|
||||
|
||||
// Find nodes
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const p1 = nodes.find((x) => x.id === 'phase-1')!;
|
||||
const p2 = nodes.find((x) => x.id === 'phase-2')!;
|
||||
const phaseNodes = nodes.filter((x) => x.type === 'phase');
|
||||
const p1 = phaseNodes[0];
|
||||
const p2 = phaseNodes[1];
|
||||
|
||||
|
||||
// expect same value, not same reference
|
||||
expect(p1.data.children).not.toBe(p2.data.children);
|
||||
@@ -98,4 +102,195 @@ describe('PhaseNode', () => {
|
||||
expect(p1_data.children.length == 1);
|
||||
expect(p2_data.children.length == 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --| Helper functions |--
|
||||
|
||||
function createPhaseNode(
|
||||
id: string,
|
||||
overrides: Partial<PhaseNodeData> = {},
|
||||
): Node<PhaseNodeData> {
|
||||
return {
|
||||
id: id,
|
||||
type: 'phase',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Phase',
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
nextPhaseId: null,
|
||||
isFirstPhase: false,
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createNode(id: string, type: string): Node {
|
||||
return {
|
||||
id: id,
|
||||
type: type,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
}
|
||||
}
|
||||
|
||||
function connect(source: string, target: string): Connection {
|
||||
return {
|
||||
source: source,
|
||||
target: target,
|
||||
sourceHandle: null,
|
||||
targetHandle: null
|
||||
};
|
||||
}
|
||||
|
||||
function edge(source: string, target: string): Edge {
|
||||
return {
|
||||
id: `${source}-${target}`,
|
||||
source: source,
|
||||
target: target,
|
||||
}
|
||||
}
|
||||
|
||||
// --| Connection Tests |--
|
||||
|
||||
describe('PhaseNode Connection logic', () => {
|
||||
beforeAll(() => {
|
||||
mockReactFlow();
|
||||
});
|
||||
|
||||
describe('PhaseConnections', () => {
|
||||
test('connecting start => phase sets isFirstPhase to true', () => {
|
||||
const phase = createPhaseNode('phase-1')
|
||||
const start = createNode('start', 'start')
|
||||
|
||||
useFlowStore.setState({ nodes: [phase, start] })
|
||||
|
||||
// verify it starts of false
|
||||
expect(phase.data.isFirstPhase).toBe(false);
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onConnect(connect('start', 'phase-1'))
|
||||
})
|
||||
|
||||
const updatedPhase = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedPhase.data.isFirstPhase).toBe(true)
|
||||
})
|
||||
|
||||
test('connecting task => phase adds child', () => {
|
||||
const phase = createPhaseNode('phase-1')
|
||||
const norm = createNode('norm-1', 'norm')
|
||||
|
||||
useFlowStore.setState({ nodes: [phase, norm] })
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onConnect(connect('norm-1', 'phase-1'))
|
||||
})
|
||||
|
||||
const updatedPhase = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedPhase.data.children).toEqual(['norm-1'])
|
||||
})
|
||||
|
||||
test('connecting phase => phase sets nextPhaseId', () => {
|
||||
const p1 = createPhaseNode('phase-1')
|
||||
const p2 = createPhaseNode('phase-2')
|
||||
|
||||
useFlowStore.setState({ nodes: [p1, p2] })
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onConnect(connect('phase-1', 'phase-2'))
|
||||
})
|
||||
|
||||
const updatedP1 = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedP1.data.nextPhaseId).toBe('phase-2')
|
||||
})
|
||||
|
||||
test('connecting phase to end => phase sets nextPhaseId to "end"', () => {
|
||||
const phase = createPhaseNode('phase-1')
|
||||
const end = createNode('end', 'end')
|
||||
|
||||
useFlowStore.setState({ nodes: [phase, end] })
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onConnect(connect('phase-1', 'end'))
|
||||
})
|
||||
|
||||
const updatedPhase = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedPhase.data.nextPhaseId).toBe('end')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PhaseDisconnections', () => {
|
||||
test('disconnecting task => phase removes child', () => {
|
||||
const phase = createPhaseNode('phase-1', { children: ['norm-1'] })
|
||||
const norm = createNode('norm-1', 'norm')
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [phase, norm],
|
||||
edges: [edge('norm-1', 'phase-1')]
|
||||
})
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onEdgesDelete([edge('norm-1', 'phase-1')])
|
||||
})
|
||||
|
||||
const updatedPhase = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedPhase.data.children).toEqual([])
|
||||
})
|
||||
|
||||
test('disconnecting start => phase sets isFirstPhase to false', () => {
|
||||
const phase = createPhaseNode('phase-1', { isFirstPhase: true })
|
||||
const start = createNode('start', 'start')
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [phase, start],
|
||||
edges: [edge('start', 'phase-1')]
|
||||
})
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onEdgesDelete([edge('start', 'phase-1')])
|
||||
})
|
||||
|
||||
const updatedPhase = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedPhase.data.isFirstPhase).toBe(false)
|
||||
})
|
||||
|
||||
test('disconnecting phase => phase sets nextPhaseId to null', () => {
|
||||
const p1 = createPhaseNode('phase-1', { nextPhaseId: 'phase-2' })
|
||||
const p2 = createPhaseNode('phase-2')
|
||||
|
||||
useFlowStore.setState({
|
||||
nodes: [p1, p2],
|
||||
edges: [edge('phase-1', 'phase-2')]
|
||||
})
|
||||
|
||||
act(() => {
|
||||
useFlowStore.getState().onEdgesDelete([edge('phase-1', 'phase-2')])
|
||||
})
|
||||
|
||||
const updatedP1 = useFlowStore
|
||||
.getState()
|
||||
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||
|
||||
expect(updatedP1.data.nextPhaseId).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,25 @@
|
||||
import { describe, it, beforeEach } from '@jest/globals';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||
import TriggerNode, {
|
||||
TriggerReduce,
|
||||
TriggerNodeCanConnect,
|
||||
type TriggerNodeData,
|
||||
TriggerConnectionSource, TriggerConnectionTarget
|
||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
import type { Node } from '@xyflow/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts';
|
||||
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
|
||||
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
|
||||
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
|
||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
describe('TriggerNode', () => {
|
||||
let user: ReturnType<typeof userEvent.setup>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = userEvent.setup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
@@ -26,11 +29,7 @@ describe('TriggerNode', () => {
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,161 +50,60 @@ describe('TriggerNode', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render TriggerNode with emotion type', () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-2',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Emotion Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'emotion',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should add a new keyword', async () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('...');
|
||||
await user.type(input, 'hello{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
|
||||
expect(node?.data.triggers.length).toBe(1);
|
||||
expect(node?.data.triggers[0].keyword).toBe('hello');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it('should remove a keyword when cleared', async () => {
|
||||
const mockNode: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||
|
||||
renderWithProviders(
|
||||
<TriggerNode
|
||||
id={mockNode.id}
|
||||
type={mockNode.type as string}
|
||||
data={mockNode.data as any}
|
||||
selected={false}
|
||||
isConnectable={true}
|
||||
zIndex={0}
|
||||
dragging={false}
|
||||
selectable={true}
|
||||
deletable={true}
|
||||
draggable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('hello');
|
||||
for (let i = 0; i < 'hello'.length; i++) {
|
||||
await user.type(input, '{backspace}');
|
||||
}
|
||||
await user.type(input, '{enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
|
||||
expect(node?.data.triggers.length).toBe(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Triggers when the condition is met/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Belief is currently/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Plan is currently/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TriggerReduce Function', () => {
|
||||
it('should reduce a trigger node to its essential data', () => {
|
||||
const conditionNode: Node = {
|
||||
id: 'belief-1',
|
||||
type: 'basic_belief',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)),
|
||||
},
|
||||
};
|
||||
|
||||
const triggerNode: Node = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Keyword Trigger',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
||||
hasReduce: true,
|
||||
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||
condition: "belief-1",
|
||||
plan: defaultPlan,
|
||||
name: "trigger-1"
|
||||
},
|
||||
};
|
||||
|
||||
const allNodes: Node[] = [triggerNode];
|
||||
const result = TriggerReduce(triggerNode, allNodes);
|
||||
useFlowStore.setState({
|
||||
nodes: [conditionNode, triggerNode],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
useFlowStore.getState().onConnect({
|
||||
source: 'belief-1',
|
||||
target: 'trigger-1',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
});
|
||||
|
||||
const result = TriggerReduce(triggerNode, useFlowStore.getState().nodes);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'trigger-1',
|
||||
type: 'keywords',
|
||||
label: 'Keyword Trigger',
|
||||
keywords: [{ id: 'kw1', keyword: 'hello' }],
|
||||
});
|
||||
name: "trigger-1",
|
||||
condition: {
|
||||
id: "belief-1",
|
||||
keyword: "",
|
||||
},
|
||||
plan: {
|
||||
id: expect.anything(),
|
||||
steps: [],
|
||||
},});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,11 +115,8 @@ describe('TriggerNode', () => {
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||
label: 'Trigger 1',
|
||||
droppable: true,
|
||||
triggerType: 'keywords',
|
||||
triggers: [],
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -230,10 +125,8 @@ describe('TriggerNode', () => {
|
||||
type: 'norm',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||
label: 'Norm 1',
|
||||
droppable: true,
|
||||
norm: 'test',
|
||||
hasReduce: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -242,10 +135,50 @@ describe('TriggerNode', () => {
|
||||
TriggerConnectionTarget(node1, node2.id);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true for TriggerNodeCanConnect if connection exists', () => {
|
||||
const connection = { source: 'trigger-1', target: 'norm-1' };
|
||||
expect(TriggerNodeCanConnect(connection as any)).toBe(true);
|
||||
|
||||
describe('TriggerConnects Function', () => {
|
||||
it('should correctly remove a goal from the triggers plan after it has been disconnected', () => {
|
||||
// first, define the goal node and trigger node.
|
||||
const goal: Node = {
|
||||
id: 'g-1',
|
||||
type: 'goal',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
|
||||
};
|
||||
|
||||
const trigger: Node<TriggerNodeData> = {
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
|
||||
};
|
||||
|
||||
// set initial store
|
||||
useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
|
||||
|
||||
// then, connect the goal to the trigger.
|
||||
act(() => {
|
||||
useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null });
|
||||
});
|
||||
|
||||
// expect the goal id to be part of a goal step of the plan.
|
||||
let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
||||
expect(updatedTrigger?.data.plan).toBeDefined();
|
||||
const plan = updatedTrigger?.data.plan as any;
|
||||
expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
|
||||
|
||||
// then, disconnect the goal from the trigger.
|
||||
act(() => {
|
||||
useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
|
||||
});
|
||||
|
||||
// finally, expect the goal id to NOT be part of the goal step of the plan.
|
||||
updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
||||
const planAfter = updatedTrigger?.data.plan as any;
|
||||
const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
|
||||
expect(stillHas).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,22 +8,22 @@ import { createElement } from 'react';
|
||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||
|
||||
|
||||
describe('NormNode', () => {
|
||||
describe('Universal Nodes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
|
||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||
const newData = {
|
||||
id: id,
|
||||
type: type,
|
||||
position: position,
|
||||
data: data,
|
||||
deletable: deletable,
|
||||
|
||||
return {
|
||||
id: id,
|
||||
type: type,
|
||||
position: position,
|
||||
data: {...defaultData, ...data},
|
||||
deletable: deletable
|
||||
}
|
||||
return {...defaultData, ...newData}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
@@ -45,34 +45,34 @@ describe('NormNode', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => {
|
||||
const lengthBefore = screen.getAllByText(/.*/).length;
|
||||
const lengthBefore = screen.getAllByText(/.*/).length;
|
||||
|
||||
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
|
||||
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
|
||||
|
||||
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
|
||||
const uiElement = found ? found[1] : null;
|
||||
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
|
||||
const uiElement = found ? found[1] : null;
|
||||
|
||||
expect(uiElement).not.toBeNull();
|
||||
const props = {
|
||||
id: newNode.id,
|
||||
type: newNode.type as string,
|
||||
data: newNode.data as any,
|
||||
selected: false,
|
||||
isConnectable: true,
|
||||
zIndex: 0,
|
||||
dragging: false,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
};
|
||||
expect(uiElement).not.toBeNull();
|
||||
const props = {
|
||||
id: newNode.id,
|
||||
type: newNode.type as string,
|
||||
data: newNode.data as any,
|
||||
selected: false,
|
||||
isConnectable: true,
|
||||
zIndex: 0,
|
||||
dragging: false,
|
||||
selectable: true,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
};
|
||||
|
||||
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
|
||||
const lengthAfter = screen.getAllByText(/.*/).length;
|
||||
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
|
||||
const lengthAfter = screen.getAllByText(/.*/).length;
|
||||
|
||||
expect(lengthBefore + 1 === lengthAfter);
|
||||
});
|
||||
expect(lengthBefore + 1 === lengthAfter);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -107,6 +107,50 @@ describe('NormNode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disconnecting', () => {
|
||||
test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => {
|
||||
// Create two nodes - one of the current type and one to connect to
|
||||
const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {});
|
||||
const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {});
|
||||
|
||||
// Add nodes to store
|
||||
useFlowStore.setState({ nodes: [sourceNode, targetNode] });
|
||||
|
||||
// Spy on the connect functions
|
||||
const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources);
|
||||
const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief');
|
||||
|
||||
// Simulate connection
|
||||
useFlowStore.getState().onConnect({
|
||||
source: 'source-1',
|
||||
target: 'target-1',
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
});
|
||||
|
||||
|
||||
// Verify the connect functions were called
|
||||
expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id);
|
||||
expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id);
|
||||
|
||||
// Find this connection, and delete it
|
||||
const edge = useFlowStore.getState().edges[0];
|
||||
useFlowStore.getState().onEdgesDelete([edge]);
|
||||
|
||||
// Find the nodes in the flow
|
||||
const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1");
|
||||
const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1");
|
||||
|
||||
// Expect them to be the same after deleting the edges
|
||||
expect(newSourceNode).toBe(sourceNode);
|
||||
expect(newTargetNode).toBe(targetNode);
|
||||
|
||||
// Restore our spies
|
||||
sourceConnectSpy.mockRestore();
|
||||
targetConnectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reducing', () => {
|
||||
test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => {
|
||||
// Create a phase node and a node of the current type
|
||||
@@ -141,7 +185,7 @@ describe('NormNode', () => {
|
||||
// Verify the correct structure is present using NodesInPhase
|
||||
expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2);
|
||||
expect(result[0]).toHaveProperty('id', 'phase-1');
|
||||
expect(result[0]).toHaveProperty('label', 'Test Phase');
|
||||
expect(result[0]).toHaveProperty('name', 'Test Phase');
|
||||
|
||||
// Restore mocks
|
||||
phaseReduceSpy.mockRestore();
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import {
|
||||
type CompositeWarningKey,
|
||||
type SeverityIndex,
|
||||
} from "../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
||||
|
||||
if (!globalThis.structuredClone) {
|
||||
globalThis.structuredClone = (obj: any) => {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
}
|
||||
|
||||
// To make sure that the tests are working, it's important that you are using
|
||||
// this implementation of ResizeObserver and DOMMatrixReadOnly
|
||||
@@ -61,10 +70,9 @@ export const mockReactFlow = () => {
|
||||
width: 200,
|
||||
height: 200,
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
beforeAll(() => {
|
||||
useFlowStore.setState({
|
||||
nodes: [],
|
||||
@@ -72,7 +80,14 @@ beforeAll(() => {
|
||||
past: [],
|
||||
future: [],
|
||||
isBatchAction: false,
|
||||
edgeReconnectSuccessful: true
|
||||
edgeReconnectSuccessful: true,
|
||||
ruleRegistry: new Map(),
|
||||
editorWarningRegistry: new Map(),
|
||||
severityIndex: new Map([
|
||||
['INFO', new Set<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]) as SeverityIndex,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +99,27 @@ afterEach(() => {
|
||||
past: [],
|
||||
future: [],
|
||||
isBatchAction: false,
|
||||
edgeReconnectSuccessful: true
|
||||
edgeReconnectSuccessful: true,
|
||||
ruleRegistry: new Map(),
|
||||
editorWarningRegistry: new Map(),
|
||||
severityIndex: new Map([
|
||||
['INFO', new Set<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]) as SeverityIndex,
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof HTMLDialogElement !== 'undefined') {
|
||||
if (!HTMLDialogElement.prototype.showModal) {
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
// basic behavior: mark as open
|
||||
this.setAttribute('open', '');
|
||||
};
|
||||
}
|
||||
if (!HTMLDialogElement.prototype.close) {
|
||||
HTMLDialogElement.prototype.close = function () {
|
||||
this.removeAttribute('open');
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
import { render, type RenderOptions } from '@testing-library/react';
|
||||
import { type ReactElement, type ReactNode } from 'react';
|
||||
import { ReactFlowProvider } from '@xyflow/react';
|
||||
import {mockReactFlow} from "../setupFlowTests.ts";
|
||||
|
||||
mockReactFlow();
|
||||
|
||||
/**
|
||||
* Custom render function that wraps components with necessary providers
|
||||
@@ -19,6 +22,48 @@ export function renderWithProviders(
|
||||
}
|
||||
|
||||
|
||||
type SidebarRect = Partial<DOMRect>;
|
||||
|
||||
const defaultRect: DOMRect = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 100,
|
||||
right: 200,
|
||||
width: 200,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a component and injects a mock `#draggable-sidebar`
|
||||
* element required by Tooltip positioning logic.
|
||||
*/
|
||||
export function renderWithSidebar(
|
||||
ui: ReactElement,
|
||||
rect: SidebarRect = {},
|
||||
options?: RenderOptions
|
||||
) {
|
||||
const sidebar = document.createElement('div');
|
||||
sidebar.id = 'draggable-sidebar';
|
||||
|
||||
sidebar.getBoundingClientRect = jest.fn(() => ({
|
||||
...defaultRect,
|
||||
...rect,
|
||||
}));
|
||||
|
||||
document.body.appendChild(sidebar);
|
||||
|
||||
const result = render(ui, options);
|
||||
|
||||
return {
|
||||
...result,
|
||||
sidebar,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Re-export everything from testing library
|
||||
//eslint-disable-next-line react-refresh/only-export-components
|
||||
export * from '@testing-library/react';
|
||||
|
||||
110
test/utils/orderPhaseNodes.test.ts
Normal file
110
test/utils/orderPhaseNodes.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {PhaseNode} from "../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||
import orderPhaseNodeArray from "../../src/utils/orderPhaseNodes.ts";
|
||||
|
||||
function createPhaseNode(
|
||||
id: string,
|
||||
isFirst: boolean = false,
|
||||
nextPhaseId: string | null = null
|
||||
): PhaseNode {
|
||||
return {
|
||||
id: id,
|
||||
type: 'phase',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Phase',
|
||||
droppable: true,
|
||||
children: [],
|
||||
hasReduce: true,
|
||||
nextPhaseId: nextPhaseId,
|
||||
isFirstPhase: isFirst,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("orderPhaseNodes", () => {
|
||||
test.each([
|
||||
{
|
||||
testCase: {
|
||||
testName: "Throws correct error when there is no first phase (empty input array)",
|
||||
input: [],
|
||||
expected: "No phaseNode with isFirstObject = true found"
|
||||
}
|
||||
},{
|
||||
testCase: {
|
||||
testName: "Throws correct error when there is no first phase",
|
||||
input: [
|
||||
createPhaseNode("phase-1", false, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
createPhaseNode("phase-3", false, "end")
|
||||
],
|
||||
expected: "No phaseNode with isFirstObject = true found"
|
||||
}
|
||||
},{
|
||||
testCase: {
|
||||
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-phase connection)",
|
||||
input: [
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, null),
|
||||
createPhaseNode("phase-3", false, "end")
|
||||
],
|
||||
expected: "Incomplete phase sequence, program does not reach the end node"
|
||||
}
|
||||
},{
|
||||
testCase: {
|
||||
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-end connection)",
|
||||
input: [
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
createPhaseNode("phase-3", false, null)
|
||||
],
|
||||
expected: "Incomplete phase sequence, program does not reach the end node"
|
||||
}
|
||||
},{
|
||||
testCase: {
|
||||
testName: "Throws correct error when the program leads to a non-existent phase",
|
||||
input: [
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
createPhaseNode("phase-3", false, "phase-4")
|
||||
],
|
||||
expected: "Incomplete phase sequence, phaseNode with id \"phase-4\" not found"
|
||||
}
|
||||
}
|
||||
])(`Error Handling: $testCase.testName`, ({testCase}) => {
|
||||
expect(() => { orderPhaseNodeArray(testCase.input) }).toThrow(testCase.expected);
|
||||
})
|
||||
test.each([
|
||||
{
|
||||
testCase: {
|
||||
testName: "Already correctly ordered phases stay ordered",
|
||||
input: [
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
createPhaseNode("phase-3", false, "end")
|
||||
],
|
||||
expected: [
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
createPhaseNode("phase-3", false, "end")
|
||||
]
|
||||
}
|
||||
},{
|
||||
testCase: {
|
||||
testName: "Incorrectly ordered phases get ordered correctly",
|
||||
input: [
|
||||
createPhaseNode("phase-3", false, "end"),
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
],
|
||||
expected: [
|
||||
createPhaseNode("phase-1", true, "phase-2"),
|
||||
createPhaseNode("phase-2", false, "phase-3"),
|
||||
createPhaseNode("phase-3", false, "end")
|
||||
]
|
||||
}
|
||||
}
|
||||
])(`Functional: $testCase.testName`, ({testCase}) => {
|
||||
const output = orderPhaseNodeArray(testCase.input);
|
||||
expect(output).toEqual(testCase.expected);
|
||||
})
|
||||
})
|
||||
116
test/utils/programStore.test.ts
Normal file
116
test/utils/programStore.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import useProgramStore, {type ReducedProgram} from "../../src/utils/programStore.ts";
|
||||
|
||||
|
||||
describe('useProgramStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store before each test
|
||||
useProgramStore.setState({
|
||||
currentProgram: { phases: [] },
|
||||
});
|
||||
});
|
||||
|
||||
const mockProgram: ReducedProgram = {
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-1',
|
||||
norms: [{ id: 'norm-1' }],
|
||||
goals: [{ id: 'goal-1' }],
|
||||
triggers: [{ id: 'trigger-1' }],
|
||||
},
|
||||
{
|
||||
id: 'phase-2',
|
||||
norms: [{ id: 'norm-2' }],
|
||||
goals: [{ id: 'goal-2' }],
|
||||
triggers: [{ id: 'trigger-2' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('should set and get the program state', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const program = useProgramStore.getState().getProgramState();
|
||||
expect(program).toEqual(mockProgram);
|
||||
});
|
||||
|
||||
it('should return the ids of all phases in the program', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const phaseIds = useProgramStore.getState().getPhaseIds();
|
||||
expect(phaseIds).toEqual(['phase-1', 'phase-2']);
|
||||
});
|
||||
|
||||
it('should return all norms for a given phase', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const norms = useProgramStore.getState().getNormsInPhase('phase-1');
|
||||
expect(norms).toEqual([{ id: 'norm-1' }]);
|
||||
});
|
||||
|
||||
it('should return all goals for a given phase', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const goals = useProgramStore.getState().getGoalsInPhase('phase-2');
|
||||
expect(goals).toEqual([{ id: 'goal-2' }]);
|
||||
});
|
||||
|
||||
it('should return all triggers for a given phase', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
const triggers = useProgramStore.getState().getTriggersInPhase('phase-1');
|
||||
expect(triggers).toEqual([{ id: 'trigger-1' }]);
|
||||
});
|
||||
|
||||
it('throws if phase does not exist when getting norms', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getNormsInPhase('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
|
||||
it('throws if phase does not exist when getting goals', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getGoalsInPhase('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
|
||||
it('throws if phase does not exist when getting triggers', () => {
|
||||
useProgramStore.getState().setProgramState(mockProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getTriggersInPhase('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
|
||||
it('should clone program state when setting it (no shared references should exist)', () => {
|
||||
const changeableMockProgram: ReducedProgram = {
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-1',
|
||||
norms: [{ id: 'norm-1' }],
|
||||
goals: [{ id: 'goal-1' }],
|
||||
triggers: [{ id: 'trigger-1' }],
|
||||
},
|
||||
{
|
||||
id: 'phase-2',
|
||||
norms: [{ id: 'norm-2' }],
|
||||
goals: [{ id: 'goal-2' }],
|
||||
triggers: [{ id: 'trigger-2' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
useProgramStore.getState().setProgramState(changeableMockProgram);
|
||||
|
||||
const storedProgram = useProgramStore.getState().getProgramState();
|
||||
|
||||
// mutate original
|
||||
(changeableMockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' });
|
||||
|
||||
// store should NOT change
|
||||
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user