Compare commits

...

130 Commits

Author SHA1 Message Date
JobvAlewijk
a4f7b48031 Merge branch 'feat/monitoringpage-pim' of ssh://git.science.uu.nl/ics/sp/2025/n25b/pepperplus-ui into feat/monitoringpage-pim 2026-01-29 16:24:19 +01:00
JobvAlewijk
b18cd5bfa5 chore: face 2026-01-29 16:24:03 +01:00
Pim Hutting
09e6287f9d chore: edited a test to pass 2026-01-26 14:35:46 +01:00
Pim Hutting
8b40001038 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-26 14:22:13 +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
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
Björn Otgaar
ec211ccbc3 chore: fix the capitalization of 3 characters to make sure they match. :) 2026-01-23 11:25:30 +01:00
Pim Hutting
9a555165e6 Merge branch 'dev' into feat/monitoringpage-pim 2026-01-22 10:19:46 +01:00
Pim Hutting
f73bbb9d02 chore: added tests and removed restart phase
this version also has recursive goals functional
2026-01-22 10:15:20 +01:00
Pim Hutting
883f0a95a6 chore: only check if play is undefined 2026-01-20 13:55:34 +01:00
Pim Hutting
a8f9965391 chore: added recursive goals to monitor page 2026-01-20 12:31:34 +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
Pim Hutting
2ca0c9c4c0 chore: added Storms change 2026-01-19 18:17:47 +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
Pim Hutting
dcc50fd978 feat: implemented basic version of reset phase
right now reset phase also clears LLM

ref: N25B-400
2026-01-18 13:56:47 +01:00
Pim Hutting
2c4f24fbb6 chore: gesture test updated 2026-01-18 12:49:14 +01:00
Pim Hutting
0a243851b1 Merge branch 'feat/monitoringpage-pim' into feat/monitoringpage 2026-01-18 12:40:41 +01:00
Pim Hutting
cada85e253 chore: added tests for monitoringpage
also moved some functions from VisProg outside VisProg.tsx into
VisProgLogic.tsx so I can reuse it for the reset experiment function
of monitor page
Also fixed a small merge error in TriggerNodes.tsx

ref: N25B-400
2026-01-18 12:38:23 +01:00
JobvAlewijk
2aede38e4d feat: 10 basic gestures
ref: N25B-300
2026-01-17 16:09:13 +01:00
Pim Hutting
ab383d77c4 chore: fixed some tests 2026-01-17 16:04:47 +01:00
Pim Hutting
45ef5a353c Merge remote-tracking branch 'origin/feat/add-inferred-belief-node' into feat/monitoringpage 2026-01-16 15:39:20 +01:00
Pim Hutting
d8cae9f838 feat: added reset experiment (in UI)
ref:N25B-400
2026-01-16 14:25:26 +01:00
Pim Hutting
c4e3ab27b2 chore: among other things, fixed connection issue
fix: connection issue
conditional norm now able to undo
 and are updated via pings
goals are able to be achieved out of turn

ref: N25B-400
2026-01-16 12:57:22 +01:00
Pim Hutting
a98a87f8ce refactor: renamed Components.tsx
renamed it to MonitoringPageComponents.tsx

ref: N25B-400
2026-01-15 09:26:40 +01:00
Pim Hutting
714ee34bbe refactor: move all ZMQ to API
also removed a lot of logs to the console to avoid cluttering

ref: N25B-400
2026-01-15 09:24:51 +01:00
Pim Hutting
7d00f35990 feat: made unachieve norm possible
also small fix of formatting keywords said -> keyword said in plan
as we are only able to detect single keywords
(multiple keywords are possible with inferred beliefs)

ref: N25B-400
2026-01-15 09:04:32 +01:00
JGerla
f99ad7ad2e feat: added tooltip and patched potential breaking point in mode toggle for the new node
ref: N25B-433
2026-01-14 13:57:58 +01:00
JGerla
33f520d310 Merge branch 'demo' into feat/add-inferred-belief-node
# Conflicts:
#	src/pages/VisProgPage/VisProg.module.css
#	src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts
2026-01-14 13:48:57 +01:00
JGerla
1bec74a078 test: wrote tests for inferred Belief node
ref: N25B-433
2026-01-14 13:47:54 +01:00
JGerla
1f0237baac refactor: moved the nobeliefCyclerule into BeliefGlobals, and fixed function and filenaming
ref: N25B-433
2026-01-14 13:44:59 +01:00
Storm
5e245a00da chore: remove useState import 2026-01-13 12:39:48 +01:00
Storm
1e951968dd Merge remote-tracking branch 'origin/demo' into feat/monitoringpage 2026-01-13 12:39:25 +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
108fdeeedc chore: gptd data typing and tests fixing 2026-01-13 12:03:49 +01:00
Björn Otgaar
79d889c10e chore: revert components page, fixing the test 2026-01-13 11:43:24 +01:00
Björn Otgaar
bac94d5f8c chore: remove unused imports 2026-01-13 11:41:58 +01:00
JGerla
3d6e065dd5 feat: added rule to prevent one belief being connected to both inferredBelief inputs
ref: N25B-433
2026-01-13 11:32:23 +01:00
JGerla
f8f0f12128 style: added documentation to the InferredBelief Node
ref: N25B-433
2026-01-13 09:26:02 +01:00
JGerla
2d9f430a30 style: cleaned up code for InferredBeliefNode.tsx
ref: N25B-433
2026-01-13 08:59:34 +01:00
JGerla
f8acdda03c feat: added rule to prevent cyclical connections between inferred belief nodes
ref: N25B-433
2026-01-13 08:52:50 +01:00
Pim Hutting
f95b1148d9 chore: made last page access more sensible
still didnt work with CB

ref: N25B-400
2026-01-13 00:53:41 +01:00
Pim Hutting
46d900305a chore: made activatable elements clickable
ref: N25B-400
2026-01-12 19:41:05 +01:00
Pim Hutting
c4a4c52ecc Merge remote-tracking branch 'origin/feat/recursive-goal-making' into feat/monitoringpage 2026-01-12 17:16:52 +01:00
JGerla
b869f7c071 feat: added reduce and connection logic
A rule still needs to be added to the inferred belief handles to prevent cyclical inferredBeliefs, but aside from that logic ifs finished.

ref: N25B-433
2026-01-12 16:44:25 +01:00
JGerla
0a4940bdd0 feat: made a basic version of inferredBeliefNode without all functionality
full reduce and connection logic is still missing

ref: N25B-433
2026-01-12 15:34:44 +01:00
Pim Hutting
96242fa6b0 chore: fix connection error
was connected to wrong endpoint after merge

ref: N25B-400
2026-01-12 15:17:38 +01:00
Pim Hutting
c2486f5f43 Merge branch 'feat/monitoringpage-pim' into feat/monitoringpage 2026-01-12 13:04:15 +01:00
Pim Hutting
f0c67c00dc fix: update goals and trigger/norms.. correctly
feat: N25B-400
2026-01-12 12:49:00 +01:00
Björn Otgaar
a0a4687aeb chore: add support for dark mode in monitoring page 2026-01-10 12:14:37 +01: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
Pim Hutting
71443c7fb6 feat: added scroll bar to simple program
note that it will take quite a lot of items
for the simple prog to be filled up, only when it's
overflown the scroll bar will appear

ref: N25B-400
2026-01-08 15:19:38 +01:00
Pim Hutting
39f013c47f feat: goals now update in UI
ref: N25B-400
2026-01-08 14:51:02 +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
6e1eb25bbc feat: add robot connection
ref: N25B-400
2026-01-08 14:01:42 +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
Pim Hutting
f2c01f67ac feat: small implementation change
ref: N25B-400
2026-01-08 11:26:31 +01:00
Pim Hutting
14cfc2bf15 fix: removed unused imports
ref: N25B-400
2026-01-08 11:01:19 +01:00
Pim Hutting
a2b4847ca4 Merge remote-tracking branch 'origin/demo' into feat/monitoringpage-pim 2026-01-08 09:54:48 +01:00
Björn Otgaar
a1e242e391 feat: added the functionality for the play, pause, next phase, reset phase, reset experiment buttons
ref: N25B-400
2026-01-07 18:31:56 +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
Pim Hutting
4356f201ab feat: added endpoint
ref:N25B-400
2026-01-07 17:39:20 +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
c9df87929b feat: add the buttons for next, reset phase and reset experiment
ref: N25B-400
2026-01-07 15:09:44 +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
Björn Otgaar
57ebe724db Merge remote-tracking branch 'origin/feat/monitoringpage-pim' into feat/monitoringpage-bjorn 2026-01-07 11:55:20 +01:00
Björn Otgaar
794e638081 feat: start with functionality
ref: N25B-400
2026-01-07 11:54:29 +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
Pim Hutting
12ef2ef86e feat: added forced speech/gestures +overrides
ref: N25B-400
2026-01-06 15:15:36 +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
Tuurminator69
0fefefe7f0 feat: removed the temporary access to MP from Home
ref: N25B-398
2026-01-05 17:41:36 +01:00
Tuurminator69
9601f56ea9 feat: merged most of simpleprogram into MP
ref: N25B-398
2026-01-05 17:35:32 +01:00
Tuurminator69
873b1cfb0b Merge branch 'feat/simple-program-page' of git.science.uu.nl:ics/sp/2025/n25b/pepperplus-ui into feat/monitoringpage 2026-01-05 16:41:51 +01: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
Tuurminator69
4bd67debf3 feat: added and changed the monitoringpage a lot
ref: N25B-398
2026-01-04 20:07:44 +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
Tuurminator69
e53e1a3958 feat: can go to a skeleton monitoringpage
ref: N25B-398
2026-01-03 21:59:51 +01:00
Tuurminator69
7a89b0aedd feat: added a skeleton for the monitoringpage.
ref: N25B-398
2026-01-03 21:07:32 +01:00
JobvAlewijk
7b05c7344c feat: added tests
ref: N25B-399
2026-01-02 21:06:41 +01:00
JobvAlewijk
d80ced547c feat: SimpleProgram no longer relies on types
ref: N25B-399
2026-01-02 20:55:24 +01:00
JobvAlewijk
cd1aa84f89 feat: using programstore
ref: N25B-399
2026-01-02 20:43:20 +01:00
JobvAlewijk
469a6c7a69 build: merge
ref: N25B-402
2026-01-02 19:56:01 +01:00
JobvAlewijk
b0a5e4770c feat: improved visuals and structure
ref: N25B-402
2025-12-30 20:56:05 +01:00
JobvAlewijk
f0fe520ea0 feat: first version of simple program shown
shows up if you run the program

ref: N25B-405
2025-12-30 18:10:51 +01:00
JGerla
b10dbae488 test: added tests for the ProgramStore
also added documentation

ref: N25B-428
2025-12-20 22:58:22 +01:00
Björn Otgaar
444e8b0289 feat: fix a lot of small changes to match cb, add functionality for all plans, add tests for the new plan editor. even more i dont really know anymore.
ref: N25B-412
2025-12-17 15:51:50 +01:00
Björn Otgaar
c1ef924be1 feat: create dialog for plan creation in triggers, make sure to bind the correct things in triggers. Change the norms to take one condition, rather than a list. yes, tests are probably still broken.
ref: N25B-412
2025-12-16 18:21:19 +01:00
Björn Otgaar
0b29cb5858 chore: remove console log
ref: N25B-392
2025-12-16 15:41:30 +01:00
Björn Otgaar
fcc279fb31 Merge branch 'demo' into feat/conditional-norm 2025-12-16 14:55:06 +01:00
Björn Otgaar
709dd28959 fix: fixing the tests
ref: N25B-392
2025-12-16 14:52:57 +01:00
Björn Otgaar
099afebe98 test: extra norm tests
ref: N25B-392
2025-12-16 14:31:00 +01:00
Björn Otgaar
8d4c3fc64b feat: add conditions and beliefs, add tests
ref: N25B-392
2025-12-16 12:03:48 +01:00
Björn Otgaar
7925023f25 fix: fix issues ariving from dev merge
ref: N25B-408
2025-12-15 14:51:58 +01:00
Björn Otgaar
2faa42bd4c Merge branch 'dev' into feat/basic-belief-nodes 2025-12-15 14:47:01 +01:00
Björn Otgaar
ae8ef317a4 test: tests for belief node
ref: N25B-408
2025-12-15 13:04:53 +01:00
Björn Otgaar
757435e9f8 fix: fix the tests and creation of nodes.
ref: N25B-408
2025-12-15 12:09:53 +01:00
Björn Otgaar
f22fe38e22 fix: revert the reduce change for eslint- might be done later in other way
ref: N25B-408
2025-12-15 12:01:39 +01:00
Björn Otgaar
9d4f10213e fix: update the recducer in phases to account for node-specific reducing
ref: N25B-408
2025-12-15 11:59:12 +01:00
Björn Otgaar
10d5a15c88 feat: basic belief node with the basic belief types defined in KB.
ref: N25B-408
2025-12-11 14:12:26 +01:00
77 changed files with 8825 additions and 530 deletions

13
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
@@ -3698,9 +3699,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz",
"integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==",
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4869,9 +4870,9 @@
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"license": "ISC",
"dependencies": {

View File

@@ -28,6 +28,7 @@
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",

View File

@@ -248,3 +248,11 @@ button.no-button {
text-decoration: underline;
}
}
.flex-center-x {
display: flex;
justify-content: center; /* horizontal centering */
text-align: center; /* center multi-line text */
width: 100%; /* allow it to stretch */
flex-wrap: wrap; /* optional: let text wrap naturally */
}

View File

@@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
import {useState} from "react";
import Logging from "./components/Logging/Logging.tsx";
function App(){
const [showLogs, setShowLogs] = useState(false);

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef, useState } from "react";
import styles from "./TextField.module.css";
export function MultilineTextField({
value = "",
setValue,
placeholder,
className,
id,
ariaLabel,
invalid = false,
minRows = 3,
}: {
value: string;
setValue: (value: string) => void;
placeholder?: string;
className?: string;
id?: string;
ariaLabel?: string;
invalid?: boolean;
minRows?: number;
}) {
const [readOnly, setReadOnly] = useState(true);
const [inputValue, setInputValue] = useState(value);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setInputValue(value);
}, [value]);
// Auto-grow logic
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [inputValue]);
const onCommit = () => {
setReadOnly(true);
setValue(inputValue);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
(e.target as HTMLTextAreaElement).blur();
}
};
return (
<textarea
ref={textareaRef}
rows={minRows}
placeholder={placeholder}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onFocus={() => setReadOnly(false)}
onBlur={onCommit}
onKeyDown={onKeyDown}
readOnly={readOnly}
id={id}
aria-label={ariaLabel}
className={`
${readOnly ? "drag" : "nodrag"}
flex-1
${styles.textField}
${styles.multiline}
${invalid ? styles.invalid : ""}
${className ?? ""}
`}
/>
);
}

View File

@@ -2,6 +2,8 @@
border: 1px solid transparent;
border-radius: 5pt;
padding: 4px 8px;
max-width: 50vw;
min-width: 10vw;
outline: none;
background-color: canvas;
transition: border-color 0.2s, box-shadow 0.2s;
@@ -25,3 +27,13 @@
.text-field:read-only:hover:not(.invalid) {
border-color: color-mix(in srgb, canvas, #777 10%);
}
.multiline {
resize: none; /* no manual resizing */
line-height: 1.4;
white-space: pre-wrap;
overflow: hidden; /* needed for auto-grow */
max-width: 100%;
width: 95%;
min-width: 95%;
}

View File

@@ -63,7 +63,7 @@ export function RealtimeTextField({
readOnly={readOnly}
id={id}
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
className={`${readOnly ? "drag" : "nodrag"} flex-1 ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`}
aria-label={ariaLabel}
/>;
}

View File

@@ -8,6 +8,9 @@
background-color: #242424;
--accent-color: #008080;
--panel-shadow:
0 1px 2px white,
0 8px 24px rgba(190, 186, 186, 0.253);
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -15,6 +18,14 @@
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--panel-shadow:
0 1px 2px rgba(221, 221, 221, 0.178),
0 8px 24px rgba(27, 27, 27, 0.507);
}
}
html, body, #root {
margin: 0;
padding: 0;
@@ -59,8 +70,21 @@ button:focus-visible {
background-color: #ffffff;
--accent-color: #00AAAA;
--select-color: rgba(gray);
--dropdown-menu-background-color: rgb(247, 247, 247);
--dropdown-menu-border: rgba(207, 207, 207, 0.986);
}
button {
background-color: #f9f9f9;
}
}
@media (prefers-color-scheme: dark) {
:root {
color: #ffffff;
--select-color: rgba(gray);
--dropdown-menu-background-color: rgba(39, 39, 39, 0.986);
--dropdown-menu-border: rgba(65, 65, 65, 0.986);
}
}

View File

@@ -0,0 +1,276 @@
.dashboardContainer {
display: grid;
grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */
grid-template-rows: auto 1fr auto; /* Header, Main, Footer */
grid-template-areas:
"header logs"
"main logs"
"footer footer";
gap: 1rem;
padding: 1rem;
background-color: var(--bg-main);
color: var(--text-main);
font-family: Arial, sans-serif;
}
/* HEADER */
.experimentOverview {
grid-area: header;
display: flex;
color: color;
justify-content: space-between;
align-items: flex-start;
background: var(--bg-surface);
color: var(--text-main);
box-shadow: var(--shadow);
padding: 1rem;
box-shadow: var(--panel-shadow);
position: static; /* ensures it scrolls away */
}
.phaseProgress {
margin-top: 0.5rem;
}
.phase {
display: inline-block;
width: 25px;
height: 25px;
margin: 0 3px;
text-align: center;
line-height: 25px;
background: gray;
}
.completed {
background-color: green;
color: white;
}
.current {
background-color: rgb(255, 123, 0);
color: white;
}
.connected {
color: green;
font-weight: bold;
}
.disconnected {
color: red;
font-weight: bold;
}
.pausePlayInactive{
background-color: gray;
color: white;
}
.pausePlayActive{
background-color: green;
color: white;
}
.next {
background-color: #6c757d;
color: white;
}
.restartExperiment{
background-color: red;
color: white;
}
/* MAIN GRID */
.phaseOverview {
grid-area: main;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, auto);
gap: 1rem;
background: var(--bg-surface);
color: var(--text-main);
padding: 1rem;
box-shadow: var(--panel-shadow);
}
.phaseBox {
background: var(--bg-surface);
border: 1px solid var(--border-color);
box-shadow: var(--panel-shadow);
padding: 1rem;
display: flex;
flex-direction: column;
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05);
height: 250px;
}
.phaseBox ul {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
flex-grow: 1;
}
.phaseBox ul::-webkit-scrollbar {
width: 6px;
}
.phaseBox ul::-webkit-scrollbar-thumb {
background-color: #ccc;
border-radius: 10px;
}
.phaseOverviewText {
grid-column: 1 / -1; /* make the title span across both columns */
font-size: 1.4rem;
font-weight: 600;
margin: 0; /* remove default section margin */
padding: 0.25rem 0; /* smaller internal space */
}
.phaseOverviewText h3{
margin: 0; /* removes top/bottom whitespace */
padding: 0; /* keeps spacing tight */
}
.phaseBox h3 {
margin-top: 0;
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.4rem;
}
.checked::before {
content: '✔️ ';
}
.statusIndicator {
display: inline-block;
margin-right: 10px;
user-select: none;
transition: transform 0.1s ease;
font-size: 1.1rem;
}
.statusIndicator.clickable {
cursor: pointer;
}
.statusIndicator.clickable:hover {
transform: scale(1.2);
}
.clickable {
cursor: pointer;
}
.clickable:hover {
transform: scale(1.2);
}
.active {
opacity: 1;
}
.statusItem {
display: flex;
align-items: center;
margin-bottom: 0.4rem;
}
.itemDescription {
line-height: 1.4;
}
/* LOGS */
.logs {
grid-area: logs;
background: var(--bg-surface);
color: var(--text-main);
box-shadow: var(--panel-shadow);
padding: 1rem;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.logs textarea {
width: 100%;
height: 83%;
margin-top: 0.5rem;
background-color: Canvas;
color: CanvasText;
border: 1px solid var(--border-color);
}
.logs button {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
/* FOOTER */
.controlsSection {
grid-area: footer;
display: flex;
justify-content: space-between;
gap: 1rem;
background: var(--bg-surface);
color: var(--text-main);
box-shadow: var(--panel-shadow);
padding: 1rem;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
}
.controlsSection button {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
.gestures,
.speech,
.directSpeech {
flex: 1;
}
.speechInput {
display: flex;
margin-top: 0.5rem;
}
.speechInput input {
flex: 1;
padding: 0.5rem;
background-color: Canvas;
color: CanvasText;
border: 1px solid var(--border-color);
}
.speechInput button {
color: white;
border: none;
padding: 0.5rem 1rem;
cursor: pointer;
background-color: Canvas;
color: CanvasText;
border: 1px solid var(--border-color);
}
/* RESPONSIVE */
@media (max-width: 900px) {
.phaseOverview {
grid-template-columns: 1fr;
}
.controlsSection {
flex-direction: column;
}
}

View File

@@ -0,0 +1,406 @@
import React, { useCallback, useState } from 'react';
import styles from './MonitoringPage.module.css';
// Store & API
import useProgramStore from "../../utils/programStore";
import {
nextPhase,
useExperimentLogger,
useStatusLogger,
pauseExperiment,
playExperiment,
type ExperimentStreamData,
type GoalUpdate,
type TriggerUpdate,
type CondNormsStateUpdate,
type PhaseUpdate
} from "./MonitoringPageAPI";
import { graphReducer, runProgramm } from '../VisProgPage/VisProgLogic.ts';
// Types
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode';
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode';
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode';
// Sub-components
import {
GestureControls,
SpeechPresets,
DirectSpeechInput,
StatusList,
RobotConnected
} from './MonitoringPageComponents';
// ----------------------------------------------------------------------
// 1. State management
// ----------------------------------------------------------------------
/**
* Manages the state of the active experiment, including phase progression,
* goal tracking, and stream event listeners.
*/
function useExperimentLogic() {
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
const setProgramState = useProgramStore((state) => state.setProgramState);
const [loading, setLoading] = useState(false);
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
const [goalIndex, setGoalIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [phaseIndex, setPhaseIndex] = useState(0);
const [isFinished, setIsFinished] = useState(false);
const phaseIds = getPhaseIds();
const phaseNames = getPhaseNames();
// --- Stream Handlers ---
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
if (data.type === 'phase_update' && data.id) {
const payload = data as PhaseUpdate;
console.log(`${data.type} received, id : ${data.id}`);
if (payload.id === "end") {
setIsFinished(true);
} else {
setIsFinished(false);
const newIndex = getPhaseIds().indexOf(payload.id);
if (newIndex !== -1) {
setPhaseIndex(newIndex);
setGoalIndex(0);
}
}
}
else if (data.type === 'goal_update') {
const payload = data as GoalUpdate;
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
console.log(`${data.type} received, id : ${data.id}`);
if (gIndex === -1) {
console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`);
} else {
setGoalIndex(gIndex);
// Mark all previous goals as achieved
setActiveIds((prev) => {
const nextState = { ...prev };
for (let i = 0; i < gIndex; i++) {
nextState[currentPhaseGoals[i].id] = true;
}
return nextState;
});
}
}
else if (data.type === 'trigger_update') {
const payload = data as TriggerUpdate;
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
const handleStatusUpdate = useCallback((data: unknown) => {
const payload = data as CondNormsStateUpdate;
if (payload.type !== 'cond_norms_state_update') return;
setActiveIds((prev) => {
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
if (!hasChanges) return prev;
const nextState = { ...prev };
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
return nextState;
});
}, []);
// Connect listeners
useExperimentLogger(handleStreamUpdate);
useStatusLogger(handleStatusUpdate);
// --- Actions ---
const resetExperiment = useCallback(async () => {
try {
setLoading(true);
const phases = graphReducer();
setProgramState({ phases });
setActiveIds({});
setPhaseIndex(0);
setGoalIndex(0);
setIsFinished(false);
await runProgramm();
console.log("Experiment & UI successfully reset.");
} catch (err) {
console.error("Failed to reset program:", err);
} finally {
setLoading(false);
}
}, [setProgramState]);
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
try {
setLoading(true);
switch (action) {
case "pause":
setIsPlaying(false);
await pauseExperiment();
break;
case "play":
setIsPlaying(true);
await playExperiment();
break;
case "nextPhase":
await nextPhase();
break;
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
return {
loading,
isPlaying,
isFinished,
phaseIds,
phaseNames,
phaseIndex,
goalIndex,
activeIds,
setActiveIds,
resetExperiment,
handleControlAction,
};
}
// ----------------------------------------------------------------------
// 2. Smaller Presentation Components
// ----------------------------------------------------------------------
/**
* Visual indicator of progress through experiment phases.
*/
function PhaseProgressBar({
phaseIds,
phaseIndex,
isFinished
}: {
phaseIds: string[],
phaseIndex: number,
isFinished: boolean
}) {
return (
<div className={styles.phaseProgress}>
{phaseIds.map((id, index) => {
let statusClass = "";
if (isFinished || index < phaseIndex) statusClass = styles.completed;
else if (index === phaseIndex) statusClass = styles.current;
return (
<span key={id} className={`${styles.phase} ${statusClass}`}>
{index + 1}
</span>
);
})}
</div>
);
}
/**
* Main control buttons (Play, Pause, Next, Reset).
*/
function ControlPanel({
loading,
isPlaying,
onAction,
onReset
}: {
loading: boolean,
isPlaying: boolean,
onAction: (a: "pause" | "play" | "nextPhase") => void,
onReset: () => void
}) {
return (
<div className={styles.experimentControls}>
<h3>Experiment Controls</h3>
<div className={styles.controlsButtons}>
<button
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
onClick={() => onAction("pause")}
disabled={loading}
></button>
<button
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
onClick={() => onAction("play")}
disabled={loading}
></button>
<button
className={styles.next}
onClick={() => onAction("nextPhase")}
disabled={loading}
></button>
<button
className={styles.restartExperiment}
onClick={onReset}
disabled={loading}
></button>
</div>
</div>
);
}
/**
* Displays lists of Goals, Triggers, and Norms for the current phase.
*/
function PhaseDashboard({
phaseId,
activeIds,
setActiveIds,
goalIndex
}: {
phaseId: string,
activeIds: Record<string, boolean>,
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
goalIndex: number
}) {
const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth);
const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
const getNorms = useProgramStore((s) => s.getNormsInPhase);
// Prepare data view models
const goals = getGoalsWithDepth(phaseId).map((g) => ({
...g,
id: g.id as string,
name: g.name as string,
achieved: activeIds[g.id as string] ?? false,
level: g.level, // Pass this new property to the UI
}));
const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({
...t,
achieved: activeIds[t.id] ?? false,
}));
const norms = (getNorms(phaseId) as NormNodeData[])
.filter(n => !n.condition)
.map(n => ({ ...n, label: n.norm }));
const conditionalNorms = (getNorms(phaseId) as (NormNodeData & { id: string })[])
.filter(n => !!n.condition)
.map(n => ({
...n,
achieved: activeIds[n.id] ?? false
}));
return (
<>
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds={setActiveIds} currentGoalIndex={goalIndex} />
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
</>
);
}
// ----------------------------------------------------------------------
// 3. Main Component
// ----------------------------------------------------------------------
const MonitoringPage: React.FC = () => {
const {
loading,
isPlaying,
isFinished,
phaseIds,
phaseNames,
phaseIndex,
goalIndex,
activeIds,
setActiveIds,
resetExperiment,
handleControlAction
} = useExperimentLogic();
if (phaseIds.length === 0) {
return <p className={styles.empty}>No program loaded.</p>;
}
return (
<div className={styles.dashboardContainer}>
{/* HEADER */}
<header className={styles.experimentOverview}>
<div className={styles.phaseName}>
<h2>Experiment Overview</h2>
<p>
{isFinished ? (
<strong>Experiment finished</strong>
) : (
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
)}
</p>
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
</div>
<ControlPanel
loading={loading}
isPlaying={isPlaying}
onAction={handleControlAction}
onReset={resetExperiment}
/>
<div className={styles.connectionStatus}>
<RobotConnected />
</div>
</header>
{/* MAIN GRID */}
<main className={styles.phaseOverview}>
<section className={styles.phaseOverviewText}>
<h3>Phase Overview</h3>
</section>
{isFinished ? (
<div className={styles.finishedMessage}>
<p>All phases have been successfully completed.</p>
</div>
) : (
<PhaseDashboard
phaseId={phaseIds[phaseIndex]}
activeIds={activeIds}
setActiveIds={setActiveIds}
goalIndex={goalIndex}
/>
)}
</main>
{/* LOGS TODO: add actual logs */}
<aside className={styles.logs}>
<h3>Logs</h3>
<div className={styles.logHeader}>
<span>Global:</span>
<button>ALL</button>
<button>Add</button>
<button className={styles.live}>Live</button>
</div>
<textarea defaultValue="Example Log: much log"></textarea>
</aside>
{/* FOOTER */}
<footer className={styles.controlsSection}>
<GestureControls />
<SpeechPresets />
<DirectSpeechInput />
</footer>
</div>
);
}
export default MonitoringPage;

View File

@@ -0,0 +1,121 @@
import React, { useEffect } from 'react';
const API_BASE = "http://localhost:8000";
const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint
/**
* HELPER: Unified sender function
*/
export const sendAPICall = async (type: string, context: string, endpoint?: string) => {
try {
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, context }),
});
if (!response.ok) throw new Error("Backend response error");
console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`);
} catch (err) {
console.error(`Failed to send api call:`, err);
}
};
/**
* Sends an API call to the CB for going to the next phase.
* In case we can't go to the next phase, the function will throw an error.
*/
export async function nextPhase(): Promise<void> {
const type = "next_phase"
const context = ""
sendAPICall(type, context)
}
/**
* Sends an API call to the CB for going to pause experiment
*/
export async function pauseExperiment(): Promise<void> {
const type = "pause"
const context = "true"
sendAPICall(type, context)
}
/**
* Sends an API call to the CB for going to resume experiment
*/
export async function playExperiment(): Promise<void> {
const type = "pause"
const context = "false"
sendAPICall(type, context)
}
/**
* Types for the experiment stream messages
*/
export type PhaseUpdate = { type: 'phase_update'; id: string };
export type GoalUpdate = { type: 'goal_update'; id: string };
export type TriggerUpdate = { type: 'trigger_update'; id: string; achieved: boolean };
export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { id: string; active: boolean }[] };
export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record<string, unknown>;
/**
* A hook that listens to the experiment stream that updates current state of the program
* via updates sent from the backend
*/
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
const callbackRef = React.useRef(onUpdate);
// Ref is updated every time with on update
React.useEffect(() => {
callbackRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
console.log("Connecting to Experiment Stream...");
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
//call function using the ref
callbackRef.current?.(parsedData);
} catch (err) {
console.warn("Stream parse error:", err);
}
};
eventSource.onerror = (err) => {
console.error("SSE Connection Error:", err);
eventSource.close();
};
return () => {
console.log("Closing Experiment Stream...");
eventSource.close();
};
}, []);
}
/**
* A hook that listens to the status stream that updates active conditional norms
* via updates sent from the backend
*/
export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) {
const callbackRef = React.useRef(onUpdate);
React.useEffect(() => {
callbackRef.current = onUpdate;
}, [onUpdate]);
useEffect(() => {
const eventSource = new EventSource(`${API_BASE}/status_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
callbackRef.current?.(parsedData);
} catch (err) { console.warn("Status stream error:", err); }
};
return () => eventSource.close();
}, []);
}

View File

@@ -0,0 +1,232 @@
import React, { useEffect, useState } from 'react';
import styles from './MonitoringPage.module.css';
import { sendAPICall } from './MonitoringPageAPI';
// --- GESTURE COMPONENT ---
export const GestureControls: React.FC = () => {
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
const gestures = [
{ label: "Wave", value: "animations/Stand/Gestures/Hey_1" },
{ label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" },
{ label: "Explain", value: "animations/Stand/Gestures/Explain_4" },
{ label: "You", value: "animations/Stand/Gestures/You_1" },
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
{ label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" },
{ label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" },
{ label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" },
{ label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" },
{ label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" },
];
return (
<div className={styles.gestures}>
<h4>Gestures</h4>
<div className={styles.gestureInputGroup}>
<select
value={selectedGesture}
onChange={(e) => setSelectedGesture(e.target.value)}
>
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
</select>
<button onClick={() => sendAPICall("gesture", selectedGesture)}>
Actuate
</button>
</div>
</div>
);
};
// --- PRESET SPEECH COMPONENT ---
export const SpeechPresets: React.FC = () => {
const phrases = [
{ label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" },
{ label: "Repeat please", text: "Could you repeat that please" },
{ label: "About yourself", text: "Tell me something about yourself" },
];
return (
<div className={styles.speech}>
<h4>Speech Presets</h4>
<ul>
{phrases.map((phrase, i) => (
<li key={i}>
<button
className={styles.speechBtn}
onClick={() => sendAPICall("speech", phrase.text)}
>
"{phrase.label}"
</button>
</li>
))}
</ul>
</div>
);
};
// --- DIRECT SPEECH (INPUT) COMPONENT ---
export const DirectSpeechInput: React.FC = () => {
const [text, setText] = useState("");
const handleSend = () => {
if (!text.trim()) return;
sendAPICall("speech", text);
setText(""); // Clear after sending
};
return (
<div className={styles.directSpeech}>
<h4>Direct Pepper Speech</h4>
<div className={styles.speechInput}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Type message..."
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button onClick={handleSend}>Send</button>
</div>
</div>
);
};
// --- interface for goals/triggers/norms/conditional norms ---
export type StatusItem = {
id?: string | number;
achieved?: boolean;
description?: string;
label?: string;
norm?: string;
name?: string;
level?: number;
};
interface StatusListProps {
title: string;
items: StatusItem[];
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
activeIds: Record<string, boolean>;
setActiveIds?: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
currentGoalIndex?: number;
}
// --- STATUS LIST COMPONENT ---
export const StatusList: React.FC<StatusListProps> = ({
title,
items,
type,
activeIds,
setActiveIds,
currentGoalIndex // Destructure this prop
}) => {
return (
<section className={styles.phaseBox}>
<h3>{title}</h3>
<ul>
{items.map((item, idx) => {
if (item.id === undefined) return null;
const isActive = !!activeIds[item.id];
const showIndicator = type !== 'norm';
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
const indentation = (item.level || 0) * 20;
const handleOverrideClick = () => {
if (!canOverride) return;
if (type === 'cond_norm' && isActive){
{/* Unachieve conditional norm */}
sendAPICall("override_unachieve", String(item.id));
}
else {
if(type === 'goal')
if(setActiveIds)
{setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));}
sendAPICall("override", String(item.id));
}
};
return (
<li key={item.id ?? idx}
className={styles.statusItem}
style={{ paddingLeft: `${indentation}px` }}
>
{showIndicator && (
<span
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}
onClick={handleOverrideClick}
>
{isActive ? "✔️" : "❌"}
</span>
)}
<span
className={styles.itemDescription}
style={{
// Visual Feedback
textDecoration: isCurrentGoal ? 'underline' : 'none',
fontWeight: isCurrentGoal ? 'bold' : 'normal',
color: isCurrentGoal ? '#007bff' : 'inherit',
backgroundColor: isCurrentGoal ? '#e7f3ff' : 'transparent', // Added subtle highlight
padding: isCurrentGoal ? '2px 4px' : '0',
borderRadius: '4px'
}}
>
{item.name || item.norm}
{isCurrentGoal && " (Current)"}
</span>
</li>
);
})}
</ul>
</section>
);
};
// --- Robot Connected ---
export const RobotConnected = () => {
/**
* The current connection state:
* - `true`: Robot is connected.
* - `false`: Robot is not connected.
* - `null`: Connection status is unknown (initial check in progress).
*/
const [connected, setConnected] = useState<boolean | null>(null);
useEffect(() => {
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
// We're expecting a stream of data like that looks like this: `data = False` or `data = True`
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
eventSource.onmessage = (event) => {
// Expecting messages in JSON format: `true` or `false`
//commented out this log as it clutters console logs, but might be useful to debug
//console.log("received message:", event.data);
try {
const data = JSON.parse(event.data);
try {
setConnected(data)
}
catch {
console.log("couldnt extract connected from incoming ping data")
}
} catch {
console.log("Ping message not in correct format:", event.data);
}
};
// Clean up the SSE connection when the component unmounts.
return () => eventSource.close();
}, []);
return (
<div>
<h3>Connection:</h3>
<p className={connected ? styles.connected : styles.disconnected }>{connected ? "● Robot is connected" : "● Robot is disconnected"}</p>
</div>
)
}

View File

@@ -0,0 +1,167 @@
/* ---------- Layout ---------- */
.container {
height: 100%;
display: flex;
flex-direction: column;
background: #1e1e1e;
color: #f5f5f5;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: clamp(0.75rem, 2vw, 1.25rem);
background: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
.header h2 {
font-size: clamp(1rem, 2.2vw, 1.4rem);
font-weight: 600;
}
.controls button {
margin-left: 0.5rem;
padding: 0.4rem 0.9rem;
border-radius: 6px;
border: none;
background: #111;
color: white;
cursor: pointer;
}
.controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Content ---------- */
.content {
flex: 1;
padding: 2%;
}
/* ---------- Grid ---------- */
.phaseGrid {
height: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 2%;
}
/* ---------- Box ---------- */
.box {
display: flex;
flex-direction: column;
background: #ffffff;
color: #1e1e1e;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25);
}
.boxHeader {
padding: 0.6rem 0.9rem;
background: linear-gradient(135deg, #dcdcdc, #e9e9e9);
font-style: italic;
font-weight: 500;
font-size: clamp(0.9rem, 1.5vw, 1.05rem);
border-bottom: 1px solid #cfcfcf;
}
.boxContent {
flex: 1;
padding: 0.8rem 1rem;
overflow-y: auto;
}
/* ---------- Lists ---------- */
.iconList {
list-style: none;
padding: 0;
margin: 0;
}
.iconList li {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.5rem;
font-size: clamp(0.85rem, 1.3vw, 1rem);
}
.bulletList {
margin: 0;
padding-left: 1.2rem;
}
.bulletList li {
margin-bottom: 0.4rem;
}
/* ---------- Icons ---------- */
.successIcon,
.failIcon {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
height: 1.5rem;
border-radius: 4px;
font-weight: bold;
color: white;
flex-shrink: 0;
}
.successIcon {
background: #3cb371;
}
.failIcon {
background: #e5533d;
}
/* ---------- Empty ---------- */
.empty {
opacity: 0.55;
font-style: italic;
font-size: 0.9rem;
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.phaseGrid {
grid-template-columns: 1fr;
grid-template-rows: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
}
.leftControls {
display: flex;
align-items: center;
gap: 1rem;
}
.backButton {
background: transparent;
border: 1px solid #555;
color: #ddd;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.backButton:hover {
background: #333;
}

View File

@@ -0,0 +1,192 @@
import React from "react";
import styles from "./SimpleProgram.module.css";
import useProgramStore from "../../utils/programStore.ts";
/**
* Generic container box with a header and content area.
*/
type BoxProps = {
title: string;
children: React.ReactNode;
};
const Box: React.FC<BoxProps> = ({ title, children }) => (
<div className={styles.box}>
<div className={styles.boxHeader}>{title}</div>
<div className={styles.boxContent}>{children}</div>
</div>
);
/**
* Renders a list of goals for a phase.
* Expects goal-like objects from the program store.
*/
const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => {
if (!goals.length) {
return <p className={styles.empty}>No goals defined.</p>;
}
return (
<ul className={styles.iconList}>
{goals.map((g, idx) => {
const goal = g as {
id?: string;
description?: string;
achieved?: boolean;
};
return (
<li key={goal.id ?? idx}>
<span
className={
goal.achieved ? styles.successIcon : styles.failIcon
}
>
{goal.achieved ? "✔" : "✖"}
</span>
{goal.description ?? "Unnamed goal"}
</li>
);
})}
</ul>
);
};
/**
* Renders a list of triggers for a phase.
*/
const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => {
if (!triggers.length) {
return <p className={styles.empty}>No triggers defined.</p>;
}
return (
<ul className={styles.iconList}>
{triggers.map((t, idx) => {
const trigger = t as {
id?: string;
label?: string;
};
return (
<li key={trigger.id ?? idx}>
<span className={styles.failIcon}></span>
{trigger.label ?? "Unnamed trigger"}
</li>
);
})}
</ul>
);
};
/**
* Renders a list of norms for a phase.
*/
const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => {
if (!norms.length) {
return <p className={styles.empty}>No norms defined.</p>;
}
return (
<ul className={styles.bulletList}>
{norms.map((n, idx) => {
const norm = n as {
id?: string;
norm?: string;
};
return <li key={norm.id ?? idx}>{norm.norm ?? "Unnamed norm"}</li>;
})}
</ul>
);
};
/**
* Displays all phase-related information in a grid layout.
*/
type PhaseGridProps = {
norms: unknown[];
goals: unknown[];
triggers: unknown[];
};
const PhaseGrid: React.FC<PhaseGridProps> = ({
norms,
goals,
triggers,
}) => (
<div className={styles.phaseGrid}>
<Box title="Norms">
<NormList norms={norms} />
</Box>
<Box title="Triggers">
<TriggerList triggers={triggers} />
</Box>
<Box title="Goals">
<GoalList goals={goals} />
</Box>
<Box title="Conditional Norms">
<p className={styles.empty}>No conditional norms defined.</p>
</Box>
</div>
);
/**
* Main program viewer.
* Reads all data from the program store and allows
* navigating between phases.
*/
const SimpleProgram: React.FC = () => {
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
const phaseIds = getPhaseIds();
const [phaseIndex, setPhaseIndex] = React.useState(0);
if (phaseIds.length === 0) {
return <p className={styles.empty}>No program loaded.</p>;
}
const phaseId = phaseIds[phaseIndex];
return (
<div className={styles.container}>
<header className={styles.header}>
<h2>
Phase {phaseIndex + 1} / {phaseIds.length}
</h2>
<div className={styles.controls}>
<button
disabled={phaseIndex === 0}
onClick={() => setPhaseIndex((i) => i - 1)}
>
Prev
</button>
<button
disabled={phaseIndex === phaseIds.length - 1}
onClick={() => setPhaseIndex((i) => i + 1)}
>
Next
</button>
</div>
</header>
<div className={styles.content}>
<PhaseGrid
norms={getNormsInPhase(phaseId)}
goals={getGoalsInPhase(phaseId)}
triggers={getTriggersInPhase(phaseId)}
/>
</div>
</div>
);
};
export default SimpleProgram;

View File

@@ -33,6 +33,12 @@
/* Node Styles */
:global(.react-flow__node.selected) {
outline: 1px dashed blue !important;
border-radius: 5pt;
outline-offset: 4px;
}
.default-node {
padding: 10px 15px;
background-color: canvas;
@@ -41,6 +47,8 @@
filter: drop-shadow(0 0 0.75rem black);
}
.node-norm {
outline: rgb(0, 149, 25) solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
@@ -71,10 +79,21 @@
filter: drop-shadow(0 0 0.25rem red);
}
.node-basic_belief {
outline: plum solid 2pt;
filter: drop-shadow(0 0 0.25rem plum);
}
.node-inferred_belief {
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.draggable-node {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: black solid 2pt;
filter: drop-shadow(0 0 0.75rem black);
}
@@ -83,6 +102,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: forestgreen solid 2pt;
filter: drop-shadow(0 0 0.25rem forestgreen);
}
@@ -91,6 +111,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: yellow solid 2pt;
filter: drop-shadow(0 0 0.25rem yellow);
}
@@ -99,6 +120,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: teal solid 2pt;
filter: drop-shadow(0 0 0.25rem teal);
}
@@ -107,6 +129,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: dodgerblue solid 2pt;
filter: drop-shadow(0 0 0.25rem dodgerblue);
}
@@ -115,6 +138,7 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: orange solid 2pt;
filter: drop-shadow(0 0 0.25rem orange);
}
@@ -123,6 +147,99 @@
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: red solid 2pt;
filter: drop-shadow(0 0 0.25rem red);
}
.draggable-node-basic_belief {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
cursor: move;
outline: plum solid 2pt;
filter: drop-shadow(0 0 0.25rem plum);
}
.draggable-node-inferred_belief {
padding: 3px 10px;
background-color: canvas;
border-radius: 5pt;
outline: mediumpurple solid 2pt;
filter: drop-shadow(0 0 0.25rem mediumpurple);
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}
.bottomLeftHandle {
left: 40% !important;
}
.bottomRightHandle {
left: 60% !important;
}
.planNoIterate {
opacity: 0.5;
font-style: italic;
text-decoration: line-through;
}
.backButton {
background: var(--bg-surface);
box-shadow: var(--panel-shadow);
margin-top: 0.5rem;
margin-left: 0.5rem;
}
.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

@@ -7,14 +7,17 @@ import {
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {useEffect} from "react";
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css'
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import {NodeTypes} from './visualProgrammingUI/NodeRegistry.ts';
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx';
import { graphReducer, runProgramm } from './VisProgLogic.ts';
// --| config starting params for flow |--
@@ -48,7 +51,8 @@ const selector = (state: FlowState) => ({
undo: state.undo,
redo: state.redo,
beginBatchAction: state.beginBatchAction,
endBatchAction: state.endBatchAction
endBatchAction: state.endBatchAction,
scrollable: state.scrollable
});
// --| define ReactFlow editor |--
@@ -72,9 +76,10 @@ const VisProgUI = () => {
undo,
redo,
beginBatchAction,
endBatchAction
endBatchAction,
scrollable
} = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore
const [zoom, setZoom] = useState(1);
// adds ctrl+z and ctrl+y support to respectively undo and redo actions
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -86,7 +91,7 @@ const VisProgUI = () => {
});
return (
<div className={`${styles.innerEditorContainer} round-lg border-lg`}>
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<ReactFlow
nodes={nodes}
edges={edges}
@@ -101,6 +106,8 @@ const VisProgUI = () => {
onConnect={onConnect}
onNodeDragStart={beginBatchAction}
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
@@ -112,7 +119,7 @@ const VisProgUI = () => {
<SaveLoadPanel></SaveLoadPanel>
</Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>undo</button>
<button onClick={() => undo()}>Undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Controls/>
@@ -136,37 +143,7 @@ function VisualProgrammingUI() {
</ReactFlowProvider>
);
}
// currently outputs the prepared program to the console
function runProgram() {
const phases = graphReducer();
const program = {phases}
console.log(JSON.stringify(program, null, 2));
fetch(
"http://localhost:8000/program",
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(program),
}
).then((res) => {
if (!res.ok) throw new Error("Failed communicating with the backend.")
console.log("Successfully sent the program to the backend.");
}).catch(() => console.log("Failed to send program to the backend."));
}
/**
* 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)
});
}
/**
* houses the entire page, so also UI elements
@@ -174,10 +151,31 @@ function graphReducer() {
* @constructor
*/
function VisProgPage() {
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
const setProgramState = useProgramStore((state) => state.setProgramState);
const runProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
setShowSimpleProgram(true); // show SimpleProgram
runProgramm(); // send to backend if needed
};
if (showSimpleProgram) {
return (
<div>
<button className={styles.backButton} onClick={() => setShowSimpleProgram(false)}>
Back to Editor
</button>
<MonitoringPage/>
</div>
);
}
return (
<>
<VisualProgrammingUI/>
<button onClick={runProgram}>run program</button>
<button onClick={runProgram}>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 runProgramm() {
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

@@ -39,10 +39,10 @@ export const UndoRedo = (
* @param {BaseFlowState} state - the current state of the editor
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
*/
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes,
edges: state.edges
});
}));
const initialState = config(set, get, api);

View File

@@ -0,0 +1,122 @@
import {type Connection} from "@xyflow/react";
import {useEffect} from "react";
import useFlowStore from "./VisProgStores.tsx";
export type ConnectionContext = {
connectionCount: number;
source: {
id: string;
handleId: string;
}
target: {
id: string;
handleId: string;
}
}
export type HandleRule = (
connection: Connection,
context: ConnectionContext
) => RuleResult;
/**
* A RuleResult describes the outcome of validating a HandleRule
*
* if a rule is not satisfied, the RuleResult includes a message that is used inside a tooltip
* that tells the user why their attempted connection is not possible
*/
export type RuleResult =
| { isSatisfied: true }
| { isSatisfied: false, message: string };
/**
* default RuleResults, can be used to create more readable handleRule definitions
*/
export const ruleResult = {
satisfied: { isSatisfied: true } as RuleResult,
unknownError: {isSatisfied: false, message: "Unknown Error" } as RuleResult,
notSatisfied: (message: string) : RuleResult => { return {isSatisfied: false, message: message } }
}
const evaluateRules = (
rules: HandleRule[],
connection: Connection,
context: ConnectionContext
) : RuleResult => {
// evaluate the rules and check if there is at least one unsatisfied rule
const failedRule = rules
.map(rule => rule(connection, context))
.find(result => !result.isSatisfied);
return failedRule ? ruleResult.notSatisfied(failedRule.message) : ruleResult.satisfied;
}
/**
* !DOCUMENTATION NOT FINISHED!
*
* - The output is a single RuleResult, meaning we only show one error message.
* Error messages are prioritised by listOrder; Thus, if multiple HandleRules evaluate to false,
* we only send the error message of the first failed rule in the target's registered list of rules.
*
* @param {string} nodeId
* @param {string} handleId
* @param type
* @param {HandleRule[]} rules
* @returns {(c: Connection) => RuleResult} a function that validates an attempted connection
*/
export function useHandleRules(
nodeId: string,
handleId: string,
type: "source" | "target",
rules: HandleRule[],
) : (c: Connection) => RuleResult {
const edges = useFlowStore.getState().edges;
const registerRules = useFlowStore((state) => state.registerRules);
useEffect(() => {
registerRules(nodeId, handleId, rules);
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
// however this would result in an infinite loop because it would change one of its own dependencies
// so we only use those dependencies that we don't change ourselves
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleId, nodeId, registerRules]);
return (connection: Connection) => {
// inside this function we consider the target to be the target of the isValidConnection event
// and not the target in the actual connection
const { target, targetHandle } = type === "source"
? connection
: { target: connection.source, targetHandle: connection.sourceHandle };
if (!targetHandle) {throw new Error("No target handle was provided");}
const targetConnections = edges.filter(edge => edge.target === target && edge.targetHandle === targetHandle);
// we construct the connectionContext
const context: ConnectionContext = {
connectionCount: targetConnections.length,
source: {id: nodeId, handleId: handleId},
target: {id: target, handleId: targetHandle},
};
const targetRules = useFlowStore.getState().getTargetRules(target, targetHandle);
// finally we return a function that evaluates all rules using the created context
return evaluateRules(targetRules, connection, context);
};
}
export function validateConnectionWithRules(
connection: Connection,
context: ConnectionContext
): RuleResult {
const rules = useFlowStore.getState().getTargetRules(
connection.target!,
connection.targetHandle!
);
return evaluateRules(rules,connection, context);
}

View File

@@ -0,0 +1,46 @@
import {
type HandleRule,
ruleResult
} from "./HandleRuleLogic.ts";
import useFlowStore from "./VisProgStores.tsx";
/**
* this specifies what types of nodes can make a connection to a handle that uses this rule
*/
export function allowOnlyConnectionsFromType(nodeTypes: string[]) : HandleRule {
return ((_, {source}) => {
const sourceType = useFlowStore.getState().nodes.find(node => node.id === source.id)!.type!;
return nodeTypes.find(type => sourceType === type)
? ruleResult.satisfied
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceType}`);
})
}
/**
* similar to allowOnlyConnectionsFromType,
* this is a more specific variant that allows you to restrict connections to specific handles on each nodeType
*/
//
export function allowOnlyConnectionsFromHandle(handles: {nodeType: string, handleId: string}[]) : HandleRule {
return ((_, {source}) => {
const sourceNode = useFlowStore.getState().nodes.find(node => node.id === source.id)!;
return handles.find(handle => sourceNode.type === handle.nodeType && source.handleId === handle.handleId)
? ruleResult.satisfied
: ruleResult.notSatisfied(`the target doesn't allow connections from nodes with type: ${sourceNode.type}`);
})
}
/**
* This rule prevents a node from making a connection between its own handles
*/
export const noSelfConnections : HandleRule =
(connection, _) => {
return connection.source !== connection.target
? ruleResult.satisfied
: ruleResult.notSatisfied("nodes are not allowed to connect to themselves");
}

View File

@@ -3,7 +3,8 @@ import EndNode, {
EndConnectionSource,
EndDisconnectionTarget,
EndDisconnectionSource,
EndReduce
EndReduce,
EndTooltip
} from "./nodes/EndNode";
import { EndNodeDefaults } from "./nodes/EndNode.default";
import StartNode, {
@@ -11,7 +12,8 @@ import StartNode, {
StartConnectionSource,
StartDisconnectionTarget,
StartDisconnectionSource,
StartReduce
StartReduce,
StartTooltip
} from "./nodes/StartNode";
import { StartNodeDefaults } from "./nodes/StartNode.default";
import PhaseNode, {
@@ -19,7 +21,8 @@ import PhaseNode, {
PhaseConnectionSource,
PhaseDisconnectionTarget,
PhaseDisconnectionSource,
PhaseReduce
PhaseReduce,
PhaseTooltip
} from "./nodes/PhaseNode";
import { PhaseNodeDefaults } from "./nodes/PhaseNode.default";
import NormNode, {
@@ -27,7 +30,8 @@ import NormNode, {
NormConnectionSource,
NormDisconnectionTarget,
NormDisconnectionSource,
NormReduce
NormReduce,
NormTooltip
} from "./nodes/NormNode";
import { NormNodeDefaults } from "./nodes/NormNode.default";
import GoalNode, {
@@ -35,7 +39,8 @@ import GoalNode, {
GoalConnectionSource,
GoalDisconnectionTarget,
GoalDisconnectionSource,
GoalReduce
GoalReduce,
GoalTooltip
} from "./nodes/GoalNode";
import { GoalNodeDefaults } from "./nodes/GoalNode.default";
import TriggerNode, {
@@ -43,9 +48,28 @@ import TriggerNode, {
TriggerConnectionSource,
TriggerDisconnectionTarget,
TriggerDisconnectionSource,
TriggerReduce
TriggerReduce,
TriggerTooltip
} from "./nodes/TriggerNode";
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
import InferredBeliefNode, {
InferredBeliefConnectionTarget,
InferredBeliefConnectionSource,
InferredBeliefDisconnectionTarget,
InferredBeliefDisconnectionSource,
InferredBeliefReduce, InferredBeliefTooltip
} from "./nodes/InferredBeliefNode";
import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default";
import BasicBeliefNode, {
BasicBeliefConnectionSource,
BasicBeliefConnectionTarget,
BasicBeliefDisconnectionSource,
BasicBeliefDisconnectionTarget,
BasicBeliefReduce
,
BasicBeliefTooltip
} from "./nodes/BasicBeliefNode.tsx";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
/**
* Registered node types in the visual programming system.
@@ -60,6 +84,8 @@ export const NodeTypes = {
norm: NormNode,
goal: GoalNode,
trigger: TriggerNode,
basic_belief: BasicBeliefNode,
inferred_belief: InferredBeliefNode,
};
/**
@@ -74,6 +100,8 @@ export const NodeDefaults = {
norm: NormNodeDefaults,
goal: GoalNodeDefaults,
trigger: TriggerNodeDefaults,
basic_belief: BasicBeliefNodeDefaults,
inferred_belief: InferredBeliefNodeDefaults,
};
@@ -90,6 +118,8 @@ export const NodeReduces = {
norm: NormReduce,
goal: GoalReduce,
trigger: TriggerReduce,
basic_belief: BasicBeliefReduce,
inferred_belief: InferredBeliefReduce,
}
@@ -107,6 +137,8 @@ export const NodeConnections = {
norm: NormConnectionTarget,
goal: GoalConnectionTarget,
trigger: TriggerConnectionTarget,
basic_belief: BasicBeliefConnectionTarget,
inferred_belief: InferredBeliefConnectionTarget,
},
Sources: {
start: StartConnectionSource,
@@ -115,6 +147,8 @@ export const NodeConnections = {
norm: NormConnectionSource,
goal: GoalConnectionSource,
trigger: TriggerConnectionSource,
basic_belief: BasicBeliefConnectionSource,
inferred_belief: InferredBeliefConnectionSource,
}
}
@@ -132,6 +166,8 @@ export const NodeDisconnections = {
norm: NormDisconnectionTarget,
goal: GoalDisconnectionTarget,
trigger: TriggerDisconnectionTarget,
basic_belief: BasicBeliefDisconnectionTarget,
inferred_belief: InferredBeliefDisconnectionTarget,
},
Sources: {
start: StartDisconnectionSource,
@@ -140,6 +176,8 @@ export const NodeDisconnections = {
norm: NormDisconnectionSource,
goal: GoalDisconnectionSource,
trigger: TriggerDisconnectionSource,
basic_belief: BasicBeliefDisconnectionSource,
inferred_belief: InferredBeliefDisconnectionSource,
},
}
@@ -151,7 +189,6 @@ export const NodeDisconnections = {
export const NodeDeletes = {
start: () => false,
end: () => false,
test: () => false, // Used for coverage of universal/ undefined nodes
}
/**
@@ -164,5 +201,20 @@ export const NodesInPhase = {
start: () => false,
end: () => false,
phase: () => false,
test: () => false, // Used for coverage of universal/ undefined nodes
basic_belief: () => false,
inferred_belief: () => false,
}
/**
* Collects the tooltips for all nodeTypes so they can be accessed by the tooltip component
*/
export const NodeTooltips = {
start: StartTooltip,
end: EndTooltip,
phase: PhaseTooltip,
norm: NormTooltip,
goal: GoalTooltip,
trigger: TriggerTooltip,
basic_belief: BasicBeliefTooltip,
inferred_belief: InferredBeliefTooltip,
}

View File

@@ -8,6 +8,8 @@ import {
type Edge,
type XYPosition,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -28,31 +30,31 @@ import { UndoRedo } from "./EditorUndoRedo.ts";
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
* @returns A fully initialized Node object ready to be added to the flow.
*/
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
const newData = {
id: id,
type: type,
position: position,
data: data,
deletable: deletable,
}
return {...defaultData, ...newData}
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
return {
id,
type,
position,
deletable,
data: {
...JSON.parse(JSON.stringify(defaultData)),
...data,
},
}
}
//* Initial nodes, created by using createNode. */
const initialNodes : Node[] = [
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}),
];
//* Initial nodes, created by using createNode. */
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
const startNode = createNode('start', 'start', {x: 110, y: 100}, {label: "Start"}, false)
const endNode = createNode('end', 'end', {x: 590, y: 100}, {label: "End"}, false)
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:235, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
// * Initial edges * /
const initialEdges: Edge[] = [
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
];
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
const initialEdges: Edge[] = [];
/**
@@ -70,14 +72,24 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
nodes: initialNodes,
edges: initialEdges,
edgeReconnectSuccessful: true,
scrollable: true,
/**
* handles changing the scrollable state of the editor,
* this is used to control if scrolling is captured by the editor
* or if it's available to other components within the reactFlowProvider
* @param {boolean} val - the desired state
*/
setScrollable: (val) => set({scrollable: val}),
/**
* Handles changes to nodes triggered by ReactFlow.
*/
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
onEdgesDelete: (edges) => {
onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)),
onEdgesDelete: (edges) => {
// we make sure any affected nodes get updated to reflect removal of edges
edges.forEach((edge) => {
const nodes = get().nodes;
@@ -118,7 +130,41 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
get().edgeReconnectSuccessful = true;
function createContext(
source: {id: string, handleId: string},
target: {id: string, handleId: string}
) : ConnectionContext {
const edges = get().edges;
const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length
return {
connectionCount: targetConnections,
source: source,
target: target
}
}
// connection validation
const context: ConnectionContext = oldEdge.source === newConnection.source
? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!})
: createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!});
const result = validateConnectionWithRules(
newConnection,
context
);
if (!result.isSatisfied) {
set({
edges: get().edges.map(e =>
e.id === oldEdge.id ? oldEdge : e
),
});
return;
}
// further reconnect logic
set({ edgeReconnectSuccessful: true });
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
@@ -177,7 +223,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
set({
@@ -223,6 +269,79 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
past: [],
future: [],
isBatchAction: false,
// handleRuleRegistry definitions
/**
* stores registered rules for handle connection validation
*/
ruleRegistry: new Map(),
/**
* gets the rules registered by that handle described by the given node and handle ids
*
* @param {string} targetNodeId
* @param {string} targetHandleId
* @returns {HandleRule[]}
*/
getTargetRules: (targetNodeId, targetHandleId) => {
const key = `${targetNodeId}:${targetHandleId}`;
const rules = get().ruleRegistry.get(key);
// helper function that handles a situation where no rules were registered
const missingRulesResponse = () => {
console.warn(
`No rules were registered for the following handle "${key}"!
returning and empty handleRule[] to avoid crashing`);
return []
}
return rules
? rules
: missingRulesResponse()
},
/**
* registers a handle's connection rules
*
* @param {string} nodeId
* @param {string} handleId
* @param {HandleRule[]} rules
*/
registerRules: (nodeId, handleId, rules) => {
const registry = get().ruleRegistry;
registry.set(`${nodeId}:${handleId}`, rules);
set({ ruleRegistry: registry }) ;
},
/**
* unregisters a handles connection rules
*
* @param {string} nodeId
* @param {string} handleId
*/
unregisterHandleRules: (nodeId, handleId) => {
set( () => {
const registry = get().ruleRegistry;
registry.delete(`${nodeId}:${handleId}`);
return { ruleRegistry: registry };
})
},
/**
* unregisters connection rules for all handles on the given node
* used for cleaning up rules on node deletion
*
* @param {string} nodeId
*/
unregisterNodeRules: (nodeId) => {
set(() => {
const registry = get().ruleRegistry;
registry.forEach((_,key) => {
if (key.startsWith(`${nodeId}:`)) registry.delete(key)
})
return { ruleRegistry: registry };
})
}
}))
);

View File

@@ -1,5 +1,15 @@
// VisProgTypes.ts
import type {Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node, OnEdgesDelete} from '@xyflow/react';
import type {
Edge,
OnNodesChange,
OnEdgesChange,
OnConnect,
OnReconnect,
Node,
OnEdgesDelete,
OnNodesDelete
} from '@xyflow/react';
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
@@ -23,10 +33,16 @@ export type FlowState = {
nodes: Node[];
edges: Edge[];
edgeReconnectSuccessful: boolean;
scrollable: boolean;
/** Handler for managing scrollable state */
setScrollable: (value: boolean) => void;
/** Handler for changes to nodes triggered by ReactFlow */
onNodesChange: OnNodesChange;
onNodesDelete: OnNodesDelete;
onEdgesDelete: OnEdgesDelete;
/** Handler for changes to edges triggered by ReactFlow */
@@ -78,7 +94,9 @@ export type FlowState = {
* @param node - the Node object to add
*/
addNode: (node: Node) => void;
} & UndoRedoState & HandleRuleRegistry;
export type UndoRedoState = {
// UndoRedo Types
past: FlowSnapshot[];
future: FlowSnapshot[];
@@ -88,4 +106,27 @@ export type FlowState = {
endBatchAction: () => void;
undo: () => void;
redo: () => void;
};
}
export type HandleRuleRegistry = {
ruleRegistry: Map<string, HandleRule[]>;
getTargetRules: (
targetNodeId: string,
targetHandleId: string
) => HandleRule[];
registerRules: (
nodeId: string,
handleId: string,
rules: HandleRule[]
) => void;
unregisterHandleRules: (
nodeId: string,
handleId: string
) => void;
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
}

View File

@@ -3,7 +3,8 @@ import { useReactFlow, type XYPosition } from '@xyflow/react';
import { type ReactNode, useCallback, useRef, useState } from 'react';
import useFlowStore from '../VisProgStores';
import styles from '../../VisProg.module.css';
import { NodeDefaults, type NodeTypes } from '../NodeRegistry'
import { NodeDefaults, type NodeTypes} from '../NodeRegistry'
import {Tooltip} from "./NodeComponents.tsx";
/**
* Props for a draggable node within the drag-and-drop toolbar.
@@ -47,14 +48,17 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
});
return (
<div className={className}
ref={draggableRef}
id={`draggable-${nodeType}`}
data-testid={`draggable-${nodeType}`}
>
{children}
</div>
);
<Tooltip nodeType={nodeType}>
<div>
<div className={className}
ref={draggableRef}
id={`draggable-${nodeType}`}
data-testid={`draggable-${nodeType}`}
>
{children}
</div>
</div>
</Tooltip>)
}
/**
@@ -69,23 +73,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP
* @param position - The XY position in the flow canvas where the node will appear.
*/
function addNodeToFlow(nodeType: keyof typeof NodeTypes, position: XYPosition) {
const { nodes, addNode } = useFlowStore.getState();
const { addNode } = useFlowStore.getState();
// Load any predefined data for this node type.
const defaultData = NodeDefaults[nodeType] ?? {}
// Currently, we find out what the Id is by checking the last node and adding one.
const sameTypeNodes = nodes.filter((node) => node.type === nodeType);
const nextNumber =
sameTypeNodes.length > 0
? (() => {
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
const parts = lastNode.id.split('-');
const lastNum = Number(parts[1]);
return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1;
})()
: 1;
const id = `${nodeType}-${nextNumber}`;
const id = crypto.randomUUID();
// Create new node
const newNode = {
@@ -145,7 +137,7 @@ export function DndToolbar() {
}));
return (
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`} id={"draggable-sidebar"}>
<div className="description">
You can drag these nodes to the pane to create new nodes.
</div>

View File

@@ -0,0 +1,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} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type JSX, useState} from "react";
import {createPortal} from "react-dom";
import styles from "../../VisProg.module.css";
import {NodeTooltips} from "../NodeRegistry.ts";
import useFlowStore from "../VisProgStores.tsx";
/**
* Props for the Toolbar component.
*
@@ -24,14 +29,94 @@ type ToolbarProps = {
* @constructor
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {deleteNode} = useFlowStore();
const {nodes, deleteNode} = useFlowStore();
const deleteParentNode = ()=> {
const deleteParentNode = () => {
deleteNode(nodeId);
}
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;
return (
<NodeToolbar>
<NodeToolbar className={"flex-row align-center"}>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
<Tooltip nodeType={nodeType}>
<div className={styles.nodeToolbarTooltip}>i</div>
</Tooltip>
</NodeToolbar>);
}
type TooltipProps = {
nodeType?: keyof typeof NodeTooltips;
children: JSX.Element;
};
/**
* A general tooltip component, that can be used as a wrapper for any component
* that has a nodeType and a corresponding nodeTooltip.
*
* currently used to show tooltips for draggable-nodes and nodes inside the editor
*
* @param {"start" | "end" | "phase" | "norm" | "goal" | "trigger" | "basic_belief" | undefined} nodeType
* @param {React.JSX.Element} children
* @returns {React.JSX.Element}
* @constructor
*/
export function Tooltip({ nodeType, children }: TooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
const [disabled , setDisabled] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const updateTooltipPos = () => {
const rect = document.getElementById("draggable-sidebar")!.getBoundingClientRect();
setCoords({
// Position exactly below the bottom edge of the draggable sidebar (plus a small gap)
top: rect.bottom + 10,
left: rect.left + rect.width / 2, // Keep it horizontally centered
});
};
return nodeType ?
(<div>
<div
onMouseDown={() => {
updateTooltipPos();
setShowTooltip(false);
setDisabled(true);
}}
onMouseUp={() => {
setDisabled(false);
}}
onMouseOver={() => {
if (!disabled) {
updateTooltipPos();
setShowTooltip(true);
}
}}
onMouseLeave={ () => setShowTooltip(false)}
>
{children}
</div>
{showTooltip && createPortal(
<div
className={"flex-row"}
style={{
pointerEvents: 'none',
position: 'fixed',
top: `${coords.top}px`,
left: `${coords.left}px`,
transform: 'translateX(-50%)', // Center based on the midpoint
}}
>
<span className={styles.customTooltipHeader}>{nodeType}</span>
<span className={styles.customTooltip}>
{NodeTooltips[nodeType] || "Available for drag"}
</span>
</div>,
document.body
)}
</div>
) : children
}

View File

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

View File

@@ -0,0 +1,124 @@
import { type Node } from "@xyflow/react"
import { GoalReduce } from "../nodes/GoalNode"
export type Plan = {
name: string,
id: string,
steps: PlanElement[],
}
export type PlanElement = Goal | Action
export type Goal = {
id: string // we let the reducer figure out the rest dynamically
type: "goal"
}
// Actions
export type Action = SpeechAction | GestureAction | LLMAction
export type SpeechAction = { id: string, text: string, type:"speech" }
export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" }
export type LLMAction = { id: string, goal: string, type:"llm" }
export type ActionTypes = "speech" | "gesture" | "llm";
// Extract the wanted information from a plan within the reducing of nodes
export function PlanReduce(_nodes: Node[], plan?: Plan, ) {
if (!plan) return ""
return {
id: plan.id,
steps: plan.steps.map((x) => StepReduce(x, _nodes))
}
}
// Extract the wanted information from a plan element.
function StepReduce(planElement: PlanElement, _nodes: Node[]) : Record<string, unknown> {
// We have different types of plan elements, requiring differnt types of output
const nodes = _nodes
const thisNode = _nodes.find((x) => x.id === planElement.id)
switch (planElement.type) {
case ("speech"):
return {
id: planElement.id,
text: planElement.text,
}
case ("gesture"):
return {
id: planElement.id,
gesture: {
type: planElement.isTag ? "tag" : "single",
name: planElement.gesture
},
}
case ("llm"):
return {
id: planElement.id,
goal: planElement.goal,
}
case ("goal"):
return thisNode ? GoalReduce(thisNode, nodes) : {}
}
}
/**
* Finds out whether the plan can iterate multiple times, or always stops after one action.
* This comes down to checking if the plan only has speech/ gesture actions, or others as well.
* @param plan: the plan to check
* @returns: a boolean
*/
export function DoesPlanIterate( _nodes: Node[], plan?: Plan,) : boolean {
// TODO: should recursively check plans that have goals (and thus more plans) in them.
if (!plan) return false
return plan.steps.filter((step) => step.type == "llm").length > 0 ||
(
// Find the goal node of this step
plan.steps.filter((step) => step.type == "goal").map((goalStep) => {
const goalId = goalStep.id;
const goalNode = _nodes.find((x) => x.id === goalId);
// In case we don't find any valid plan, this node doesn't iterate
if (!goalNode || !goalNode.data.plan) return false;
// Otherwise, check if this node can fail - if so, we should have the option to iterate
return (goalNode && goalNode.data.plan && goalNode.data.can_fail)
})
).includes(true);
}
/**
* Checks if any of the plan's goal steps has its can_fail value set to true.
* @param plan: plan to check
* @param _nodes: nodes in flow store.
*/
export function HasCheckingSubGoal(plan: Plan, _nodes: Node[]) {
const goalSteps = plan.steps.filter((x) => x.type == "goal");
return goalSteps.map((goalStep) => {
// Find the goal node and check its can_fail data boolean.
const goalId = goalStep.id;
const goalNode = _nodes.find((x) => x.id === goalId);
return (goalNode && goalNode.data.can_fail)
}).includes(true);
}
/**
* Returns the value of the action.
* Since typescript can't polymorphicly access the value field,
* we need to switch over the types and return the correct field.
* @param action: action to retrieve the value from
* @returns string | undefined
*/
export function GetActionValue(action: Action) {
let returnAction;
switch (action.type) {
case "gesture":
returnAction = action as GestureAction
return returnAction.gesture;
case "speech":
returnAction = action as SpeechAction
return returnAction.text;
case "llm":
returnAction = action as LLMAction
return returnAction.goal;
default:
}
}

View File

@@ -0,0 +1,35 @@
// This file is to avoid sharing both functions and components which eslint dislikes. :)
import type { GoalNode } from "../nodes/GoalNode"
import type { Goal, Plan } from "./Plan"
/**
* Inserts a goal into a plan
* @param plan: plan to insert goal into
* @param goalNode: the goal node to insert into the plan.
* @returns: a new plan with the goal inside.
*/
export function insertGoalInPlan(plan: Plan, goalNode: GoalNode): Plan {
const planElement : Goal = {
id: goalNode.id,
type: "goal",
}
return {
...plan,
steps: [...plan.steps, planElement],
}
}
/**
* Deletes a goal from a plan
* @param plan: plan to delete goal from
* @param goalID: the goal node to delete.
* @returns: a new plan with the goal removed.
*/
export function deleteGoalInPlanByID(plan: Plan, goalID: string) {
const updatedPlan = {...plan,
steps: plan.steps.filter((x) => x.id !== goalID)
}
return updatedPlan.steps.length == 0 ? undefined : updatedPlan
}

View File

@@ -0,0 +1,71 @@
.planDialog {
overflow:visible;
width: 80vw;
max-width: 900px;
transition: width 0.25s ease;
overscroll-behavior: contain;
}
.planDialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
.planEditor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
min-width: 600px;
}
.planEditorLeft {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.planEditorRight {
display: flex;
flex-direction: column;
gap: 0.5rem;
border-left: 1px solid var(--border-color, #ccc);
padding-left: 1rem;
max-height: 300px;
overflow-y: auto;
}
.planStep {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: text-decoration 0.2s;
}
.planStep:hover {
text-decoration: line-through;
}
.stepType {
opacity: 0.7;
font-size: 0.85em;
}
.stepIndex {
opacity: 0.6;
}
.emptySteps {
opacity: 0.5;
font-style: italic;
}
.stepSuggestion {
opacity: 0.5;
font-style: italic;
}

View File

@@ -0,0 +1,250 @@
import {useRef, useState} from "react";
import useFlowStore from "../VisProgStores.tsx";
import styles from './PlanEditor.module.css';
import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan";
import { defaultPlan } from "../components/Plan.default";
import { TextField } from "../../../../components/TextField";
import GestureValueEditor from "./GestureValueEditor";
type PlanEditorDialogProps = {
plan?: Plan;
onSave: (plan: Plan | undefined) => void;
description? : string;
};
export default function PlanEditorDialog({
plan,
onSave,
description,
}: PlanEditorDialogProps) {
// UseStates and references
const dialogRef = useRef<HTMLDialogElement | null>(null);
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
const [newActionType, setNewActionType] = useState<ActionTypes>("speech");
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
const [newActionValue, setNewActionValue] = useState("");
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
const { setScrollable } = useFlowStore();
const nodes = useFlowStore().nodes;
//Button Actions
const openCreate = () => {
setScrollable(false);
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
dialogRef.current?.showModal();
};
const openCreateWithDescription = () => {
setScrollable(false);
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!});
setNewActionType("llm")
setNewActionValue(description!)
dialogRef.current?.showModal();
}
const openEdit = () => {
setScrollable(false);
if (!plan) return;
setDraftPlan(structuredClone(plan));
dialogRef.current?.showModal();
};
const close = () => {
setScrollable(true);
dialogRef.current?.close();
setDraftPlan(null);
};
const buildAction = (): Action => {
const id = crypto.randomUUID();
setHasInteractedWithPlan(true)
switch (newActionType) {
case "speech":
return { id, text: newActionValue, type: "speech" };
case "gesture":
return { id, gesture: newActionValue, isTag: newActionGestureType, type: "gesture" };
case "llm":
return { id, goal: newActionValue, type: "llm" };
}
};
return (<>
{/* Create and edit buttons */}
{!plan && (
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
Create Plan
</button>
)}
{plan && (
<button className={styles.nodeButton} onClick={openEdit}>
Edit Plan
</button>
)}
{/* Start of dialog (plan editor) */}
<dialog
ref={dialogRef}
className={`${styles.planDialog}`}
//onWheel={(e) => e.stopPropagation()}
data-testid={"PlanEditorDialogTestID"}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
{/* Plan name text field */}
{draftPlan && (
<TextField
value={draftPlan.name}
setValue={(name) =>
setDraftPlan({ ...draftPlan, name })}
placeholder="Plan name"
data-testid="name_text_field"/>
)}
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
{draftPlan && (<div className={styles.planEditor}>
<div className={styles.planEditorLeft}>
{/* Left Side (Action Adder) */}
<h4>Add Action</h4>
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
<label> Filled in as a suggestion! </label>
<label> Feel free to change! </label>
</div>)}
<label>
Action Type <wbr />
{/* Type selection */}
<select
value={newActionType}
onChange={(e) => {
setNewActionType(e.target.value as ActionTypes);
// Reset value when action type changes
setNewActionValue("");
}}>
<option value="speech">Speech Action</option>
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
{/* Action value editor*/}
{newActionType === "gesture" ? (
// Gesture get their own editor component
<GestureValueEditor
value={newActionValue}
setValue={setNewActionValue}
setType={setNewActionGestureType}
placeholder="Gesture name"
/>
) : (
<TextField
value={newActionValue}
setValue={setNewActionValue}
placeholder={
newActionType === "speech" ? "Speech text"
: "LLM goal"
}
/>
)}
{/* Adding steps */}
<button
type="button"
disabled={!newActionValue}
onClick={() => {
if (!draftPlan) return;
// Add action to steps
const action = buildAction();
setDraftPlan({
...draftPlan,
steps: [...draftPlan.steps, action],});
// Reset current action building
setNewActionValue("");
setNewActionType("speech");
}}>
Add Step
</button>
</div>
{/* Right Side (Steps shown) */}
<div className={styles.planEditorRight}>
<h4>Steps</h4>
{/* Show if there are no steps yet */}
{draftPlan.steps.length === 0 && (
<div className={styles.emptySteps}>
No steps yet
</div>
)}
{/* Map over all steps */}
{draftPlan.steps.map((step, index) => (
<div
role="button"
tabIndex={0}
key={step.id}
className={styles.planStep}
// Extra logic for screen readers to access using keyboard
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}}
onClick={() => {
setDraftPlan({
...draftPlan,
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
}}>
<span className={styles.stepIndex}>{index + 1}.</span>
<span className={styles.stepType}>{step.type}:</span>
<span className={styles.stepName}>
{
// This just tries to find the goals name, i know it looks ugly:(
step.type === "goal"
? ((nodes.find(x => x.id === step.id)?.data.name as string) == "" ?
"unnamed goal": (nodes.find(x => x.id === step.id)?.data.name as string))
: (GetActionValue(step) ?? "")}
</span>
</div>
))}
</div>
</div>
)}
{/* Buttons */}
<div className="flex-row gap-md">
{/* Close button */}
<button type="button" onClick={close}>
Cancel
</button>
{/* Confirm/ Create button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
if (!draftPlan) return;
onSave(draftPlan);
close();
}}>
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
</button>
{/* Reset button */}
<button
type="button"
disabled={!draftPlan}
onClick={() => {
onSave(undefined);
close();
}}>
Reset
</button>
</div>
</form>
</dialog>
</>
);
}

View File

@@ -0,0 +1,43 @@
:global(.react-flow__handle.connected) {
background: lightgray;
border-color: green;
filter: drop-shadow(0 0 0.25rem 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.25rem #ff6060);
}
:global(.react-flow__handle.connectingto) {
background: #ff6060;
border-color: coral;
filter: drop-shadow(0 0 0.25rem coral);
}
:global(.react-flow__handle.valid) {
background: #55dd99;
border-color: green;
filter: drop-shadow(0 0 0.25rem 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,88 @@
import {
Handle,
type HandleProps,
type Connection,
useNodeId, useNodeConnections
} from '@xyflow/react';
import {useState} from '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!
})
// initialise the handles state with { isValid: true } to show that connections are possible
const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
return (
<Handle
{...otherProps}
id={id}
type={type}
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
isValidConnection={(connection) => {
const result = validate(connection as Connection);
setHandleState(result);
return result.isSatisfied;
}}
title={handleState.message}
/>
);
}
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!
})
// initialise the handles state with { isValid: true } to show that connections are possible
const [handleState, setHandleState] = useState<{ isSatisfied: boolean, message?: string }>({ isSatisfied: true });
return (
<Handle
{...otherProps}
id={id}
type={type}
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
isConnectable={connections.length === 0}
isValidConnection={(connection) => {
const result = validate(connection as Connection);
setHandleState(result);
return result.isSatisfied;
}}
title={handleState.message}
/>
);
}

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,237 @@
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 | Face
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:"};
type Face = { type: "face", id: string, value: string, label: "Face detected"};
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>
<option value="face">Face detected</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"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
]}/>
</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;
case "face":
result["face_present"] = true;
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,13 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
/**
@@ -32,7 +34,9 @@ export default function EndNode(props: NodeProps<EndNode>) {
<div className={"flex-row gap-sm"}>
End
</div>
<Handle type="target" position={Position.Left} id="target"/>
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
allowOnlyConnectionsFromType(["phase"])
]}/>
</div>
</>
);
@@ -51,6 +55,10 @@ export function EndReduce(node: Node, _nodes: Node[]) {
}
}
export const EndTooltip = `
The end node signifies the endpoint of your program;
the output of the final phase of your program should connect to the end node`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called

View File

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

View File

@@ -1,5 +1,4 @@
import {
Handle,
type NodeProps,
Position,
type Node,
@@ -7,21 +6,33 @@ import {
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import { MultilineTextField } from '../../../../components/MultilineTextField';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
/**
* The default data dot a phase node
* @param label: the label of this phase
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param desciption: description of the goal
* @param desciption: description of the goal - this will be checked for completion
* @param hasReduce: whether this node has reducing functionality (true by default)
* @param can_fail: whether this plan should be checked- this plan could possible fail
* @param plan: The (possible) attached plan to this goal
*/
export type GoalNodeData = {
label: string;
name: string;
description: string;
droppable: boolean;
achieved: boolean;
hasReduce: boolean;
can_fail: boolean;
plan?: Plan;
};
export type GoalNode = Node<GoalNodeData>
@@ -34,16 +45,23 @@ export type GoalNode = Node<GoalNodeData>
*/
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
const {updateNodeData} = useFlowStore();
const _nodes = useFlowStore().nodes;
const text_input_id = `goal_${id}_text_input`;
const checkbox_id = `goal_${id}_checkbox`;
const planIterate = DoesPlanIterate(_nodes, data.plan);
const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes)
const setDescription = (value: string) => {
updateNodeData(id, {...data, description: value});
}
const setAchieved = (value: boolean) => {
updateNodeData(id, {...data, achieved: value});
const setName= (value: string) => {
updateNodeData(id, {...data, name: value})
}
const setFailable = (value: boolean) => {
updateNodeData(id, {...data, can_fail: value});
}
return <>
@@ -53,21 +71,58 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
<label htmlFor={text_input_id}>Goal:</label>
<TextField
id={text_input_id}
value={data.description}
setValue={(val) => setDescription(val)}
value={data.name}
setValue={(val) => setName(val)}
placeholder={"To ..."}
/>
</div>
<div className={"flex-row gap-md align-center"}>
<label htmlFor={checkbox_id}>Achieved:</label>
{(data.can_fail || hasCheckSubGoal) && (<div>
<label htmlFor={text_input_id}>Description/ Condition of goal:</label>
<div className={"flex-wrap"}>
<MultilineTextField
id={text_input_id}
value={data.description}
setValue={setDescription}
placeholder={"Describe the condition of this goal..."}
/>
</div>
</div>)}
<div>
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until all steps complete. 🟢"} </label>
</div>
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
{planIterate ? "" : <s></s>}
<label htmlFor={checkbox_id}>{!planIterate ? "This plan always succeeds!" : "Check if this plan fails"}:</label>
<input
id={checkbox_id}
type={"checkbox"}
checked={data.achieved || false}
onChange={(e) => setAchieved(e.target.checked)}
disabled={!planIterate || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
checked={!planIterate || data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes))}
onChange={(e) => planIterate ? setFailable(e.target.checked) : setFailable(false)}
/>
</div>
<Handle type="source" position={Position.Right} id="GoalSource"/>
)}
<div>
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(id, {
...data,
plan,
});
}}
description={data.name}
/>
</div>
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
</div>
</>;
}
@@ -80,21 +135,42 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
*/
export function GoalReduce(node: Node, _nodes: Node[]) {
const data = node.data as GoalNodeData;
return {
return {
id: node.id,
label: data.label,
name: data.name,
description: data.description,
achieved: data.achieved,
can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)),
plan: data.plan ? PlanReduce(_nodes, data.plan) : "",
}
}
export const GoalTooltip = `
The goal node allows you to set goals that Pepper has to achieve
before moving to the next phase of your program`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
// Goals should only be targeted by other goals, for them to be part of our plan.
const nodes = useFlowStore.getState().nodes;
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode || otherNode.type !== "goal") return;
const data = _thisNode.data as GoalNodeData
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
if (!data.plan) {
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
}
}
/**
@@ -112,7 +188,9 @@ export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) {
* @param _sourceNodeId the source of the disconnected connection
*/
export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
// We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list.
const data = _thisNode.data as GoalNodeData
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}
/**

View File

@@ -0,0 +1,16 @@
import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
/**
* Default data for this node
*/
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
label: "AND/OR",
droppable: true,
inferredBelief: {
left: undefined,
operator: true,
right: undefined
},
hasReduce: true,
};

View File

@@ -0,0 +1,80 @@
.operator-switch {
display: inline-flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
font-family: sans-serif;
/* Change this font-size to scale the whole component */
font-size: 12px;
}
/* hide the default checkbox */
.operator-switch input {
display: none;
}
/* The Track */
.switch-visual {
position: relative;
/* height is now 3x the font size */
height: 3em;
aspect-ratio: 1 / 2;
background-color: ButtonFace;
border-radius: 2em;
transition: 0.2s;
}
/* The Knob */
.switch-visual::after {
content: "";
position: absolute;
top: 0.1em;
left: 0.1em;
width: 1em;
height: 1em;
background: Canvas;
border: 0.175em solid mediumpurple;
border-radius: 50%;
transition: transform 0.2s ease-in-out, border-color 0.2s;
}
/* Labels */
.switch-labels {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 3em; /* Matches the track height */
font-weight: 800;
color: Canvas;
line-height: 1.4;
padding: 0.2em 0;
}
.operator-switch input:checked + .switch-visual::after {
/* Moves the slider down */
transform: translateY(1.4em);
}
/*change the colours to highlight the selected operator*/
.operator-switch input:checked ~ .switch-labels{
:first-child {
transition: ease-in-out color 0.2s;
color: ButtonFace;
}
:last-child {
transition: ease-in-out color 0.2s;
color: mediumpurple;
}
}
.operator-switch input:not(:checked) ~ .switch-labels{
:first-child {
transition: ease-in-out color 0.2s;
color: mediumpurple;
}
:last-child {
transition: ease-in-out color 0.2s;
color: ButtonFace;
}
}

View File

@@ -0,0 +1,176 @@
import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react';
import {useState} from "react";
import styles from '../../VisProg.module.css';
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 } = 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,
}
});
}
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeInferredBelief}`}>
{/* The checkbox used to toggle the operator between 'AND' and 'OR' */}
<label className={switchStyles.operatorSwitch}>
<input
type="checkbox"
checked={data.inferredBelief.operator}
onChange={onToggle}
/>
<div className={switchStyles.switchVisual}></div>
<div className={switchStyles.switchLabels}>
<span title={"Belief is fulfilled if either of the supplied beliefs is true"}>OR</span>
<span title={"Belief is fulfilled if all of the supplied beliefs are true"}>AND</span>
</div>
</label>
{/* outgoing connections */}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromType(["norm", "trigger"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
{/* incoming connections */}
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '30%'}} id="beliefLeft" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '70%'}} id="beliefRight" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
</div>
</>
);
};
/**
* Reduces each BasicBelief, including its children down into its core data.
* @param {Node} node - The BasicBelief node to reduce.
* @param {Node[]} nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of BasicBeliefs.
*/
export function InferredBeliefReduce(node: Node, nodes: Node[]) {
const data = node.data as InferredBeliefNodeData;
const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left);
const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right);
if (!leftBelief) { throw new Error("No Left belief found")}
if (!rightBelief) { throw new Error("No Right Belief found")}
const result: Record<string, unknown> = {
id: node.id,
left: BeliefGlobalReduce(leftBelief, nodes),
operator: data.inferredBelief.operator ? "AND" : "OR",
right: BeliefGlobalReduce(rightBelief, nodes),
};
return result
}

View File

@@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode";
export const NormNodeDefaults: NormNodeData = {
label: "Norm Node",
droppable: true,
condition: undefined,
norm: "",
hasReduce: true,
critical: false,

View File

@@ -1,5 +1,4 @@
import {
Handle,
type NodeProps,
Position,
type Node,
@@ -7,7 +6,10 @@ import {
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
/**
* The default data dot a phase node
@@ -19,6 +21,7 @@ import useFlowStore from '../VisProgStores';
export type NormNodeData = {
label: string;
droppable: boolean;
condition?: string; // id of this node's belief.
norm: string;
hasReduce: boolean;
critical: boolean;
@@ -67,7 +70,19 @@ export default function NormNode(props: NodeProps<NormNode>) {
onChange={(e) => setCritical(e.target.checked)}
/>
</div>
<Handle type="source" position={Position.Right} id="norms"/>
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
</div>)}
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
]}/>
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
]}/>
</div>
</>;
};
@@ -76,25 +91,43 @@ export default function NormNode(props: NodeProps<NormNode>) {
/**
* Reduces each Norm, including its children down into its relevant data.
* @param node The Node Properties of this node.
* @param _nodes all the nodes in the graph
* @param nodes all the nodes in the graph
*/
export function NormReduce(node: Node, _nodes: Node[]) {
export function NormReduce(node: Node, nodes: Node[]) {
const data = node.data as NormNodeData;
return {
id: node.id,
label: data.label,
norm: data.norm,
critical: data.critical,
}
// conditions nodes - make sure to check for empty arrays
const result: Record<string, unknown> = {
id: node.id,
label: data.label,
norm: data.norm,
critical: data.critical,
};
if (data.condition) {
const conditionNode = nodes.find((node) => node.id === data.condition);
// In case something went wrong, and our condition doesn't actually exist;
if (conditionNode == undefined) return result;
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
}
return result
}
export const NormTooltip = `
A norm describes a behavioral rule Pepper must follow during the connected phase(-s),
for example: "respond using formal language"`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is the condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
data.condition = _sourceNodeId;
}
}
/**
@@ -112,7 +145,9 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
* @param _sourceNodeId the source of the disconnected connection
*/
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as NormNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
}
/**

View File

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

View File

@@ -1,11 +1,12 @@
import {
Handle,
type NodeProps,
Position,
type Node
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts";
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
@@ -16,12 +17,15 @@ import { TextField } from '../../../../components/TextField';
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
* @param children: ID's of children of this node
* @param hasReduce: whether this node has reducing functionality (true by default)
* @param nextPhaseId:
*/
export type PhaseNodeData = {
label: string;
droppable: boolean;
children: string[];
hasReduce: boolean;
nextPhaseId: string | "end" | null;
isFirstPhase: boolean;
};
export type PhaseNode = Node<PhaseNodeData>
@@ -50,9 +54,17 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
placeholder={"Phase ..."}
/>
</div>
<Handle type="target" position={Position.Left} id="target"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
<Handle type="source" position={Position.Right} id="source"/>
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
noSelfConnections,
allowOnlyConnectionsFromType(["phase", "start"]),
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
]}/>
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
noSelfConnections,
allowOnlyConnectionsFromType(["phase", "end"]),
]}/>
</div>
</>
);
@@ -65,8 +77,8 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
*/
export function PhaseReduce(node: Node, nodes: Node[]) {
const thisnode = node as PhaseNode;
const data = thisnode.data as PhaseNodeData;
const thisNode = node as PhaseNode;
const data = thisNode.data as PhaseNodeData;
// node typings that are not in phase
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
@@ -85,8 +97,8 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
// Build the result object
const result: Record<string, unknown> = {
id: thisnode.id,
label: data.label,
id: thisNode.id,
name: data.label,
};
nodesInPhase.forEach((type) => {
@@ -96,26 +108,39 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
console.warn(`No reducer found for node type ${type}`);
result[type + "s"] = [];
} else {
result[type + "s"] = typedChildren.map((child) => reducer(child, nodes));
result[type + "s"] = [];
for (const typedChild of typedChildren) {
(result[type + "s"] as object[]).push(reducer(typedChild, nodes))
}
}
});
return result;
}
export const PhaseTooltip = `
A phase is a single stage of the program, during a phase Pepper will behave
in accordance with any connected norms, goals and triggers`;
/**
* This function is called whenever a connection is made with this node type as the target (phase)
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const node = _thisNode as PhaseNode
const data = node.data as PhaseNodeData
// we only add none phase nodes to the children
if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) {
data.children.push(_sourceNodeId)
}
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
switch (sourceNode.type) {
case "phase": break;
case "start": data.isFirstPhase = true; break;
// we only add none phase or start nodes to the children
// endNodes cannot be the source of an outgoing connection
// so we don't need to cover them with a special case
// before handling the default behavior
default: data.children.push(_sourceNodeId); break;
}
}
/**
@@ -124,7 +149,19 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
* @param _targetNodeId the target of the created connection
*/
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
const targetNode = nodes.find((node) => node.id === _targetNodeId)
if (!targetNode) {throw new Error("Source node not found")}
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
// or "end" if the target node is the end node
switch (targetNode.type) {
case 'phase': data.nextPhaseId = _targetNodeId; break;
case 'end': data.nextPhaseId = "end"; break;
default: break;
}
}
/**
@@ -133,9 +170,23 @@ export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
* @param _sourceNodeId the source of the disconnected connection
*/
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const node = _thisNode as PhaseNode
const data = node.data as PhaseNodeData
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
const sourceType = sourceNode ? sourceNode.type : "deleted";
switch (sourceType) {
case "phase": break;
case "start": data.isFirstPhase = false; break;
// we only add none phase or start nodes to the children
// endNodes cannot be the source of an outgoing connection
// so we don't need to cover them with a special case
// before handling the default behavior
default:
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
break;
}
}
/**
@@ -144,5 +195,12 @@ export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string)
* @param _targetNodeId the target of the diconnected connection
*/
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as PhaseNodeData
const nodes = useFlowStore.getState().nodes;
// if the target is a phase or end node set the nextPhaseId to null,
// as we are no longer connected to a subsequent phaseNode or to the endNode
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
data.nextPhaseId = null;
}
}

View File

@@ -1,11 +1,12 @@
import {
Handle,
type NodeProps,
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
export type StartNodeData = {
@@ -31,7 +32,9 @@ export default function StartNode(props: NodeProps<StartNode>) {
<div className={"flex-row gap-sm"}>
Start
</div>
<Handle type="source" position={Position.Right} id="source"/>
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
]}/>
</div>
</>
);
@@ -50,6 +53,10 @@ export function StartReduce(node: Node, _nodes: Node[]) {
}
}
export const StartTooltip = `
The start node acts as the starting point for a program,
it should be connected to the left handle of the first phase of your program`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called

View File

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

View File

@@ -1,17 +1,20 @@
import {
Handle,
type NodeProps,
Position,
type Connection,
type Edge,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import { useState } from 'react';
import { RealtimeTextField, TextField } from '../../../../components/TextField';
import duplicateIndices from '../../../../utils/duplicateIndices';
import {PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
import type { GoalNode } from './GoalNode.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
import { TextField } from '../../../../components/TextField.tsx';
/**
* The default data structure for a Trigger node
@@ -21,32 +24,20 @@ import duplicateIndices from '../../../../utils/duplicateIndices';
*
* @property label: the display label of this Trigger node.
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
* @property triggerType - The type of trigger ("keywords" or a custom string).
* @property triggers - The list of keyword triggers (if applicable).
* @property hasReduce - Whether this node supports reduction logic.
*/
export type TriggerNodeData = {
label: string;
name: string;
droppable: boolean;
triggerType: "keywords" | string;
triggers: Keyword[] | never;
condition?: string; // id of the belief
plan?: Plan;
hasReduce: boolean;
};
export type TriggerNode = Node<TriggerNodeData>
/**
* Determines whether a Trigger node can connect to another node or edge.
*
* @param connection - The connection or edge being attempted to connect towards.
* @returns `true` if the connection is defined; otherwise, `false`.
*/
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
return (connection != undefined);
}
/**
* Defines how a Trigger node should be rendered
* @param props - Node properties provided by React Flow, including `id` and `data`.
@@ -56,23 +47,54 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
const data = props.data;
const {updateNodeData} = useFlowStore();
const setKeywords = (keywords: Keyword[]) => {
updateNodeData(props.id, {...data, triggers: keywords});
const setName= (value: string) => {
updateNodeData(props.id, {...data, name: value})
}
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
{data.triggerType === "emotion" && (
<div className={"flex-row gap-md"}>Emotion?</div>
)}
{data.triggerType === "keywords" && (
<Keywords
keywords={data.triggers}
setKeywords={setKeywords}
/>
)}
<Handle type="source" position={Position.Right} id="TriggerSource"/>
<TextField
value={props.data.name}
setValue={(val) => setName(val)}
placeholder={"Name of this trigger..."}
/>
<div className={"flex-row gap-md"}>Triggers when the condition is met.</div>
<div className={"flex-row gap-md"}>Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}</div>
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]}/>
<SingleConnectionHandle
type="target"
position={Position.Bottom}
id="TriggerBeliefs"
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(['basic_belief']),
]}
/>
<MultiConnectionHandle
type="target"
position={Position.Bottom}
id="GoalTarget"
style={{ left: '60%' }}
rules={[
allowOnlyConnectionsFromType(['goal']),
]}
/>
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(props.id, {
...data,
plan,
});
}}
/>
</div>
</>;
}
@@ -80,27 +102,27 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
/**
* Reduces each Trigger, including its children down into its core data.
* @param node - The Trigger node to reduce.
* @param _nodes - The list of all nodes in the current flow graph.
* @param nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of triggers.
*/
export function TriggerReduce(node: Node, _nodes: Node[]) {
const data = node.data;
switch (data.triggerType) {
case "keywords":
return {
id: node.id,
type: "keywords",
label: data.label,
keywords: data.triggers,
};
default:
return {
...data,
id: node.id,
};
export function TriggerReduce(node: Node, nodes: Node[]) {
const data = node.data as TriggerNodeData;
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
return {
id: node.id,
name: node.data.name,
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
plan: !data.plan ? "" : PlanReduce(nodes, data.plan), // Make sure we have a plan when reducing, or default to ""
}
}
export const TriggerTooltip = `
A trigger node is used to make Pepper execute a predefined plan -
consisting of one or more actions - when the connected beliefs are met`;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
@@ -108,6 +130,27 @@ export function TriggerReduce(node: Node, _nodes: Node[]) {
*/
export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as TriggerNodeData;
// If we got a belief connected, this is the condition for the norm.
const nodes = useFlowStore.getState().nodes;
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}
else if (otherNode.type === 'goal') {
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
if (!data.plan) {
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode)
}
}
}
/**
@@ -126,6 +169,11 @@ export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string)
*/
export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// no additional connection logic exists yet
const data = _thisNode.data as TriggerNodeData;
// remove if the target of disconnection was our condition
if (_sourceNodeId == data.condition) data.condition = undefined
data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId)
}
/**
@@ -155,92 +203,4 @@ export type KeywordTriggerNodeProps = {
}
/** Union type for all possible Trigger node configurations. */
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
/**
* Renders an input element that allows users to add new keyword triggers.
*
* When the input is committed, the `addKeyword` callback is called with the new keyword.
*
* @param param0 - An object containing the `addKeyword` function.
* @returns A React element(React.JSX.Element) providing an input for adding keywords.
*/
function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) {
const [input, setInput] = useState("");
const text_input_id = "keyword_adder_input";
return <div className={"flex-row gap-md"}>
<label htmlFor={text_input_id}>New Keyword:</label>
<RealtimeTextField
id={text_input_id}
value={input}
setValue={setInput}
onCommit={() => {
if (!input) return;
addKeyword(input);
setInput("");
}}
placeholder={"..."}
className={"flex-1"}
/>
</div>;
}
/**
* Displays and manages a list of keyword triggers for a Trigger node.
* Handles adding, editing, and removing keywords, as well as detecting duplicate entries.
*
* @param keywords - The current list of keyword triggers.
* @param setKeywords - A callback to update the keyword list in the parent node.
* @returns A React element(React.JSX.Element) for editing keyword triggers.
*/
function Keywords({
keywords,
setKeywords,
}: {
keywords: Keyword[];
setKeywords: (keywords: Keyword[]) => void;
}) {
type Interpolatable = string | number | boolean | bigint | null | undefined;
const inputElementId = (id: Interpolatable) => `keyword_${id}_input`;
/** Indices of duplicates in the keyword array. */
const [duplicates, setDuplicates] = useState<number[]>([]);
function replace(id: string, value: string) {
value = value.trim();
const newKeywords = value === ""
? keywords.filter((kw) => kw.id != id)
: keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw);
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
function add(value: string) {
value = value.trim();
if (value === "") return;
const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}];
setKeywords(newKeywords);
setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword)));
}
return <>
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
{[...keywords].map(({id, keyword}, index) => {
return <div key={id} className={"flex-row gap-md"}>
<label htmlFor={inputElementId(id)}>Keyword:</label>
<TextField
id={inputElementId(id)}
value={keyword}
setValue={(val) => replace(id, val)}
placeholder={"..."}
className={"flex-1"}
invalid={duplicates.includes(index)}
/>
</div>;
})}
<KeywordAdder addKeyword={add} />
</>;
}
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;

View File

@@ -0,0 +1,40 @@
import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
/**
* takes an array of phaseNodes and orders them according to their nextPhaseId attributes,
* starting with the phase that has isFirstPhase = true
*
* @param {PhaseNode[]} nodes an unordered phaseNode array
* @returns {PhaseNode[]} the ordered phaseNode array
*/
export default function orderPhaseNodeArray(nodes: PhaseNode[]) : PhaseNode[] {
// find the first phaseNode of the sequence
const start = nodes.find(node => node.data.isFirstPhase);
if (!start) {
throw new Error('No phaseNode with isFirstObject = true found');
}
// prepare for ordering of phaseNodes
const orderedPhaseNodes: PhaseNode[] = [];
const IdMap = new Map(nodes.map(node => [node.id, node]));
let currentNode: PhaseNode | undefined = start;
// populate orderedPhaseNodes array with the phaseNodes in the correct order
while (currentNode) {
orderedPhaseNodes.push(currentNode);
if (!currentNode.data.nextPhaseId) {
throw new Error("Incomplete phase sequence, program does not reach the end node");
}
if (currentNode.data.nextPhaseId === "end") break;
currentNode = IdMap.get(currentNode.data.nextPhaseId);
if (!currentNode) {
throw new Error(`Incomplete phase sequence, phaseNode with id "${orderedPhaseNodes.at(-1)?.data.nextPhaseId}" not found`);
}
}
return orderedPhaseNodes;
}

133
src/utils/programStore.ts Normal file
View File

@@ -0,0 +1,133 @@
import {create} from "zustand";
// the type of a reduced program
export type ReducedProgram = { phases: Record<string, unknown>[] };
export type GoalWithDepth = Record<string, unknown> & { level: number };
/**
* 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[];
getPhaseNames: () => string[];
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[];
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 names of all phases in the program
*/
getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] 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`)
},
getGoalsWithDepth: (currentPhaseId: string) => {
const program = get().currentProgram;
const phase = program.phases.find(val => val["id"] === currentPhaseId);
if (!phase) {
throw new Error(`phase with id:"${currentPhaseId}" not found`);
}
const rootGoals = phase["goals"] as Record<string, unknown>[];
const flatList: GoalWithDepth[] = [];
const isGoal = (item: Record<string, unknown>) => {
return item["plan"] !== undefined;
};
// Recursive helper function
const traverse = (goals: Record<string, unknown>[], depth: number) => {
goals.forEach((goal) => {
// 1. Add the current goal to the list
flatList.push({ ...goal, level: depth });
// 2. Check for children
const plan = goal["plan"] as Record<string, unknown> | undefined;
if (plan && Array.isArray(plan["steps"])) {
const steps = plan["steps"] as Record<string, unknown>[];
// 3. FILTER: Only recurse on steps that are actually goals
// If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list
const childGoals = steps.filter(isGoal);
if (childGoals.length > 0) {
traverse(childGoals, depth + 1);
}
}
});
};
// Start traversal
traverse(rootGoals, 0);
return flatList;
},
/**
* 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

@@ -0,0 +1,299 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage';
import useProgramStore from '../../../src/utils/programStore';
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
import * as VisProg from '../../../src/pages/VisProgPage/VisProgLogic';
// --- Mocks ---
// Mock the Zustand store
jest.mock('../../../src/utils/programStore', () => ({
__esModule: true,
default: jest.fn(),
}));
// Mock the API layer including hooks
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
nextPhase: jest.fn(),
resetPhase: jest.fn(),
pauseExperiment: jest.fn(),
playExperiment: jest.fn(),
// We mock these to capture the callbacks and trigger them manually in tests
useExperimentLogger: jest.fn(),
useStatusLogger: jest.fn(),
}));
// Mock VisProg functionality
jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({
graphReducer: jest.fn(),
runProgramm: jest.fn(),
}));
// Mock Child Components to reduce noise (optional, but keeps unit test focused)
// For this test, we will allow them to render to test data passing,
// but we mock RobotConnected as it has its own side effects
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageComponents', () => {
const original = jest.requireActual('../../../src/pages/MonitoringPage/MonitoringPageComponents');
return {
...original,
RobotConnected: () => <div data-testid="robot-connected-mock">Robot Status</div>,
};
});
describe('MonitoringPage', () => {
// Capture stream callbacks
let streamUpdateCallback: (data: any) => void;
let statusUpdateCallback: (data: any) => void;
// Setup default store state
const mockGetPhaseIds = jest.fn();
const mockGetPhaseNames = jest.fn();
const mockGetNorms = jest.fn();
const mockGetGoals = jest.fn();
const mockGetGoalsWithDepth = jest.fn();
const mockGetTriggers = jest.fn();
const mockSetProgramState = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Default Store Implementation
(useProgramStore as unknown as jest.Mock).mockImplementation((selector) => {
const state = {
getPhaseIds: mockGetPhaseIds,
getPhaseNames: mockGetPhaseNames,
getNormsInPhase: mockGetNorms,
getGoalsInPhase: mockGetGoals,
getTriggersInPhase: mockGetTriggers,
getGoalsWithDepth: mockGetGoalsWithDepth,
setProgramState: mockSetProgramState,
};
return selector(state);
});
// Capture the hook callbacks
(MonitoringAPI.useExperimentLogger as jest.Mock).mockImplementation((cb) => {
streamUpdateCallback = cb;
});
(MonitoringAPI.useStatusLogger as jest.Mock).mockImplementation((cb) => {
statusUpdateCallback = cb;
});
// Default mock return values
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]);
mockGetGoalsWithDepth.mockReturnValue([
{ id: 'g1', name: 'Goal 1', level: 0 },
{ id: 'g2', name: 'Goal 2', level: 0 }
]);
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
mockGetNorms.mockReturnValue([
{ id: 'n1', norm: 'Norm 1', condition: null },
{ id: 'cn1', norm: 'Cond Norm 1', condition: 'some-cond' }
]);
});
test('renders "No program loaded" when phaseIds are empty', () => {
mockGetPhaseIds.mockReturnValue([]);
render(<MonitoringPage />);
expect(screen.getByText('No program loaded.')).toBeInTheDocument();
});
test('renders the dashboard with initial state', () => {
render(<MonitoringPage />);
// Check Header
expect(screen.getByText('Phase 1:')).toBeInTheDocument();
expect(screen.getByText('Intro')).toBeInTheDocument();
// Check Lists
expect(screen.getByText(/Goal 1/)).toBeInTheDocument();
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
expect(screen.getByText('Norm 1')).toBeInTheDocument();
expect(screen.getByText('Cond Norm 1')).toBeInTheDocument();
});
describe('Control Buttons', () => {
test('Pause calls API and updates UI', async () => {
render(<MonitoringPage />);
const pauseBtn = screen.getByText('❚❚');
await act(async () => {
fireEvent.click(pauseBtn);
});
expect(MonitoringAPI.pauseExperiment).toHaveBeenCalled();
// Ensure local state toggled (we check if play button is now inactive style or pause active)
});
test('Play calls API and updates UI', async () => {
render(<MonitoringPage />);
const playBtn = screen.getByText('▶');
await act(async () => {
fireEvent.click(playBtn);
});
expect(MonitoringAPI.playExperiment).toHaveBeenCalled();
});
test('Next Phase calls API', async () => {
render(<MonitoringPage />);
await act(async () => {
fireEvent.click(screen.getByText('⏭'));
});
expect(MonitoringAPI.nextPhase).toHaveBeenCalled();
});
test('Reset Experiment calls logic and resets state', async () => {
render(<MonitoringPage />);
// Mock graph reducer return
(VisProg.graphReducer as jest.Mock).mockReturnValue([{ id: 'new-phase' }]);
await act(async () => {
fireEvent.click(screen.getByText('⟲'));
});
expect(VisProg.graphReducer).toHaveBeenCalled();
expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] });
expect(VisProg.runProgramm).toHaveBeenCalled();
});
test('Reset Experiment handles errors gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
(VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail'));
render(<MonitoringPage />);
await act(async () => {
fireEvent.click(screen.getByText('⟲'));
});
expect(consoleSpy).toHaveBeenCalledWith('Failed to reset program:', expect.any(Error));
consoleSpy.mockRestore();
});
});
describe('Stream Updates (useExperimentLogger)', () => {
test('Handles phase_update to next phase', () => {
render(<MonitoringPage />);
expect(screen.getByText('Intro')).toBeInTheDocument(); // Phase 0
act(() => {
streamUpdateCallback({ type: 'phase_update', id: 'phase-2' });
});
expect(screen.getByText('Main')).toBeInTheDocument(); // Phase 1
});
test('Handles phase_update to "end"', () => {
render(<MonitoringPage />);
act(() => {
streamUpdateCallback({ type: 'phase_update', id: 'end' });
});
expect(screen.getByText('Experiment finished')).toBeInTheDocument();
expect(screen.getByText('All phases have been successfully completed.')).toBeInTheDocument();
});
test('Handles phase_update with unknown ID gracefully', () => {
render(<MonitoringPage />);
act(() => {
streamUpdateCallback({ type: 'phase_update', id: 'unknown-phase' });
});
// Should remain on current phase
expect(screen.getByText('Intro')).toBeInTheDocument();
});
test('Handles goal_update: advances index and marks previous as achieved', () => {
render(<MonitoringPage />);
// Initial: Goal 1 (index 0) is current.
// Send update for Goal 2 (index 1).
act(() => {
streamUpdateCallback({ type: 'goal_update', id: 'g2' });
});
// Goal 1 should now be marked achieved (passed via activeIds)
// Goal 2 should be current.
// We can inspect the "StatusList" props implicitly by checking styling or indicators if not mocked,
// but since we render the full component, we check the class/text.
// Goal 1 should have checkmark (override logic puts checkmark for activeIds)
// The implementation details of StatusList show ✔️ for activeIds.
const items = screen.getAllByRole('listitem');
// Helper to find checkmarks within items
expect(items[0]).toHaveTextContent('Goal 1');
// After update, g1 is active (achieved), g2 is current
// logic: loop i < gIndex (1). activeIds['g1'] = true.
});
test('Handles goal_update with unknown ID', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
render(<MonitoringPage />);
act(() => {
streamUpdateCallback({ type: 'goal_update', id: 'unknown-goal' });
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Goal unknown-goal not found'));
warnSpy.mockRestore();
});
test('Handles trigger_update', () => {
render(<MonitoringPage />);
// Trigger 1 initially not achieved
act(() => {
streamUpdateCallback({ type: 'trigger_update', id: 't1', achieved: true });
});
// StatusList logic: if activeId is true, show ✔️
// We look for visual confirmation or check logic
const triggerList = screen.getByText('Triggers').parentElement;
expect(triggerList).toHaveTextContent('✔️'); // Assuming 't1' is the only trigger
});
});
describe('Status Updates (useStatusLogger)', () => {
test('Handles cond_norms_state_update', () => {
render(<MonitoringPage />);
// Initial state: activeIds empty.
act(() => {
statusUpdateCallback({
type: 'cond_norms_state_update',
norms: [{ id: 'cn1', active: true }]
});
});
// Conditional Norm 1 should now be active
const cnList = screen.getByText('Conditional Norms').parentElement;
expect(cnList).toHaveTextContent('✔️');
});
test('Ignores status update if no changes detected', () => {
render(<MonitoringPage />);
// First update
act(() => {
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
});
// Second identical update - strictly checking if this causes a rerender is hard in RTL,
// but we ensure no errors and state remains consistent.
act(() => {
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
});
const cnList = screen.getByText('Conditional Norms').parentElement;
expect(cnList).toHaveTextContent('✔️');
});
});
});

View File

@@ -0,0 +1,220 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
pauseExperiment,
playExperiment,
useExperimentLogger,
useStatusLogger
} from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
// --- MOCK EVENT SOURCE SETUP ---
// This mocks the browser's EventSource so we can manually 'push' messages to our hooks
const mockInstances: MockEventSource[] = [];
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null; // Added onerror support
closed = false;
constructor(url: string) {
this.url = url;
mockInstances.push(this);
}
sendMessage(data: string) {
if (this.onmessage) {
this.onmessage({ data } as MessageEvent);
}
}
triggerError(err: any) {
if (this.onerror) {
this.onerror(err);
}
}
close() {
this.closed = true;
}
}
// Mock global EventSource
beforeAll(() => {
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
});
// Mock global fetch
beforeEach(() => {
globalThis.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ reply: 'ok' }),
})
) as jest.Mock;
});
// Cleanup after every test
afterEach(() => {
cleanup();
jest.restoreAllMocks();
mockInstances.length = 0;
});
describe('MonitoringPageAPI', () => {
describe('sendAPICall', () => {
test('sends correct POST request', async () => {
await sendAPICall('test_type', 'test_ctx');
expect(globalThis.fetch).toHaveBeenCalledWith(
'http://localhost:8000/button_pressed',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }),
})
);
});
test('appends endpoint if provided', async () => {
await sendAPICall('t', 'c', '/extra');
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining('/button_pressed/extra'),
expect.any(Object)
);
});
test('logs error on fetch network failure', async () => {
(globalThis.fetch as jest.Mock).mockRejectedValue('Network error');
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await sendAPICall('t', 'c');
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error');
});
test('throws error if response is not ok', async () => {
(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false });
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
await sendAPICall('t', 'c');
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error));
});
});
describe('Helper Functions', () => {
test('nextPhase sends correct params', async () => {
await nextPhase();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) })
);
});
test('pauseExperiment sends correct params', async () => {
await pauseExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) })
);
});
test('playExperiment sends correct params', async () => {
await playExperiment();
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) })
);
});
});
describe('useExperimentLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
// Hook must be rendered to start the effect
renderHook(() => useExperimentLogger(onUpdate));
// Retrieve the mocked instance created by the hook
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/experiment_stream');
// Simulate incoming message
act(() => {
eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' }));
});
expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' });
});
test('handles JSON parse errors in stream', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('invalid-json');
});
expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error));
});
test('handles SSE connection error', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.triggerError('Connection lost');
});
expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost');
expect(eventSource.closed).toBe(true);
});
test('closes EventSource on unmount', () => {
const { unmount } = renderHook(() => useExperimentLogger());
const eventSource = mockInstances[0];
const closeSpy = jest.spyOn(eventSource, 'close');
unmount();
expect(closeSpy).toHaveBeenCalled();
expect(eventSource.closed).toBe(true);
});
});
describe('useStatusLogger', () => {
test('connects to SSE and receives messages', () => {
const onUpdate = jest.fn();
renderHook(() => useStatusLogger(onUpdate));
const eventSource = mockInstances[0];
expect(eventSource.url).toContain('/status_stream');
act(() => {
eventSource.sendMessage(JSON.stringify({ some: 'data' }));
});
expect(onUpdate).toHaveBeenCalledWith({ some: 'data' });
});
test('handles JSON parse errors', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
renderHook(() => useStatusLogger());
const eventSource = mockInstances[0];
act(() => {
eventSource.sendMessage('bad-data');
});
expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error));
});
});
});

View File

@@ -0,0 +1,226 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import '@testing-library/jest-dom';
// Corrected Imports
import {
GestureControls,
SpeechPresets,
DirectSpeechInput,
StatusList,
RobotConnected
} from '../../../src/pages/MonitoringPage/MonitoringPageComponents';
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
// Mock the API Call function with the correct path
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
sendAPICall: jest.fn(),
}));
describe('MonitoringPageComponents', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('GestureControls', () => {
test('renders and sends gesture command', () => {
render(<GestureControls />);
fireEvent.change(screen.getByRole('combobox'), {
target: { value: 'animations/Stand/Gestures/Hey_1' }
});
// Click button
fireEvent.click(screen.getByText('Actuate'));
// Expect the API to be called with that new value
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1');
});
});
describe('SpeechPresets', () => {
test('renders buttons and sends speech command', () => {
render(<SpeechPresets />);
const btn = screen.getByText('"Hello, I\'m Pepper"');
fireEvent.click(btn);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper");
});
});
describe('DirectSpeechInput', () => {
test('inputs text and sends on button click', () => {
render(<DirectSpeechInput />);
const input = screen.getByPlaceholderText('Type message...');
fireEvent.change(input, { target: { value: 'Custom text' } });
fireEvent.click(screen.getByText('Send'));
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text');
expect(input).toHaveValue(''); // Should clear
});
test('sends on Enter key', () => {
render(<DirectSpeechInput />);
const input = screen.getByPlaceholderText('Type message...');
fireEvent.change(input, { target: { value: 'Enter text' } });
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text');
});
test('does not send empty text', () => {
render(<DirectSpeechInput />);
fireEvent.click(screen.getByText('Send'));
expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled();
});
});
describe('StatusList', () => {
const mockSet = jest.fn();
const items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' }
];
test('renders list items', () => {
render(<StatusList title="Test List" items={items} type="goal" activeIds={{}} />);
expect(screen.getByText('Test List')).toBeInTheDocument();
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
test('Goals: click override on inactive item calls API', () => {
render(
<StatusList
title="Goals"
items={items}
type="goal"
activeIds={{}}
setActiveIds={mockSet}
/>
);
// Click the X (inactive)
const indicator = screen.getAllByText('❌')[0];
fireEvent.click(indicator);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1');
expect(mockSet).toHaveBeenCalled();
});
test('Conditional Norms: click override on ACTIVE item unachieves', () => {
render(
<StatusList
title="CN"
items={items}
type="cond_norm"
activeIds={{ '1': true }}
/>
);
const indicator = screen.getByText('✔️'); // It is active
fireEvent.click(indicator);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1');
});
test('Current Goal highlighting', () => {
render(
<StatusList
title="Goals"
items={items}
type="goal"
activeIds={{}}
currentGoalIndex={0}
/>
);
// Using regex to handle the "(Current)" text
expect(screen.getByText(/Item 1/)).toBeInTheDocument();
expect(screen.getByText(/(Current)/)).toBeInTheDocument();
});
});
describe('RobotConnected', () => {
let mockEventSource: any;
beforeAll(() => {
Object.defineProperty(window, 'EventSource', {
writable: true,
value: jest.fn().mockImplementation(() => ({
close: jest.fn(),
onmessage: null,
})),
});
});
beforeEach(() => {
mockEventSource = new window.EventSource('url');
(window.EventSource as unknown as jest.Mock).mockClear();
(window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource);
});
test('displays disconnected initially', () => {
render(<RobotConnected />);
expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument();
});
test('updates to connected when SSE receives true', async () => {
render(<RobotConnected />);
act(() => {
if(mockEventSource.onmessage) {
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
}
});
expect(await screen.findByText('● Robot is connected')).toBeInTheDocument();
});
test('handles invalid JSON gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
render(<RobotConnected />);
act(() => {
if(mockEventSource.onmessage) {
mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent);
}
});
// Should catch error and log it, state remains disconnected
expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json');
consoleSpy.mockRestore();
});
test('logs error if state update fails (inner catch block)', async () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
// 1. Force useState to return a setter that throws an error
const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); });
// We use mockImplementation to return [currentState, throwingSetter]
const useStateSpy = jest.spyOn(React, 'useState')
.mockImplementation(() => [null, mockThrowingSetter]);
render(<RobotConnected />);
// 2. Trigger the event with VALID JSON ("true")
// This passes the first JSON.parse try/catch,
// but fails when calling setConnected(true) because of our mock.
await act(async () => {
if (mockEventSource.onmessage) {
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
}
});
// 3. Verify the specific error log from line 205
expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data");
// Cleanup spies
useStateSpy.mockRestore();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,83 @@
import { render, screen, fireEvent } from "@testing-library/react";
import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram";
import useProgramStore from "../../../src/utils/programStore";
/**
* Helper to preload the program store before rendering.
*/
function loadProgram(phases: Record<string, unknown>[]) {
useProgramStore.getState().setProgramState({ phases });
}
describe("SimpleProgram", () => {
beforeEach(() => {
loadProgram([]);
});
test("shows empty state when no program is loaded", () => {
render(<SimpleProgram />);
expect(screen.getByText("No program loaded.")).toBeInTheDocument();
});
test("renders first phase content", () => {
loadProgram([
{
id: "phase-1",
norms: [{ id: "n1", norm: "Be polite" }],
goals: [{ id: "g1", description: "Finish task", achieved: true }],
triggers: [{ id: "t1", label: "Keyword trigger" }],
},
]);
render(<SimpleProgram />);
expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument();
expect(screen.getByText("Be polite")).toBeInTheDocument();
expect(screen.getByText("Finish task")).toBeInTheDocument();
expect(screen.getByText("Keyword trigger")).toBeInTheDocument();
});
test("allows navigating between phases", () => {
loadProgram([
{
id: "phase-1",
norms: [],
goals: [],
triggers: [],
},
{
id: "phase-2",
norms: [{ id: "n2", norm: "Be careful" }],
goals: [],
triggers: [],
},
]);
render(<SimpleProgram />);
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
fireEvent.click(screen.getByText("Next ▶"));
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
expect(screen.getByText("Be careful")).toBeInTheDocument();
});
test("prev button is disabled on first phase", () => {
loadProgram([
{ id: "phase-1", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
expect(screen.getByText("◀ Prev")).toBeDisabled();
});
test("next button is disabled on last phase", () => {
loadProgram([
{ id: "phase-1", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
expect(screen.getByText("Next ▶")).toBeDisabled();
});
});

View File

@@ -3,6 +3,9 @@ import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/
import { mockReactFlow } from '../../../setupFlowTests.ts';
beforeAll(() => {
mockReactFlow();
});

View File

@@ -0,0 +1,86 @@
import {renderHook} from "@testing-library/react";
import type {Connection} from "@xyflow/react";
import {
ruleResult,
type RuleResult,
useHandleRules
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
describe('useHandleRules', () => {
it('should register rules on mount and validate connection', () => {
const rules = [() => ({ isSatisfied: true } as RuleResult)];
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
// Confirm rules registered
const storedRules = useFlowStore.getState().getTargetRules('node1', 'h1');
expect(storedRules).toEqual(rules);
// Validate a connection
const connection = { source: 'node2', sourceHandle: 'h2', target: 'node1', targetHandle: 'h1' };
const validation = result.current(connection);
expect(validation).toEqual(ruleResult.satisfied);
});
it('should throw error if targetHandle missing', () => {
const rules: any[] = [];
const { result } = renderHook(() => useHandleRules('node1', 'h1', 'target', rules));
expect(() =>
result.current({ source: 'a', target: 'b', targetHandle: null, sourceHandle: null })
).toThrow('No target handle was provided');
});
});
describe('useHandleRules with multiple failed rules', () => {
it('should return the first failed rule message and consider connectionCount', () => {
// Mock rules for the target handle
const failingRules = [
(_conn: any, ctx: any) => {
if (ctx.connectionCount >= 1) {
return { isSatisfied: false, message: 'Max connections reached' } as RuleResult;
}
return { isSatisfied: true } as RuleResult;
},
() => ({ isSatisfied: false, message: 'Other rule failed' } as RuleResult),
() => ({ isSatisfied: true } as RuleResult),
];
// Register rules for the target handle
useFlowStore.getState().registerRules('targetNode', 'targetHandle', failingRules);
// Add one existing edge to simulate connectionCount
useFlowStore.setState({
edges: [
{
id: 'edge-1',
source: 'sourceNode',
sourceHandle: 'sourceHandle',
target: 'targetNode',
targetHandle: 'targetHandle',
},
],
});
// Create hook for a source node handle
const rulesForSource = [
(_c: Connection) => ({ isSatisfied: true } as RuleResult)
];
const { result } = renderHook(() =>
useHandleRules('sourceNode', 'sourceHandle', 'source', rulesForSource)
);
const connection = {
source: 'sourceNode',
sourceHandle: 'sourceHandle',
target: 'targetNode',
targetHandle: 'targetHandle',
};
const validation = result.current(connection);
// Should fail with first failing rule message
expect(validation).toEqual(ruleResult.notSatisfied('Max connections reached'));
});
});

View File

@@ -0,0 +1,84 @@
import {ruleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import {
allowOnlyConnectionsFromType,
allowOnlyConnectionsFromHandle, noSelfConnections
} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts";
import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
beforeEach(() => {
useFlowStore.setState({
nodes: [
{ id: 'nodeA', type: 'typeA', position: { x: 0, y: 0 }, data: {} },
{ id: 'nodeB', type: 'typeB', position: { x: 0, y: 0 }, data: {} },
],
});
});
describe('allowOnlyConnectionsFromType', () => {
it('should allow connection from allowed node type', () => {
const rule = allowOnlyConnectionsFromType(['typeA']);
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.satisfied);
});
it('should not allow connection from disallowed node type', () => {
const rule = allowOnlyConnectionsFromType(['typeA']);
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
});
});
describe('allowOnlyConnectionsFromHandle', () => {
it('should allow connection from node with correct type and handle', () => {
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.satisfied);
});
it('should not allow connection from node with wrong handle', () => {
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
const connection = { source: 'nodeA', sourceHandle: 'wrongHandle', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'wrongHandle' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeA"));
});
it('should not allow connection from node with wrong type', () => {
const rule = allowOnlyConnectionsFromHandle([{ nodeType: 'typeA', handleId: 'h1' }]);
const connection = { source: 'nodeB', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeB', handleId: 'h1' }, target: { id: 'nodeA', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("the target doesn't allow connections from nodes with type: typeB"));
});
});
describe('noSelfConnections', () => {
it('should allow connection from node with other type and handle', () => {
const rule = noSelfConnections;
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeB', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.satisfied);
});
it('should not allow connection from other handle on same node', () => {
const rule = noSelfConnections;
const connection = { source: 'nodeA', sourceHandle: 'h1', target: 'nodeA', targetHandle: 'h2' };
const context = { connectionCount: 0, source: { id: 'nodeA', handleId: 'h1' }, target: { id: 'nodeB', handleId: 'h2' } };
const result = rule(connection, context);
expect(result).toEqual(ruleResult.notSatisfied("nodes are not allowed to connect to themselves"));
});
});

View File

@@ -1,5 +1,6 @@
import {act} from '@testing-library/react';
import type {Connection, Edge, 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';
@@ -594,5 +595,48 @@ describe('FlowStore Functionality', () => {
expect(updatedState.nodes).toHaveLength(1);
expect(updatedState.nodes[0]).toMatchObject(expected.node);
})
})
describe('Handle Rule Registry', () => {
it('should register and retrieve rules', () => {
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
expect(rules).toEqual(mockRules);
});
it('should warn and return empty array if rules are missing', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const rules = useFlowStore.getState().getTargetRules('missingNode', 'missingHandle');
expect(rules).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No rules were registered'));
consoleSpy.mockRestore();
});
it('should unregister a specific handle rule', () => {
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
useFlowStore.getState().unregisterHandleRules('node1', 'handleA');
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
expect(rules).toEqual([]);
});
it('should unregister all rules for a node', () => {
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
useFlowStore.getState().registerRules('node1', 'handleB', mockRules);
useFlowStore.getState().registerRules('node2', 'handleC', mockRules);
useFlowStore.getState().unregisterNodeRules('node1');
expect(useFlowStore.getState().getTargetRules('node1', 'handleA')).toEqual([]);
expect(useFlowStore.getState().getTargetRules('node1', 'handleB')).toEqual([]);
expect(useFlowStore.getState().getTargetRules('node2', 'handleC')).toEqual(mockRules);
});
});
})
});

View File

@@ -95,7 +95,11 @@ describe("Drag & drop node creation", () => {
const node = nodes[0];
expect(node.type).toBe("phase");
expect(node.id).toBe("phase-1");
// UUID Expression
expect(node.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
);
// screenToFlowPosition was mocked to subtract 100
expect(node.position).toEqual({

View File

@@ -0,0 +1,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

@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
});
test("onLoad with invalid JSON does not update store", async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const file = new File(["not json"], "bad.json", { type: "application/json" });
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Give some input
act(() => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(window.alert).toHaveBeenCalledTimes(1);
const nodesAfter = useFlowStore.getState().nodes;
expect(nodesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
// Clean up the spy
consoleSpy.mockRestore();
});
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import {type Connection, getOutgoers, type Node} from '@xyflow/react';
import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx";
import {
BeliefGlobalReduce, noBeliefCycles,
noMatchingLeftRightBelief
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts";
import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
describe('BeliefGlobalReduce', () => {
const nodes: Node[] = [];
beforeEach(() => {
jest.clearAllMocks();
});
it('delegates to BasicBeliefReduce for basic_belief nodes', () => {
const spy = jest
.spyOn(BasicModule, 'BasicBeliefReduce')
.mockReturnValue('basic-result' as any);
const node = { id: '1', type: 'basic_belief' } as Node;
const result = BeliefGlobalReduce(node, nodes);
expect(spy).toHaveBeenCalledWith(node, nodes);
expect(result).toBe('basic-result');
});
it('delegates to InferredBeliefReduce for inferred_belief nodes', () => {
const spy = jest
.spyOn(InferredModule, 'InferredBeliefReduce')
.mockReturnValue('inferred-result' as any);
const node = { id: '2', type: 'inferred_belief' } as Node;
const result = BeliefGlobalReduce(node, nodes);
expect(spy).toHaveBeenCalledWith(node, nodes);
expect(result).toBe('inferred-result');
});
it('returns undefined for unknown node types', () => {
const node = { id: '3', type: 'other' } as Node;
const result = BeliefGlobalReduce(node, nodes);
expect(result).toBeUndefined();
expect(BasicBeliefReduce).not.toHaveBeenCalled();
expect(InferredBeliefReduce).not.toHaveBeenCalled();
});
});
describe('noMatchingLeftRightBelief rule', () => {
let getStateSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
});
it('is satisfied when target node is not an inferred belief', () => {
getStateSpy.mockReturnValue({
nodes: [{ id: 't1', type: 'basic_belief' }],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 's1', target: 't1' } as Connection,
null as any
);
expect(result).toBe(ruleResult.satisfied);
});
it('is satisfied when inferred belief has no matching left/right', () => {
getStateSpy.mockReturnValue({
nodes: [
{
id: 't1',
type: 'inferred_belief',
data: {
inferredBelief: {
left: 'a',
right: 'b',
},
},
},
],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 'c', target: 't1' } as Connection,
null as any
);
expect(result).toBe(ruleResult.satisfied);
});
it('is NOT satisfied when source matches left input', () => {
getStateSpy.mockReturnValue({
nodes: [
{
id: 't1',
type: 'inferred_belief',
data: {
inferredBelief: {
left: 's1',
right: 's2',
},
},
},
],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 's1', target: 't1' } as Connection,
null as any
);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain(
'Connecting one belief to both input handles of an inferred belief node is not allowed'
);
}
});
it('is NOT satisfied when source matches right input', () => {
getStateSpy.mockReturnValue({
nodes: [
{
id: 't1',
type: 'inferred_belief',
data: {
inferredBelief: {
left: 's1',
right: 's2',
},
},
},
],
} as any);
const result = noMatchingLeftRightBelief(
{ source: 's2', target: 't1' } as Connection,
null as any
);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain(
'Connecting one belief to both input handles of an inferred belief node is not allowed'
);
}
});
});
jest.mock('@xyflow/react', () => ({
getOutgoers: jest.fn(),
getConnectedEdges: jest.fn(), // include if some tests require it
}));
describe('noBeliefCycles rule', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns notSatisfied when source === target', () => {
const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain('Cyclical connection exists');
}
});
it('returns satisfied when there are no outgoing inferred beliefs', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [{ id: 'n1', type: 'inferred_belief' }],
edges: [],
} as any);
(getOutgoers as jest.Mock).mockReturnValue([]);
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
expect(result).toBe(ruleResult.satisfied);
});
it('returns notSatisfied for direct cycle', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [
{ id: 'n1', type: 'inferred_belief' },
{ id: 'n2', type: 'inferred_belief' },
],
edges: [{ source: 'n2', target: 'n1' }],
} as any);
// @ts-expect-error is acting up
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }];
return [];
});
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain('Cyclical connection exists');
}
});
it('returns notSatisfied for indirect cycle', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [
{ id: 'A', type: 'inferred_belief' },
{ id: 'B', type: 'inferred_belief' },
{ id: 'C', type: 'inferred_belief' },
],
edges: [
{ source: 'A', target: 'B' },
{ source: 'B', target: 'C' },
{ source: 'C', target: 'A' },
],
} as any);
// @ts-expect-error is acting up
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
const mapping: Record<string, any[]> = {
A: [{ id: 'B', type: 'inferred_belief' }],
B: [{ id: 'C', type: 'inferred_belief' }],
C: [{ id: 'A', type: 'inferred_belief' }],
};
return mapping[id] || [];
});
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
expect(result.isSatisfied).toBe(false);
if (!(result.isSatisfied)) {
expect(result.message).toContain('Cyclical connection exists');
}
});
it('returns satisfied when no cycle exists in a multi-node graph', () => {
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
nodes: [
{ id: 'A', type: 'inferred_belief' },
{ id: 'B', type: 'inferred_belief' },
{ id: 'C', type: 'inferred_belief' },
],
edges: [
{ source: 'A', target: 'B' },
{ source: 'B', target: 'C' },
],
} as any);
// @ts-expect-error is acting up
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
const mapping: Record<string, any[]> = {
A: [{ id: 'B', type: 'inferred_belief' }],
B: [{ id: 'C', type: 'inferred_belief' }],
C: [],
};
return mapping[id] || [];
});
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
expect(result).toBe(ruleResult.satisfied);
});
});

View File

@@ -0,0 +1,742 @@
// BasicBeliefNode.test.tsx
import { describe, it, beforeEach } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom';
describe('BasicBeliefNode', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
});
describe('Rendering', () => {
it('should render the basic belief node with keyword type by default', () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'help', value: 'help', label: 'Keyword said:' },
hasReduce: true,
},
};
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByText('Belief:')).toBeInTheDocument();
expect(screen.getByDisplayValue('Keyword said:')).toBeInTheDocument();
expect(screen.getByDisplayValue('help')).toBeInTheDocument();
});
it('should render with semantic belief type', () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-2',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'semantic', id: 'test', value: 'test value', description: "test description", label: 'Detected with LLM:' },
hasReduce: true,
},
};
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByDisplayValue('Detected with LLM:')).toBeInTheDocument();
expect(screen.getByDisplayValue('test value')).toBeInTheDocument();
});
it('should render with object belief type', () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-3',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'object', id: 'obj1', value: 'cup', label: 'Object found:' },
hasReduce: true,
},
};
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByDisplayValue('Object found:')).toBeInTheDocument();
expect(screen.getByDisplayValue('cup')).toBeInTheDocument();
});
it('should render with emotion belief type and select dropdown', () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-4',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
hasReduce: true,
},
};
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument();
// For emotion type, we should check that the select has the correct value selected
const selectElement = screen.getByDisplayValue('Happy');
expect(selectElement).toBeInTheDocument();
expect((selectElement as HTMLSelectElement).value).toBe('happy');
});
it('should render emotion dropdown with all emotion options', () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-5',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
hasReduce: true,
},
};
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const selectElement = screen.getByDisplayValue('Happy');
expect(selectElement).toBeInTheDocument();
// Check that all emotion options are present
expect(screen.getByText('Happy')).toBeInTheDocument();
expect(screen.getByText('Angry')).toBeInTheDocument();
expect(screen.getByText('Sad')).toBeInTheDocument();
expect(screen.getByText('Cheerful')).toBeInTheDocument();
});
it('should render without wrapping quotes for object type', () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-6',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' },
hasReduce: true,
},
};
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
// Object type should not have wrapping quotes
const inputs = screen.getAllByDisplayValue('chair');
expect(inputs.length).toBe(1); // Only the text input, no extra quote elements
});
});
describe('User Interactions', () => {
it('should update belief type when select is changed', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const select = screen.getByDisplayValue('Keyword said:');
await user.selectOptions(select, 'semantic');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.type).toBe('semantic');
// Note: The component doesn't update the label when changing type
// So we can't test for label change
});
});
it('should update text value when typing for keyword type', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByPlaceholderText('keyword...');
await user.type(input, 'help me{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.value).toBe('help me');
});
});
it('should update text value when typing for semantic type', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'semantic', id: 'test', value: 'test value', description: "test description", label: 'Detected with LLM:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByDisplayValue('test value') as HTMLInputElement;
// Clear the input
for (let i = 0; i < 'test value'.length; i++) {
await user.type(input, '{backspace}');
}
await user.type(input, 'new semantic value{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.value).toBe('new semantic value');
});
});
it('should update emotion value when selecting from dropdown', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const select = screen.getByDisplayValue('Happy');
await user.selectOptions(select, 'sad');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.value).toBe('sad');
});
});
it('should preserve value when switching between text-based belief types', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: 'test value', label: 'Keyword said:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
// Switch from keyword to semantic
const typeSelect = screen.getByDisplayValue('Keyword said:');
await user.selectOptions(typeSelect, 'semantic');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.type).toBe('semantic');
expect(updatedNode?.data.belief.value).toBe('test value'); // Value should be preserved
});
});
it('should automatically choose the first option when switching to emotion type, and carry on to the text values', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: 'some text', label: 'Keyword said:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
// Switch from keyword to emotion
const typeSelect = screen.getByDisplayValue('Keyword said:');
await user.selectOptions(typeSelect, 'emotion');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.type).toBe('emotion');
// The component doesn't reset the value when changing types
// So it keeps the old value even though it doesn't make sense for emotion type
expect(updatedNode?.data.belief.value).toBe('Happy');
});
});
});
// ... rest of the tests remain the same, just fixing the Integration with Store section ...
describe('Integration with Store', () => {
it('should properly update the store when changing belief value', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByPlaceholderText('keyword...');
await user.type(input, 'emergency{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
expect(state.nodes).toHaveLength(1);
expect(state.nodes[0].id).toBe('belief-1');
const beliefData = state.nodes[0].data as BasicBeliefNodeData;
expect(beliefData.belief.value).toBe('emergency');
expect(beliefData.belief.type).toBe('keyword');
});
});
it('should properly update the store when changing belief type', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: 'test', label: 'Keyword said:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const select = screen.getByDisplayValue('Keyword said:');
await user.selectOptions(select, 'object');
await waitFor(() => {
const state = useFlowStore.getState();
const beliefData = state.nodes[0].data as BasicBeliefNodeData;
expect(beliefData.belief.type).toBe('object');
// Note: The component doesn't update the label when changing type
expect(beliefData.belief.value).toBe('test'); // Value should be preserved
});
});
it('should not affect other nodes when updating one belief node', async () => {
const belief1: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief 1',
droppable: true,
belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' },
hasReduce: true,
},
};
const belief2: Node<BasicBeliefNodeData> = {
id: 'belief-2',
type: 'basic_belief',
position: { x: 100, y: 0 },
data: {
label: 'Belief 2',
droppable: true,
belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [belief1, belief2],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={belief1.id}
type={belief1.type as string}
data={belief1.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByDisplayValue('hello') as HTMLInputElement;
// Clear the input
for (let i = 0; i < 'hello'.length; i++) {
await user.type(input, '{backspace}');
}
await user.type(input, 'goodbye{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedBelief1 = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
const unchangedBelief2 = state.nodes.find(n => n.id === 'belief-2') as Node<BasicBeliefNodeData>;
expect(updatedBelief1.data.belief.value).toBe('goodbye');
expect(unchangedBelief2.data.belief.value).toBe('chair');
expect(unchangedBelief2.data.belief.type).toBe('object');
});
});
it('should handle multiple rapid updates to belief value', async () => {
const mockNode: Node<BasicBeliefNodeData> = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
label: 'Belief',
droppable: true,
belief: { type: 'semantic', id: 'test', value: 'test value', description: "test description", label: 'Detected with LLM:' },
hasReduce: true,
},
};
useFlowStore.setState({
nodes: [mockNode],
edges: [],
});
renderWithProviders(
<BasicBeliefNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByDisplayValue('test value') as HTMLInputElement;
await user.type(input, '1');
await waitFor(() => {
const state = useFlowStore.getState();
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
expect(nodeData.belief.value).toBe('test value');
});
await user.type(input, '2');
await waitFor(() => {
const state = useFlowStore.getState();
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
expect(nodeData.belief.value).toBe('test value');
});
await user.type(input, '{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
expect(nodeData.belief.value).toBe('test value12');
});
});
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, beforeEach } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
import GoalNode, { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom';
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
describe('GoalNode', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
jest.clearAllMocks();
});
it('renders the Goal node with default data', () => {
const mockNode: Node = {
id: 'goal-1',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)) },
};
renderWithProviders(
<GoalNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByPlaceholderText('To ...')).toBeInTheDocument();
});
it('updates goal name when user types and commits', async () => {
const mockNode: Node<GoalNodeData> = {
id: 'goal-2',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: '' },
};
useFlowStore.setState({ nodes: [mockNode], edges: [] });
renderWithProviders(
<GoalNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByPlaceholderText('To ...');
await user.type(input, 'Save the world{enter}');
await waitFor(() => {
const state = useFlowStore.getState();
const updated = state.nodes.find(n => n.id === 'goal-2');
expect(updated?.data.name).toBe('Save the world');
});
});
it('shows plan message and disabled checked checkbox when plan does not iterate', () => {
const mockNode: Node<GoalNodeData> = {
id: 'goal-3',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: defaultPlan, name: 'G' },
};
useFlowStore.setState({ nodes: [mockNode], edges: [] });
renderWithProviders(
<GoalNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByText(/Will follow plan 'Default Plan' until all steps complete./i)).toBeInTheDocument();
const checkbox = screen.getByLabelText(/This plan always succeeds!/i) as HTMLInputElement;
expect(checkbox).toBeDisabled();
expect(checkbox.checked).toBe(true);
});
it('allows toggling can_fail when plan iterates', async () => {
// plan with an llm-step will make DoesPlanIterate return true
const iterPlan = { ...defaultPlan, id: 'p-iter', steps: [{ id: 'a-1', type: 'llm', goal: 'do' }] } as any;
const mockNode: Node<GoalNodeData> = {
id: 'goal-4',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: iterPlan, name: 'Iterating' },
};
useFlowStore.setState({ nodes: [mockNode], edges: [] });
renderWithProviders(
<GoalNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const checkbox = screen.getByLabelText(/Check if this plan fails/i) as HTMLInputElement;
expect(checkbox).not.toBeDisabled();
expect(checkbox.checked).toBe(false);
await user.click(checkbox);
await waitFor(() => {
const state = useFlowStore.getState();
const updated = state.nodes.find(n => n.id === 'goal-4');
expect(updated?.data.can_fail).toBe(true);
});
});
it('disables the checkbox and shows description when plan includes a checking sub-goal', () => {
const childGoal: Node = {
id: 'child-1',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true },
};
const p = { ...defaultPlan, id: 'p-2', steps: [{ id: 'child-1', type: 'goal' } as any] } as any;
const mockNode: Node<GoalNodeData> = {
id: 'goal-5',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'HasCheck' },
};
useFlowStore.setState({ nodes: [mockNode, childGoal], edges: [] });
renderWithProviders(
<GoalNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
expect(checkbox).toBeDisabled();
expect(checkbox.checked).toBe(true);
// description box should be visible because there's a checking subgoal
expect(screen.getByPlaceholderText('Describe the condition of this goal...')).toBeInTheDocument();
});
it('reduces its data correctly (GoalReduce)', () => {
const childGoal: Node = {
id: 'child-2',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true },
};
const p = { ...defaultPlan, id: 'p-3', steps: [{ id: 'child-2', type: 'goal' } as any] } as any;
const mockNode: Node = {
id: 'goal-6',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'ReduceMe', description: 'desc', can_fail: false },
};
const reduced = GoalReduce(mockNode, [mockNode, childGoal]);
expect(reduced).toEqual({
id: 'goal-6',
name: 'ReduceMe',
description: 'desc',
can_fail: true,
plan: {
id: expect.anything(),
steps: expect.any(Array),
}
});
});
it('adds a goal into a plan when a goal is connected to another', () => {
const source: Node = { id: 'g-src', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Source' } };
const target: Node = { id: 'g-target', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Target' } };
useFlowStore.setState({ nodes: [source, target], edges: [] });
// Simulate react-flow connect
useFlowStore.getState().onConnect({ source: 'g-src', target: 'g-target', sourceHandle: null, targetHandle: null });
const state = useFlowStore.getState();
const updatedTarget = state.nodes.find(n => n.id === 'g-target');
expect(updatedTarget?.data.plan).toBeDefined();
const plan = updatedTarget?.data.plan as any;
expect(plan.steps.length).toBe(1);
expect(plan.steps[0].id).toBe('g-src');
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import type {Node, Edge} from '@xyflow/react';
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
import {
type InferredBelief,
InferredBeliefConnectionTarget,
InferredBeliefDisconnectionTarget,
InferredBeliefReduce,
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
// helper functions
function inferredNode(overrides = {}): Node {
return {
id: 'i1',
type: 'inferred_belief',
position: {x: 0, y: 0},
data: {
inferredBelief: {
left: undefined,
operator: true,
right: undefined,
},
...overrides,
},
} as Node;
}
describe('InferredBelief connection logic', () => {
let getStateSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
jest.clearAllMocks();
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
});
it('sets left belief when connected on beliefLeft handle', () => {
const node = inferredNode();
getStateSpy.mockReturnValue({
nodes: [{ id: 'b1', type: 'basic_belief' }],
edges: [
{
source: 'b1',
target: 'i1',
targetHandle: 'beliefLeft',
} as Edge,
],
} as any);
InferredBeliefConnectionTarget(node, 'b1');
expect((node.data.inferredBelief as InferredBelief).left).toBe('b1');
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
});
it('sets right belief when connected on beliefRight handle', () => {
const node = inferredNode();
getStateSpy.mockReturnValue({
nodes: [{ id: 'b2', type: 'basic_belief' }],
edges: [
{
source: 'b2',
target: 'i1',
targetHandle: 'beliefRight',
} as Edge,
],
} as any);
InferredBeliefConnectionTarget(node, 'b2');
expect((node.data.inferredBelief as InferredBelief).right).toBe('b2');
});
it('ignores connections from unsupported node types', () => {
const node = inferredNode();
getStateSpy.mockReturnValue({
nodes: [{ id: 'x', type: 'norm' }],
edges: [],
} as any);
InferredBeliefConnectionTarget(node, 'x');
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
});
it('clears left or right belief on disconnection', () => {
const node = inferredNode({
inferredBelief: { left: 'a', right: 'b', operator: true },
});
InferredBeliefDisconnectionTarget(node, 'a');
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
InferredBeliefDisconnectionTarget(node, 'b');
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
});
});
describe('InferredBeliefReduce', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('throws if left belief is missing', () => {
const node = inferredNode({
inferredBelief: { left: 'l', right: 'r', operator: true },
});
expect(() =>
InferredBeliefReduce(node, [{ id: 'r' } as Node])
).toThrow('No Left belief found');
});
it('throws if right belief is missing', () => {
const node = inferredNode({
inferredBelief: { left: 'l', right: 'r', operator: true },
});
expect(() =>
InferredBeliefReduce(node, [{ id: 'l' } as Node])
).toThrow('No Right Belief found');
});
});

View File

@@ -10,8 +10,9 @@ import NormNode, {
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom'
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
describe('NormNode', () => {
let user: ReturnType<typeof userEvent.setup>;
@@ -26,12 +27,7 @@ describe('NormNode', () => {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
label: 'Test Norm',
droppable: true,
norm: '',
hasReduce: true,
},
data: {...JSON.parse(JSON.stringify(NormNodeDefaults))},
};
renderWithProviders(
@@ -60,6 +56,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Be respectful to humans',
@@ -94,8 +91,10 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
conditions: [],
norm: '',
hasReduce: true,
critical: false
@@ -129,6 +128,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Dragged norm',
@@ -165,6 +165,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: '',
@@ -210,6 +211,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Initial norm text',
@@ -261,6 +263,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: '',
@@ -314,6 +317,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: '',
@@ -358,6 +362,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: '',
@@ -399,25 +404,41 @@ describe('NormNode', () => {
describe('NormReduce Function', () => {
it('should reduce a norm node to its essential data', () => {
const condition: Node = {
id: "belief-1",
type: 'basic_belief',
position: {x: 10, y: 10},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
}
const normNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Safety Norm',
droppable: true,
norm: 'Never harm humans',
hasReduce: true,
condition: "belief-1"
},
};
const allNodes: Node[] = [normNode];
const allNodes: Node[] = [normNode, condition];
const result = NormReduce(normNode, allNodes);
expect(result).toEqual({
id: 'norm-1',
label: 'Safety Norm',
norm: 'Never harm humans',
critical: false,
condition: {
id: "belief-1",
keyword: ""
},
});
});
@@ -427,6 +448,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1',
droppable: true,
norm: 'Be helpful',
@@ -439,6 +461,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 2',
droppable: true,
norm: 'Be honest',
@@ -463,6 +486,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Empty Norm',
droppable: true,
norm: '',
@@ -482,6 +506,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Custom Label',
droppable: false,
norm: 'Test norm',
@@ -502,6 +527,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Test',
@@ -514,6 +540,7 @@ describe('NormNode', () => {
type: 'phase',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Phase 1',
droppable: true,
children: [],
@@ -532,6 +559,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'Test',
@@ -544,6 +572,7 @@ describe('NormNode', () => {
type: 'phase',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Phase 1',
droppable: true,
children: [],
@@ -562,6 +591,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...NormNodeDefaults,
label: 'Test Norm',
droppable: true,
norm: 'Test',
@@ -583,6 +613,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: '',
@@ -634,6 +665,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: '',
@@ -682,6 +714,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1',
droppable: true,
norm: 'Original norm 1',
@@ -694,6 +727,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 2',
droppable: true,
norm: 'Original norm 2',
@@ -748,6 +782,7 @@ describe('NormNode', () => {
type: 'norm',
position: { x: 0, y: 0 },
data: {
...NormNodeDefaults,
label: 'Test Norm',
droppable: true,
norm: 'haa haa fuyaaah - link',
@@ -778,21 +813,140 @@ describe('NormNode', () => {
);
const input = screen.getByPlaceholderText('Pepper should ...');
expect(input).toBeDefined()
await user.type(input, 'a');
await user.type(input, 'a{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka');
});
await user.type(input, 'b');
await user.type(input, 'b{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab');
});
await user.type(input, 'c');
await user.type(input, 'c{enter}');
await waitFor(() => {
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
}, { timeout: 3000 });
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc');
});
});
});
describe('Integration beliefs', () => {
it('should update visually when adding beliefs', async () => {
// Setup state
const mockNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'haa haa fuyaaah - link',
hasReduce: true,
}
};
const mockBelief: Node = {
id: 'basic_belief-1',
type: 'basic_belief',
position: {x:100, y:100},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
};
useFlowStore.setState({
nodes: [mockNode, mockBelief],
edges: [],
});
// Simulate connecting
NormConnectionTarget(mockNode, mockBelief.id);
BasicBeliefConnectionSource(mockBelief, mockNode.id)
renderWithProviders(
<div>
<NormNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
<BasicBeliefNode
id={mockBelief.id}
type={mockBelief.type as string}
data={mockBelief.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
</div>
);
await waitFor(() => {
expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument();
});
});
it('should update the data when adding beliefs', async () => {
// Setup state
const mockNode: Node = {
id: 'norm-1',
type: 'norm',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Test Norm',
droppable: true,
norm: 'haa haa fuyaaah - link',
hasReduce: true,
}
};
const mockBelief1: Node = {
id: 'basic_belief-1',
type: 'basic_belief',
position: {x:100, y:100},
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
}
};
useFlowStore.setState({
nodes: [mockNode, mockBelief1],
edges: [],
});
// Simulate connecting
useFlowStore.getState().onConnect({
source: 'basic_belief-1',
target: 'norm-1',
sourceHandle: null,
targetHandle: null,
});
const state = useFlowStore.getState();
const updatedNorm = state.nodes.find(n => n.id === 'norm-1');
expect(updatedNorm?.data.condition).toEqual("basic_belief-1");
});
});
});

View File

@@ -1,8 +1,10 @@
import type { Node, Edge, Connection } from '@xyflow/react'
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
import { getByTestId, render } from '@testing-library/react';
import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
import {act, getByTestId, render} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
import {mockReactFlow} from "../../../../setupFlowTests.ts";
class ResizeObserver {
@@ -76,8 +78,10 @@ describe('PhaseNode', () => {
// Find nodes
const nodes = useFlowStore.getState().nodes;
const p1 = nodes.find((x) => x.id === 'phase-1')!;
const p2 = nodes.find((x) => x.id === 'phase-2')!;
const phaseNodes = nodes.filter((x) => x.type === 'phase');
const p1 = phaseNodes[0];
const p2 = phaseNodes[1];
// expect same value, not same reference
expect(p1.data.children).not.toBe(p2.data.children);
@@ -98,4 +102,195 @@ describe('PhaseNode', () => {
expect(p1_data.children.length == 1);
expect(p2_data.children.length == 2);
});
});
});
// --| Helper functions |--
function createPhaseNode(
id: string,
overrides: Partial<PhaseNodeData> = {},
): Node<PhaseNodeData> {
return {
id: id,
type: 'phase',
position: { x: 0, y: 0 },
data: {
label: 'Phase',
droppable: true,
children: [],
hasReduce: true,
nextPhaseId: null,
isFirstPhase: false,
...overrides,
},
}
}
function createNode(id: string, type: string): Node {
return {
id: id,
type: type,
position: { x: 0, y: 0 },
data: {},
}
}
function connect(source: string, target: string): Connection {
return {
source: source,
target: target,
sourceHandle: null,
targetHandle: null
};
}
function edge(source: string, target: string): Edge {
return {
id: `${source}-${target}`,
source: source,
target: target,
}
}
// --| Connection Tests |--
describe('PhaseNode Connection logic', () => {
beforeAll(() => {
mockReactFlow();
});
describe('PhaseConnections', () => {
test('connecting start => phase sets isFirstPhase to true', () => {
const phase = createPhaseNode('phase-1')
const start = createNode('start', 'start')
useFlowStore.setState({ nodes: [phase, start] })
// verify it starts of false
expect(phase.data.isFirstPhase).toBe(false);
act(() => {
useFlowStore.getState().onConnect(connect('start', 'phase-1'))
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.isFirstPhase).toBe(true)
})
test('connecting task => phase adds child', () => {
const phase = createPhaseNode('phase-1')
const norm = createNode('norm-1', 'norm')
useFlowStore.setState({ nodes: [phase, norm] })
act(() => {
useFlowStore.getState().onConnect(connect('norm-1', 'phase-1'))
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.children).toEqual(['norm-1'])
})
test('connecting phase => phase sets nextPhaseId', () => {
const p1 = createPhaseNode('phase-1')
const p2 = createPhaseNode('phase-2')
useFlowStore.setState({ nodes: [p1, p2] })
act(() => {
useFlowStore.getState().onConnect(connect('phase-1', 'phase-2'))
})
const updatedP1 = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedP1.data.nextPhaseId).toBe('phase-2')
})
test('connecting phase to end => phase sets nextPhaseId to "end"', () => {
const phase = createPhaseNode('phase-1')
const end = createNode('end', 'end')
useFlowStore.setState({ nodes: [phase, end] })
act(() => {
useFlowStore.getState().onConnect(connect('phase-1', 'end'))
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.nextPhaseId).toBe('end')
})
})
describe('PhaseDisconnections', () => {
test('disconnecting task => phase removes child', () => {
const phase = createPhaseNode('phase-1', { children: ['norm-1'] })
const norm = createNode('norm-1', 'norm')
useFlowStore.setState({
nodes: [phase, norm],
edges: [edge('norm-1', 'phase-1')]
})
act(() => {
useFlowStore.getState().onEdgesDelete([edge('norm-1', 'phase-1')])
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.children).toEqual([])
})
test('disconnecting start => phase sets isFirstPhase to false', () => {
const phase = createPhaseNode('phase-1', { isFirstPhase: true })
const start = createNode('start', 'start')
useFlowStore.setState({
nodes: [phase, start],
edges: [edge('start', 'phase-1')]
})
act(() => {
useFlowStore.getState().onEdgesDelete([edge('start', 'phase-1')])
})
const updatedPhase = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedPhase.data.isFirstPhase).toBe(false)
})
test('disconnecting phase => phase sets nextPhaseId to null', () => {
const p1 = createPhaseNode('phase-1', { nextPhaseId: 'phase-2' })
const p2 = createPhaseNode('phase-2')
useFlowStore.setState({
nodes: [p1, p2],
edges: [edge('phase-1', 'phase-2')]
})
act(() => {
useFlowStore.getState().onEdgesDelete([edge('phase-1', 'phase-2')])
})
const updatedP1 = useFlowStore
.getState()
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
expect(updatedP1.data.nextPhaseId).toBeNull()
})
})
})

View File

@@ -1,22 +1,25 @@
import { describe, it, beforeEach } from '@jest/globals';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { screen } from '@testing-library/react';
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
import TriggerNode, {
TriggerReduce,
TriggerNodeCanConnect,
type TriggerNodeData,
TriggerConnectionSource, TriggerConnectionTarget
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom';
import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts';
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
import { act } from '@testing-library/react';
describe('TriggerNode', () => {
let user: ReturnType<typeof userEvent.setup>;
beforeEach(() => {
user = userEvent.setup();
jest.clearAllMocks();
});
describe('Rendering', () => {
@@ -26,11 +29,7 @@ describe('TriggerNode', () => {
type: 'trigger',
position: { x: 0, y: 0 },
data: {
label: 'Keyword Trigger',
droppable: true,
triggerType: 'keywords',
triggers: [],
hasReduce: true,
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
},
};
@@ -51,161 +50,60 @@ describe('TriggerNode', () => {
/>
);
expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText('...')).toBeInTheDocument();
});
it('should render TriggerNode with emotion type', () => {
const mockNode: Node<TriggerNodeData> = {
id: 'trigger-2',
type: 'trigger',
position: { x: 0, y: 0 },
data: {
label: 'Emotion Trigger',
droppable: true,
triggerType: 'emotion',
triggers: [],
hasReduce: true,
},
};
renderWithProviders(
<TriggerNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument();
});
});
describe('User Interactions', () => {
it('should add a new keyword', async () => {
const mockNode: Node<TriggerNodeData> = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: {
label: 'Keyword Trigger',
droppable: true,
triggerType: 'keywords',
triggers: [],
hasReduce: true,
},
};
useFlowStore.setState({ nodes: [mockNode], edges: [] });
renderWithProviders(
<TriggerNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByPlaceholderText('...');
await user.type(input, 'hello{enter}');
await waitFor(() => {
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
expect(node?.data.triggers.length).toBe(1);
expect(node?.data.triggers[0].keyword).toBe('hello');
});
});
it('should remove a keyword when cleared', async () => {
const mockNode: Node<TriggerNodeData> = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: {
label: 'Keyword Trigger',
droppable: true,
triggerType: 'keywords',
triggers: [{ id: 'kw1', keyword: 'hello' }],
hasReduce: true,
},
};
useFlowStore.setState({ nodes: [mockNode], edges: [] });
renderWithProviders(
<TriggerNode
id={mockNode.id}
type={mockNode.type as string}
data={mockNode.data as any}
selected={false}
isConnectable={true}
zIndex={0}
dragging={false}
selectable={true}
deletable={true}
draggable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>
);
const input = screen.getByDisplayValue('hello');
for (let i = 0; i < 'hello'.length; i++) {
await user.type(input, '{backspace}');
}
await user.type(input, '{enter}');
await waitFor(() => {
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
expect(node?.data.triggers.length).toBe(0);
});
expect(screen.getByText(/Triggers when the condition is met/i)).toBeInTheDocument();
expect(screen.getByText(/Belief is currently/i)).toBeInTheDocument();
expect(screen.getByText(/Plan is currently/i)).toBeInTheDocument();
});
});
describe('TriggerReduce Function', () => {
it('should reduce a trigger node to its essential data', () => {
const conditionNode: Node = {
id: 'belief-1',
type: 'basic_belief',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)),
},
};
const triggerNode: Node = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: {
label: 'Keyword Trigger',
droppable: true,
triggerType: 'keywords',
triggers: [{ id: 'kw1', keyword: 'hello' }],
hasReduce: true,
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
condition: "belief-1",
plan: defaultPlan,
name: "trigger-1"
},
};
const allNodes: Node[] = [triggerNode];
const result = TriggerReduce(triggerNode, allNodes);
useFlowStore.setState({
nodes: [conditionNode, triggerNode],
edges: [],
});
useFlowStore.getState().onConnect({
source: 'belief-1',
target: 'trigger-1',
sourceHandle: null,
targetHandle: null,
});
const result = TriggerReduce(triggerNode, useFlowStore.getState().nodes);
expect(result).toEqual({
id: 'trigger-1',
type: 'keywords',
label: 'Keyword Trigger',
keywords: [{ id: 'kw1', keyword: 'hello' }],
});
name: "trigger-1",
condition: {
id: "belief-1",
keyword: "",
},
plan: {
id: expect.anything(),
steps: [],
},});
});
});
@@ -217,11 +115,8 @@ describe('TriggerNode', () => {
type: 'trigger',
position: { x: 0, y: 0 },
data: {
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
label: 'Trigger 1',
droppable: true,
triggerType: 'keywords',
triggers: [],
hasReduce: true,
},
};
@@ -230,10 +125,8 @@ describe('TriggerNode', () => {
type: 'norm',
position: { x: 100, y: 0 },
data: {
...JSON.parse(JSON.stringify(NormNodeDefaults)),
label: 'Norm 1',
droppable: true,
norm: 'test',
hasReduce: true,
},
};
@@ -242,10 +135,48 @@ describe('TriggerNode', () => {
TriggerConnectionTarget(node1, node2.id);
}).not.toThrow();
});
});
it('should return true for TriggerNodeCanConnect if connection exists', () => {
const connection = { source: 'trigger-1', target: 'norm-1' };
expect(TriggerNodeCanConnect(connection as any)).toBe(true);
describe('TriggerConnects Function', () => {
it('should correctly remove a goal from the triggers plan after it has been disconnected', () => {
// first, define the goal node and trigger node.
const goal: Node = {
id: 'g-1',
type: 'goal',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
};
const trigger: Node<TriggerNodeData> = {
id: 'trigger-1',
type: 'trigger',
position: { x: 0, y: 0 },
data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
};
// set initial store
useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
// then, connect the goal to the trigger.
act(() => {
useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null });
});
// expect the goal id to be part of a goal step of the plan.
let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
expect(updatedTrigger?.data.plan).toBeDefined();
const plan = updatedTrigger?.data.plan as any;
expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
// then, disconnect the goal from the trigger.
act(() => {
useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
});
// finally, expect the goal id to NOT be part of the goal step of the plan.
updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
const planAfter = updatedTrigger?.data.plan as any;
const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
expect(stillHas).toBeUndefined();
});
});
});
});

View File

@@ -8,22 +8,22 @@ import { createElement } from 'react';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
describe('NormNode', () => {
describe('Universal Nodes', () => {
beforeEach(() => {
jest.clearAllMocks();
});
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
const newData = {
id: id,
type: type,
position: position,
data: data,
deletable: deletable,
return {
id: id,
type: type,
position: position,
data: {...defaultData, ...data},
deletable: deletable
}
return {...defaultData, ...newData}
}
}
/**
@@ -45,34 +45,34 @@ describe('NormNode', () => {
describe('Rendering', () => {
test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => {
const lengthBefore = screen.getAllByText(/.*/).length;
const lengthBefore = screen.getAllByText(/.*/).length;
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {});
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
const uiElement = found ? found[1] : null;
const found = Object.entries(NodeTypes).find(([t]) => t === nodeType);
const uiElement = found ? found[1] : null;
expect(uiElement).not.toBeNull();
const props = {
id: newNode.id,
type: newNode.type as string,
data: newNode.data as any,
selected: false,
isConnectable: true,
zIndex: 0,
dragging: false,
selectable: true,
deletable: true,
draggable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
};
expect(uiElement).not.toBeNull();
const props = {
id: newNode.id,
type: newNode.type as string,
data: newNode.data as any,
selected: false,
isConnectable: true,
zIndex: 0,
dragging: false,
selectable: true,
deletable: true,
draggable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
};
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
const lengthAfter = screen.getAllByText(/.*/).length;
renderWithProviders(createElement(uiElement as React.ComponentType<any>, props));
const lengthAfter = screen.getAllByText(/.*/).length;
expect(lengthBefore + 1 === lengthAfter);
});
expect(lengthBefore + 1 === lengthAfter);
});
});
@@ -107,6 +107,50 @@ describe('NormNode', () => {
});
});
describe('Disconnecting', () => {
test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => {
// Create two nodes - one of the current type and one to connect to
const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {});
const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {});
// Add nodes to store
useFlowStore.setState({ nodes: [sourceNode, targetNode] });
// Spy on the connect functions
const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources);
const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief');
// Simulate connection
useFlowStore.getState().onConnect({
source: 'source-1',
target: 'target-1',
sourceHandle: null,
targetHandle: null,
});
// Verify the connect functions were called
expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id);
expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id);
// Find this connection, and delete it
const edge = useFlowStore.getState().edges[0];
useFlowStore.getState().onEdgesDelete([edge]);
// Find the nodes in the flow
const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1");
const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1");
// Expect them to be the same after deleting the edges
expect(newSourceNode).toBe(sourceNode);
expect(newTargetNode).toBe(targetNode);
// Restore our spies
sourceConnectSpy.mockRestore();
targetConnectSpy.mockRestore();
});
});
describe('Reducing', () => {
test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => {
// Create a phase node and a node of the current type
@@ -141,7 +185,7 @@ describe('NormNode', () => {
// Verify the correct structure is present using NodesInPhase
expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2);
expect(result[0]).toHaveProperty('id', 'phase-1');
expect(result[0]).toHaveProperty('label', 'Test Phase');
expect(result[0]).toHaveProperty('name', 'Test Phase');
// Restore mocks
phaseReduceSpy.mockRestore();

View File

@@ -2,6 +2,11 @@ import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
if (!globalThis.structuredClone) {
globalThis.structuredClone = (obj: any) => {
return JSON.parse(JSON.stringify(obj));
};
}
// To make sure that the tests are working, it's important that you are using
// this implementation of ResizeObserver and DOMMatrixReadOnly
@@ -61,6 +66,7 @@ export const mockReactFlow = () => {
width: 200,
height: 200,
});
};
@@ -72,7 +78,8 @@ beforeAll(() => {
past: [],
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true
edgeReconnectSuccessful: true,
ruleRegistry: new Map()
});
});
@@ -84,7 +91,21 @@ afterEach(() => {
past: [],
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true
edgeReconnectSuccessful: true,
ruleRegistry: new Map()
});
});
if (typeof HTMLDialogElement !== 'undefined') {
if (!HTMLDialogElement.prototype.showModal) {
HTMLDialogElement.prototype.showModal = function () {
// basic behavior: mark as open
this.setAttribute('open', '');
};
}
if (!HTMLDialogElement.prototype.close) {
HTMLDialogElement.prototype.close = function () {
this.removeAttribute('open');
};
}
}

View File

@@ -19,6 +19,48 @@ export function renderWithProviders(
}
type SidebarRect = Partial<DOMRect>;
const defaultRect: DOMRect = {
top: 0,
left: 0,
bottom: 100,
right: 200,
width: 200,
height: 100,
x: 0,
y: 0,
toJSON: () => {},
};
/**
* Renders a component and injects a mock `#draggable-sidebar`
* element required by Tooltip positioning logic.
*/
export function renderWithSidebar(
ui: ReactElement,
rect: SidebarRect = {},
options?: RenderOptions
) {
const sidebar = document.createElement('div');
sidebar.id = 'draggable-sidebar';
sidebar.getBoundingClientRect = jest.fn(() => ({
...defaultRect,
...rect,
}));
document.body.appendChild(sidebar);
const result = render(ui, options);
return {
...result,
sidebar,
};
}
// Re-export everything from testing library
//eslint-disable-next-line react-refresh/only-export-components
export * from '@testing-library/react';

View File

@@ -0,0 +1,110 @@
import type {PhaseNode} from "../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
import orderPhaseNodeArray from "../../src/utils/orderPhaseNodes.ts";
function createPhaseNode(
id: string,
isFirst: boolean = false,
nextPhaseId: string | null = null
): PhaseNode {
return {
id: id,
type: 'phase',
position: { x: 0, y: 0 },
data: {
label: 'Phase',
droppable: true,
children: [],
hasReduce: true,
nextPhaseId: nextPhaseId,
isFirstPhase: isFirst,
},
}
}
describe("orderPhaseNodes", () => {
test.each([
{
testCase: {
testName: "Throws correct error when there is no first phase (empty input array)",
input: [],
expected: "No phaseNode with isFirstObject = true found"
}
},{
testCase: {
testName: "Throws correct error when there is no first phase",
input: [
createPhaseNode("phase-1", false, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
],
expected: "No phaseNode with isFirstObject = true found"
}
},{
testCase: {
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-phase connection)",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, null),
createPhaseNode("phase-3", false, "end")
],
expected: "Incomplete phase sequence, program does not reach the end node"
}
},{
testCase: {
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-end connection)",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, null)
],
expected: "Incomplete phase sequence, program does not reach the end node"
}
},{
testCase: {
testName: "Throws correct error when the program leads to a non-existent phase",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "phase-4")
],
expected: "Incomplete phase sequence, phaseNode with id \"phase-4\" not found"
}
}
])(`Error Handling: $testCase.testName`, ({testCase}) => {
expect(() => { orderPhaseNodeArray(testCase.input) }).toThrow(testCase.expected);
})
test.each([
{
testCase: {
testName: "Already correctly ordered phases stay ordered",
input: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
],
expected: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
]
}
},{
testCase: {
testName: "Incorrectly ordered phases get ordered correctly",
input: [
createPhaseNode("phase-3", false, "end"),
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
],
expected: [
createPhaseNode("phase-1", true, "phase-2"),
createPhaseNode("phase-2", false, "phase-3"),
createPhaseNode("phase-3", false, "end")
]
}
}
])(`Functional: $testCase.testName`, ({testCase}) => {
const output = orderPhaseNodeArray(testCase.input);
expect(output).toEqual(testCase.expected);
})
})

View File

@@ -0,0 +1,226 @@
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);
});
});
describe('getGoalsWithDepth', () => {
const complexProgram: ReducedProgram = {
phases: [
{
id: 'phase-nested',
goals: [
// Level 0: Root Goal 1
{
id: 'root-1',
name: 'Root Goal 1',
plan: {
steps: [
// This is an ACTION (no plan), should be ignored
{ id: 'action-1', type: 'speech' },
// Level 1: Child Goal
{
id: 'child-1',
name: 'Child Goal',
plan: {
steps: [
// Level 2: Grandchild Goal
{
id: 'grandchild-1',
name: 'Grandchild',
plan: { steps: [] } // Empty plan is still a plan
}
]
}
}
]
}
},
// Level 0: Root Goal 2 (Sibling)
{
id: 'root-2',
name: 'Root Goal 2',
plan: { steps: [] }
}
]
}
]
};
it('should flatten nested goals and assign correct depth levels', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
expect(goals).toHaveLength(4);
// Check Root 1
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
// Check Child 1
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
// Check Grandchild 1
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
// Check Root 2
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
});
it('should ignore steps that are not goals (missing "plan" property)', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// The 'action-1' object should NOT be in the list
const action = goals.find(g => g.id === 'action-1');
expect(action).toBeUndefined();
});
it('throws if phase does not exist', () => {
useProgramStore.getState().setProgramState(complexProgram);
expect(() =>
useProgramStore.getState().getGoalsWithDepth('missing-phase')
).toThrow('phase with id:"missing-phase" not found');
});
});
it('should return the names of all phases in the program', () => {
// Define a program specifically with names for this test
const programWithNames: ReducedProgram = {
phases: [
{
id: 'phase-1',
name: 'Introduction Phase', // Assuming the property is 'name'
norms: [],
goals: [],
triggers: [],
},
{
id: 'phase-2',
name: 'Execution Phase',
norms: [],
goals: [],
triggers: [],
},
],
};
useProgramStore.getState().setProgramState(programWithNames);
const phaseNames = useProgramStore.getState().getPhaseNames();
expect(phaseNames).toEqual(['Introduction Phase', 'Execution Phase']);
});