Compare commits

...

105 Commits

Author SHA1 Message Date
JGerla
a85dbeaca6 refactor: adapted to fit the split made in monitoringPage
ref: N25B-450
2026-01-27 10:47:00 +01:00
JGerla
53568476d5 feat: added warnings to inferredBeliefNodes
ref: N25B-450
2026-01-23 18:30:26 +01:00
JGerla
58bd57818e Merge branch 'dev' into feat/editor-user-feedback
# Conflicts:
#	src/pages/VisProgPage/VisProg.tsx
#	src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx
#	src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
2026-01-23 18:23:46 +01:00
JGerla
ee79660276 feat: added documentation comments
ref: N25B-450
2026-01-23 18:18:08 +01:00
JGerla
85b84c2281 feat: added visibility toggle with autoHide option
ref: N25B-450
2026-01-23 17:10:48 +01:00
Twirre
f9e0eb95f8 Merge branch 'feat/add-inferred-belief-node' into 'dev'
feat: added an inferred belief node to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!42
2026-01-23 12:57:35 +00:00
Gerla, J. (Justin)
47c5e94b8f feat: added an inferred belief node to the editor 2026-01-23 12:57:34 +00:00
JGerla
820884f8aa fix: updated styles to work with darkMode
ref: N25B-450
2026-01-23 11:45:08 +01:00
Pim Hutting
b17d1e7618 Merge branch 'fix/correct-capitalization' into 'dev'
chore: fix the capitalization of 3 characters to make sure they match. :)

See merge request ics/sp/2025/n25b/pepperplus-ui!46
2026-01-23 10:42:36 +00:00
JGerla
641d794cf0 fix: updated styles to work with darkMode
ref: N25B-450
2026-01-23 11:42:06 +01:00
Björn Otgaar
ec211ccbc3 chore: fix the capitalization of 3 characters to make sure they match. :) 2026-01-23 11:25:30 +01:00
JGerla
7757a04694 fix: fixed warning for missing plan in goal
ref: N25B-450
2026-01-22 20:34:48 +01:00
JGerla
2a6ead352d feat: added warning for missing plan in goal
ref: N25B-450
2026-01-22 20:34:20 +01:00
JGerla
274ffb0238 fix: fixed bug with warning rendering in the sidebar
ref: N25B-450
2026-01-22 18:19:36 +01:00
JGerla
a00fd02634 fix: fixed runProgram button being disabled if program is invalid
ref: N25B-450
2026-01-22 17:55:57 +01:00
JGerla
f6b692e420 test: removed test, as it can be tested manually with better accuracy and the test was causing issues instead of verifying functionality
ref: N25B-450
2026-01-22 16:28:36 +01:00
JGerla
2cbd905f0b style: updated comment
ref: N25B-450
2026-01-22 16:18:07 +01:00
JGerla
84d9cbb19d fix: fixed centering for jumptonode from warningSidebar
ref: N25B-450
2026-01-22 16:14:46 +01:00
JGerla
e5b438c17e fix: fixed tests
ref: N25B-450
2026-01-22 14:21:03 +01:00
JGerla
64dcdc49b3 fix: added missing key attribute to the warningListItem
ref: N25B-450
2026-01-22 13:56:35 +01:00
JGerla
9c64455a19 test: added tests for the warningSidebar
ref: N25B-450
2026-01-22 13:54:24 +01:00
JGerla
9f359de953 test: added tests for editorwarnings
ref: N25B-450
2026-01-22 13:31:02 +01:00
JGerla
9d2f5127c1 fix: fixed incorrect warningkey creation in unregisterWarningsForId
ref: N25B-450
2026-01-22 13:09:44 +01:00
JGerla
bb053fda21 feat: added title based tooltips and updated handle styling to reflect in and output handles
ref: N25B-450
2026-01-22 13:04:56 +01:00
JGerla
f92467b409 Merge branch 'dev' into feat/editor-user-feedback 2026-01-22 12:19:20 +01:00
JGerla
c9c7f55aa0 feat: removed usage of structuredClone inside the editorWarningSystem
ref: N25B-450
2026-01-22 12:02:20 +01:00
JGerla
d6d74d4c6b feat: made warnings undo redo safe and added warnings to phase nodes
ref: N25B-450
2026-01-22 11:26:48 +01:00
JGerla
e86c06c3e5 fix: fixed deleting behaviour
ref: N25B-450
2026-01-22 10:26:21 +01:00
JGerla
363054afda feat: updated visuals
ref: N25B-450
2026-01-20 14:00:01 +01:00
JGerla
327d1de621 feat: Added visual separation between global and node or handle specific warnings
ref: N25B-450
2026-01-20 12:54:47 +01:00
JGerla
3f6d95683d feat: Added visual separation between global and node or handle specific warnings
ref: N25B-450
2026-01-20 12:50:29 +01:00
Gerla, J. (Justin)
6f4471ce6f Merge branch 'demo' into 'dev'
feat: merged demo into dev

See merge request ics/sp/2025/n25b/pepperplus-ui!43
2026-01-20 11:10:58 +00:00
JGerla
5d55ebaaa2 feat: Added global warning for incomplete program chain
ref: N25B-450
2026-01-20 11:53:51 +01:00
JGerla
23a02b2b4a Merge branch 'demo' into feat/editor-user-feedback 2026-01-20 11:13:22 +01:00
JGerla
487ee30923 feat: made jumpToNode select the node after focussing the editor
ref: N25B-450
2026-01-20 10:22:08 +01:00
Pim Hutting
8c28dd6c1c Merge branch 'feat/recursive-goal-making' into 'demo'
Adding goal nodes automatically adds them to the plan, and correctly reduces them

See merge request ics/sp/2025/n25b/pepperplus-ui!40
2026-01-19 14:00:04 +00:00
JGerla
5a9b78fdda feat: added jump to node on clicking the warning
ref: N25B-450
2026-01-15 16:24:08 +01:00
JGerla
a6f24b677f feat: added two new warnings
ref: N25B-450
2026-01-15 16:09:24 +01:00
JGerla
022a6708ea feat: added a Warnings sidebar
warnings are now displayed in the sidebar

ref: N25B-450
2026-01-15 16:04:09 +01:00
JGerla
f62f416af3 Merge remote-tracking branch 'origin/feat/editor-user-feedback' into feat/editor-user-feedback
# Conflicts:
#	src/pages/VisProgPage/VisProg.tsx
2026-01-15 14:23:55 +01:00
JGerla
385ec250cc feat: finished basic warning system
nodes can now register warnings to prevent running the program

ref: N25B-450
2026-01-15 14:23:35 +01:00
JGerla
35bf3ad9e5 feat: finished basic warning system
nodes can now register warnings to prevent running the program

ref: N25B-450
2026-01-15 14:22:50 +01:00
JGerla
66daafe1f0 feat: added disabling of runProgram button if program is not valid
ref: N25B-450
2026-01-15 12:21:09 +01:00
JGerla
5d650b36ce feat: implemented the rest of the warning registry
ref: N25B-450
2026-01-15 12:15:32 +01:00
JGerla
e9acab456e feat: implemented getWarningsBySeverity
ref: N25B-450
2026-01-14 16:59:28 +01:00
JGerla
1a8670ba13 feat: implemented delete all warnings for a node
ref: N25B-450
2026-01-14 16:52:43 +01:00
JGerla
f174623a4c feat: implemented basic add and remove functions
ref: N25B-450
2026-01-14 16:46:50 +01:00
JGerla
b3b77b94ad feat: slightly modified structure for better global logic
ref: N25B-450
2026-01-14 16:11:48 +01:00
JGerla
67558a7ac7 feat: added full definition of editor warning infrastructure
Everything is now defined architecturally, and can be implemented properly.

ref: N25B-450
2026-01-14 16:04:44 +01:00
Björn Otgaar
e1257bdf48 Merge branch 'demo' into feat/recursive-goal-making 2026-01-13 12:33:27 +01:00
Björn Otgaar
3f7e196bb7 Merge branch 'feat/add-node-tooltips' into 'demo'
feat: added node-tooltips to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!39
2026-01-13 11:29:45 +00:00
Gerla, J. (Justin)
566c4c18cc feat: added node-tooltips to the editor 2026-01-13 11:29:45 +00:00
Björn Otgaar
8ffc919e7e fix: added a missing test, corrected the imports, added behavior for disconnected goals as last step
ref: N25B-434
2026-01-10 11:26:26 +01:00
Björn Otgaar
a5a345b9a9 test: add tests for goal and triggers 2026-01-08 14:38:37 +01:00
Björn Otgaar
96afba2a1d chore: add name field to trigger nodes 2026-01-08 14:09:44 +01:00
Björn Otgaar
c7ed3c8ef2 feat: added correct showing of goals with the description and can_fail
ref: N25B-434
2026-01-08 12:31:46 +01:00
Björn Otgaar
e6f29a0f6b chore: fix the eslint issues 2026-01-08 11:33:10 +01:00
Björn Otgaar
a4428c0d67 feat: automatic addition of goals to a current goal node, adding it to the plan, making sure the data stays correct. Also for the trigger nodes. :)
ref: N25B-434
2026-01-07 17:55:54 +01:00
5385bd72b1 Merge branch 'refactor/nodes-match-functionality' into 'demo'
Refactor of visual programming page to fully match the CB's program schema. Includes overhaul of UI elements for plan creation.

See merge request ics/sp/2025/n25b/pepperplus-ui!38
2026-01-07 15:19:46 +00:00
Björn Otgaar
e805c882fe Merge branch 'refactor/nodes-match-functionality' into feat/recursive-goal-making 2026-01-07 15:51:14 +01:00
Björn Otgaar
35ab95bd35 chore: correct reducing 2026-01-07 15:50:45 +01:00
Björn Otgaar
ad8111d6c2 chore: initial branch commit 2026-01-07 15:44:56 +01:00
Björn Otgaar
4e07b95722 chore: fix specific (new) handles 2026-01-07 15:24:45 +01:00
Björn Otgaar
442df423d1 Merge branch 'demo' into refactor/nodes-match-functionality 2026-01-07 15:15:17 +01:00
Björn Otgaar
bd079a4121 Merge branch 'feat/add-connection-validation-and-limits' into 'demo'
feat: added rule based connection validation and connection limits to the editor

See merge request ics/sp/2025/n25b/pepperplus-ui!35
2026-01-07 13:32:54 +00:00
Gerla, J. (Justin)
9e7c192804 feat: added rule based connection validation and connection limits to the editor 2026-01-07 13:32:53 +00:00
Björn Otgaar
d2d4dc1242 fix: small fixes for merge 2026-01-07 13:15:46 +01:00
Björn Otgaar
e6b0d7564d Merge branch 'demo' into refactor/nodes-match-functionality 2026-01-07 13:15:40 +01:00
Gerla, J. (Justin)
6d1c17e77b Merge branch 'feat/conditional-norm' into 'demo'
Conditional Norms

See merge request ics/sp/2025/n25b/pepperplus-ui!34
2026-01-07 09:27:23 +00:00
Björn Otgaar
4e9a048c90 Conditional Norms 2026-01-07 09:27:23 +00:00
Björn Otgaar
c13fb7d33d refactor: change the belief nodes to include a description part 2026-01-06 16:28:41 +01:00
Pim Hutting
0ad2d5935f Merge branch 'fix/incorrect-phase-reduction-order' into 'demo'
fix: incorrect phase reduction order

See merge request ics/sp/2025/n25b/pepperplus-ui!33
2026-01-06 15:12:01 +00:00
Gerla, J. (Justin)
9b3414ba98 fix: incorrect phase reduction order 2026-01-06 15:12:00 +00:00
Björn Otgaar
381cb0c822 Merge branch 'demo' into refactor/nodes-match-functionality 2026-01-06 15:51:28 +01:00
Björn Otgaar
0b74763e24 chore: diffewrent semantic placeholder 2026-01-06 15:40:09 +01:00
Björn Otgaar
08374ac2c2 chore: fix the tests with 2 lines 2026-01-06 15:37:36 +01:00
Björn Otgaar
46c2e0ede6 chore: remove belief default text 2026-01-06 15:35:19 +01:00
Pim Hutting
9c80391fea Merge branch 'feat/basic-belief-nodes' into 'demo'
Create a basic belief node that can be used in further stages to create inferred belief and be inputs for other nodes.

See merge request ics/sp/2025/n25b/pepperplus-ui!32
2026-01-06 14:29:13 +00:00
Björn Otgaar
f4745c736f refactor: update the goal node to have a description for plans that need to be checked, and correctly give the value to the CB.
ref: N25B-412
2026-01-06 15:28:31 +01:00
Björn Otgaar
508fa48be6 fix: fix the goal node's "can_fail" to have the correct property. 2026-01-06 14:47:56 +01:00
JobvAlewijk
9dae45e398 Merge branch 'feat/make-program-data-available-on-all-pages' into 'demo'
feat: made (reduced) program data available on all pages

See merge request ics/sp/2025/n25b/pepperplus-ui!36
2026-01-06 12:27:22 +00:00
Gerla, J. (Justin)
bd93b04bfd feat: made (reduced) program data available on all pages 2026-01-06 12:27:22 +00:00
Björn Otgaar
216b136a75 chore: change goal text, correct output for gestures, allow step specific reducing, fix tests/ add tests for new things 2026-01-05 16:38:06 +01:00
JGerla
111400bd82 fix: fixed scrolling behavior inside editor when plan editor window is opened
ref: N25B-412
2026-01-05 15:53:20 +01:00
Björn Otgaar
01d73b777a chore: fix one test 2026-01-05 10:37:05 +01:00
Björn Otgaar
9f26edb6ec chore: dont use object, use detected object. 2026-01-05 10:34:45 +01:00
Björn Otgaar
8f1367ed83 chore: emotion dropdown doesnt automatically assign new value 2026-01-05 10:30:29 +01:00
Björn Otgaar
bd2ffe622f chore: remove old connect function from basic belief 2026-01-05 10:24:08 +01:00
Björn Otgaar
b4df868e26 Merge branch 'demo' into feat/basic-belief-nodes 2026-01-05 10:20:10 +01:00
Björn Otgaar
149b82cb66 feat: create tests, more integration testing, fix ID tests, use UUID (almost) everywhere
ref: N25B-412
2026-01-04 18:29:19 +01:00
Björn Otgaar
c5f44536b7 feat: seperation of concerns for gesture value editor, adjusting output of nodes, integration testing, css file changes, and probably much more.
ref: N25B-412
2026-01-04 15:18:07 +01:00
Björn Otgaar
444e8b0289 feat: fix a lot of small changes to match cb, add functionality for all plans, add tests for the new plan editor. even more i dont really know anymore.
ref: N25B-412
2025-12-17 15:51:50 +01:00
Björn Otgaar
c1ef924be1 feat: create dialog for plan creation in triggers, make sure to bind the correct things in triggers. Change the norms to take one condition, rather than a list. yes, tests are probably still broken.
ref: N25B-412
2025-12-16 18:21:19 +01:00
Björn Otgaar
0b29cb5858 chore: remove console log
ref: N25B-392
2025-12-16 15:41:30 +01:00
Björn Otgaar
fcc279fb31 Merge branch 'demo' into feat/conditional-norm 2025-12-16 14:55:06 +01:00
Björn Otgaar
709dd28959 fix: fixing the tests
ref: N25B-392
2025-12-16 14:52:57 +01:00
Björn Otgaar
099afebe98 test: extra norm tests
ref: N25B-392
2025-12-16 14:31:00 +01:00
Björn Otgaar
8d4c3fc64b feat: add conditions and beliefs, add tests
ref: N25B-392
2025-12-16 12:03:48 +01:00
Björn Otgaar
7925023f25 fix: fix issues ariving from dev merge
ref: N25B-408
2025-12-15 14:51:58 +01:00
Björn Otgaar
2faa42bd4c Merge branch 'dev' into feat/basic-belief-nodes 2025-12-15 14:47:01 +01:00
Björn Otgaar
ae8ef317a4 test: tests for belief node
ref: N25B-408
2025-12-15 13:04:53 +01:00
Björn Otgaar
757435e9f8 fix: fix the tests and creation of nodes.
ref: N25B-408
2025-12-15 12:09:53 +01:00
Björn Otgaar
f22fe38e22 fix: revert the reduce change for eslint- might be done later in other way
ref: N25B-408
2025-12-15 12:01:39 +01:00
Björn Otgaar
9d4f10213e fix: update the recducer in phases to account for node-specific reducing
ref: N25B-408
2025-12-15 11:59:12 +01:00
Björn Otgaar
10d5a15c88 feat: basic belief node with the basic belief types defined in KB.
ref: N25B-408
2025-12-11 14:12:26 +01:00
70 changed files with 8305 additions and 552 deletions

528
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 */
}

View 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 ?? ""}
`}
/>
);
}

View File

@@ -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%;
}

View File

@@ -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}
/>;
}

View File

@@ -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);
}
}

View File

@@ -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); }
}

View File

@@ -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>
</>
)
}

View 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);
}

View File

@@ -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

View 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);
}

View 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");
}

View File

@@ -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,
}

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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(),
};
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
import type { Plan, PlanElement } from "./Plan";
export const defaultPlan: Plan = {
name: "Default Plan",
id: "-1",
steps: [] as PlanElement[],
}

View 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:
}
}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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>
</>
);
}

View File

@@ -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;
}

View File

@@ -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;
}}
/>
);
}

View File

@@ -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;
}

View File

@@ -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]);
});
};
}

View File

@@ -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,
};

View File

@@ -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
}

View File

@@ -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);
};

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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)
}
/**

View File

@@ -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,
};

View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
}
/**

View File

@@ -8,4 +8,6 @@ export const PhaseNodeDefaults: PhaseNodeData = {
droppable: true,
children: [],
hasReduce: true,
nextPhaseId: null,
isFirstPhase: false,
};

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -5,8 +5,7 @@ import type { TriggerNodeData } from "./TriggerNode";
*/
export const TriggerNodeDefaults: TriggerNodeData = {
label: "Trigger Node",
name: "",
droppable: true,
triggers: [],
triggerType: "keywords",
hasReduce: true,
};

View File

@@ -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;

View 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
View 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;

View File

@@ -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(); });

View File

@@ -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'));
});
});

View File

@@ -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"));
});
});

View File

@@ -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);
});
});
})
});

View File

@@ -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({

View File

@@ -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);
});
});
})

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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)
});
})
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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');
});
});
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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");
});
});
});

View File

@@ -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()
})
})
})

View File

@@ -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();
});
});
});

View File

@@ -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();

View File

@@ -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');
};
}
}

View File

@@ -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';

View 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);
})
})

View 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);
});
});