Compare commits

38 Commits

Author SHA1 Message Date
Björn Otgaar
47e7207c32 feat: initial commit - adding a plan to phases and different ui for phase order editing
ref: N25B-451
2026-01-14 19:44:53 +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
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
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
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
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
6e1eb25bbc feat: add robot connection
ref: N25B-400
2026-01-08 14:01:42 +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
Pim Hutting
4356f201ab feat: added endpoint
ref:N25B-400
2026-01-07 17:39:20 +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
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
Pim Hutting
12ef2ef86e feat: added forced speech/gestures +overrides
ref: N25B-400
2026-01-06 15:15:36 +01: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
Tuurminator69
4bd67debf3 feat: added and changed the monitoringpage a lot
ref: N25B-398
2026-01-04 20:07:44 +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
50 changed files with 1656 additions and 2867 deletions

515
package-lock.json generated
View File

@@ -10,11 +10,9 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",
"reactflow": "^11.11.4",
"zustand": "^5.0.8"
},
"devDependencies": {
@@ -2055,276 +2053,6 @@
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/background/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/controls/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/core/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/minimap/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-resizer/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@reactflow/node-toolbar/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.35",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz",
@@ -2919,102 +2647,12 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
"integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/d3-axis": "*",
"@types/d3-brush": "*",
"@types/d3-chord": "*",
"@types/d3-color": "*",
"@types/d3-contour": "*",
"@types/d3-delaunay": "*",
"@types/d3-dispatch": "*",
"@types/d3-drag": "*",
"@types/d3-dsv": "*",
"@types/d3-ease": "*",
"@types/d3-fetch": "*",
"@types/d3-force": "*",
"@types/d3-format": "*",
"@types/d3-geo": "*",
"@types/d3-hierarchy": "*",
"@types/d3-interpolate": "*",
"@types/d3-path": "*",
"@types/d3-polygon": "*",
"@types/d3-quadtree": "*",
"@types/d3-random": "*",
"@types/d3-scale": "*",
"@types/d3-scale-chromatic": "*",
"@types/d3-selection": "*",
"@types/d3-shape": "*",
"@types/d3-time": "*",
"@types/d3-time-format": "*",
"@types/d3-timer": "*",
"@types/d3-transition": "*",
"@types/d3-zoom": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-axis": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz",
"integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-brush": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz",
"integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-chord": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz",
"integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-contour": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz",
"integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==",
"license": "MIT",
"dependencies": {
"@types/d3-array": "*",
"@types/geojson": "*"
}
},
"node_modules/@types/d3-delaunay": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
"integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==",
"license": "MIT"
},
"node_modules/@types/d3-dispatch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz",
"integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
@@ -3024,54 +2662,6 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-dsv": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz",
"integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-fetch": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz",
"integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==",
"license": "MIT",
"dependencies": {
"@types/d3-dsv": "*"
}
},
"node_modules/@types/d3-force": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz",
"integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==",
"license": "MIT"
},
"node_modules/@types/d3-format": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz",
"integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-hierarchy": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz",
"integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -3081,78 +2671,12 @@
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-polygon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz",
"integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==",
"license": "MIT"
},
"node_modules/@types/d3-quadtree": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz",
"integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==",
"license": "MIT"
},
"node_modules/@types/d3-random": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz",
"integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==",
"license": "MIT"
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-time-format": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz",
"integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -3179,12 +2703,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
@@ -4453,15 +3971,6 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -7436,9 +6945,9 @@
}
},
"node_modules/react-router": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -7457,24 +6966,6 @@
}
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",

View File

@@ -14,11 +14,9 @@
"dependencies": {
"@neodrag/react": "^2.3.1",
"@xyflow/react": "^12.8.6",
"clsx": "^2.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router": "^7.9.3",
"reactflow": "^11.11.4",
"zustand": "^5.0.8"
},
"devDependencies": {

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

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

View File

@@ -0,0 +1,224 @@
import React, { useEffect, useState } from 'react';
import styles from './MonitoringPage.module.css';
/**
* HELPER: Unified sender function
*/
const sendUserInterrupt = async (type: string, context: string) => {
try {
const response = await fetch("http://localhost:8000/button_pressed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, context }),
});
if (!response.ok) throw new Error("Backend response error");
console.log(`Interrupt Sent - Type: ${type}, Context: ${context}`);
} catch (err) {
console.error(`Failed to send interrupt:`, err);
}
};
// --- GESTURE COMPONENT ---
export const GestureControls: React.FC = () => {
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
const gestures = [
{ label: "Body Talk 1", value: "animations/Stand/BodyTalk/Speaking/BodyTalk_1" },
{ label: "Thinking 8", value: "animations/Stand/Gestures/Thinking_8" },
{ label: "Thinking 1", value: "animations/Stand/Gestures/Thinking_1" },
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
];
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={() => sendUserInterrupt("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={() => sendUserInterrupt("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;
sendUserInterrupt("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 ---
type StatusItem = {
id?: string | number;
achieved?: boolean;
description?: string;
label?: string;
norm?: string;
};
interface StatusListProps {
title: string;
items: StatusItem[];
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
activeIds: Record<string, boolean>;
currentGoalIndex?: number;
}
// --- STATUS LIST COMPONENT ---
export const StatusList: React.FC<StatusListProps> = ({
title,
items,
type,
activeIds,
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 canOverride = showIndicator && !isActive;
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
const handleOverrideClick = () => {
if (!canOverride) return;
sendUserInterrupt("override", String(item.id));
};
return (
<li key={item.id ?? idx} className={styles.statusItem}>
{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.description || item.label || 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`
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,274 @@
.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;
}
.restartPhase{
background-color: rgb(255, 123, 0);
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;
}
.clickable {
cursor: pointer;
}
.clickable:hover {
transform: scale(1.2);
}
.active {
cursor: default;
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,347 @@
import React from 'react';
import styles from './MonitoringPage.module.css';
import useProgramStore from "../../utils/programStore.ts";
import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList, RobotConnected } from './Components';
import { nextPhase, useExperimentLogger, pauseExperiment, playExperiment, resetExperiment, resetPhase, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts"
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx';
// Stream message types are defined in MonitoringPageAPI as `ExperimentStreamData`.
// Types for reduced program items (output from node reducers):
export type ReducedPlanStep = {
id: string;
text?: string;
gesture?: { type: string; name?: string };
goal?: string;
} & Record<string, unknown>;
export type ReducedPlan = { id: string; steps: ReducedPlanStep[] } | "";
export type ReducedGoal = { id: string; name: string; description?: string; can_fail?: boolean; plan?: ReducedPlan };
export type ReducedCondition = {
id: string;
keyword?: string;
emotion?: string;
object?: string;
name?: string;
description?: string;
} & Record<string, unknown>;
export type ReducedTrigger = { id: string; name: string; condition?: ReducedCondition | ""; plan?: ReducedPlan };
export type ReducedNorm = { id: string; label?: string; norm?: string; condition?: ReducedCondition | "" };
const MonitoringPage: 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);
// Can be used to block actions until feedback from CB.
const [loading, setLoading] = React.useState(false);
const [activeIds, setActiveIds] = React.useState<Record<string, boolean>>({});
const [goalIndex, setGoalIndex] = React.useState(0);
const [isPlaying, setIsPlaying] = React.useState(false);
const phaseIds = getPhaseIds();
const [phaseIndex, setPhaseIndex] = React.useState(0);
//see if we reached end node
const [isFinished, setIsFinished] = React.useState(false);
const handleStreamUpdate = React.useCallback((data: ExperimentStreamData) => {
// Check for phase updates
if (data.type === 'phase_update' && data.id) {
const payload = data as PhaseUpdate;
if (payload.id === "end") {
setIsFinished(true);
} else {
setIsFinished(false);
const allIds = getPhaseIds();
const newIndex = allIds.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 ReducedGoal[];
const gIndex = currentPhaseGoals.findIndex((g: ReducedGoal) => g.id === payload.id);
if (gIndex !== -1) {
//set current goal to the goal that is just started
setGoalIndex(gIndex);
// All previous goals are set to "active" which means they are achieved
setActiveIds((prev) => {
const nextState = { ...prev };
// We loop until i is LESS than gIndex.
// This leaves currentPhaseGoals[gIndex] as isActive: false.
for (let i = 0; i < gIndex; i++) {
nextState[currentPhaseGoals[i].id ] = true;
}
return nextState;
});
console.log(`Now pursuing goal: ${payload.id}. Previous goals marked achieved.`);
}
}
else if (data.type === 'trigger_update') {
const payload = data as TriggerUpdate;
setActiveIds((prev) => ({
...prev,
[payload.id]: payload.achieved
}));
}
else if (data.type === 'cond_norms_state_update') {
const payload = data as CondNormsStateUpdate;
setActiveIds((prev) => {
const nextState = { ...prev };
// payload.norms is typed on the union, so safe to use directly
payload.norms.forEach((normUpdate) => {
nextState[normUpdate.id] = normUpdate.active;
console.log(`Conditional norm ${normUpdate.id} set to active: ${normUpdate.active}`);
});
return nextState;
});
console.log("Updated conditional norms state:", payload.norms);
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
useExperimentLogger(handleStreamUpdate);
if (phaseIds.length === 0) {
return <p className={styles.empty}>No program loaded.</p>;
}
const phaseId = phaseIds[phaseIndex];
const goals = (getGoalsInPhase(phaseId) as ReducedGoal[]).map(g => ({
...g,
label: g.name,
achieved: activeIds[g.id] ?? false,
}));
const triggers = (getTriggersInPhase(phaseId) as ReducedTrigger[]).map(t => ({
...t,
label: (() => {
let prefix = "";
if (t.condition && typeof t.condition !== "string" && "keyword" in t.condition && typeof t.condition.keyword === "string") {
prefix = `if keywords said: "${t.condition.keyword}"`;
} else if (t.condition && typeof t.condition !== "string" && "name" in t.condition && typeof t.condition.name === "string") {
prefix = `if LLM belief: ${t.condition.name}`;
} else { //fallback
prefix = t.name || "Trigger"; // use typed `name` as a reliable fallback
}
const stepLabels = (t.plan && typeof t.plan !== "string" ? t.plan.steps : []).map((step: ReducedPlanStep) => {
if ("text" in step && typeof step.text === "string") {
return `say: "${step.text}"`;
}
if ("gesture" in step && step.gesture) {
const g = step.gesture;
return `perform gesture: ${g.name || g.type}`;
}
if ("goal" in step && typeof step.goal === "string") {
return `perform LLM: ${step.goal}`;
}
return "Action"; // Fallback
}) || [];
const planText = stepLabels.length > 0
? `➔ Do: ${stepLabels.join(", ")}`
: "➔ (No actions set)";
return `${prefix} ${planText}`;
})(),
isActive: activeIds[t.id] ?? false
}));
const norms = (getNormsInPhase(phaseId) as NormNodeData[])
.filter(n => !n.condition)
.map(n => ({
...n,
label: n.norm,
}));
const conditionalNorms = (getNormsInPhase(phaseId) as ReducedNorm[])
.filter(n => !!n.condition) // Only items with a condition
.map(n => ({
...n,
label: (() => {
let prefix = "";
if (n.condition && typeof n.condition !== "string" && "keyword" in n.condition && typeof n.condition.keyword === "string") {
prefix = `if keywords said: "${n.condition.keyword}"`;
} else if (n.condition && typeof n.condition !== "string" && "name" in n.condition && typeof n.condition.name === "string") {
prefix = `if LLM belief: ${n.condition.name}`;
}
return `${prefix} ➔ Norm: ${n.norm}`;
})(),
achieved: activeIds[n.id] ?? false
}));
// Handle logic of 'next' button.
const handleButton = async (button: string, _context?: string, _endpoint?: string) => {
try {
setLoading(true);
switch (button) {
case "pause":
await pauseExperiment();
break;
case "play":
await playExperiment();
break;
case "nextPhase":
await nextPhase();
break;
case "resetPhase":
await resetPhase();
break;
case "resetExperiment":
await resetExperiment();
break;
default:
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
return (
<div className={styles.dashboardContainer}>
{/* HEADER */}
<header className={styles.experimentOverview}>
<div className={styles.phaseName}>
<h2>Experiment Overview</h2>
<p><strong>Phase</strong> {` ${phaseIndex + 1}`} </p>
<div className={styles.phaseProgress}>
{phaseIds.map((id, index) => (
<span
key={id}
className={`${styles.phase} ${
index < phaseIndex ? styles.completed :
index === phaseIndex ? styles.current : ""
}`}
>
{index + 1}
</span>
))}
</div>
</div>
<div className={styles.experimentControls}>
<h3>Experiment Controls</h3>
<div className={styles.controlsButtons}>
{/*Pause button*/}
<button
className={`${!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}`}
onClick={() => {
setIsPlaying(false);
handleButton("pause");}
}
disabled={loading}
></button>
{/*Play button*/}
<button
className={`${isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}`}
onClick={() => {
setIsPlaying(true);
handleButton("play");}
}
disabled={loading}
></button>
{/*Next button*/}
<button
className={styles.next}
onClick={() => handleButton("nextPhase")}
disabled={loading}
>
</button>
{/*Restart Phase button*/}
<button
className={styles.restartPhase}
onClick={() => handleButton("resetPhase")}
disabled={loading}
>
</button>
{/*Restart Experiment button*/}
<button
className={styles.restartExperiment}
onClick={() => handleButton("resetExperiment")}
disabled={loading}
>
</button>
</div>
</div>
<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>
) : (
<>
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} 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} />
</>
)}
</main>
{/* 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,107 @@
import { useEffect } from 'react';
const API_BASE_BP = "http://localhost:8000/button_pressed"; // Change depending on Pims interup agent/ correct endpoint
const API_BASE = "http://localhost:8000";
/**
* HELPER: Unified sender function
* In a real app, you might move this to a /services or /hooks folder
*/
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 reset the currect phase
* In case we can't go to the next phase, the function will throw an error.
*/
export async function resetPhase(): Promise<void> {
const type = "reset_phase"
const context = ""
sendAPICall(type, context)
}
/**
* Sends an API call to the CB for going to reset the experiment
* In case we can't go to the next phase, the function will throw an error.
*/
export async function resetExperiment(): Promise<void> {
const type = "reset_experiment"
const context = ""
sendAPICall(type, context)
}
export async function pauseExperiment(): Promise<void> {
const type = "pause"
const context = "true"
sendAPICall(type, context)
}
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 and logs data to the console.
* It does not render anything.
*/
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
useEffect(() => {
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
eventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
if (onUpdate) {
console.log(event.data);
onUpdate(parsedData);
}
} catch (err) {
console.warn("Stream parse error:", err);
}
};
eventSource.onerror = (err) => {
console.error("SSE Connection Error:", err);
eventSource.close();
};
return () => {
eventSource.close();
};
}, [onUpdate]);
}

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

@@ -84,10 +84,7 @@
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;
@@ -161,14 +158,6 @@
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;
@@ -183,6 +172,18 @@
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;

View File

@@ -4,22 +4,21 @@ import {
Panel,
ReactFlow,
ReactFlowProvider,
MarkerType, getOutgoers
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {graphReducer, runProgram} from "./VisProgLogic.tsx";
import warningStyles from './visualProgrammingUI/components/WarningSidebar.module.css'
import {type CSSProperties, useEffect, useState} from "react";
import {useShallow} from 'zustand/react/shallow';
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
import useProgramStore from "../../utils/programStore.ts";
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx";
import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx";
import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx";
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
import styles from './VisProg.module.css'
import { NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx';
// --| config starting params for flow |--
@@ -44,7 +43,6 @@ const selector = (state: FlowState) => ({
nodes: state.nodes,
edges: state.edges,
onNodesChange: state.onNodesChange,
onNodesDelete: state.onNodesDelete,
onEdgesDelete: state.onEdgesDelete,
onEdgesChange: state.onEdgesChange,
onConnect: state.onConnect,
@@ -70,7 +68,6 @@ const VisProgUI = () => {
const {
nodes, edges,
onNodesChange,
onNodesDelete,
onEdgesDelete,
onEdgesChange,
onConnect,
@@ -93,36 +90,15 @@ const VisProgUI = () => {
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
const {unregisterWarning, registerWarning} = useFlowStore();
useEffect(() => {
if (checkPhaseChain()) {
unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM');
} else {
// create global warning for incomplete program chain
const incompleteProgramWarning : EditorWarning = {
scope: {
id: globalWarning,
handleId: undefined
},
type: 'INCOMPLETE_PROGRAM',
severity: "ERROR",
description: "there is no complete phase chain from the startNode to the EndNode"
}
registerWarning(incompleteProgramWarning);
}
},[edges, registerWarning, unregisterWarning])
return (
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NodeTypes}
onNodesChange={onNodesChange}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
@@ -133,11 +109,9 @@ const VisProgUI = () => {
onNodeDragStop={endBatchAction}
preventScrolling={scrollable}
onMove={(_, viewport) => setZoom(viewport.zoom)}
reconnectRadius={15}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
style={{flexGrow: 3}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
@@ -146,16 +120,12 @@ const VisProgUI = () => {
<SaveLoadPanel></SaveLoadPanel>
</Panel>
<Panel position="bottom-center">
<button onClick={() => undo()}>Undo</button>
<button onClick={() => undo()}>undo</button>
<button onClick={() => redo()}>Redo</button>
</Panel>
<Panel position="center-right" className={warningStyles.warningsSidebar}>
<WarningsSidebar/>
</Panel>
<Controls/>
<Background/>
</ReactFlow>
</div>
);
};
@@ -175,23 +145,41 @@ function VisualProgrammingUI() {
);
}
const checkPhaseChain = (): boolean => {
const {nodes, edges} = useFlowStore.getState();
// currently outputs the prepared program to the console
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.");
function checkForCompleteChain(currentNodeId: string): boolean {
const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges)
.filter(node => ["end", "phase"].includes(node.type!));
// 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."));
}
if (outgoingPhases.length === 0) return false;
if (outgoingPhases.some(node => node.type === "end" )) return true;
/**
* Reduces the graph into its phases' information and recursively calls their reducing function
*/
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)
});
}
const next = outgoingPhases.map(node => checkForCompleteChain(node.id))
.find(result => result);
return !!next;
}
return checkForCompleteChain('start');
};
/**
* houses the entire page, so also UI elements
@@ -199,29 +187,31 @@ const checkPhaseChain = (): boolean => {
* @constructor
*/
function VisProgPage() {
const [programValidity, setProgramValidity] = useState<boolean>(true);
const {isProgramValid, severityIndex} = useFlowStore();
const validity = () => {return isProgramValid();}
useEffect(() => {
setProgramValidity(validity);
// the following eslint disable is required as it wants us to use all possible dependencies for the useEffect statement,
// however this would cause unneeded updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [severityIndex]);
const [showSimpleProgram, setShowSimpleProgram] = useState(false);
const setProgramState = useProgramStore((state) => state.setProgramState);
const processProgram = () => {
const runProgram = () => {
const phases = graphReducer(); // reduce graph
setProgramState({ phases }); // <-- save to store
runProgram(); // send to backend if needed
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={processProgram} disabled={!programValidity}>Run Program</button>
<button onClick={runProgram}>run program</button>
</>
)
}

View File

@@ -1,43 +0,0 @@
import useProgramStore from "../../utils/programStore";
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
import useFlowStore from './visualProgrammingUI/VisProgStores';
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
/**
* Reduces the graph into its phases' information and recursively calls their reducing function
*/
export function graphReducer() {
const { nodes } = useFlowStore.getState();
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
.map((n) => {
const reducer = NodeReduces['phase'];
return reducer(n, nodes)
});
}
/**
* Outputs the prepared program to the console and sends it to the backend
*/
export function runProgram() {
const phases = graphReducer();
const program = {phases}
console.log(JSON.stringify(program, null, 2));
fetch(
"http://localhost:8000/program",
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(program),
}
).then((res) => {
if (!res.ok) throw new Error("Failed communicating with the backend.")
console.log("Successfully sent the program to the backend.");
// store reduced program in global program store for further use in the UI
// when the program was sent to the backend successfully:
useProgramStore.getState().setProgramState(structuredClone(program));
}).catch(() => console.log("Failed to send program to the backend."));
console.log(program);
}

View File

@@ -1,18 +1,10 @@
import type {Edge, Node} from "@xyflow/react";
import type {StateCreator, StoreApi } from 'zustand/vanilla';
import type {
SeverityIndex,
WarningRegistry
} from "./components/EditorWarnings.tsx";
import type {FlowState} from "./VisProgTypes.tsx";
export type FlowSnapshot = {
nodes: Node[];
edges: Edge[];
warnings: {
warningRegistry: WarningRegistry;
severityIndex: SeverityIndex;
}
}
/**
@@ -49,11 +41,7 @@ export const UndoRedo = (
*/
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
nodes: state.nodes,
edges: state.edges,
warnings: {
warningRegistry: state.editorWarningRegistry,
severityIndex: state.severityIndex,
}
edges: state.edges
}));
const initialState = config(set, get, api);
@@ -90,8 +78,6 @@ export const UndoRedo = (
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
editorWarningRegistry: snapshot.warnings.warningRegistry,
severityIndex: snapshot.warnings.severityIndex,
});
state.future.push(currentSnapshot); // push current to redo
@@ -111,8 +97,6 @@ export const UndoRedo = (
set({
nodes: snapshot.nodes,
edges: snapshot.edges,
editorWarningRegistry: snapshot.warnings.warningRegistry,
severityIndex: snapshot.warnings.severityIndex,
});
state.past.push(currentSnapshot); // push current to undo

View File

@@ -107,16 +107,4 @@ export function useHandleRules(
// 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

@@ -43,4 +43,3 @@ export const noSelfConnections : HandleRule =
}

View File

@@ -52,24 +52,15 @@ import TriggerNode, {
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
,
BasicBeliefReduce,
BasicBeliefTooltip
} from "./nodes/BasicBeliefNode.tsx";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
} from "./nodes/BasicBeliefNode";
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default";
/**
* Registered node types in the visual programming system.
@@ -85,7 +76,6 @@ export const NodeTypes = {
goal: GoalNode,
trigger: TriggerNode,
basic_belief: BasicBeliefNode,
inferred_belief: InferredBeliefNode,
};
/**
@@ -101,7 +91,6 @@ export const NodeDefaults = {
goal: GoalNodeDefaults,
trigger: TriggerNodeDefaults,
basic_belief: BasicBeliefNodeDefaults,
inferred_belief: InferredBeliefNodeDefaults,
};
@@ -119,7 +108,6 @@ export const NodeReduces = {
goal: GoalReduce,
trigger: TriggerReduce,
basic_belief: BasicBeliefReduce,
inferred_belief: InferredBeliefReduce,
}
@@ -138,7 +126,6 @@ export const NodeConnections = {
goal: GoalConnectionTarget,
trigger: TriggerConnectionTarget,
basic_belief: BasicBeliefConnectionTarget,
inferred_belief: InferredBeliefConnectionTarget,
},
Sources: {
start: StartConnectionSource,
@@ -147,8 +134,7 @@ export const NodeConnections = {
norm: NormConnectionSource,
goal: GoalConnectionSource,
trigger: TriggerConnectionSource,
basic_belief: BasicBeliefConnectionSource,
inferred_belief: InferredBeliefConnectionSource,
basic_belief: BasicBeliefConnectionSource
}
}
@@ -167,7 +153,6 @@ export const NodeDisconnections = {
goal: GoalDisconnectionTarget,
trigger: TriggerDisconnectionTarget,
basic_belief: BasicBeliefDisconnectionTarget,
inferred_belief: InferredBeliefDisconnectionTarget,
},
Sources: {
start: StartDisconnectionSource,
@@ -177,7 +162,6 @@ export const NodeDisconnections = {
goal: GoalDisconnectionSource,
trigger: TriggerDisconnectionSource,
basic_belief: BasicBeliefDisconnectionSource,
inferred_belief: InferredBeliefDisconnectionSource,
},
}
@@ -202,7 +186,6 @@ export const NodesInPhase = {
end: () => false,
phase: () => false,
basic_belief: () => false,
inferred_belief: () => false,
}
/**
@@ -216,5 +199,4 @@ export const NodeTooltips = {
goal: GoalTooltip,
trigger: TriggerTooltip,
basic_belief: BasicBeliefTooltip,
inferred_belief: InferredBeliefTooltip,
}

View File

@@ -9,8 +9,6 @@ import {
type XYPosition,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts";
import {editorWarningRegistry} from "./components/EditorWarnings.tsx";
import type { FlowState } from './VisProgTypes';
import {
NodeDefaults,
@@ -45,18 +43,19 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
}
}
//* 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 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})
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode];
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
// Initial edges, leave empty as setting initial edges...
// ...breaks logic that is dependent on connection events
const initialEdges: Edge[] = [];
/**
* useFlowStore contains the implementation for all editor functionality
* and stores the current state of the visual programming editor
@@ -87,9 +86,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
*/
onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}),
onNodesDelete: (nodes) => nodes.forEach((_node) => {
return;
}),
onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)),
onEdgesDelete: (edges) => {
// we make sure any affected nodes get updated to reflect removal of edges
@@ -132,41 +129,7 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
* Handles reconnecting an edge between nodes.
*/
onReconnect: (oldEdge, newConnection) => {
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 });
get().edgeReconnectSuccessful = true;
set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) });
// We make sure to perform any required data updates on the newly reconnected nodes
@@ -219,32 +182,19 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
* Deletes a node by ID, respecting NodeDeletes rules.
* Also removes all edges connected to that node.
*/
deleteNode: (nodeId, deleteElements) => {
deleteNode: (nodeId) => {
get().pushSnapshot();
// Let's find our node to check if they have a special deletion function
const ourNode = get().nodes.find((n)=>n.id==nodeId);
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
// If there's no function, OR, our function tells us we can delete it, let's do so...
if (ourFunction == undefined || ourFunction()) {
if (deleteElements){
deleteElements({
nodes: get().nodes.filter((n) => n.id === nodeId),
edges: get().edges.filter((e) => e.source !== nodeId && e.target === nodeId)}
).then(() => {
get().unregisterNodeRules(nodeId);
get().unregisterWarningsForId(nodeId);
});
} else {
set({
nodes: get().nodes.filter((n) => n.id !== nodeId),
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
})
}
}
set({
nodes: get().nodes.filter((n) => n.id !== nodeId),
edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId),
})}
},
/**
@@ -356,12 +306,8 @@ const useFlowStore = create<FlowState>(UndoRedo((set, get) => ({
})
return { ruleRegistry: registry };
})
},
...editorWarningRegistry(get, set),
}
}))
);
export default useFlowStore;

View File

@@ -7,9 +7,8 @@ import type {
OnReconnect,
Node,
OnEdgesDelete,
OnNodesDelete, DeleteElementsOptions
OnNodesDelete
} from '@xyflow/react';
import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx";
import type {HandleRule} from "./HandleRuleLogic.ts";
import type { NodeTypes } from './NodeRegistry';
import type {FlowSnapshot} from "./EditorUndoRedo.ts";
@@ -69,10 +68,7 @@ export type FlowState = {
* Deletes a node and any connected edges.
* @param nodeId - the ID of the node to delete
*/
deleteNode: (nodeId: string, deleteElements?: (params: DeleteElementsOptions) => Promise<{
deletedNodes: Node[]
deletedEdges: Edge[]
}>) => void;
deleteNode: (nodeId: string) => void;
/**
* Replaces the current nodes array in the store.
@@ -98,7 +94,7 @@ export type FlowState = {
* @param node - the Node object to add
*/
addNode: (node: Node) => void;
} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry;
} & UndoRedoState & HandleRuleRegistry;
export type UndoRedoState = {
// UndoRedo Types
@@ -133,7 +129,4 @@ export type HandleRuleRegistry = {
// cleans up all registered rules of all handles of the provided node
unregisterNodeRules: (nodeId: string) => void
}
}

View File

@@ -1,245 +0,0 @@
/* contains all logic for the VisProgEditor warning system
*
* Missing but desirable features:
* - Warning filtering:
* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
* then hide any startNode, phaseNode, or endNode specific warnings
*/
import useFlowStore from "../VisProgStores.tsx";
import type {FlowState} from "../VisProgTypes.tsx";
// --| Type definitions |--
export type WarningId = NodeId | "GLOBAL_WARNINGS";
export type NodeId = string;
export type WarningType =
| 'MISSING_INPUT'
| 'MISSING_OUTPUT'
| 'PLAN_IS_UNDEFINED'
| 'INCOMPLETE_PROGRAM'
| 'NOT_CONNECTED_TO_PROGRAM'
| string
export type WarningSeverity =
| 'INFO' // Acceptable, but important to be aware of
| 'WARNING' // Acceptable, but probably undesirable behavior
| 'ERROR' // Prevents running program, should be fixed before running program is allowed
/**
* warning scope, include a handleId if the warning is handle specific
*/
export type WarningScope = {
id: string;
handleId?: string;
}
export type EditorWarning = {
scope: WarningScope;
type: WarningType;
severity: WarningSeverity;
description: string;
};
/**
* a scoped WarningKey,
* the handleId scoping is only needed for handle specific errors
*
* "`WarningType`:`handleId`"
*/
export type WarningKey = string; // for warnings that can occur on a per-handle basis
/**
* a composite key used in the severityIndex
*
* "`WarningId`|`WarningKey`"
*/
export type CompositeWarningKey = string;
export type WarningRegistry = Map<WarningId , Map<WarningKey, EditorWarning>>;
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => void;
type ZustandGet = () => FlowState;
export type EditorWarningRegistry = {
/**
* stores all editor warnings
*/
editorWarningRegistry: WarningRegistry;
/**
* index of warnings by severity
*/
severityIndex: SeverityIndex;
/**
* gets all warnings and returns them as a list of warnings
* @returns {EditorWarning[]}
*/
getWarnings: () => EditorWarning[];
/**
* gets all warnings with the current severity
* @param {WarningSeverity} warningSeverity
* @returns {EditorWarning[]}
*/
getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
/**
* checks if there are no warnings of breaking severity
* @returns {boolean}
*/
isProgramValid: () => boolean;
/**
* registers a warning to the warningRegistry and the SeverityIndex
* @param {EditorWarning} warning
*/
registerWarning: (warning: EditorWarning) => void;
/**
* unregisters a warning from the warningRegistry and the SeverityIndex
* @param {EditorWarning} warning
*/
unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
/**
* unregisters warnings from the warningRegistry and the SeverityIndex
* @param {WarningId} warning
*/
unregisterWarningsForId: (id: WarningId) => void;
}
// --| implemented logic |--
/**
* the id to use for global editor warnings
* @type {string}
*/
export const globalWarning = "GLOBAL_WARNINGS";
export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
editorWarningRegistry: new Map<NodeId, Map<WarningKey, EditorWarning>>(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]),
getWarningsBySeverity: (warningSeverity) => {
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
const sIndex = new Map(get().severityIndex);
const warningKeys = sIndex.get(warningSeverity);
const warnings: EditorWarning[] = [];
warningKeys?.forEach(
(compositeKey) => {
const [id, warningKey] = compositeKey.split('|');
const warning = wRegistry.get(id)?.get(warningKey);
if (warning) {
warnings.push(warning);
}
}
)
return warnings;
},
isProgramValid: () => {
const sIndex = get().severityIndex;
return (sIndex.get("ERROR")!.size === 0);
},
getWarnings: () => Array.from(get().editorWarningRegistry.values())
.flatMap(innerMap => Array.from(innerMap.values())),
registerWarning: (warning) => {
const { scope: {id, handleId}, type, severity } = warning;
const warningKey = handleId ? `${type}:${handleId}` : type;
const compositeKey = `${id}|${warningKey}`;
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
const sIndex = new Map(get().severityIndex);
// add to warning registry
if (!wRegistry.has(id)) {
wRegistry.set(id, new Map());
}
wRegistry.get(id)!.set(warningKey, warning);
// add to severityIndex
if (!sIndex.get(severity)!.has(compositeKey)) {
sIndex.get(severity)!.add(compositeKey);
}
set({
editorWarningRegistry: wRegistry,
severityIndex: sIndex
})
},
unregisterWarning: (id, warningKey) => {
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
const sIndex = new Map(get().severityIndex);
// verify if the warning was created already
const warning = wRegistry.get(id)?.get(warningKey);
if (!warning) return;
// remove from warning registry
wRegistry.get(id)!.delete(warningKey);
// remove from severityIndex
sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
set({
editorWarningRegistry: wRegistry,
severityIndex: sIndex
})
},
unregisterWarningsForId: (id) => {
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
const sIndex = new Map(get().severityIndex);
const nodeWarnings = wRegistry.get(id);
// remove from severity index
if (nodeWarnings) {
nodeWarnings.forEach((warning) => {
const warningKey = warning.scope.handleId
? `${warning.type}:${warning.scope.handleId}`
: warning.type;
sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
});
}
// remove from warning registry
wRegistry.delete(id);
set({
editorWarningRegistry: wRegistry,
severityIndex: sIndex
})
},
}}
/**
* returns a summary of the warningRegistry
* @returns {{info: number, warning: number, error: number, isValid: boolean}}
*/
export function warningSummary() {
const {severityIndex, isProgramValid} = useFlowStore.getState();
return {
info: severityIndex.get('INFO')!.size,
warning: severityIndex.get('WARNING')!.size,
error: severityIndex.get('ERROR')!.size,
isValid: isProgramValid(),
};
}

View File

@@ -1,4 +1,4 @@
import {NodeToolbar, useReactFlow} 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";
@@ -30,11 +30,10 @@ type ToolbarProps = {
*/
export function Toolbar({nodeId, allowDelete}: ToolbarProps) {
const {nodes, deleteNode} = useFlowStore();
const { deleteElements } = useReactFlow();
const deleteParentNode = () => {
deleteNode(nodeId, deleteElements);
deleteNode(nodeId);
};
const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips;

View File

@@ -6,7 +6,6 @@
overscroll-behavior: contain;
}
.planDialog::backdrop {
background: rgba(0, 0, 0, 0.4);
}
@@ -68,4 +67,17 @@
font-style: italic;
}
.dragHandle {
margin-left: auto;
cursor: grab;
opacity: 0.5;
user-select: none;
}
.dragHandle:active {
cursor: grabbing;
}
.planStepDragging {
opacity: 0.4;
}

View File

@@ -6,16 +6,26 @@ import { defaultPlan } from "../components/Plan.default";
import { TextField } from "../../../../components/TextField";
import GestureValueEditor from "./GestureValueEditor";
/**
* The properties of a plan editor.
* @property plan: The optional plan loaded into this editor.
* @property onSave: The function that will be called upon save.
* @property description: Optional description which is already set.
* @property onlyEditPhasing: Optional boolean to toggle
* whether or not this editor is part of the phase editing.
*/
type PlanEditorDialogProps = {
plan?: Plan;
onSave: (plan: Plan | undefined) => void;
description? : string;
onlyEditPhasing? : boolean;
};
export default function PlanEditorDialog({
plan,
onSave,
description,
onlyEditPhasing = false,
}: PlanEditorDialogProps) {
// UseStates and references
const dialogRef = useRef<HTMLDialogElement | null>(null);
@@ -24,10 +34,11 @@ export default function PlanEditorDialog({
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
const [newActionValue, setNewActionValue] = useState("");
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const { setScrollable } = useFlowStore();
const nodes = useFlowStore().nodes;
//Button Actions
// Button Actions
const openCreate = () => {
setScrollable(false);
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
@@ -89,9 +100,9 @@ export default function PlanEditorDialog({
data-testid={"PlanEditorDialogTestID"}
>
<form method="dialog" className="flex-col gap-md">
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
<h3> {onlyEditPhasing ? "Editing Phase Ordering" : (draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan")} </h3>
{/* Plan name text field */}
{draftPlan && (
{(draftPlan && !onlyEditPhasing) && (
<TextField
value={draftPlan.name}
setValue={(name) =>
@@ -104,12 +115,14 @@ export default function PlanEditorDialog({
{draftPlan && (<div className={styles.planEditor}>
<div className={styles.planEditorLeft}>
{/* Left Side (Action Adder) */}
<h4>Add Action</h4>
<h4>{onlyEditPhasing ? "You can't add any actions, only rearrange the steps." : "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>
{(!onlyEditPhasing) && (<label>
Action Type <wbr />
{/* Type selection */}
<select
@@ -123,10 +136,10 @@ export default function PlanEditorDialog({
<option value="gesture">Gesture Action</option>
<option value="llm">LLM Action</option>
</select>
</label>
</label>)}
{/* Action value editor*/}
{newActionType === "gesture" ? (
{/* Action value editor*/}
{!onlyEditPhasing && newActionType === "gesture" ? (
// Gesture get their own editor component
<GestureValueEditor
value={newActionValue}
@@ -134,17 +147,20 @@ export default function PlanEditorDialog({
setType={setNewActionGestureType}
placeholder="Gesture name"
/>
) : (
<TextField
)
:
// Only show the text field if we're not just rearranging.
(!onlyEditPhasing &&
(<TextField
value={newActionValue}
setValue={setNewActionValue}
placeholder={
newActionType === "speech" ? "Speech text"
: "LLM goal"
}
/>
)}
/>)
)}
{/* Adding steps */}
<button
type="button"

View File

@@ -1,16 +1,7 @@
:global(.react-flow__handle.source){
border-radius: 100%;
}
:global(.react-flow__handle.target){
border-radius: 15%;
}
:global(.react-flow__handle.connected) {
background: lightgray;
border-color: green;
filter: drop-shadow(0 0 0.15rem green);
filter: drop-shadow(0 0 0.25rem green);
}
:global(.singleConnectionHandle.connected) {
@@ -25,19 +16,19 @@
:global(.singleConnectionHandle.unconnected){
background: lightsalmon;
border-color: #ff6060;
filter: drop-shadow(0 0 0.15rem #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.15rem 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.15rem green);
filter: drop-shadow(0 0 0.25rem green);
}
:global(.react-flow__handle) {

View File

@@ -4,6 +4,7 @@ import {
type Connection,
useNodeId, useNodeConnections
} from '@xyflow/react';
import {useState} from 'react';
import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts";
import "./RuleBasedHandle.module.css";
@@ -28,16 +29,21 @@ export function MultiConnectionHandle({
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") + ` ${type}`}
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
isValidConnection={(connection) => {
const result = validate(connection as Connection);
setHandleState(result);
return result.isSatisfied;
}}
title={handleState.message}
/>
);
}
@@ -60,18 +66,22 @@ export function SingleConnectionHandle({
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") + ` ${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

@@ -1,203 +0,0 @@
.warnings-sidebar {
min-width: auto;
max-width: 340px;
margin-right: 0;
height: 100%;
background: canvas;
display: flex;
flex-direction: row;
}
.warnings-toggle-bar {
background-color: ButtonFace;
justify-items: center;
align-content: center;
width: 1rem;
cursor: pointer;
}
.warnings-toggle-bar.error:first-child:has(.arrow-right){
background-color: hsl(from red h s 75%);
}
.warnings-toggle-bar.warning:first-child:has(.arrow-right) {
background-color: hsl(from orange h s 75%);
}
.warnings-toggle-bar.info:first-child:has(.arrow-right) {
background-color: hsl(from steelblue h s 75%);
}
.warnings-toggle-bar:hover {
background-color: GrayText !important ;
.arrow-left {
border-right-color: ButtonFace;
transition: transform 0.15s ease-in-out;
transform: rotateY(180deg);
}
.arrow-right {
border-left-color: ButtonFace;
transition: transform 0.15s ease-in-out;
transform: rotateY(180deg);
}
}
.warnings-content {
width: 320px;
flex: 1;
flex-direction: column;
border-left: 2px solid CanvasText;
}
.warnings-header {
padding: 12px;
border-bottom: 2px solid CanvasText;
}
.severity-tabs {
display: flex;
gap: 4px;
}
.severity-tab {
flex: 1;
padding: 4px;
background: ButtonFace;
color: GrayText;
border: none;
cursor: pointer;
}
.count {
padding: 4px;
color: GrayText;
border: none;
cursor: pointer;
}
.severity-tab.active {
color: ButtonText;
border: 2px solid currentColor;
.count {
color: ButtonText;
}
}
.warning-group-header {
background: ButtonFace;
padding: 6px;
font-weight: bold;
}
.warnings-list {
flex: 1;
min-height: 0;
overflow-y: scroll;
}
.warnings-empty {
margin: auto;
}
.warning-item {
display: flex;
flex-direction: column;
margin: 5px;
gap: 2px;
padding: 0;
border-radius: 5px;
cursor: pointer;
color: GrayText;
}
.warning-item:hover {
background: ButtonFace;
}
.warning-item--error {
border: 2px solid red;
background-color: hsl(from red h s 96%);
.item-header{
background-color: red;
.type{
color: hsl(from red h s 96%);
}
}
}
.warning-item--error:hover {
background-color: hsl(from red h s 75%);
}
.warning-item--warning {
border: 2px solid orange;
background-color: hsl(from orange h s 96%);
.item-header{
background-color: orange;
.type{
color: hsl(from orange h s 96%);
}
}
}
.warning-item--warning:hover {
background-color: hsl(from orange h s 75%);
}
.warning-item--info {
border: 2px solid steelblue;
background-color: hsl(from steelblue h s 96%);
.item-header{
background-color: steelblue;
.type{
color: hsl(from steelblue h s 96%);
}
}
}
.warning-item--info:hover {
background-color: hsl(from steelblue h s 75%);
}
.warning-item .item-header {
padding: 8px 8px;
opacity: 1;
font-weight: bolder;
}
.warning-item .item-header .type{
padding: 2px 8px;
font-size: 0.9rem;
}
.warning-item .description {
padding: 5px 10px;
font-size: 0.8rem;
}
.auto-hide {
background-color: Canvas;
border-top: 2px solid CanvasText;
margin-top: auto;
width: 100%;
height: 2.5rem;
display: flex;
align-items: center;
padding: 0 12px;
}
/* arrows for toggleBar */
.arrow-right {
width: 0;
height: 0;
border-top: 0.5rem solid transparent;
border-bottom: 0.5rem solid transparent;
border-left: 0.6rem solid GrayText;
}
.arrow-left {
width: 0;
height: 0;
border-top: 0.5rem solid transparent;
border-bottom: 0.5rem solid transparent;
border-right: 0.6rem solid GrayText;
}

View File

@@ -1,225 +0,0 @@
import {useReactFlow, useStoreApi} from "@xyflow/react";
import clsx from "clsx";
import {useEffect, useState} from "react";
import useFlowStore from "../VisProgStores.tsx";
import {
warningSummary,
type WarningSeverity,
type EditorWarning, globalWarning
} from "./EditorWarnings.tsx";
import styles from "./WarningSidebar.module.css";
/**
* the warning sidebar, shows all warnings
*
* @returns {React.JSX.Element}
* @constructor
*/
export function WarningsSidebar() {
const warnings = useFlowStore.getState().getWarnings();
const [hide, setHide] = useState(false);
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
const [autoHide, setAutoHide] = useState(false);
// let autohide change hide status only when autohide is toggled
// and allow for user to change the hide state even if autohide is enabled
const hasWarnings = warnings.length > 0;
useEffect(() => {
if (autoHide) {
setHide(!hasWarnings);
}
}, [autoHide, hasWarnings]);
const filtered = severityFilter === 'ALL'
? warnings
: warnings.filter(w => w.severity === severityFilter);
const summary = warningSummary();
// Finds the first key where the count > 0
const getHighestSeverity = () => {
if (summary.error > 0) return styles.error;
if (summary.warning > 0) return styles.warning;
if (summary.info > 0) return styles.info;
return '';
};
return (
<aside className={`flex-row`} >
<div
className={`${styles.warningsToggleBar} ${getHighestSeverity()}`}
onClick={() => setHide(!hide)}
title={"toggle warnings"}
>
<div className={`${hide ? styles.arrowRight : styles.arrowLeft}`}></div>
</div>
<div
id="warningSidebar"
className={styles.warningsContent}
style={hide ? {display: "none"} : {display: "flex"}}
>
<WarningsHeader
severityFilter={severityFilter}
onChange={setSeverityFilter}
/>
<WarningsList warnings={filtered} />
<div className={styles.autoHide}>
<input
id="autoHideSwitch"
type={"checkbox"}
checked={autoHide}
onChange={(e) => setAutoHide(e.target.checked)}
/><label>Hide if there are no warnings</label>
</div>
</div>
</aside>
);
}
/**
* the header of the warning sidebar, contains severity filtering buttons
*
* @param {WarningSeverity | "ALL"} severityFilter
* @param {(severity: (WarningSeverity | "ALL")) => void} onChange
* @returns {React.JSX.Element}
* @constructor
*/
function WarningsHeader({
severityFilter,
onChange,
}: {
severityFilter: WarningSeverity | 'ALL';
onChange: (severity: WarningSeverity | 'ALL') => void;
}) {
const summary = warningSummary();
return (
<div className={styles.warningsHeader}>
<h3>Warnings</h3>
<div className={styles.severityTabs}>
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
<button
key={severity}
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
onClick={() => onChange(severity)}
>
{severity}
{severity !== 'ALL' && (
<span className={styles.count}>
{summary[severity.toLowerCase() as keyof typeof summary]}
</span>
)}
</button>
))}
</div>
</div>
);
}
/**
* the list of warnings in the warning sidebar
*
* @param {{warnings: EditorWarning[]}} props
* @returns {React.JSX.Element}
* @constructor
*/
function WarningsList(props: { warnings: EditorWarning[] }) {
const splitWarnings = {
global: props.warnings.filter(w => w.scope.id === globalWarning),
other: props.warnings.filter(w => w.scope.id !== globalWarning),
}
if (props.warnings.length === 0) {
return (
<div className={styles.warningsEmpty}>
No warnings!
</div>
)
}
return (
<div className={styles.warningsList}>
<div className={styles.warningGroupHeader}>global:</div>
<div className={styles.warningsGroup}>
{splitWarnings.global.map((warning) => (
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
? `:${warning.scope.handleId}`
: "")}
/>
))}
{splitWarnings.global.length === 0 && "No global warnings!"}
</div>
<div className={styles.warningGroupHeader}>other:</div>
<div className={styles.warningsGroup}>
{splitWarnings.other.map((warning) => (
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
? `:${warning.scope.handleId}`
: "")}
/>
))}
{splitWarnings.other.length === 0 && "No other warnings!"}
</div>
</div>
);
}
/**
* a single warning in the warning sidebar
*
* @param {{warning: EditorWarning, key: string}} props
* @returns {React.JSX.Element}
* @constructor
*/
function WarningListItem(props: { warning: EditorWarning, key: string}) {
const jumpToNode = useJumpToNode();
return (
<div
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
onClick={() => jumpToNode(props.warning.scope.id)}
>
<div className={styles.itemHeader}>
<span className={styles.type}>{props.warning.type}</span>
</div>
<div className={styles.description}>
{props.warning.description}
</div>
</div>
);
}
/**
* moves the editor to the provided node
* @returns {(nodeId: string) => void}
*/
function useJumpToNode() {
const { getNode, setCenter, getViewport } = useReactFlow();
const { addSelectedNodes } = useStoreApi().getState();
return (nodeId: string) => {
// user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
if (nodeId === globalWarning) return;
const node = getNode(nodeId);
if (!node) return;
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
const { position } = node;
const viewport = getViewport();
const { width, height } = nodeElement.getBoundingClientRect();
//move to node
setCenter(
position!.x + ((width / viewport.zoom) / 2),
position!.y + ((height / viewport.zoom) / 2),
{duration: 300, interpolate: "smooth" }
).then(() => {
addSelectedNodes([nodeId]);
});
};
}

View File

@@ -1,4 +1,4 @@
import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx";
import type { BasicBeliefNodeData } from "./BasicBeliefNode";
/**

View File

@@ -3,14 +3,13 @@ import {
Position,
type Node,
} from '@xyflow/react';
import { Toolbar } from '../components/NodeComponents.tsx';
import { Toolbar } from '../components/NodeComponents';
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";
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
import { MultilineTextField } from '../../../../components/MultilineTextField';
/**
* The default data structure for a BasicBelief node
@@ -32,7 +31,7 @@ export type BasicBeliefNodeData = {
};
// These are all the types a basic belief could be.
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
@@ -190,9 +189,8 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
</div>
)}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
noMatchingLeftRightBelief,
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
]} title="Connect to any number of trigger and/or normNode(-s)"/>
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
]}/>
</div>
</>
);

View File

@@ -1,63 +0,0 @@
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,15 +1,12 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node,
} from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from "../VisProgStores.tsx";
@@ -30,27 +27,6 @@ export type EndNode = Node<EndNodeData>
* @returns React.JSX.Element
*/
export default function EndNode(props: NodeProps<EndNode>) {
const {registerWarning, unregisterWarning} = useFlowStore.getState();
const connections = useNodeConnections({
id: props.id,
handleId: 'target'
})
useEffect(() => {
const noConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'target'
},
type: 'MISSING_INPUT',
severity: "ERROR",
description: "the endNode does not have an incoming connection from a phaseNode"
}
if (connections.length === 0) { registerWarning(noConnectionWarning); }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); }
}, [connections.length, props.id, registerWarning, unregisterWarning]);
return (
<>
<Toolbar nodeId={props.id} allowDelete={false}/>
@@ -60,7 +36,7 @@ export default function EndNode(props: NodeProps<EndNode>) {
</div>
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
allowOnlyConnectionsFromType(["phase"])
]} title="Connect to a phaseNode"/>
]}/>
</div>
</>
);

View File

@@ -1,10 +1,8 @@
import {
type NodeProps,
Position,
type Node
type Node,
} from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import { TextField } from '../../../../components/TextField';
@@ -46,7 +44,7 @@ export type GoalNode = Node<GoalNodeData>
* @returns React.JSX.Element
*/
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const {updateNodeData} = useFlowStore();
const _nodes = useFlowStore().nodes;
const text_input_id = `goal_${id}_text_input`;
@@ -66,24 +64,6 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
updateNodeData(id, {...data, can_fail: value});
}
useEffect(() => {
const noPlanWarning : EditorWarning = {
scope: {
id: id,
handleId: undefined
},
type: 'PLAN_IS_UNDEFINED',
severity: 'ERROR',
description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button"
};
if (!data.plan){
registerWarning(noPlanWarning);
return;
}
unregisterWarning(id, noPlanWarning.type);
},[data.plan, id, registerWarning, unregisterWarning])
return <>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
@@ -138,11 +118,9 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
</div>
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]} title="Connect to any number of phase and/or goalNode(-s)"/>
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[
allowOnlyConnectionsFromType(["goal"])]
} title="Connect to any number of goalNode(-s)"/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
</div>

View File

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

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

@@ -1,200 +0,0 @@
import {getConnectedEdges, type Node, type NodeProps, Position, useNodeConnections} from '@xyflow/react';
import {useEffect, useState} from "react";
import styles from '../../VisProg.module.css';
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import {Toolbar} from '../components/NodeComponents.tsx';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from "../VisProgStores.tsx";
import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
import switchStyles from './InferredBeliefNode.module.css';
/**
* The default data structure for an InferredBelief node
*/
export type InferredBeliefNodeData = {
label: string;
droppable: boolean;
inferredBelief: InferredBelief;
hasReduce: boolean;
};
/**
* stores a boolean to represent the operator
* and a left and right BeliefNode (can be both an inferred and a basic belief)
* in the form of their corresponding id's
*/
export type InferredBelief = {
left: string | undefined,
operator: boolean,
right: string | undefined,
}
export type InferredBeliefNode = Node<InferredBeliefNodeData>;
/**
* This function is called whenever a connection is made with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the received connection
*/
export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as InferredBeliefNodeData;
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId
&& ['basic_belief', 'inferred_belief'].includes(node.type!)))
) {
const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges);
switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){
case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break;
case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break;
}
}
}
/**
* This function is called whenever a connection is made with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the created connection
*/
export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
/**
* This function is called whenever a connection is disconnected with this node type as the target
* @param _thisNode the node of this node type which function is called
* @param _sourceNodeId the source of the disconnected connection
*/
export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as InferredBeliefNodeData;
if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined;
if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined;
}
/**
* This function is called whenever a connection is disconnected with this node type as the source
* @param _thisNode the node of this node type which function is called
* @param _targetNodeId the target of the diconnected connection
*/
export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
// no additional connection logic exists yet
}
export const InferredBeliefTooltip = `
Combines two beliefs into a single belief using logical inference,
the node can be toggled between using "AND" and "OR" mode for inference`;
/**
* Defines how an InferredBelief node should be rendered
* @param {NodeProps<InferredBeliefNode>} props - Node properties provided by React Flow, including `id` and `data`.
* @returns The rendered InferredBeliefNode React element. (React.JSX.Element)
*/
export default function InferredBeliefNode(props: NodeProps<InferredBeliefNode>) {
const data = props.data;
const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore();
// start of as an AND operator, true: "AND", false: "OR"
const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true);
// used to toggle operator
function onToggle() {
const newOperator = !enforceAllBeliefs; // compute the new value
setEnforceAllBeliefs(newOperator);
updateNodeData(props.id, {
...data,
inferredBelief: {
...data.inferredBelief,
operator: enforceAllBeliefs,
}
});
}
const beliefConnections = useNodeConnections({
id: props.id,
handleType: "target",
})
useEffect(() => {
const noBeliefsWarning : EditorWarning = {
scope: {
id: props.id,
handleId: undefined
},
type: 'MISSING_INPUT',
severity: 'ERROR',
description: `This AND/OR node is missing one or more beliefs,
please make sure to use both inputs of an AND/OR node`
};
if (beliefConnections.length < 2){
registerWarning(noBeliefsWarning);
return;
}
unregisterWarning(props.id, noBeliefsWarning.type);
},[beliefConnections.length, props.id, registerWarning, unregisterWarning])
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeInferredBelief}`}>
{/* The checkbox used to toggle the operator between 'AND' and 'OR' */}
<label className={switchStyles.operatorSwitch}>
<input
type="checkbox"
checked={data.inferredBelief.operator}
onChange={onToggle}
/>
<div className={switchStyles.switchVisual}></div>
<div className={switchStyles.switchLabels}>
<span title={"Belief is fulfilled if either of the supplied beliefs is true"}>OR</span>
<span title={"Belief is fulfilled if all of the supplied beliefs are true"}>AND</span>
</div>
</label>
{/* outgoing connections */}
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromType(["norm", "trigger"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
{/* incoming connections */}
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '30%'}} id="beliefLeft" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '70%'}} id="beliefRight" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
noBeliefCycles,
noMatchingLeftRightBelief
]}/>
</div>
</>
);
};
/**
* Reduces each BasicBelief, including its children down into its core data.
* @param {Node} node - The BasicBelief node to reduce.
* @param {Node[]} nodes - The list of all nodes in the current flow graph.
* @returns A simplified object containing the node label and its list of BasicBeliefs.
*/
export function InferredBeliefReduce(node: Node, nodes: Node[]) {
const data = node.data as InferredBeliefNodeData;
const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left);
const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right);
if (!leftBelief) { throw new Error("No Left belief found")}
if (!rightBelief) { throw new Error("No Right Belief found")}
const result: Record<string, unknown> = {
id: node.id,
left: BeliefGlobalReduce(leftBelief, nodes),
operator: data.inferredBelief.operator ? "AND" : "OR",
right: BeliefGlobalReduce(rightBelief, nodes),
};
return result
}

View File

@@ -9,7 +9,7 @@ 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";
import { BasicBeliefReduce } from './BasicBeliefNode';
/**
* The default data dot a phase node
@@ -79,10 +79,10 @@ export default function NormNode(props: NodeProps<NormNode>) {
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
]} title="Connect to any number of phaseNode(-s)"/>
]}/>
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
allowOnlyConnectionsFromType(["basic_belief"])
]}/>
</div>
</>;
};
@@ -105,10 +105,11 @@ export function NormReduce(node: Node, nodes: Node[]) {
};
if (data.condition) {
const reducer = BasicBeliefReduce; // TODO: also add inferred.
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)
result["condition"] = reducer(conditionNode, nodes)
}
return result
}
@@ -125,7 +126,7 @@ export const NormTooltip = `
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
const data = _thisNode.data as NormNodeData;
// If we got a belief connected, this is the condition for the norm.
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
data.condition = _sourceNodeId;
}
}

View File

@@ -10,4 +10,5 @@ export const PhaseNodeDefaults: PhaseNodeData = {
hasReduce: true,
nextPhaseId: null,
isFirstPhase: false,
plan: undefined,
};

View File

@@ -1,10 +1,8 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node
} from '@xyflow/react';
import {useEffect, useRef} from "react";
import {type EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
@@ -12,6 +10,11 @@ import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
import useFlowStore from '../VisProgStores';
import { TextField } from '../../../../components/TextField';
import PlanEditorDialog from '../components/PlanEditor.tsx';
import type { Plan } from '../components/Plan.tsx';
import { insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import type { GoalNode } from './GoalNode.tsx';
/**
* The default data dot a phase node
@@ -28,6 +31,7 @@ export type PhaseNodeData = {
hasReduce: boolean;
nextPhaseId: string | "end" | null;
isFirstPhase: boolean;
plan?: Plan;
};
export type PhaseNode = Node<PhaseNodeData>
@@ -39,107 +43,10 @@ export type PhaseNode = Node<PhaseNodeData>
*/
export default function PhaseNode(props: NodeProps<PhaseNode>) {
const data = props.data;
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const {updateNodeData} = useFlowStore();
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
const label_input_id = `phase_${props.id}_label_input`;
const connections = useNodeConnections({
id: props.id,
handleType: "target",
handleId: 'data'
})
const phaseOutCons = useNodeConnections({
id: props.id,
handleType: "source",
handleId: 'source',
})
const phaseInCons = useNodeConnections({
id: props.id,
handleType: "target",
handleId: 'target',
})
useEffect(() => {
const noConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'data'
},
type: 'MISSING_INPUT',
severity: "WARNING",
description: "the phaseNode has no incoming goals, norms, and/or triggers"
}
if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
}, [connections.length, props.id, registerWarning, unregisterWarning]);
useEffect(() => {
const notConnectedInfo : EditorWarning = {
scope: {
id: props.id,
handleId: undefined,
},
type: 'NOT_CONNECTED_TO_PROGRAM',
severity: "INFO",
description: "The PhaseNode is not connected to other nodes"
};
const noIncomingPhaseWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'target'
},
type: 'MISSING_INPUT',
severity: "WARNING",
description: "the phaseNode has no incoming connection from a phase or the startNode"
}
const noOutgoingPhaseWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'source'
},
type: 'MISSING_OUTPUT',
severity: "WARNING",
description: "the phaseNode has no outgoing connection to a phase or the endNode"
}
// register relevant warning and unregister others
if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
registerWarning(notConnectedInfo);
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
return;
}
if (phaseOutCons.length === 0) {
registerWarning(noOutgoingPhaseWarning);
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
return;
}
if (phaseInCons.length === 0) {
registerWarning(noIncomingPhaseWarning);
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
return;
}
// unregister all warnings if none should be present
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
}, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current) {
const { width, height } = ref.current.getBoundingClientRect();
console.log('Node width:', width, 'height:', height);
}
}, []);
return (
<>
<Toolbar nodeId={props.id} allowDelete={true}/>
@@ -153,17 +60,35 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
placeholder={"Phase ..."}
/>
</div>
{(data.plan && data.plan.steps.length > 0) && (<div>
<PlanEditorDialog
plan={data.plan}
onSave={(plan) => {
updateNodeData(props.id, {
...data,
plan,
});
}}
description={props.data.label}
onlyEditPhasing={true}
/>
</div>)}
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
noSelfConnections,
allowOnlyConnectionsFromType(["phase", "start"]),
]} title="Connect to a phase or the startNode"/>
]}/>
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
]}/>
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
noSelfConnections,
allowOnlyConnectionsFromType(["phase", "end"]),
]} title="Connect to a phase or the endNode"/>
]}/>
</div>
</>
);
@@ -228,9 +153,9 @@ export const PhaseTooltip = `
*/
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
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;
@@ -238,6 +163,18 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
// 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
case "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, sourceNode as GoalNode)
}
// Else, lets just insert this goal into our current plan.
else {
data.plan = insertGoalInPlan(structuredClone(data.plan), sourceNode as GoalNode)
}
break;
}
default: data.children.push(_sourceNodeId); break;
}
}

View File

@@ -1,15 +1,12 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node,
} from '@xyflow/react';
import {useEffect} from "react";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {type EditorWarning} from "../components/EditorWarnings.tsx";
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
import useFlowStore from "../VisProgStores.tsx";
export type StartNodeData = {
@@ -28,27 +25,6 @@ export type StartNode = Node<StartNodeData>
* @returns React.JSX.Element
*/
export default function StartNode(props: NodeProps<StartNode>) {
const {registerWarning, unregisterWarning} = useFlowStore.getState();
const connections = useNodeConnections({
id: props.id,
handleId: 'source'
})
useEffect(() => {
const noConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'source'
},
type: 'MISSING_OUTPUT',
severity: "ERROR",
description: "the startNode does not have an outgoing connection to a phaseNode"
}
if (connections.length === 0) { registerWarning(noConnectionWarning); }
else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); }
}, [connections.length, props.id, registerWarning, unregisterWarning]);
return (
<>
<Toolbar nodeId={props.id} allowDelete={false}/>
@@ -58,7 +34,7 @@ export default function StartNode(props: NodeProps<StartNode>) {
</div>
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
]} title="Connect to a phaseNode"/>
]}/>
</div>
</>
);

View File

@@ -1,18 +1,16 @@
import {
type NodeProps,
Position,
type Node, useNodeConnections
type Node,
} from '@xyflow/react';
import {useEffect} from "react";
import type {EditorWarning} from "../components/EditorWarnings.tsx";
import { Toolbar } from '../components/NodeComponents';
import styles from '../../VisProg.module.css';
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
import useFlowStore from '../VisProgStores';
import {PlanReduce, type Plan } from '../components/Plan';
import PlanEditorDialog from '../components/PlanEditor';
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
import PlanEditorDialog from '../components/PlanEditor';
import { BasicBeliefReduce } from './BasicBeliefNode';
import type { GoalNode } from './GoalNode.tsx';
import { defaultPlan } from '../components/Plan.default.ts';
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
@@ -47,79 +45,14 @@ export type TriggerNode = Node<TriggerNodeData>
*/
export default function TriggerNode(props: NodeProps<TriggerNode>) {
const data = props.data;
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
const {updateNodeData} = useFlowStore();
const setName= (value: string) => {
updateNodeData(props.id, {...data, name: value})
}
const beliefInput = useNodeConnections({
id: props.id,
handleType: "target",
handleId: "TriggerBeliefs"
})
const outputCons = useNodeConnections({
id: props.id,
handleType: "source",
handleId: "TriggerSource"
})
useEffect(() => {
const noPhaseConnectionWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'TriggerSource'
},
type: 'MISSING_OUTPUT',
severity: 'INFO',
description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
};
if (outputCons.length === 0){
registerWarning(noPhaseConnectionWarning);
return;
}
unregisterWarning(props.id, `${noPhaseConnectionWarning.type}:${noPhaseConnectionWarning.scope.handleId}`);
},[outputCons.length, props.id, registerWarning, unregisterWarning])
useEffect(() => {
const noBeliefWarning : EditorWarning = {
scope: {
id: props.id,
handleId: 'TriggerBeliefs'
},
type: 'MISSING_INPUT',
severity: 'ERROR',
description: "This triggerNode is missing a condition/belief, please make sure to connect a belief node to "
};
if (beliefInput.length === 0 && outputCons.length !== 0){
registerWarning(noBeliefWarning);
return;
}
unregisterWarning(props.id, `${noBeliefWarning.type}:${noBeliefWarning.scope.handleId}`);
},[beliefInput.length, outputCons.length, props.id, registerWarning, unregisterWarning])
useEffect(() => {
const noPlanWarning : EditorWarning = {
scope: {
id: props.id,
handleId: undefined
},
type: 'PLAN_IS_UNDEFINED',
severity: 'ERROR',
description: "This triggerNode is missing a plan, please make sure to create a plan by using the create plan button"
};
if (!data.plan && outputCons.length !== 0){
registerWarning(noPlanWarning);
return;
}
unregisterWarning(props.id, noPlanWarning.type);
},[data.plan, outputCons.length, props.id, registerWarning, unregisterWarning])
return <>
<Toolbar nodeId={props.id} allowDelete={true}/>
<div className={`${styles.defaultNode} ${styles.nodeTrigger} flex-col gap-sm`}>
<TextField
@@ -132,16 +65,15 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
]} title="Connect to any number of phaseNodes"/>
]}/>
<SingleConnectionHandle
type="target"
position={Position.Bottom}
id="TriggerBeliefs"
style={{ left: '40%' }}
style={{ left: '40%' }}
rules={[
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
allowOnlyConnectionsFromType(['basic_belief']),
]}
title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"
/>
<MultiConnectionHandle
@@ -152,7 +84,6 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
rules={[
allowOnlyConnectionsFromType(['goal']),
]}
title="Connect to any number of goalNodes"
/>
<PlanEditorDialog
@@ -171,13 +102,13 @@ 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 as TriggerNodeData;
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
return {
id: node.id,
name: node.data.name,
@@ -205,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
if (!otherNode) return;
if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) {
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
data.condition = _sourceNodeId;
}
@@ -241,7 +172,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin
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)
}

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

@@ -34,17 +34,10 @@ describe("UndoRedo Middleware", () => {
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
}
},
],
edges: [],
warnings: {
warningRegistry: new Map(),
severityIndex: new Map()
}
edges: []
}],
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
@@ -60,11 +53,7 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
edges: []
});
expect(state.future).toEqual([]);
});
@@ -91,9 +80,7 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
edges: []
});
act(() => {
@@ -127,11 +114,7 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
edges: []
});
});
@@ -157,9 +140,7 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
edges: []
});
act(() => {
@@ -195,11 +176,7 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
edges: []
});
});
@@ -222,9 +199,7 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
edges: []
});
act(() => { store.getState().beginBatchAction(); });

View File

@@ -1,9 +1,5 @@
import {act} from '@testing-library/react';
import {
type Connection,
type Edge,
type Node,
} from "@xyflow/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";
@@ -402,7 +398,6 @@ describe('FlowStore Functionality', () => {
}]
});
act(()=> {
deleteNode(nodeId);
});

View File

@@ -1,152 +0,0 @@
import { describe, it, expect} from '@jest/globals';
import {
type EditorWarning, warningSummary
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
function makeWarning(
overrides?: Partial<EditorWarning>
): EditorWarning {
return {
scope: { id: 'node-1' },
type: 'MISSING_INPUT',
severity: 'ERROR',
description: 'Missing input',
...overrides,
};
}
describe("editorWarnings", () => {
describe('registerWarning', () => {
it('registers a node-level warning', () => {
const warning = makeWarning();
const {registerWarning, getWarnings} = useFlowStore.getState()
registerWarning(warning);
const warnings = getWarnings();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toEqual(warning);
});
it('registers a handle-level warning with scoped key', () => {
const warning = makeWarning({
scope: { id: 'node-1', handleId: 'input-1' },
});
const {registerWarning} = useFlowStore.getState()
registerWarning(warning);
const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1');
expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true);
});
it('updates severityIndex correctly', () => {
const {registerWarning, severityIndex} = useFlowStore.getState()
registerWarning(makeWarning());
expect(severityIndex.get('ERROR')!.size).toBe(1);
});
});
describe('getWarningsBySeverity', () => {
it('returns only warnings of requested severity', () => {
const {registerWarning, getWarningsBySeverity} = useFlowStore.getState()
registerWarning(
makeWarning({ severity: 'ERROR' })
);
registerWarning(
makeWarning({
severity: 'WARNING',
type: 'MISSING_OUTPUT',
})
);
const errors = getWarningsBySeverity('ERROR');
const warnings = getWarningsBySeverity('WARNING');
expect(errors).toHaveLength(1);
expect(warnings).toHaveLength(1);
});
});
describe('isProgramValid', () => {
it('returns true when no ERROR warnings exist', () => {
expect(useFlowStore.getState().isProgramValid()).toBe(true);
});
it('returns false when ERROR warnings exist', () => {
const {registerWarning, isProgramValid} = useFlowStore.getState()
registerWarning(makeWarning());
expect(isProgramValid()).toBe(false);
});
});
describe('unregisterWarning', () => {
it('removes warning from registry and severityIndex', () => {
const warning = makeWarning();
const {
registerWarning,
getWarnings,
unregisterWarning,
severityIndex
} = useFlowStore.getState()
registerWarning(warning);
unregisterWarning('node-1', 'MISSING_INPUT');
expect(getWarnings()).toHaveLength(0);
expect(severityIndex.get('ERROR')!.size).toBe(0);
});
it('does nothing if warning does not exist', () => {
expect(() =>
useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST')
).not.toThrow();
});
});
describe('unregisterWarningsForId', () => {
it('removes all warnings for a node', () => {
const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState()
registerWarning(
makeWarning({
scope: { id: 'node-1', handleId: 'h1' },
})
);
registerWarning(
makeWarning({
scope: { id: 'node-1' },
type: 'MISSING_OUTPUT',
severity: 'WARNING',
})
);
unregisterWarningsForId('node-1');
expect(getWarnings()).toHaveLength(0);
expect(
severityIndex.get('ERROR')!.size
).toBe(0);
expect(
severityIndex.get('WARNING')!.size
).toBe(0);
});
});
describe('warningSummary', () => {
it('returns correct counts and validity', () => {
const {registerWarning} = useFlowStore.getState()
registerWarning(
makeWarning({ severity: 'ERROR' })
);
const summary = warningSummary();
expect(summary.error).toBe(1);
expect(summary.warning).toBe(0);
expect(summary.info).toBe(0);
expect(summary.isValid).toBe(false);
});
});
})

View File

@@ -1,138 +0,0 @@
import {fireEvent, render, screen} from '@testing-library/react';
import '@testing-library/jest-dom';
import {useReactFlow, useStoreApi} from "@xyflow/react";
import {
type EditorWarning,
globalWarning
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
jest.mock('@xyflow/react', () => ({
useReactFlow: jest.fn(),
useStoreApi: jest.fn(),
}));
jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx');
function makeWarning(
overrides?: Partial<EditorWarning>
): EditorWarning {
return {
scope: { id: 'node-1' },
type: 'MISSING_INPUT',
severity: 'ERROR',
description: 'Missing input',
...overrides,
};
}
describe('WarningsSidebar', () => {
let getStateSpy: jest.SpyInstance;
const setCenter = jest.fn(() => Promise.resolve());
const getNode = jest.fn();
const addSelectedNodes = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// React Flow hooks
(useReactFlow as jest.Mock).mockReturnValue({
getNode,
setCenter,
});
(useStoreApi as jest.Mock).mockReturnValue({
getState: () => ({ addSelectedNodes }),
});
// Use spyOn to override store
const mockWarnings = [
makeWarning({ description: 'Node warning', scope: { id: 'node-1' } }),
makeWarning({
description: 'Global warning',
scope: { id: globalWarning },
type: 'INCOMPLETE_PROGRAM',
severity: 'WARNING',
}),
makeWarning({
description: 'Info warning',
scope: { id: 'node-2' },
severity: 'INFO',
}),
];
getStateSpy = jest
.spyOn(useFlowStore, 'getState')
.mockReturnValue({
getWarnings: () => mockWarnings,
} as any);
});
afterEach(() => {
getStateSpy.mockRestore();
});
it('renders warnings header', () => {
render(<WarningsSidebar />);
expect(screen.getByText('Warnings')).toBeInTheDocument();
});
it('renders all warning descriptions', () => {
render(<WarningsSidebar />);
expect(screen.getByText('Node warning')).toBeInTheDocument();
expect(screen.getByText('Global warning')).toBeInTheDocument();
expect(screen.getByText('Info warning')).toBeInTheDocument();
});
it('splits global and other warnings correctly', () => {
render(<WarningsSidebar />);
expect(screen.getByText('global:')).toBeInTheDocument();
expect(screen.getByText('other:')).toBeInTheDocument();
});
it('shows empty state when no warnings exist', () => {
getStateSpy.mockReturnValueOnce({
getWarnings: () => [],
} as any);
render(<WarningsSidebar />);
expect(screen.getByText('No warnings!')).toBeInTheDocument();
});
it('filters by severity', () => {
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('ERROR'));
expect(screen.getByText('Node warning')).toBeInTheDocument();
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
expect(screen.queryByText('Info warning')).not.toBeInTheDocument();
});
it('filters INFO severity correctly', () => {
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('INFO'));
expect(screen.getByText('Info warning')).toBeInTheDocument();
expect(screen.queryByText('Node warning')).not.toBeInTheDocument();
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
});
it('clicking global warning does NOT jump', () => {
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('Global warning'));
expect(setCenter).not.toHaveBeenCalled();
expect(addSelectedNodes).not.toHaveBeenCalled();
});
it('does nothing if node does not exist', () => {
getNode.mockReturnValue(undefined);
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('Node warning'));
expect(setCenter).not.toHaveBeenCalled();
});
});

View File

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

@@ -3,7 +3,7 @@ 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 BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
import type { Node } from '@xyflow/react';
import '@testing-library/jest-dom';

View File

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

@@ -1,9 +1,5 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import {
type CompositeWarningKey,
type SeverityIndex,
} from "../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
if (!globalThis.structuredClone) {
@@ -73,6 +69,8 @@ export const mockReactFlow = () => {
};
beforeAll(() => {
useFlowStore.setState({
nodes: [],
@@ -81,13 +79,7 @@ beforeAll(() => {
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true,
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]) as SeverityIndex,
ruleRegistry: new Map()
});
});
@@ -100,13 +92,7 @@ afterEach(() => {
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true,
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]) as SeverityIndex,
ruleRegistry: new Map()
});
});

View File

@@ -2,9 +2,6 @@
import { render, type RenderOptions } from '@testing-library/react';
import { type ReactElement, type ReactNode } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import {mockReactFlow} from "../setupFlowTests.ts";
mockReactFlow();
/**
* Custom render function that wraps components with necessary providers