Compare commits

...

132 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
Pim Hutting
faaf67138d Merge branch 'feat/norm-critical-checkbox' into 'demo'
feat: add critical checkbox to the norm node, send it with the program, add test.

See merge request ics/sp/2025/n25b/pepperplus-ui!29
2025-12-16 13:12:59 +00:00
JobvAlewijk
ed2e0ecb7b Merge branch 'feat/quiet-llm' into 'dev'
feat: implemented extra log level for LLM token stream

See merge request ics/sp/2025/n25b/pepperplus-ui!30
2025-12-16 11:26:37 +00:00
Luijkx,S.O.H. (Storm)
c25073f20d feat: implemented extra log level for LLM token stream 2025-12-16 11:26:35 +00: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
JobvAlewijk
905b9da815 Merge branch 'fix/edge-disconnections-are-not-reflected-in-reduced-program' into 'dev'
fix: edge-disconnections-are-not-reflected-in-reduced-program

See merge request ics/sp/2025/n25b/pepperplus-ui!31
2025-12-14 21:56:18 +00:00
Gerla, J. (Justin)
58ab95eee1 fix: edge-disconnections-are-not-reflected-in-reduced-program 2025-12-14 21:56:18 +00: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
Björn Otgaar
62c8118650 Apply 1 suggestion(s) to 1 file(s)
Co-authored-by: Twirre <s.a.meulenbelt@students.uu.nl>
2025-12-11 09:54:39 +00:00
Björn Otgaar
d5480f957b Apply 1 suggestion(s) to 1 file(s)
Co-authored-by: Twirre <s.a.meulenbelt@students.uu.nl>
2025-12-11 09:54:34 +00:00
Björn Otgaar
062e9e3f38 feat: add critical checkbox to the norm node, send it with the program, add test.
ref: N25B-390
2025-12-10 15:38:54 +01:00
Pim Hutting
8149d67491 Merge branch 'fix/deep-clone-data' into 'dev'
fix deep cloning bug where phases don't have their own children but store references

See merge request ics/sp/2025/n25b/pepperplus-ui!27
2025-12-09 14:55:46 +00:00
JobvAlewijk
647ea1979a Merge branch 'feat/save-load-nodes' into 'dev'
Added loading/savior behaviour + buttons

See merge request ics/sp/2025/n25b/pepperplus-ui!24
2025-12-09 14:46:26 +00:00
Pim Hutting
501f56e009 chore: solve merge conflicts with dev
ref: N25B-189
2025-12-09 14:21:23 +01:00
JobvAlewijk
ed11680771 Merge branch 'dev' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ui into fix/deep-clone-data 2025-12-07 17:07:00 +01:00
Gerla, J. (Justin)
80aa1fca2b Merge branch 'test/new-ui-node-tests' into 'dev'
test: high coverage for all UI tests

See merge request ics/sp/2025/n25b/pepperplus-ui!26
2025-12-07 15:32:20 +00:00
JobvAlewijk
086caea737 test: high coverage for all UI tests 2025-12-07 15:32:20 +00:00
JobvAlewijk
c639a37dfc Merge branch 'feat/undo-redo-support' into 'dev'
feat: added undo and redo functionality

See merge request ics/sp/2025/n25b/pepperplus-ui!25
2025-12-07 15:21:59 +00:00
Gerla, J. (Justin)
5e22ed8806 feat: added undo and redo functionality 2025-12-07 15:21:59 +00:00
Björn Otgaar
95397ceccc fix: fix the tests by simulating user actions rather than the function, and avoid the cyclic dependancy which was present
ref: N25B-371
2025-12-04 12:33:27 +01:00
Björn Otgaar
c167144b4d fix: fix eslint issues, adjust norm test for dev merge
ref: N25B-371
2025-12-03 11:41:14 +01:00
Björn Otgaar
f0c250626f Merge branch 'dev' into fix/deep-clone-data 2025-12-03 11:29:15 +01:00
Björn Otgaar
d9faeafe32 test: create test for phase node to account for the previous bug.
ref: N25B-371
2025-12-03 11:28:15 +01:00
Björn Otgaar
518045ed1c Merge branch 'dev' into fix/deep-clone-data 2025-12-02 15:06:14 +01:00
Björn Otgaar
fe13017f2d test: test for the actual better clone- and make sure we use the JSON stringify and parse for this since tests are weird
ref: N25B-371
2025-12-02 14:12:35 +01:00
Björn Otgaar
7640c32830 fix: fix the creation of new phases so that the data is deepcloned instead of referenced 2025-12-02 12:47:38 +01:00
Björn Otgaar
a95fbd15e6 test: create universal tests and rewrite nodes to have optional parameters for more code coverage
ref: N25B-362
2025-12-02 12:01:23 +01:00
JobvAlewijk
d4393e7635 test: scroll
ref: N25B-292
2025-12-02 11:36:10 +01:00
JobvAlewijk
2261da9915 test: robot, and 2 nodes tests added.
ref: N25B-292
2025-11-27 18:45:11 +01:00
Björn Otgaar
c5d9b8342d chore: create new tests for the UI, namely normnode, and one for all nodes 2025-11-27 17:14:19 +01:00
77 changed files with 10903 additions and 458 deletions

528
package-lock.json generated
View File

@@ -10,9 +10,11 @@
"dependencies": { "dependencies": {
"@neodrag/react": "^2.3.1", "@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6", "@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.9.3", "react-router": "^7.9.3",
"reactflow": "^11.11.4",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@@ -24,6 +26,7 @@
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
@@ -2052,6 +2055,276 @@
"url": "https://opencollective.com/pkgr" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.35", "version": "1.0.0-beta.35",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
@@ -2646,12 +2919,102 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/d3-color": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT" "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": { "node_modules/@types/d3-drag": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
@@ -2661,6 +3024,54 @@
"@types/d3-selection": "*" "@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": { "node_modules/@types/d3-interpolate": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -2670,12 +3081,78 @@
"@types/d3-color": "*" "@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": { "node_modules/@types/d3-selection": {
"version": "3.0.11", "version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT" "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": { "node_modules/@types/d3-transition": {
"version": "3.0.9", "version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -2702,6 +3179,12 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/hast": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -3698,9 +4181,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.6", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3970,6 +4453,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "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": { "node_modules/co": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -4869,9 +5361,9 @@
} }
}, },
"node_modules/glob": { "node_modules/glob": {
"version": "10.4.5", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -6944,9 +7436,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.9.3", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "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": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",

View File

@@ -14,9 +14,11 @@
"dependencies": { "dependencies": {
"@neodrag/react": "^2.3.1", "@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6", "@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.9.3", "react-router": "^7.9.3",
"reactflow": "^11.11.4",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@@ -28,6 +30,7 @@
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",

View File

@@ -248,3 +248,11 @@ button.no-button {
text-decoration: underline; 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

@@ -15,6 +15,7 @@ type Setter<T> = (value: T | ((prev: T) => T)) => void;
*/ */
const optionMapping = new Map([ const optionMapping = new Map([
["ALL", 0], ["ALL", 0],
["LLM", 9],
["DEBUG", 10], ["DEBUG", 10],
["INFO", 20], ["INFO", 20],
["WARNING", 30], ["WARNING", 30],

View File

@@ -19,7 +19,7 @@ import {cell, type Cell} from "../../utils/cellStore.ts";
export type LogRecord = { export type LogRecord = {
name: string; name: string;
message: string; message: string;
levelname: 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string; levelname: 'LLM' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL' | string;
levelno: number; levelno: number;
created: number; created: number;
relativeCreated: number; relativeCreated: number;

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: 1px solid transparent;
border-radius: 5pt; border-radius: 5pt;
padding: 4px 8px; padding: 4px 8px;
max-width: 50vw;
min-width: 10vw;
outline: none; outline: none;
background-color: canvas; background-color: canvas;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
@@ -25,3 +27,13 @@
.text-field:read-only:hover:not(.invalid) { .text-field:read-only:hover:not(.invalid) {
border-color: color-mix(in srgb, canvas, #777 10%); 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} readOnly={readOnly}
id={id} id={id}
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes // 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} aria-label={ariaLabel}
/>; />;
} }
@@ -105,8 +105,9 @@ export function TextField({
}) { }) {
const [inputValue, setInputValue] = useState(value); const [inputValue, setInputValue] = useState(value);
// Re-render when the value gets updated externally useEffect(() => {
useEffect(() => setInputValue(value), [setInputValue, value]); setInputValue(value);
}, [value]);
const onCommit = () => setValue(inputValue); const onCommit = () => setValue(inputValue);

View File

@@ -59,8 +59,21 @@ button:focus-visible {
background-color: #ffffff; background-color: #ffffff;
--accent-color: #00AAAA; --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 { button {
background-color: #f9f9f9; 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 */ /* Node Styles */
:global(.react-flow__node.selected) {
outline: 1px dashed blue !important;
border-radius: 5pt;
outline-offset: 4px;
}
.default-node { .default-node {
padding: 10px 15px; padding: 10px 15px;
background-color: canvas; background-color: canvas;
@@ -41,6 +47,8 @@
filter: drop-shadow(0 0 0.75rem black); filter: drop-shadow(0 0 0.75rem black);
} }
.node-norm { .node-norm {
outline: rgb(0, 149, 25) solid 2pt; outline: rgb(0, 149, 25) solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen); filter: drop-shadow(0 0 0.25rem forestgreen);
@@ -71,10 +79,21 @@
filter: drop-shadow(0 0 0.25rem red); 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 { .draggable-node {
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: black solid 2pt; outline: black solid 2pt;
filter: drop-shadow(0 0 0.75rem black); filter: drop-shadow(0 0 0.75rem black);
} }
@@ -83,6 +102,7 @@
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: forestgreen solid 2pt; outline: forestgreen solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen); filter: drop-shadow(0 0 0.25rem forestgreen);
} }
@@ -91,6 +111,7 @@
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: yellow solid 2pt; outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow); filter: drop-shadow(0 0 0.25rem yellow);
} }
@@ -99,6 +120,7 @@
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: teal solid 2pt; outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal); filter: drop-shadow(0 0 0.25rem teal);
} }
@@ -107,6 +129,7 @@
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: dodgerblue solid 2pt; outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue); filter: drop-shadow(0 0 0.25rem dodgerblue);
} }
@@ -115,6 +138,7 @@
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: orange solid 2pt; outline: orange solid 2pt;
filter: drop-shadow(0 0 0.25rem orange); filter: drop-shadow(0 0 0.25rem orange);
} }
@@ -123,6 +147,87 @@
padding: 3px 10px; padding: 3px 10px;
background-color: canvas; background-color: canvas;
border-radius: 5pt; border-radius: 5pt;
cursor: move;
outline: red solid 2pt; outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red); 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,15 +4,21 @@ import {
Panel, Panel,
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
MarkerType, MarkerType, getOutgoers
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
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 {useShallow} from 'zustand/react/shallow';
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; 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 useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css' 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'; import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
// --| config starting params for flow |-- // --| config starting params for flow |--
@@ -38,11 +44,18 @@ const selector = (state: FlowState) => ({
nodes: state.nodes, nodes: state.nodes,
edges: state.edges, edges: state.edges,
onNodesChange: state.onNodesChange, onNodesChange: state.onNodesChange,
onNodesDelete: state.onNodesDelete,
onEdgesDelete: state.onEdgesDelete,
onEdgesChange: state.onEdgesChange, onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect, onConnect: state.onConnect,
onReconnectStart: state.onReconnectStart, onReconnectStart: state.onReconnectStart,
onReconnectEnd: state.onReconnectEnd, onReconnectEnd: state.onReconnectEnd,
onReconnect: state.onReconnect onReconnect: state.onReconnect,
undo: state.undo,
redo: state.redo,
beginBatchAction: state.beginBatchAction,
endBatchAction: state.endBatchAction,
scrollable: state.scrollable
}); });
// --| define ReactFlow editor |-- // --| define ReactFlow editor |--
@@ -57,29 +70,74 @@ const VisProgUI = () => {
const { const {
nodes, edges, nodes, edges,
onNodesChange, onNodesChange,
onNodesDelete,
onEdgesDelete,
onEdgesChange, onEdgesChange,
onConnect, onConnect,
onReconnect, onReconnect,
onReconnectStart, onReconnectStart,
onReconnectEnd onReconnectEnd,
undo,
redo,
beginBatchAction,
endBatchAction,
scrollable
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore } = 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) => {
if (e.ctrlKey && e.key === 'z') undo();
if (e.ctrlKey && e.key === 'y') redo();
};
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 ( 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 <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NodeTypes} nodeTypes={NodeTypes}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
onReconnect={onReconnect} onReconnect={onReconnect}
onReconnectStart={onReconnectStart} onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd} onReconnectEnd={onReconnectEnd}
onConnect={onConnect} onConnect={onConnect}
onNodeDragStart={beginBatchAction}
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
reconnectRadius={15}
snapToGrid snapToGrid
fitView fitView
proOptions={{hideAttribution: true}} proOptions={{hideAttribution: true}}
style={{flexGrow: 3}}
> >
<Panel position="top-center" className={styles.dndPanel}> <Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */} <DndToolbar/> {/* contains the drag and drop panel for nodes */}
@@ -87,15 +145,21 @@ const VisProgUI = () => {
<Panel position = "bottom-left" className={styles.saveLoadPanel}> <Panel position = "bottom-left" className={styles.saveLoadPanel}>
<SaveLoadPanel></SaveLoadPanel> <SaveLoadPanel></SaveLoadPanel>
</Panel> </Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>Undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Panel position="center-right" className={warningStyles.warningsSidebar}>
<WarningsSidebar/>
</Panel>
<Controls/> <Controls/>
<Background/> <Background/>
</ReactFlow> </ReactFlow>
</div> </div>
); );
}; };
/** /**
* Places the VisProgUI component inside a ReactFlowProvider * Places the VisProgUI component inside a ReactFlowProvider
* *
@@ -111,36 +175,23 @@ function VisualProgrammingUI() {
); );
} }
// currently outputs the prepared program to the console const checkPhaseChain = (): boolean => {
function runProgram() { const {nodes, edges} = useFlowStore.getState();
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."));
}
/** function checkForCompleteChain(currentNodeId: string): boolean {
* Reduces the graph into its phases' information and recursively calls their reducing function const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
*/ .filter(node => ["end", "phase"].includes(node.type!));
function graphReducer() {
const { nodes } = useFlowStore.getState(); if (outgoingPhases.length === 0) return false;
return nodes if (outgoingPhases.some(node => node.type === "end" )) return true;
.filter((n) => n.type == 'phase')
.map((n) => { const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
const reducer = NodeReduces['phase']; .find(result => result);
return reducer(n, nodes) return !!next;
}); }
}
return checkForCompleteChain('start');
};
/** /**
* houses the entire page, so also UI elements * houses the entire page, so also UI elements
@@ -148,10 +199,29 @@ function graphReducer() {
* @constructor * @constructor
*/ */
function VisProgPage() { 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 ( return (
<> <>
<VisualProgrammingUI/> <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

@@ -0,0 +1,145 @@
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;
}
}
/**
* A reduced version of the flowState type,
* This removes the functions that are provided by UndoRedo from the expected input type
*/
type BaseFlowState = Omit<FlowState, 'undo' | 'redo' | 'pushSnapshot' | 'beginBatchAction' | 'endBatchAction'>;
/**
* UndoRedo is implemented as a middleware for the FlowState store,
* this allows us to keep the undo redo logic separate from the flowState,
* and thus from the internal editor logic
*
* Allows users to undo and redo actions in the visual programming editor
*
* @param {(set: StoreApi<FlowState>["setState"], get: () => FlowState, api: StoreApi<FlowState>) => BaseFlowState} config
* @returns {StateCreator<FlowState>}
* @constructor
*/
export const UndoRedo = (
config: (
set: StoreApi<FlowState>['setState'],
get: () => FlowState,
api: StoreApi<FlowState>
) => BaseFlowState ) : StateCreator<FlowState> => (set, get, api) => {
let batchTimeout: number | null = null;
/**
* Captures the current state for
*
* @param {BaseFlowState} state - the current state of the editor
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
*/
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes,
edges: state.edges,
warnings: {
warningRegistry: state.editorWarningRegistry,
severityIndex: state.severityIndex,
}
}));
const initialState = config(set, get, api);
return {
...initialState,
/**
* Adds a snapshot of the current state to the undo history
*/
pushSnapshot: () => {
const state = get();
// we don't add new snapshots during an ongoing batch action
if (!state.isBatchAction) {
set({
past: [...state.past, getSnapshot(state)],
future: []
});
}
},
/**
* Undoes the last action from the editor,
* The state before undoing is added to the future for potential redoing
*/
undo: () => {
const state = get();
if (!state.past.length) return;
const snapshot = state.past.pop()!; // pop last snapshot
const currentSnapshot: FlowSnapshot = getSnapshot(state);
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
editorWarningRegistry: snapshot.warnings.warningRegistry,
severityIndex: snapshot.warnings.severityIndex,
});
state.future.push(currentSnapshot); // push current to redo
},
/**
* redoes the last undone action,
* The state before redoing is added to the past for potential undoing
*/
redo: () => {
const state = get();
if (!state.future.length) return;
const snapshot = state.future.pop()!; // pop last redo
const currentSnapshot: FlowSnapshot = getSnapshot(state);
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
editorWarningRegistry: snapshot.warnings.warningRegistry,
severityIndex: snapshot.warnings.severityIndex,
});
state.past.push(currentSnapshot); // push current to undo
},
/**
* Begins a batched action
*
* An example of a batched action is dragging a node in the editor,
* where we want the entire action of moving a node to a different position
* to be covered by one undoable snapshot
*/
beginBatchAction: () => {
get().pushSnapshot();
set({ isBatchAction: true });
if (batchTimeout) clearTimeout(batchTimeout);
},
/**
* Ends a batched action,
* a very short timeout is used to prevent new snapshots from being added
* until we are certain that the batch event is finished
*/
endBatchAction: () => {
batchTimeout = window.setTimeout(() => {
set({ isBatchAction: false });
}, 10);
}
}
}

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

@@ -1,15 +1,75 @@
import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode"; import EndNode, {
import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode"; EndConnectionTarget,
import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode"; EndConnectionSource,
import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode"; EndDisconnectionTarget,
EndDisconnectionSource,
EndReduce,
EndTooltip
} from "./nodes/EndNode";
import { EndNodeDefaults } from "./nodes/EndNode.default"; import { EndNodeDefaults } from "./nodes/EndNode.default";
import StartNode, {
StartConnectionTarget,
StartConnectionSource,
StartDisconnectionTarget,
StartDisconnectionSource,
StartReduce,
StartTooltip
} from "./nodes/StartNode";
import { StartNodeDefaults } from "./nodes/StartNode.default"; import { StartNodeDefaults } from "./nodes/StartNode.default";
import PhaseNode, {
PhaseConnectionTarget,
PhaseConnectionSource,
PhaseDisconnectionTarget,
PhaseDisconnectionSource,
PhaseReduce,
PhaseTooltip
} from "./nodes/PhaseNode";
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default"; import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
import NormNode, {
NormConnectionTarget,
NormConnectionSource,
NormDisconnectionTarget,
NormDisconnectionSource,
NormReduce,
NormTooltip
} from "./nodes/NormNode";
import { NormNodeDefaults } from "./nodes/NormNode.default"; import { NormNodeDefaults } from "./nodes/NormNode.default";
import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode"; import GoalNode, {
GoalConnectionTarget,
GoalConnectionSource,
GoalDisconnectionTarget,
GoalDisconnectionSource,
GoalReduce,
GoalTooltip
} from "./nodes/GoalNode";
import { GoalNodeDefaults } from "./nodes/GoalNode.default"; import { GoalNodeDefaults } from "./nodes/GoalNode.default";
import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; import TriggerNode, {
TriggerConnectionTarget,
TriggerConnectionSource,
TriggerDisconnectionTarget,
TriggerDisconnectionSource,
TriggerReduce,
TriggerTooltip
} from "./nodes/TriggerNode";
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; 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. * Registered node types in the visual programming system.
@@ -24,6 +84,8 @@ export const NodeTypes = {
norm: NormNode, norm: NormNode,
goal: GoalNode, goal: GoalNode,
trigger: TriggerNode, trigger: TriggerNode,
basic_belief: BasicBeliefNode,
inferred_belief: InferredBeliefNode,
}; };
/** /**
@@ -38,6 +100,8 @@ export const NodeDefaults = {
norm: NormNodeDefaults, norm: NormNodeDefaults,
goal: GoalNodeDefaults, goal: GoalNodeDefaults,
trigger: TriggerNodeDefaults, trigger: TriggerNodeDefaults,
basic_belief: BasicBeliefNodeDefaults,
inferred_belief: InferredBeliefNodeDefaults,
}; };
@@ -54,21 +118,67 @@ export const NodeReduces = {
norm: NormReduce, norm: NormReduce,
goal: GoalReduce, goal: GoalReduce,
trigger: TriggerReduce, trigger: TriggerReduce,
basic_belief: BasicBeliefReduce,
inferred_belief: InferredBeliefReduce,
} }
/** /**
* Connection functions for each node type. * Connection functions for each node type.
* *
* These functions define how nodes of a particular type can connect to other nodes. * These functions define any additional actions a node may perform
* when a new connection is made
*/ */
export const NodeConnects = { export const NodeConnections = {
start: StartConnects, Targets: {
end: EndConnects, start: StartConnectionTarget,
phase: PhaseConnects, end: EndConnectionTarget,
norm: NormConnects, phase: PhaseConnectionTarget,
goal: GoalConnects, norm: NormConnectionTarget,
trigger: TriggerConnects, goal: GoalConnectionTarget,
trigger: TriggerConnectionTarget,
basic_belief: BasicBeliefConnectionTarget,
inferred_belief: InferredBeliefConnectionTarget,
},
Sources: {
start: StartConnectionSource,
end: EndConnectionSource,
phase: PhaseConnectionSource,
norm: NormConnectionSource,
goal: GoalConnectionSource,
trigger: TriggerConnectionSource,
basic_belief: BasicBeliefConnectionSource,
inferred_belief: InferredBeliefConnectionSource,
}
}
/**
* Disconnection functions for each node type.
*
* These functions define any additional actions a node may perform
* when a connection is disconnected
*/
export const NodeDisconnections = {
Targets: {
start: StartDisconnectionTarget,
end: EndDisconnectionTarget,
phase: PhaseDisconnectionTarget,
norm: NormDisconnectionTarget,
goal: GoalDisconnectionTarget,
trigger: TriggerDisconnectionTarget,
basic_belief: BasicBeliefDisconnectionTarget,
inferred_belief: InferredBeliefDisconnectionTarget,
},
Sources: {
start: StartDisconnectionSource,
end: EndDisconnectionSource,
phase: PhaseDisconnectionSource,
norm: NormDisconnectionSource,
goal: GoalDisconnectionSource,
trigger: TriggerDisconnectionSource,
basic_belief: BasicBeliefDisconnectionSource,
inferred_belief: InferredBeliefDisconnectionSource,
},
} }
/** /**
@@ -91,4 +201,20 @@ export const NodesInPhase = {
start: () => false, start: () => false,
end: () => false, end: () => false,
phase: () => false, phase: () => false,
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,8 +8,17 @@ import {
type Edge, type Edge,
type XYPosition, type XYPosition,
} from '@xyflow/react'; } 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 type { FlowState } from './VisProgTypes';
import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; import {
NodeDefaults,
NodeConnections as NodeCs,
NodeDisconnections as NodeDs,
NodeDeletes
} from './NodeRegistry';
import { UndoRedo } from "./EditorUndoRedo.ts";
/** /**
@@ -22,120 +31,221 @@ import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry';
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). * @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. * @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) { function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
const newData = { return {
id: id, id,
type: type, type,
position: position, position,
data: data, deletable,
deletable: deletable, data: {
} ...JSON.parse(JSON.stringify(defaultData)),
return {...defaultData, ...newData} ...data,
},
}
} }
//* Initial nodes to populate the flow at startup. //* Initial nodes, created by using createNode. */
const initialNodes : Node[] = [ // Start and End don't need to apply the UUID, since they are technically never compiled into a program.
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}), const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"]}),
];
//* Initial edges to connect the startup nodes. const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
const initialEdges: Edge[] = [
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
const initialEdges: Edge[] = [];
/** /**
* How we have defined the functions for our FlowState. * useFlowStore contains the implementation for all editor functionality
* We have the normal functionality of a default FlowState with some exceptions to account for extra functionality. * and stores the current state of the visual programming editor
* The biggest changes are in onConnect and onDelete, which we have given extra functionality based on the nodes defined functions. *
*
* * Provides: * * Provides:
* - Node and edge state management * - Node and edge state management
* - Node creation, deletion, and updates * - Node creation, deletion, and updates
* - Custom connection handling via NodeConnects * - Custom connection handling via NodeConnects
* - Edge reconnection handling * - Edge reconnection handling
* - Undo Redo functionality through custom middleware
*/ */
const useFlowStore = create<FlowState>((set, get) => ({ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
nodes: initialNodes, nodes: initialNodes,
edges: initialEdges, edges: initialEdges,
edgeReconnectSuccessful: true, 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. * Handles changes to nodes triggered by ReactFlow.
*/ */
onNodesChange: (changes) => onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
set({nodes: applyNodeChanges(changes, get().nodes)}),
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;
const sourceNode = nodes.find((n) => n.id == edge.source);
const targetNode = nodes.find((n) => n.id == edge.target);
if (sourceNode) { NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target); }
if (targetNode) { NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source); }
});
},
/** /**
* Handles changes to edges triggered by ReactFlow. * Handles changes to edges triggered by ReactFlow.
*/ */
onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), onEdgesChange: (changes) => {
set({ edges: applyEdgeChanges(changes, get().edges) })
},
/** /**
* Handles creating a new connection between nodes. * Handles creating a new connection between nodes.
* Updates edges and calls the node-specific connection functions. * Updates edges and calls the node-specific connection functions.
*/ */
onConnect: (connection) => { onConnect: (connection) => {
const edges = addEdge(connection, get().edges); get().pushSnapshot();
const nodes = get().nodes; set({edges: addEdge(connection, get().edges)});
// connection has: { source, sourceHandle, target, targetHandle }
// Let's find the source and target ID's.
const sourceNode = nodes.find((n) => n.id == connection.source);
const targetNode = nodes.find((n) => n.id == connection.target);
// In case the nodes weren't found, return basic functionality.
if (sourceNode == undefined || targetNode == undefined || sourceNode.type == undefined || targetNode.type == undefined) {
set({ nodes, edges });
return;
}
// We should find out how their data changes by calling their respective functions. // We make sure to perform any required data updates on the newly connected nodes
const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] const nodes = get().nodes;
const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects]
const sourceNode = nodes.find((n) => n.id == connection.source);
// We're going to have to update their data based on how they want to update it. const targetNode = nodes.find((n) => n.id == connection.target);
sourceConnectFunction(sourceNode, targetNode, true)
targetConnectFunction(targetNode, sourceNode, false) if (sourceNode) { NodeCs.Sources[sourceNode.type as keyof typeof NodeCs.Sources](sourceNode, connection.target); }
set({ nodes, edges }); if (targetNode) { NodeCs.Targets[targetNode.type as keyof typeof NodeCs.Targets](targetNode, connection.source); }
}, },
/** /**
* Handles reconnecting an edge between nodes. * Handles reconnecting an edge between nodes.
*/ */
onReconnect: (oldEdge, newConnection) => { 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) }); set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
const nodes = get().nodes;
const oldSourceNode = nodes.find((n) => n.id == oldEdge.source)!;
const oldTargetNode = nodes.find((n) => n.id == oldEdge.target)!;
const newSourceNode = nodes.find((n) => n.id == newConnection.source)!;
const newTargetNode = nodes.find((n) => n.id == newConnection.target)!;
if (oldSourceNode === newSourceNode && oldTargetNode === newTargetNode) return;
NodeCs.Sources[newSourceNode.type as keyof typeof NodeCs.Sources](newSourceNode, newConnection.target);
NodeCs.Targets[newTargetNode.type as keyof typeof NodeCs.Targets](newTargetNode, newConnection.source);
NodeDs.Sources[oldSourceNode.type as keyof typeof NodeDs.Sources](oldSourceNode, oldEdge.target);
NodeDs.Targets[oldTargetNode.type as keyof typeof NodeDs.Targets](oldTargetNode, oldEdge.source);
}, },
onReconnectStart: () => set({ edgeReconnectSuccessful: false }), onReconnectStart: () => {
get().pushSnapshot();
set({ edgeReconnectSuccessful: false })
},
/**
* handles potential dropping (deleting) of an edge
* if it is not reconnected to a node after detaching it
*
* @param _evt - the event
* @param edge - the described edge
*/
onReconnectEnd: (_evt, edge) => { onReconnectEnd: (_evt, edge) => {
if (!get().edgeReconnectSuccessful) { if (!get().edgeReconnectSuccessful) {
// delete the edge from the flowState
set({ edges: get().edges.filter((e) => e.id !== edge.id) }); set({ edges: get().edges.filter((e) => e.id !== edge.id) });
// update node data to reflect the dropped edge
const nodes = get().nodes;
const sourceNode = nodes.find((n) => n.id == edge.source)!;
const targetNode = nodes.find((n) => n.id == edge.target)!;
NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target);
NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source);
} }
set({ edgeReconnectSuccessful: true }); set({ edgeReconnectSuccessful: true });
}, },
/** /**
* Deletes a node by ID, respecting NodeDeletes rules. * Deletes a node by ID, respecting NodeDeletes rules.
* Also removes all edges connected to that node. * 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 // Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] 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 there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) { if (ourFunction == undefined || ourFunction()) {
set({ if (deleteElements){
nodes: get().nodes.filter((n) => n.id !== nodeId), deleteElements({
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), 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),
})
}
}
},
/** /**
* Replaces the entire nodes array in the store. * Replaces the entire nodes array in the store.
@@ -151,6 +261,7 @@ const useFlowStore = create<FlowState>((set, get) => ({
* Updates the data of a node by merging new data with existing data. * Updates the data of a node by merging new data with existing data.
*/ */
updateNodeData: (nodeId, data) => { updateNodeData: (nodeId, data) => {
get().pushSnapshot();
set({ set({
nodes: get().nodes.map((node) => { nodes: get().nodes.map((node) => {
if (node.id === nodeId) { if (node.id === nodeId) {
@@ -165,8 +276,92 @@ const useFlowStore = create<FlowState>((set, get) => ({
* Adds a new node to the flow store. * Adds a new node to the flow store.
*/ */
addNode: (node: Node) => { addNode: (node: Node) => {
get().pushSnapshot();
set({ nodes: [...get().nodes, node] }); set({ nodes: [...get().nodes, node] });
}, },
}));
// undo redo default values
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; export default useFlowStore;

View File

@@ -1,6 +1,19 @@
// VisProgTypes.ts // VisProgTypes.ts
import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } 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 { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
/** /**
* Type representing all registered node types. * Type representing all registered node types.
@@ -21,10 +34,18 @@ export type FlowState = {
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
edgeReconnectSuccessful: boolean; edgeReconnectSuccessful: boolean;
scrollable: boolean;
/** Handler for managing scrollable state */
setScrollable: (value: boolean) => void;
/** Handler for changes to nodes triggered by ReactFlow */ /** Handler for changes to nodes triggered by ReactFlow */
onNodesChange: OnNodesChange; onNodesChange: OnNodesChange;
onNodesDelete: OnNodesDelete;
onEdgesDelete: OnEdgesDelete;
/** Handler for changes to edges triggered by ReactFlow */ /** Handler for changes to edges triggered by ReactFlow */
onEdgesChange: OnEdgesChange; onEdgesChange: OnEdgesChange;
@@ -42,13 +63,16 @@ export type FlowState = {
* @param _ - event or unused parameter * @param _ - event or unused parameter
* @param edge - the edge that finished reconnecting * @param edge - the edge that finished reconnecting
*/ */
onReconnectEnd: (_: unknown, edge: { id: string }) => void; onReconnectEnd: (_: unknown, edge: Edge) => void;
/** /**
* Deletes a node and any connected edges. * Deletes a node and any connected edges.
* @param nodeId - the ID of the node to delete * @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. * Replaces the current nodes array in the store.
@@ -74,4 +98,42 @@ export type FlowState = {
* @param node - the Node object to add * @param node - the Node object to add
*/ */
addNode: (node: Node) => void; addNode: (node: Node) => void;
}; } & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
export type UndoRedoState = {
// UndoRedo Types
past: FlowSnapshot[];
future: FlowSnapshot[];
pushSnapshot: () => void;
isBatchAction: boolean;
beginBatchAction: () => void;
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 { type ReactNode, useCallback, useRef, useState } from 'react';
import useFlowStore from '../VisProgStores'; import useFlowStore from '../VisProgStores';
import styles from '../../VisProg.module.css'; 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. * Props for a draggable node within the drag-and-drop toolbar.
@@ -47,10 +48,17 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
}); });
return ( return (
<div className={className} ref={draggableRef}> <Tooltip nodeType={nodeType}>
{children} <div>
</div> <div className={className}
); ref={draggableRef}
id={`draggable-${nodeType}`}
data-testid={`draggable-${nodeType}`}
>
{children}
</div>
</div>
</Tooltip>)
} }
/** /**
@@ -64,33 +72,21 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
* @param nodeType - The type of node to create (from `NodeTypes`). * @param nodeType - The type of node to create (from `NodeTypes`).
* @param position - The XY position in the flow canvas where the node will appear. * @param position - The XY position in the flow canvas where the node will appear.
*/ */
function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) {
const { nodes, setNodes } = useFlowStore.getState(); const { addNode } = useFlowStore.getState();
// Load any predefined data for this node type. // Load any predefined data for this node type.
const defaultData = NodeDefaults[nodeType] ?? {} const defaultData = NodeDefaults[nodeType] ?? {}
const id = crypto.randomUUID();
// 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}`;
// Create new node // Create new node
const newNode = { const newNode = {
id: id, id: id,
type: nodeType, type: nodeType,
position, position,
data: {...defaultData} data: JSON.parse(JSON.stringify(defaultData))
} }
setNodes([...nodes, newNode]); addNode(newNode);
} }
/** /**
@@ -125,7 +121,7 @@ export function DndToolbar() {
if (isInFlow) { if (isInFlow) {
const position = screenToFlowPosition(screenPosition); const position = screenToFlowPosition(screenPosition);
addNode(nodeType, position); addNodeToFlow(nodeType, position);
} }
}, },
[screenToFlowPosition], [screenToFlowPosition],
@@ -141,7 +137,7 @@ export function DndToolbar() {
})); }));
return ( 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"> <div className="description">
You can drag these nodes to the pane to create new nodes. You can drag these nodes to the pane to create new nodes.
</div> </div>
@@ -149,6 +145,7 @@ export function DndToolbar() {
{/* Maps over all the nodes that are droppable, and puts them in the panel */} {/* Maps over all the nodes that are droppable, and puts them in the panel */}
{droppableNodes.map(({type, data}) => ( {droppableNodes.map(({type, data}) => (
<DraggableNode <DraggableNode
key={type}
className={styles[`draggable-node-${type}`]} // Our current style signature for nodes className={styles[`draggable-node-${type}`]} // Our current style signature for nodes
nodeType={type} nodeType={type}
onDrop={handleNodeDrop} onDrop={handleNodeDrop}

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 '@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"; import useFlowStore from "../VisProgStores.tsx";
/** /**
* Props for the Toolbar component. * Props for the Toolbar component.
* *
@@ -24,14 +29,95 @@ type ToolbarProps = {
* @constructor * @constructor
*/ */
export function Toolbar({nodeId, allowDelete}: ToolbarProps) { export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {deleteNode} = useFlowStore(); const {nodes, deleteNode} = useFlowStore();
const { deleteElements } = useReactFlow();
const deleteParentNode = ()=> { const deleteParentNode = () => {
deleteNode(nodeId);
} deleteNode(nodeId, deleteElements);
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
return ( return (
<NodeToolbar> <NodeToolbar className={"flex-row align-center"}>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button> <button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
<Tooltip nodeType={nodeType}>
<div className={styles.nodeToolbarTooltip}>i</div>
</Tooltip>
</NodeToolbar>); </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,17 @@
import { import {
Handle,
type NodeProps, type NodeProps,
Position, Position,
type Node, type Node, useNodeConnections
} from '@xyflow/react'; } from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents'; import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from "../VisProgStores.tsx";
/** /**
* The typing of this node's data * The typing of this node's data
@@ -24,6 +30,27 @@ export type EndNode = Node<EndNodeData>
* @returns React.JSX.Element * @returns React.JSX.Element
*/ */
export default function EndNode(props: NodeProps<EndNode>) { 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 ( return (
<> <>
<Toolbar nodeId={props.id} allowDelete={false}/> <Toolbar nodeId={props.id} allowDelete={false}/>
@@ -31,7 +58,9 @@ export default function EndNode(props: NodeProps<EndNode>) {
<div className={"flex-row gap-sm"}> <div className={"flex-row gap-sm"}>
End End
</div> </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> </div>
</> </>
); );
@@ -40,28 +69,52 @@ export default function EndNode(props: NodeProps<EndNode>) {
/** /**
* Functionality for reducing this node into its more compact json program * Functionality for reducing this node into its more compact json program
* @param node the node to reduce * @param node the node to reduce
* @param nodes all nodes present * @param _nodes all nodes present
* @returns Dictionary, {id: node.id} * @returns Dictionary, {id: node.id}
*/ */
export function EndReduce(node: Node, nodes: Node[]) { export function EndReduce(node: Node, _nodes: Node[]) {
// Replace this for nodes functionality // Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in EndReduce")
}
return { return {
id: node.id id: node.id
} }
} }
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`;
/** /**
* Any connection functionality that should get called when a connection is made to this node type (end) * This function is called whenever a connection is made with this node type as the target
* @param thisNode the node of which the functionality gets called * @param _thisNode the node of this node type which function is called
* @param otherNode the other node which has connected * @param _sourceNodeId the source of the received connection
* @param isThisSource whether this node is the one that is the source of the connection
*/ */
export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { export function EndConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// Replace this for connection logic // no additional connection logic exists yet
if (thisNode == undefined && otherNode == undefined && isThisSource == false) { }
console.warn("Impossible node connection called in EndConnects")
} /**
* 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 EndConnectionSource(_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 EndDisconnectionTarget(_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 EndDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
} }

View File

@@ -5,8 +5,10 @@ import type { GoalNodeData } from "./GoalNode";
*/ */
export const GoalNodeDefaults: GoalNodeData = { export const GoalNodeDefaults: GoalNodeData = {
label: "Goal Node", label: "Goal Node",
name: "",
droppable: true, droppable: true,
description: "The robot will strive towards this goal", description: "",
achieved: false, achieved: false,
hasReduce: true, hasReduce: true,
can_fail: false,
}; };

View File

@@ -1,27 +1,40 @@
import { import {
Handle,
type NodeProps, type NodeProps,
Position, Position,
type Node, type Node
} from '@xyflow/react'; } from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents'; import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField'; import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores'; 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 * The default data dot a phase node
* @param label: the label of this phase * @param label: the label of this phase
* @param droppable: whether this node is droppable from the drop bar (initialized as true) * @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 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 = { export type GoalNodeData = {
label: string; label: string;
name: string;
description: string; description: string;
droppable: boolean; droppable: boolean;
achieved: boolean; achieved: boolean;
hasReduce: boolean; hasReduce: boolean;
can_fail: boolean;
plan?: Plan;
}; };
export type GoalNode = Node<GoalNodeData> export type GoalNode = Node<GoalNodeData>
@@ -32,43 +45,106 @@ export type GoalNode = Node<GoalNodeData>
* @param props NodeProps, like id, label, children * @param props NodeProps, like id, label, children
* @returns React.JSX.Element * @returns React.JSX.Element
*/ */
export default function GoalNode(props: NodeProps<GoalNode>) { export default function GoalNode({id, data}: NodeProps<GoalNode>) {
const data = props.data const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const {updateNodeData} = useFlowStore(); const _nodes = useFlowStore().nodes;
const text_input_id = `goal_${props.id}_text_input`; const text_input_id = `goal_${id}_text_input`;
const checkbox_id = `goal_${props.id}_checkbox`; 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) => { const setDescription = (value: string) => {
updateNodeData(props.id, {...data, description: value}); updateNodeData(id, {...data, description: value});
} }
const setAchieved = (value: boolean) => { const setName= (value: string) => {
updateNodeData(props.id, {...data, achieved: value}); 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 <> return <>
<Toolbar nodeId={props.id} allowDelete={true}/> <Toolbar nodeId={id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}> <div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
<div className={"flex-row gap-md"}> <div className={"flex-row gap-md"}>
<label htmlFor={text_input_id}>Goal:</label> <label htmlFor={text_input_id}>Goal:</label>
<TextField <TextField
id={text_input_id} id={text_input_id}
value={data.description} value={data.name}
setValue={(val) => setDescription(val)} setValue={(val) => setName(val)}
placeholder={"To ..."} placeholder={"To ..."}
/> />
</div> </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 <input
id={checkbox_id} id={checkbox_id}
type={"checkbox"} type={"checkbox"}
value={data.achieved ? "checked" : ""} disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
onChange={(e) => setAchieved(e.target.checked)} checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
/> />
</div> </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> </div>
</>; </>;
} }
@@ -76,32 +152,74 @@ export default function GoalNode(props: NodeProps<GoalNode>) {
/** /**
* Reduces each Goal, including its children down into its relevant data. * Reduces each Goal, including its children down into its relevant data.
* @param node: The Node Properties of this node. * @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 GoalReduce(node: Node, nodes: Node[]) { export function GoalReduce(node: Node, _nodes: Node[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in GoalReduce")
}
const data = node.data as GoalNodeData; const data = node.data as GoalNodeData;
return { return {
id: node.id, id: node.id,
label: data.label, name: data.name,
description: data.description, 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 (Goal) * 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 _thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection * @param _sourceNodeId the source of the received connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/ */
export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// Replace this for connection logic // Goals should only be targeted by other goals, for them to be part of our plan.
if (thisNode == undefined && otherNode == undefined && isThisSource == false) { const nodes = useFlowStore.getState().nodes;
console.warn("Impossible node connection called in EndConnects") 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)
}
}
/**
* 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 GoalConnectionSource(_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 GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// 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)
}
/**
* 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 GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
} }

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,8 @@ import type { NormNodeData } from "./NormNode";
export const NormNodeDefaults: NormNodeData = { export const NormNodeDefaults: NormNodeData = {
label: "Norm Node", label: "Norm Node",
droppable: true, droppable: true,
condition: undefined,
norm: "", norm: "",
hasReduce: true, hasReduce: true,
critical: false,
}; };

View File

@@ -1,5 +1,4 @@
import { import {
Handle,
type NodeProps, type NodeProps,
Position, Position,
type Node, type Node,
@@ -7,7 +6,10 @@ import {
import { Toolbar } from '../components/NodeComponents'; import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField'; import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores'; import useFlowStore from '../VisProgStores';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
/** /**
* The default data dot a phase node * The default data dot a phase node
@@ -19,8 +21,10 @@ import useFlowStore from '../VisProgStores';
export type NormNodeData = { export type NormNodeData = {
label: string; label: string;
droppable: boolean; droppable: boolean;
condition?: string; // id of this node's belief.
norm: string; norm: string;
hasReduce: boolean; hasReduce: boolean;
critical: boolean;
}; };
export type NormNode = Node<NormNodeData> export type NormNode = Node<NormNodeData>
@@ -35,11 +39,16 @@ export default function NormNode(props: NodeProps<NormNode>) {
const {updateNodeData} = useFlowStore(); const {updateNodeData} = useFlowStore();
const text_input_id = `norm_${props.id}_text_input`; const text_input_id = `norm_${props.id}_text_input`;
const checkbox_id = `goal_${props.id}_checkbox`;
const setValue = (value: string) => { const setValue = (value: string) => {
updateNodeData(props.id, {norm: value}); updateNodeData(props.id, {norm: value});
} }
const setCritical = (value: boolean) => {
updateNodeData(props.id, {...data, critical: value});
}
return <> return <>
<Toolbar nodeId={props.id} allowDelete={true}/> <Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeNorm}`}> <div className={`${styles.defaultNode} ${styles.nodeNorm}`}>
@@ -52,7 +61,28 @@ export default function NormNode(props: NodeProps<NormNode>) {
placeholder={"Pepper should ..."} placeholder={"Pepper should ..."}
/> />
</div> </div>
<Handle type="source" position={Position.Right} id="norms"/> <div className={"flex-row gap-md align-center"}>
<label htmlFor={checkbox_id}>Critical:</label>
<input
id={checkbox_id}
type={"checkbox"}
checked={data.critical || false}
onChange={(e) => setCritical(e.target.checked)}
/>
</div>
{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> </div>
</>; </>;
}; };
@@ -60,31 +90,71 @@ export default function NormNode(props: NodeProps<NormNode>) {
/** /**
* Reduces each Norm, including its children down into its relevant data. * Reduces each Norm, including its children down into its relevant data.
* @param node: The Node Properties of this node. * @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[]) {
// Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in NormReduce")
}
const data = node.data as NormNodeData; const data = node.data as NormNodeData;
return {
id: node.id, // conditions nodes - make sure to check for empty arrays
label: data.label, const result: Record<string, unknown> = {
norm: data.norm, 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) {
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;
} }
} }
/** /**
* This function is called whenever a connection is made with this node type (Norm) * 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 _thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection * @param _targetNodeId the target of the created connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/ */
export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
// Replace this for connection logic // no additional connection logic exists yet
if (thisNode == undefined && otherNode == undefined && isThisSource == false) { }
console.warn("Impossible node connection called in EndConnects")
} /**
* 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 NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = 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 NormDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
} }

View File

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

View File

@@ -1,12 +1,15 @@
import { import {
Handle,
type NodeProps, type NodeProps,
Position, Position,
type Node, type Node, useNodeConnections
} from '@xyflow/react'; } from '@xyflow/react';
import {useEffect, useRef} from "react";
import {type EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents'; import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; import styles from '../../VisProg.module.css';
import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry'; import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
import useFlowStore from '../VisProgStores'; import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField'; 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 droppable: whether this node is droppable from the drop bar (initialized as true)
* @param children: ID's of children of this node * @param children: ID's of children of this node
* @param hasReduce: whether this node has reducing functionality (true by default) * @param hasReduce: whether this node has reducing functionality (true by default)
* @param nextPhaseId:
*/ */
export type PhaseNodeData = { export type PhaseNodeData = {
label: string; label: string;
droppable: boolean; droppable: boolean;
children: string[]; children: string[];
hasReduce: boolean; hasReduce: boolean;
nextPhaseId: string | "end" | null;
isFirstPhase: boolean;
}; };
export type PhaseNode = Node<PhaseNodeData> export type PhaseNode = Node<PhaseNodeData>
@@ -33,10 +39,107 @@ export type PhaseNode = Node<PhaseNodeData>
*/ */
export default function PhaseNode(props: NodeProps<PhaseNode>) { export default function PhaseNode(props: NodeProps<PhaseNode>) {
const data = props.data; const data = props.data;
const {updateNodeData} = useFlowStore(); const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
const label_input_id = `phase_${props.id}_label_input`; 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 ( return (
<> <>
<Toolbar nodeId={props.id} allowDelete={true}/> <Toolbar nodeId={props.id} allowDelete={true}/>
@@ -50,9 +153,17 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
placeholder={"Phase ..."} placeholder={"Phase ..."}
/> />
</div> </div>
<Handle type="target" position={Position.Left} id="target"/> <SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
<Handle type="target" position={Position.Bottom} id="norms"/> noSelfConnections,
<Handle type="source" position={Position.Right} id="source"/> 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> </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. * @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
*/ */
export function PhaseReduce(node: Node, nodes: Node[]) { export function PhaseReduce(node: Node, nodes: Node[]) {
const thisnode = node as PhaseNode; const thisNode = node as PhaseNode;
const data = thisnode.data as PhaseNodeData; const data = thisNode.data as PhaseNodeData;
// node typings that are not in phase // node typings that are not in phase
const nodesNotInPhase: string[] = Object.entries(NodesInPhase) const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
@@ -78,13 +189,15 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
.filter(([t]) => !nodesNotInPhase.includes(t)) .filter(([t]) => !nodesNotInPhase.includes(t))
.map(([t]) => t); .map(([t]) => t);
// children nodes // children nodes - make sure to check for empty arrays
const childrenNodes = nodes.filter((node) => data.children.includes(node.id)); let childrenNodes: Node[] = [];
if (data.children)
childrenNodes = nodes.filter((node) => data.children.includes(node.id));
// Build the result object // Build the result object
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
id: thisnode.id, id: thisNode.id,
label: data.label, name: data.label,
}; };
nodesInPhase.forEach((type) => { nodesInPhase.forEach((type) => {
@@ -94,23 +207,99 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
console.warn(`No reducer found for node type ${type}`); console.warn(`No reducer found for node type ${type}`);
result[type + "s"] = []; result[type + "s"] = [];
} else { } 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; 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 (phase) * 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 _thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection * @param _sourceNodeId the source of the received connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/ */
export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
console.log("Connect functionality called.") const data = _thisNode.data as PhaseNodeData
const node = thisNode as PhaseNode
const data = node.data as PhaseNodeData const nodes = useFlowStore.getState().nodes;
if (!isThisSource) const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
data.children.push(otherNode.id) 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;
}
}
/**
* This function is called whenever a connection is made with this node type as the source (phase)
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
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;
}
}
/**
* This function is called whenever a connection is disconnected 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 disconnected connection
*/
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
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;
}
}
/**
* This function is called whenever a connection is disconnected with this node type as the source (phase)
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
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 { import {
Handle,
type NodeProps, type NodeProps,
Position, Position,
type Node, type Node, useNodeConnections
} from '@xyflow/react'; } from '@xyflow/react';
import {useEffect} from "react";
import { Toolbar } from '../components/NodeComponents'; import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; 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 = { export type StartNodeData = {
@@ -24,6 +28,27 @@ export type StartNode = Node<StartNodeData>
* @returns React.JSX.Element * @returns React.JSX.Element
*/ */
export default function StartNode(props: NodeProps<StartNode>) { 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 ( return (
<> <>
<Toolbar nodeId={props.id} allowDelete={false}/> <Toolbar nodeId={props.id} allowDelete={false}/>
@@ -31,7 +56,9 @@ export default function StartNode(props: NodeProps<StartNode>) {
<div className={"flex-row gap-sm"}> <div className={"flex-row gap-sm"}>
Start Start
</div> </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> </div>
</> </>
); );
@@ -40,28 +67,52 @@ export default function StartNode(props: NodeProps<StartNode>) {
/** /**
* The reduce function for this node type. * The reduce function for this node type.
* @param node this node * @param node this node
* @param nodes all the nodes in the graph * @param _nodes all the nodes in the graph
* @returns a reduced structure of this node * @returns a reduced structure of this node
*/ */
export function StartReduce(node: Node, nodes: Node[]) { export function StartReduce(node: Node, _nodes: Node[]) {
// Replace this for nodes functionality // Replace this for nodes functionality
if (nodes.length <= -1) {
console.warn("Impossible nodes length in StartReduce")
}
return { return {
id: node.id id: node.id
} }
} }
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 (start) * 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 _thisNode the node of this node type which function is called
* @param otherNode the other node which was part of the connection * @param _sourceNodeId the source of the received connection
* @param isThisSource whether this instance of the node was the source in the connection, true = yes.
*/ */
export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { export function StartConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// Replace this for connection logic // no additional connection logic exists yet
if (thisNode == undefined && otherNode == undefined && isThisSource == false) { }
console.warn("Impossible node connection called in EndConnects")
} /**
* 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 StartConnectionSource(_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 StartDisconnectionTarget(_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 StartDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
} }

View File

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

View File

@@ -1,17 +1,22 @@
import { import {
Handle,
type NodeProps, type NodeProps,
Position, Position,
type Connection, type Node, useNodeConnections
type Edge,
type Node,
} from '@xyflow/react'; } from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents'; import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css'; import styles from '../../VisProg.module.css';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores'; import useFlowStore from '../VisProgStores';
import { useState } from 'react'; import {PlanReduce, type Plan } from '../components/Plan';
import { RealtimeTextField, TextField } from '../../../../components/TextField'; import PlanEditorDialog from '../components/PlanEditor';
import duplicateIndices from '../../../../utils/duplicateIndices'; 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 * 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 label: the display label of this Trigger node.
* @property droppable: Whether this node can be dropped from the toolbar (default: true). * @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. * @property hasReduce - Whether this node supports reduction logic.
*/ */
export type TriggerNodeData = { export type TriggerNodeData = {
label: string; label: string;
name: string;
droppable: boolean; droppable: boolean;
triggerType: "keywords" | string; condition?: string; // id of the belief
triggers: Keyword[] | never; plan?: Plan;
hasReduce: boolean; hasReduce: boolean;
}; };
export type TriggerNode = Node<TriggerNodeData> 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 * Defines how a Trigger node should be rendered
* @param props - Node properties provided by React Flow, including `id` and `data`. * @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>) { export default function TriggerNode(props: NodeProps<TriggerNode>) {
const data = props.data; const data = props.data;
const {updateNodeData} = useFlowStore(); const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const setKeywords = (keywords: Keyword[]) => { const setName= (value: string) => {
updateNodeData(props.id, {...data, triggers: keywords}); 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 <> return <>
<Toolbar nodeId={props.id} allowDelete={true}/> <Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}> <div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
{data.triggerType === "emotion" && ( <TextField
<div className={"flex-row gap-md"}>Emotion?</div> value={props.data.name}
)} setValue={(val) => setName(val)}
{data.triggerType === "keywords" && ( placeholder={"Name of this trigger..."}
<Keywords />
keywords={data.triggers} <div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
setKeywords={setKeywords} <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={[
<Handle type="source" position={Position.Right} id="TriggerSource"/> 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> </div>
</>; </>;
} }
@@ -84,39 +175,83 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
* @returns A simplified object containing the node label and its list of triggers. * @returns A simplified object containing the node label and its list of triggers.
*/ */
export function TriggerReduce(node: Node, nodes: Node[]) { export function TriggerReduce(node: Node, nodes: Node[]) {
// Replace this for nodes functionality const data = node.data as TriggerNodeData;
if (nodes.length <= -1) { const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
console.warn("Impossible nodes length in TriggerReduce") const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
} return {
const data = node.data; id: node.id,
switch (data.triggerType) { name: node.data.name,
case "keywords": condition: conditionData, // Make sure we have a condition before reducing, or default to ""
return { plan: !data.plan ? "" : PlanReduce(nodes, data.plan), // Make sure we have a plan when reducing, or default to ""
id: node.id, }
type: "keywords",
label: data.label, }
keywords: data.triggers,
};
default: export const TriggerTooltip = `
return { A trigger node is used to make Pepper execute a predefined plan -
...data, consisting of one or more actions - when the connected beliefs are met`;
id: node.id,
}; /**
* 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 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)
}
} }
} }
/** /**
* Handles logic that occurs when a connection is made involving a Trigger node. * 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 thisNode - The current Trigger node being connected. * @param _targetNodeId the target of the created connection
* @param otherNode - The other node involved in the connection.
* @param isThisSource - Whether this node was the source of the connection.
*/ */
export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) {
// Replace this for connection logic // no additional connection logic exists yet
if (thisNode == undefined && otherNode == undefined && isThisSource == false) { }
console.warn("Impossible node connection called in EndConnects")
} /**
* 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 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)
}
/**
* 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 TriggerDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
} }
// Definitions for the possible triggers, being keywords and emotions // Definitions for the possible triggers, being keywords and emotions
@@ -137,92 +272,4 @@ export type KeywordTriggerNodeProps = {
} }
/** Union type for all possible Trigger node configurations. */ /** Union type for all possible Trigger node configurations. */
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; 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} />
</>;
}

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

@@ -127,10 +127,10 @@ describe("Logging component", () => {
render(<Logging/>); render(<Logging/>);
expect(screen.getByText("Logs")).toBeInTheDocument(); expect(screen.getByText("Logs")).toBeDefined();
expect(screen.getByText("WARNING")).toBeInTheDocument(); expect(screen.getByText("WARNING")).toBeDefined();
expect(screen.getByText("logging")).toBeInTheDocument(); expect(screen.getByText("logging")).toBeDefined();
expect(screen.getByText("Ping")).toBeInTheDocument(); expect(screen.getByText("Ping")).toBeDefined();
let timestamp = screen.queryByText("ABS TIME"); let timestamp = screen.queryByText("ABS TIME");
if (!timestamp) { if (!timestamp) {
@@ -141,7 +141,7 @@ describe("Logging component", () => {
} }
await user.click(timestamp); await user.click(timestamp);
expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); expect(screen.getByText("00:00:12.345")).toBeDefined();
}); });
it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => {
@@ -188,7 +188,7 @@ describe("Logging component", () => {
logCell.set({...current, message: "Updated"}); logCell.set({...current, message: "Updated"});
}); });
expect(screen.getByText("Updated")).toBeInTheDocument(); expect(screen.getByText("Updated")).toBeDefined();
await waitFor(() => { await waitFor(() => {
expect(scrollSpy).toHaveBeenCalledTimes(1); expect(scrollSpy).toHaveBeenCalledTimes(1);
}); });

View File

@@ -0,0 +1,167 @@
import { render, screen, act, cleanup, fireEvent } from '@testing-library/react';
import Robot from '../../../src/pages/Robot/Robot';
// Mock EventSource
const mockInstances: MockEventSource[] = [];
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
mockInstances.push(this);
}
sendMessage(data: string) {
this.onmessage?.({ data } as MessageEvent);
}
close() {
this.closed = true;
}
}
// Mock global EventSource
beforeAll(() => {
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
});
// Mock fetch
beforeEach(() => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ reply: 'ok' }),
})
) as jest.Mock;
});
// Cleanup
afterEach(() => {
cleanup();
jest.restoreAllMocks();
mockInstances.length = 0;
});
describe('Robot', () => {
test('renders initial state', () => {
render(<Robot />);
expect(screen.getByText('Robot interaction')).toBeInTheDocument();
expect(screen.getByText('Force robot speech')).toBeInTheDocument();
expect(screen.getByText('Listening 🔴')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter a message')).toBeInTheDocument();
});
test('sends message via button', async () => {
render(<Robot />);
const input = screen.getByPlaceholderText('Enter a message');
const button = screen.getByText('Speak');
fireEvent.change(input, { target: { value: 'Hello' } });
await act(async () => fireEvent.click(button));
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/message',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hello' }),
})
);
});
test('sends message via Enter key', async () => {
render(<Robot />);
const input = screen.getByPlaceholderText('Enter a message');
fireEvent.change(input, { target: { value: 'Hi Enter' } });
await act(async () =>
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 })
);
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/message',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'Hi Enter' }),
})
);
expect((input as HTMLInputElement).value).toBe('');
});
test('handles fetch errors', async () => {
globalThis.fetch = jest.fn(() => Promise.reject('Network error')) as jest.Mock;
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<Robot />);
const input = screen.getByPlaceholderText('Enter a message');
const button = screen.getByText('Speak');
fireEvent.change(input, { target: { value: 'Error test' } });
await act(async () => fireEvent.click(button));
expect(consoleSpy).toHaveBeenCalledWith(
'Error sending message: ',
'Network error'
);
});
test('updates conversation on SSE', async () => {
render(<Robot />);
const eventSource = mockInstances[0];
await act(async () => {
eventSource.sendMessage(JSON.stringify({ voice_active: true }));
eventSource.sendMessage(JSON.stringify({ speech: 'User says hi' }));
eventSource.sendMessage(JSON.stringify({ llm_response: 'Assistant replies' }));
});
expect(screen.getByText('Listening 🟢')).toBeInTheDocument();
expect(screen.getByText('User says hi')).toBeInTheDocument();
expect(screen.getByText('Assistant replies')).toBeInTheDocument();
});
test('handles invalid SSE JSON', async () => {
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
render(<Robot />);
const eventSource = mockInstances[0];
await act(async () => eventSource.sendMessage('bad-json'));
expect(logSpy).toHaveBeenCalledWith('Unparsable SSE message:', 'bad-json');
});
test('resets conversation with Reset button', async () => {
render(<Robot />);
const eventSource = mockInstances[0];
await act(async () =>
eventSource.sendMessage(JSON.stringify({ speech: 'Hello' }))
);
expect(screen.getByText('Hello')).toBeInTheDocument();
fireEvent.click(screen.getByText('Reset'));
expect(screen.queryByText('Hello')).not.toBeInTheDocument();
});
test('toggles conversationIndex with Stop/Start button', () => {
render(<Robot />);
const stopButton = screen.getByText('Stop');
fireEvent.click(stopButton);
expect(screen.getByText('Start')).toBeInTheDocument();
fireEvent.click(screen.getByText('Start'));
expect(screen.getByText('Stop')).toBeInTheDocument();
});
test('closes EventSource on unmount', () => {
const { unmount } = render(<Robot />);
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});

View File

@@ -0,0 +1,267 @@
import {act} from '@testing-library/react';
import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import { mockReactFlow } from '../../../setupFlowTests.ts';
beforeAll(() => {
mockReactFlow();
});
describe("UndoRedo Middleware", () => {
beforeEach(() => {
jest.useFakeTimers();
});
test("pushSnapshot adds a snapshot to past and clears future", () => {
const store = useFlowStore;
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
past: [],
future: [{
nodes: [
{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}
],
edges: [],
warnings: {
warningRegistry: new Map(),
severityIndex: new Map()
}
}],
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
store.getState().pushSnapshot();
})
const state = store.getState();
expect(state.past.length).toBe(1);
expect(state.past[0]).toEqual({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
});
expect(state.future).toEqual([]);
});
test("pushSnapshot does nothing during batch action", () => {
const store = useFlowStore;
act(() => {
store.setState({ isBatchAction: true });
store.getState().pushSnapshot();
})
expect(store.getState().past.length).toBe(0);
});
test("undo restores last snapshot and pushes current snapshot to future", () => {
const store = useFlowStore;
// initial state
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
store.getState().pushSnapshot();
// modified state
store.setState({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
store.getState().undo();
})
expect(store.getState().nodes).toEqual([{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}]);
expect(store.getState().future.length).toBe(1);
expect(store.getState().future[0]).toEqual({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
});
});
test("undo does nothing when past is empty", () => {
const store = useFlowStore;
store.setState({past: []});
act(() => { store.getState().undo(); });
expect(store.getState().nodes).toEqual([]);
expect(store.getState().future).toEqual([]);
});
test("redo restores last future snapshot and pushes current to past", () => {
const store = useFlowStore;
// initial
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
store.getState().pushSnapshot();
store.setState({
nodes: [{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
});
store.getState().undo();
// redo should restore node with id 'B'
store.getState().redo();
})
expect(store.getState().nodes).toEqual([{
id: 'B',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'B'}
}]);
expect(store.getState().past.length).toBe(1); // snapshot A stored
expect(store.getState().past[0]).toEqual({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
});
});
test("redo does nothing when future is empty", () => {
const store = useFlowStore;
store.setState({past: []});
act(() => { store.getState().redo(); });
expect(store.getState().nodes).toEqual([]);
});
test("beginBatchAction pushes snapshot and sets isBatchAction=true", () => {
const store = useFlowStore;
store.setState({
nodes: [{
id: 'A',
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => { store.getState().beginBatchAction(); });
expect(store.getState().isBatchAction).toBe(true);
expect(store.getState().past.length).toBe(1);
});
test("endBatchAction sets isBatchAction=false after timeout", () => {
const store = useFlowStore;
store.setState({ isBatchAction: true });
act(() => { store.getState().endBatchAction(); });
// isBatchAction should remain true before the timer has advanced
expect(store.getState().isBatchAction).toBe(true);
jest.advanceTimersByTime(10);
// it should now be set to false as the timer has advanced enough
expect(store.getState().isBatchAction).toBe(false);
});
test("multiple beginBatchAction calls clear the timeout", () => {
const store = useFlowStore;
act(() => {
store.getState().beginBatchAction();
store.getState().endBatchAction(); // starts timeout
store.getState().beginBatchAction(); // should clear previous timeout
});
jest.advanceTimersByTime(10);
// After advancing the timers, isBatchAction should still be true,
// as the timeout should have been cleared
expect(store.getState().isBatchAction).toBe(true);
});
});

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,4 +1,12 @@
import {act} from '@testing-library/react'; import {act} from '@testing-library/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'; import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import { mockReactFlow } from '../../../setupFlowTests.ts'; import { mockReactFlow } from '../../../setupFlowTests.ts';
@@ -6,18 +14,187 @@ beforeAll(() => {
mockReactFlow(); mockReactFlow();
}); });
// default state values for testing,
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
};
const phaseNode: Node = {
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 1',
droppable: true,
children: ["norm-1"],
hasReduce: true,
},
};
const testEdge: Edge = {
id: 'xy-edge__1-2',
source: 'norm-1',
target: 'phase-1',
sourceHandle: null,
targetHandle: null,
}
const testStateReconnectEnd = {
nodes: [phaseNode, normNode],
edges: [testEdge],
}
const phaseNodeUnconnected = {
id: 'phase-2',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 2',
droppable: true,
children: [],
hasReduce: true,
},
};
const testConnection: Connection = {
source: 'norm-1',
target: 'phase-2',
sourceHandle: null,
targetHandle: null,
}
const testStateOnConnect = {
nodes: [phaseNodeUnconnected, normNode],
edges: [],
}
describe('FlowStore Functionality', () => { describe('FlowStore Functionality', () => {
describe('Node changes', () => { describe('Node changes', () => {
// currently just using a single function from the ReactFlow library, // currently just using a single function from the ReactFlow library,
// so testing would mean we are testing already tested behavior. // so testing would mean we are testing already tested behavior.
// if implementation gets modified tests should be added for custom behavior // if implementation gets modified tests should be added for custom behavior
}); });
describe('ReactFlow onEdgesDelete', () => {
test('Deleted edge is reflected in removed phaseNode child', () => {
const {onEdgesDelete} = useFlowStore.getState();
useFlowStore.setState({
nodes: [{
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 1',
droppable: true,
children: ["norm-1"],
hasReduce: true,
},
},{
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
}],
edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted
})
act(() => {
onEdgesDelete([testEdge])
});
const outcome = useFlowStore.getState();
expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0);
})
test('Deleted edge is reflected in phaseNode,even if normNode was already deleted and caused edge removal', () => {
const { onEdgesDelete } = useFlowStore.getState();
useFlowStore.setState({
nodes: [{
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 1',
droppable: true,
children: ["norm-1"],
hasReduce: true,
},
}],
edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted
})
act(() => {
onEdgesDelete([testEdge]);
})
const outcome = useFlowStore.getState();
expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0);
})
test('edge removal resulting from deletion of targetNode calls only the connection function for the sourceNode', () => {
const { onEdgesDelete } = useFlowStore.getState();
useFlowStore.setState({
nodes: [{
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
}],
edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted
})
const targetDisconnectSpy = jest.spyOn(NodeDisconnections.Targets, 'phase');
const sourceDisconnectSpy = jest.spyOn(NodeDisconnections.Sources, 'norm');
act(() => {
onEdgesDelete([testEdge]);
})
expect(sourceDisconnectSpy).toHaveBeenCalledWith(normNode, 'phase-1');
expect(targetDisconnectSpy).not.toHaveBeenCalled();
sourceDisconnectSpy.mockRestore();
targetDisconnectSpy.mockRestore();
})
})
describe('Edge changes', () => { describe('Edge changes', () => {
// currently just using a single function from the ReactFlow library, // currently just using a single function from the ReactFlow library,
// so testing would mean we are testing already tested behavior. // so testing would mean we are testing already tested behavior.
// if implementation gets modified tests should be added for custom behavior // if implementation gets modified tests should be added for custom behavior
}) })
describe('ReactFlow onConnect', () => { describe('ReactFlow onConnect', () => {
test('Adds connecting node to children of phaseNode', () => {
const {onConnect} = useFlowStore.getState();
useFlowStore.setState({
nodes: testStateOnConnect.nodes,
edges: testStateOnConnect.edges
})
act(() => {
onConnect(testConnection);
})
const outcome = useFlowStore.getState();
// phaseNode adds the normNode to its children
expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual(['norm-1']);
})
test('adds an edge when onConnect is triggered', () => { test('adds an edge when onConnect is triggered', () => {
const {onConnect} = useFlowStore.getState(); const {onConnect} = useFlowStore.getState();
@@ -39,6 +216,53 @@ describe('FlowStore Functionality', () => {
}); });
}); });
describe('ReactFlow onReconnect', () => { describe('ReactFlow onReconnect', () => {
test('PhaseNodes correctly change their children', () => {
const {onReconnect} = useFlowStore.getState();
useFlowStore.setState({
nodes: [{
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 1',
droppable: true,
children: ["norm-1"],
hasReduce: true,
},
},{
id: 'phase-2',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 2',
droppable: true,
children: [],
hasReduce: true,
},
},{
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
}],
edges: [testEdge],
})
act(() => {
onReconnect(testEdge, testConnection);
})
const outcome = useFlowStore.getState();
// phaseNodes lose and gain children when norm node's connection is changed from phaseNode to PhaseNodeUnconnected
expect((outcome.nodes[1].data as PhaseNodeData).children).toEqual(['norm-1']);
expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual([]);
})
test('reconnects an existing edge when onReconnect is triggered', () => { test('reconnects an existing edge when onReconnect is triggered', () => {
const {onReconnect} = useFlowStore.getState(); const {onReconnect} = useFlowStore.getState();
const oldEdge = { const oldEdge = {
@@ -93,36 +317,63 @@ describe('FlowStore Functionality', () => {
); );
}); });
test('successfully removes edge if no successful reconnect occurred', () => { test('successfully removes edge if no successful reconnect occurred', () => {
const {onReconnectEnd} = useFlowStore.getState(); const {onReconnectEnd} = useFlowStore.getState();
useFlowStore.setState({edgeReconnectSuccessful: false}); useFlowStore.setState({
edgeReconnectSuccessful: false,
edges: testStateReconnectEnd.edges,
nodes: testStateReconnectEnd.nodes
});
act(() => { act(() => {
onReconnectEnd(null, {id: 'xy-edge__A-B'}); onReconnectEnd(null, testEdge);
}); });
const updatedState = useFlowStore.getState(); const updatedState = useFlowStore.getState();
expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edgeReconnectSuccessful).toBe(true);
expect(updatedState.edges).toHaveLength(0); expect(updatedState.edges).toHaveLength(0);
expect(updatedState.nodes[0].data.children).toEqual([]);
}); });
test('does not remove reconnecting edge if successful reconnect occurred', () => { test('does not remove reconnecting edge if successful reconnect occurred', () => {
const {onReconnectEnd} = useFlowStore.getState(); const {onReconnectEnd} = useFlowStore.getState();
useFlowStore.setState({
edgeReconnectSuccessful: true,
edges: [testEdge],
nodes: [{
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
label: 'Phase 1',
droppable: true,
children: ["norm-1"],
hasReduce: true,
},
},{
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
}]
});
act(() => { act(() => {
onReconnectEnd(null, {id: 'xy-edge__A-B'}); onReconnectEnd(null, testEdge);
}); });
const updatedState = useFlowStore.getState(); const updatedState = useFlowStore.getState();
expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edgeReconnectSuccessful).toBe(true);
expect(updatedState.edges).toHaveLength(1); expect(updatedState.edges).toHaveLength(1);
expect(updatedState.edges).toMatchObject([ expect(updatedState.edges).toMatchObject([testEdge]);
{ expect(updatedState.nodes[0].data.children).toEqual(["norm-1"]);
id: 'xy-edge__A-B',
source: 'A',
target: 'B'
}]
);
}); });
}); });
describe('ReactFlow deleteNode', () => { describe('ReactFlow deleteNode', () => {
@@ -151,6 +402,7 @@ describe('FlowStore Functionality', () => {
}] }]
}); });
act(()=> { act(()=> {
deleteNode(nodeId); deleteNode(nodeId);
}); });
@@ -348,5 +600,48 @@ describe('FlowStore Functionality', () => {
expect(updatedState.nodes).toHaveLength(1); expect(updatedState.nodes).toHaveLength(1);
expect(updatedState.nodes[0]).toMatchObject(expected.node); 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

@@ -1,5 +1,110 @@
describe('Not implemented', () => { import { getByTestId, render } from '@testing-library/react';
test('nothing yet', () => { import userEvent from '@testing-library/user-event';
expect(true) import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
}); import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
jest.mock('@neodrag/react', () => ({
useDraggable: (ref: React.RefObject<HTMLElement>, options: any) => {
// We access the real useEffect from React to attach a listener
// This bridges the gap between the test's userEvent and the component's logic
const { useEffect } = jest.requireActual('react');
useEffect(() => {
const element = ref.current;
if (!element) return;
// When the test fires a "pointerup" (end of click/drag),
// we manually trigger the library's onDragEnd callback.
const handlePointerUp = (e: PointerEvent) => {
if (options.onDragEnd) {
options.onDragEnd({ event: e });
}
};
element.addEventListener('pointerup', handlePointerUp as EventListener);
return () => {
element.removeEventListener('pointerup', handlePointerUp as EventListener);
};
}, [ref, options]);
},
}));
// We will mock @xyflow/react so we control screenToFlowPosition
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
useReactFlow: () => ({
screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({
x: x - 100,
y: y - 100,
}),
}),
};
}); });
describe("Drag & drop node creation", () => {
test("drops a phase node inside the canvas and adds it with transformed position", async () => {
const user = userEvent.setup();
const { container } = render(<VisProgPage />);
// --- Mock ReactFlow bounding box ---
// Your DndToolbar checks these values:
const flowEl = container.querySelector('.react-flow');
jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 800,
top: 0,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {},
});
const phaseLabel = getByTestId(container, 'draggable-phase')
await user.pointer([
// touch the screen at element1
{keys: '[TouchA>]', target: phaseLabel},
// move the touch pointer to element2
{pointerName: 'TouchA', coords: {x: 300, y: 250}},
// release the touch pointer at the last position (element2)
{keys: '[/TouchA]'},
]);
// Read the Zustand store
const { nodes } = useFlowStore.getState();
// --- Assertions ---
expect(nodes.length).toBe(1);
const node = nodes[0];
expect(node.type).toBe("phase");
// 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({
x: 200,
y: 150,
});
});
});

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,14 @@
import { render } from '@testing-library/react';
import { act } from '@testing-library/react';
import ScrollIntoView from '../../../../../src/components/ScrollIntoView';
test('scrolls the element into view on render', () => {
const scrollMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollMock;
act(() => {
render(<ScrollIntoView />);
});
expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth' });
});

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

@@ -0,0 +1,952 @@
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 NormNode, {
NormReduce,
type NormNodeData,
NormConnectionSource, NormConnectionTarget
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/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>;
beforeEach(() => {
user = userEvent.setup();
});
describe('Rendering', () => {
it('should render the norm node with default data', () => {
const mockNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {...JSON.parse(JSON.stringify(NormNodeDefaults))},
};
renderWithProviders(
<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}
/>
);
expect(screen.getByPlaceholderText('Pepper should ...')).toBeInTheDocument();
});
it('should render with pre-populated norm text', () => {
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: 'Be respectful to humans',
hasReduce: true,
},
};
renderWithProviders(
<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}
/>
);
const input = screen.getByDisplayValue('Be respectful to humans');
expect(input).toBeInTheDocument();
});
it('should render with selected state', () => {
const mockNode: Node<NormNodeData> = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
conditions: [],
norm: '',
hasReduce: true,
critical: false
},
};
renderWithProviders(
<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}
/>
);
const norm = screen.getByText("Norm :")
expect(norm).toBeInTheDocument();
});
it('should render with dragging 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: 'Dragged norm',
hasReduce: true,
},
};
renderWithProviders(
<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}
/>
);
const input = screen.getByDisplayValue('Dragged norm');
expect(input).toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('should update norm text when user types in the input field', async () => {
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: '',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByPlaceholderText('Pepper should ...');
await user.type(input, 'Be polite to guests{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'norm-1');
expect(updatedNode?.data.norm).toBe('Be polite to guests');
});
});
it('should handle clearing the norm text', async () => {
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: 'Initial norm text',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByDisplayValue('Initial norm text') as HTMLInputElement;
// clearing the norm text is the same as just deleting all characters one by one
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
for (let a = 0; a < 'Initial norm text'.length; a++){
await user.type(input, '{backspace}')
}
await user.type(input,'{enter}')
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'norm-1');
expect(updatedNode?.data.norm).toBe('');
});
});
it('should update norm text multiple times', async () => {
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: '',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByPlaceholderText('Pepper should ...');
await user.type(input, 'First norm{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('First norm');
});
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
for (let a = 0; a < 'First norm'.length; a++){
await user.type(input, '{backspace}')
}
await user.type(input, 'Second norm{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('Second norm');
});
});
it('should handle special characters in norm text', async () => {
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: '',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByPlaceholderText('Pepper should ...');
await user.type(input, "Don't harm & be nice!{enter}" );
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe("Don't harm & be nice!");
});
});
it('should handle long norm text', async () => {
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
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: '',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByPlaceholderText('Pepper should ...');
await user.type(input, longText);
await user.type(input, "{enter}")
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe(longText);
});
});
});
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, 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: ""
},
});
});
it('should reduce multiple norm nodes independently', () => {
const norm1: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1',
droppable: true,
norm: 'Be helpful',
hasReduce: true,
},
};
const norm2: Node = {
id: 'norm-2',
type: 'norm',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 2',
droppable: true,
norm: 'Be honest',
hasReduce: true,
},
};
const allNodes: Node[] = [norm1, norm2];
const result1 = NormReduce(norm1, allNodes);
const result2 = NormReduce(norm2, allNodes);
expect(result1.id).toBe('norm-1');
expect(result1.norm).toBe('Be helpful');
expect(result2.id).toBe('norm-2');
expect(result2.norm).toBe('Be honest');
});
it('should handle empty norm text', () => {
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Empty Norm',
droppable: true,
norm: '',
hasReduce: true,
},
};
const result = NormReduce(normNode, [normNode]);
expect(result.norm).toBe('');
expect(result.id).toBe('norm-1');
});
it('should preserve node label in reduction', () => {
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Custom Label',
droppable: false,
norm: 'Test norm',
hasReduce: false,
},
};
const result = NormReduce(normNode, [normNode]);
expect(result.label).toBe('Custom Label');
});
});
describe('NormConnects Function', () => {
it('should handle connection without errors', () => {
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
};
const phaseNode: Node = {
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Phase 1',
droppable: true,
children: [],
hasReduce: true,
},
};
expect(() => {
NormConnectionSource(normNode, phaseNode.id);
}).not.toThrow();
});
it('should handle connection when norm is target', () => {
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
};
const phaseNode: Node = {
id: 'phase-1',
type: 'phase',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Phase 1',
droppable: true,
children: [],
hasReduce: true,
},
};
expect(() => {
NormConnectionTarget(normNode, phaseNode.id);
}).not.toThrow();
});
it('should handle self-connection', () => {
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...NormNodeDefaults,
label: 'Test Norm',
droppable: true,
norm: 'Test',
hasReduce: true,
},
};
expect(() => {
NormConnectionTarget(normNode, normNode.id);
NormConnectionSource(normNode, normNode.id);
}).not.toThrow();
});
});
describe('Integration with Store', () => {
it('should properly update the store when editing norm text', async () => {
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: '',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByPlaceholderText('Pepper should ...');
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
for (let a = 0; a < 20; a++){
await user.type(input, '{backspace}')
}
await user.type(input, 'New norm value{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
expect(state.nodes).toHaveLength(1);
expect(state.nodes[0].id).toBe('norm-1');
expect(state.nodes[0].data.norm).toBe('New norm value');
});
});
it('should properly update the store when editing critical checkbox', async () => {
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: '',
hasReduce: true,
critical: false,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const checkbox = screen.getByLabelText('Critical:');
await user.click(checkbox);
await waitFor(() => {
const state = useFlowStore.getState();
expect(state.nodes).toHaveLength(1);
expect(state.nodes[0].id).toBe('norm-1');
expect(state.nodes[0].data.norm).toBe('');
expect(state.nodes[0].data.critical).toBe(true);
});
});
it('should not affect other nodes when updating one norm node', async () => {
const norm1: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1',
droppable: true,
norm: 'Original norm 1',
hasReduce: true,
},
};
const norm2: Node = {
id: 'norm-2',
type: 'norm',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 2',
droppable: true,
norm: 'Original norm 2',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [norm1, norm2],
edges: [],
});
renderWithProviders(
<NormNode
id={norm1.id}
type={norm1.type as string}
data={norm1.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('Original norm 1') as HTMLInputElement;
// TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/
for (let a = 0; a < 20; a++){
await user.type(input, '{backspace}')
}
await user.type(input, 'Updated norm 1{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNorm1 = state.nodes.find(n => n.id === 'norm-1');
const unchangedNorm2 = state.nodes.find(n => n.id === 'norm-2');
expect(updatedNorm1?.data.norm).toBe('Updated norm 1');
expect(unchangedNorm2?.data.norm).toBe('Original norm 2');
});
});
it('should maintain data consistency with multiple rapid updates', async () => {
const mockNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...NormNodeDefaults,
label: 'Test Norm',
droppable: true,
norm: 'haa haa fuyaaah - link',
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<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}
/>
);
const input = screen.getByPlaceholderText('Pepper should ...');
expect(input).toBeDefined()
await user.type(input, 'a{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka');
});
await user.type(input, 'b{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab');
});
await user.type(input, 'c{enter}');
await waitFor(() => {
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

@@ -0,0 +1,296 @@
import type { Node, Edge, Connection } from '@xyflow/react'
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
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 {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;
jest.mock('@neodrag/react', () => ({
useDraggable: (ref: React.RefObject<HTMLElement>, options: any) => {
// We access the real useEffect from React to attach a listener
// This bridges the gap between the test's userEvent and the component's logic
const { useEffect } = jest.requireActual('react');
useEffect(() => {
const element = ref.current;
if (!element) return;
// When the test fires a "pointerup" (end of click/drag),
// we manually trigger the library's onDragEnd callback.
const handlePointerUp = (e: PointerEvent) => {
if (options.onDragEnd) {
options.onDragEnd({ event: e });
}
};
element.addEventListener('pointerup', handlePointerUp as EventListener);
return () => {
element.removeEventListener('pointerup', handlePointerUp as EventListener);
};
}, [ref, options]);
},
}));
describe('PhaseNode', () => {
it('each created phase gets its own children array, not the same reference ', async () => {
const user = userEvent.setup();
const { container } = render(<VisProgPage />);
// --- Mock ReactFlow bounding box ---
// Your DndToolbar checks these values:
const flowEl = container.querySelector('.react-flow');
jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({
left: 0,
right: 800,
top: 0,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => {},
});
// Find the draggable norm node in the toolbar
const phaseButton = getByTestId(container, 'draggable-phase')
// Simulate dropping phase down in graph (twice)
for (let i = 0; i < 2; i++) {
await user.pointer([
// touch the screen at element1
{keys: '[TouchA>]', target: phaseButton},
// move the touch pointer to element2
{pointerName: 'TouchA', coords: {x: 300, y: 250}},
// release the touch pointer at the last position (element2)
{keys: '[/TouchA]'},
]);
}
// Find nodes
const nodes = useFlowStore.getState().nodes;
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);
expect(p1.data.children).toEqual(p2.data.children);
// Add nodes to children
const p1_data = p1.data as PhaseNodeData;
const p2_data = p2.data as PhaseNodeData;
p1_data.children.push("norm-1");
p2_data.children.push("norm-2");
p2_data.children.push("goal-1");
// check that after adding, its not the same reference, and its not the same children
expect(p1.data.children).not.toBe(p2.data.children);
expect(p1.data.children).not.toEqual(p2.data.children);
// expect them to have the correct length.
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

@@ -0,0 +1,101 @@
import { describe, it } from '@jest/globals';
import '@testing-library/jest-dom';
import { screen } from '@testing-library/react';
import type { Node } from '@xyflow/react';
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
import StartNode, {
StartConnectionSource, StartConnectionTarget,
StartReduce
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode';
describe('StartNode', () => {
describe('Rendering', () => {
it('renders the StartNode correctly', () => {
const mockNode: Node = {
id: 'start-1',
type: 'start', // TypeScript now knows this is a string
position: { x: 0, y: 0 },
data: {
label: 'Start Node',
droppable: false,
hasReduce: true,
},
};
renderWithProviders(
<StartNode
id={mockNode.id}
type={mockNode.type!} // <--- fix here
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={false}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByText('Start')).toBeInTheDocument();
// The handle should exist in the DOM
expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument();
});
});
describe('StartReduce Function', () => {
it('reduces the StartNode to its minimal structure', () => {
const mockNode: Node = {
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: {
label: 'Start Node',
droppable: false,
hasReduce: true,
},
};
const result = StartReduce(mockNode, [mockNode]);
expect(result).toEqual({ id: 'start-1' });
});
});
describe('StartConnects Function', () => {
it('handles connections without throwing', () => {
const startNode: Node = {
id: 'start-1',
type: 'start',
position: { x: 0, y: 0 },
data: {
label: 'Start Node',
droppable: false,
hasReduce: true,
},
};
const otherNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 100, y: 0 },
data: {
label: 'Norm Node',
droppable: true,
norm: 'test',
hasReduce: true,
},
};
expect(() => StartConnectionSource(startNode, otherNode.id)).not.toThrow();
expect(() => StartConnectionTarget(startNode, otherNode.id)).not.toThrow();
});
});
});

View File

@@ -0,0 +1,184 @@
import { describe, it, beforeEach } from '@jest/globals';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
import TriggerNode, {
TriggerReduce,
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', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render TriggerNode with keywords type', () => {
const mockNode: Node<TriggerNodeData> = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
},
};
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(/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: {
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
condition: "belief-1",
plan: defaultPlan,
name: "trigger-1"
},
};
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',
name: "trigger-1",
condition: {
id: "belief-1",
keyword: "",
},
plan: {
id: expect.anything(),
steps: [],
},});
});
});
describe('TriggerConnects Function', () => {
it('should handle connection without errors', () => {
const node1: Node = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
label: 'Trigger 1',
},
};
const node2: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1',
},
};
expect(() => {
TriggerConnectionSource(node1, node2.id);
TriggerConnectionTarget(node1, node2.id);
}).not.toThrow();
});
});
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

@@ -0,0 +1,195 @@
import { describe, beforeEach } from '@jest/globals';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
import type { XYPosition } from '@xyflow/react';
import { NodeTypes, NodeDefaults, NodeConnections, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry';
import '@testing-library/jest-dom'
import { createElement } from 'react';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
describe('Universal Nodes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
return {
id: id,
type: type,
position: position,
data: {...defaultData, ...data},
deletable: deletable
}
}
/**
* 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 getAllTypes() {
return Object.entries(NodeTypes).map(([t])=>t)
}
describe('Rendering', () => {
test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => {
const lengthBefore = screen.getAllByText(/.*/).length;
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;
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;
expect(lengthBefore + 1 === lengthAfter);
});
});
describe('Connecting', () => {
test.each(getAllTypes())('it should call the connect function when %s node is connected', (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', 'end', {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, 'end');
// 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);
sourceConnectSpy.mockRestore();
targetConnectSpy.mockRestore();
});
});
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
const phaseNode = createNode('phase-1', 'phase', {x: 200, y: 100}, { label: 'Test Phase', children: [] });
const testNode = createNode('node-1', nodeType, {x: 100, y: 100}, {});
// Add the test node as a child of the phase
(phaseNode.data as any).children.push(testNode.id);
// Add nodes to store
useFlowStore.setState({ nodes: [phaseNode, testNode] });
// Spy on the reduce functions
const phaseReduceSpy = jest.spyOn(NodeReduces, 'phase');
const nodeReduceSpy = jest.spyOn(NodeReduces, nodeType as keyof typeof NodeReduces);
// Simulate reducing - using the graphReducer
const result = graphReducer();
// Verify the reduce functions were called
expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]);
// Check if this node type is in NodesInPhase and returns false
const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase];
if (nodesInPhaseFunc && !nodesInPhaseFunc() && nodeType !== 'phase') {
// Node is NOT in phase, so it should NOT be called
expect(nodeReduceSpy).not.toHaveBeenCalled();
} else {
// Node IS in phase, so it SHOULD be called
expect(nodeReduceSpy).toHaveBeenCalled();
}
// 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('name', 'Test Phase');
// Restore mocks
phaseReduceSpy.mockRestore();
nodeReduceSpy.mockRestore();
});
});
});

View File

@@ -1,7 +1,16 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react'; 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'; 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 // To make sure that the tests are working, it's important that you are using
// this implementation of ResizeObserver and DOMMatrixReadOnly // this implementation of ResizeObserver and DOMMatrixReadOnly
@@ -61,15 +70,24 @@ export const mockReactFlow = () => {
width: 200, width: 200,
height: 200, height: 200,
}); });
}; };
beforeAll(() => { beforeAll(() => {
useFlowStore.setState({ useFlowStore.setState({
nodes: [], nodes: [],
edges: [], edges: [],
edgeReconnectSuccessful: true past: [],
future: [],
isBatchAction: false,
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,
}); });
}); });
@@ -78,7 +96,30 @@ afterEach(() => {
useFlowStore.setState({ useFlowStore.setState({
nodes: [], nodes: [],
edges: [], edges: [],
edgeReconnectSuccessful: true past: [],
future: [],
isBatchAction: false,
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');
};
}
}

41
test/test-utils/mocks.ts Normal file
View File

@@ -0,0 +1,41 @@
import { jest } from '@jest/globals';
import React from 'react';
import '@testing-library/jest-dom';
/**
* Mock for @xyflow/react
* Provides simplified versions of React Flow hooks and components
*/
jest.mock('@xyflow/react', () => ({
useReactFlow: jest.fn(() => ({
screenToFlowPosition: jest.fn((pos: any) => pos),
getNode: jest.fn(),
getNodes: jest.fn(() => []),
getEdges: jest.fn(() => []),
setNodes: jest.fn(),
setEdges: jest.fn(),
})),
ReactFlowProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', { 'data-testid': 'react-flow-provider' }, children),
ReactFlow: ({ children, ...props }: any) =>
React.createElement('div', { 'data-testid': 'react-flow', ...props }, children),
Handle: ({ type, position, id }: any) =>
React.createElement('div', { 'data-testid': `handle-${type}-${id}`, 'data-position': position }),
Panel: ({ children, position }: any) =>
React.createElement('div', { 'data-testid': 'panel', 'data-position': position }, children),
Controls: () => React.createElement('div', { 'data-testid': 'controls' }),
Background: () => React.createElement('div', { 'data-testid': 'background' }),
}));
/**
* Mock for @neodrag/react
* Simplifies drag behavior for testing
*/
jest.mock('@neodrag/react', () => ({
useDraggable: jest.fn((ref: any, options?: any) => {
// Store the options so we can trigger them in tests
if (ref && ref.current) {
(ref.current as any)._dragOptions = options;
}
}),
}));

View File

@@ -0,0 +1,69 @@
// __tests__/utils/test-utils.tsx
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
* This ensures all components have access to ReactFlow context
*/
export function renderWithProviders(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
function Wrapper({ children }: { children: ReactNode }) {
return <ReactFlowProvider>{children}</ReactFlowProvider>;
}
return render(ui, { wrapper: Wrapper, ...options });
}
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);
});
});