From 82785dc8cb9a583076363828d6fd5935a16ffaa2 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Wed, 28 Jan 2026 08:27:30 +0000 Subject: [PATCH] feat: The Big One UI --- package-lock.json | 515 +++++++++++++++++- package.json | 2 + src/App.tsx | 1 + src/index.css | 11 + .../MonitoringPage/MonitoringPage.module.css | 276 ++++++++++ src/pages/MonitoringPage/MonitoringPage.tsx | 406 ++++++++++++++ src/pages/MonitoringPage/MonitoringPageAPI.ts | 121 ++++ .../MonitoringPageComponents.tsx | 232 ++++++++ src/pages/VisProgPage/VisProg.module.css | 12 + src/pages/VisProgPage/VisProg.tsx | 123 +++-- src/pages/VisProgPage/VisProgLogic.ts | 43 ++ src/pages/VisProgPage/VisProgLogic.tsx | 43 ++ .../visualProgrammingUI/EditorUndoRedo.ts | 18 +- .../visualProgrammingUI/VisProgStores.tsx | 47 +- .../visualProgrammingUI/VisProgTypes.tsx | 15 +- .../components/EditorWarnings.tsx | 245 +++++++++ .../components/NodeComponents.tsx | 7 +- .../components/RuleBasedHandle.module.css | 17 +- .../components/RuleBasedHandle.tsx | 14 +- .../components/SaveLoadPanel.tsx | 2 + .../components/WarningSidebar.module.css | 203 +++++++ .../components/WarningSidebar.tsx | 225 ++++++++ .../nodes/BasicBeliefNode.tsx | 8 +- .../visualProgrammingUI/nodes/EndNode.tsx | 28 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 30 +- .../nodes/InferredBeliefNode.tsx | 30 +- .../visualProgrammingUI/nodes/NormNode.tsx | 4 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 109 +++- .../visualProgrammingUI/nodes/StartNode.tsx | 28 +- .../visualProgrammingUI/nodes/TriggerNode.tsx | 77 ++- src/utils/programStore.ts | 52 ++ .../monitoringPage/MonitoringPage.test.tsx | 299 ++++++++++ .../monitoringPage/MonitoringPageAPI.test.ts | 220 ++++++++ .../MonitoringPageComponents.test.tsx | 226 ++++++++ test/pages/simpleProgram/SimpleProgram.tsx | 83 +++ .../EditorUndoRedo.test.ts | 41 +- .../VisProgStores.test.tsx | 7 +- .../components/EditorWarnings.test.tsx | 152 ++++++ .../components/SaveLoadPanel.test.tsx | 11 +- .../components/WarningSidebar.test.tsx | 138 +++++ .../nodes/BeliefNode.test.tsx | 26 +- .../nodes/TriggerNode.test.tsx | 6 +- test/setupFlowTests.ts | 22 +- test/test-utils/test-utils.tsx | 3 + test/utils/programStore.test.ts | 112 +++- 45 files changed, 4147 insertions(+), 143 deletions(-) create mode 100644 src/pages/MonitoringPage/MonitoringPage.module.css create mode 100644 src/pages/MonitoringPage/MonitoringPage.tsx create mode 100644 src/pages/MonitoringPage/MonitoringPageAPI.ts create mode 100644 src/pages/MonitoringPage/MonitoringPageComponents.tsx create mode 100644 src/pages/VisProgPage/VisProgLogic.ts create mode 100644 src/pages/VisProgPage/VisProgLogic.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx create mode 100644 test/pages/monitoringPage/MonitoringPage.test.tsx create mode 100644 test/pages/monitoringPage/MonitoringPageAPI.test.ts create mode 100644 test/pages/monitoringPage/MonitoringPageComponents.test.tsx create mode 100644 test/pages/simpleProgram/SimpleProgram.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx diff --git a/package-lock.json b/package-lock.json index a52343e..31d1339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,11 @@ "dependencies": { "@neodrag/react": "^2.3.1", "@xyflow/react": "^12.8.6", + "clsx": "^2.1.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.3", + "reactflow": "^11.11.4", "zustand": "^5.0.8" }, "devDependencies": { @@ -2053,6 +2055,276 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/background/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -2647,12 +2919,102 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", @@ -2662,6 +3024,54 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2671,12 +3081,78 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "license": "MIT" }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -2703,6 +3179,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -3971,6 +4453,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -6945,9 +7436,9 @@ } }, "node_modules/react-router": { - "version": "7.9.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", - "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6966,6 +7457,24 @@ } } }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", diff --git a/package.json b/package.json index 45f66df..a9837a1 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "dependencies": { "@neodrag/react": "^2.3.1", "@xyflow/react": "^12.8.6", + "clsx": "^2.1.1", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router": "^7.9.3", + "reactflow": "^11.11.4", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 75d423d..e0576a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); diff --git a/src/index.css b/src/index.css index f4e6ffe..7f56d84 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/MonitoringPage/MonitoringPage.module.css b/src/pages/MonitoringPage/MonitoringPage.module.css new file mode 100644 index 0000000..183fe4b --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPage.module.css @@ -0,0 +1,276 @@ +.dashboardContainer { + display: grid; + grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */ + grid-template-rows: auto 1fr auto; /* Header, Main, Footer */ + grid-template-areas: + "header logs" + "main logs" + "footer footer"; + gap: 1rem; + padding: 1rem; + background-color: var(--bg-main); + color: var(--text-main); + font-family: Arial, sans-serif; +} + +/* HEADER */ +.experimentOverview { + grid-area: header; + display: flex; + color: color; + justify-content: space-between; + align-items: flex-start; + background: var(--bg-surface); + color: var(--text-main); + box-shadow: var(--shadow); + padding: 1rem; + box-shadow: var(--panel-shadow); + position: static; /* ensures it scrolls away */ +} + +.phaseProgress { + margin-top: 0.5rem; +} + +.phase { + display: inline-block; + width: 25px; + height: 25px; + margin: 0 3px; + text-align: center; + line-height: 25px; + background: gray; +} + +.completed { + background-color: green; + color: white; +} + +.current { + background-color: rgb(255, 123, 0); + color: white; +} + +.connected { + color: green; + font-weight: bold; +} + +.disconnected { + color: red; + font-weight: bold; +} + +.pausePlayInactive{ + background-color: gray; + color: white; +} + +.pausePlayActive{ + background-color: green; + color: white; +} + +.next { + background-color: #6c757d; + color: white; +} + +.restartExperiment{ + background-color: red; + color: white; +} + +/* MAIN GRID */ +.phaseOverview { + grid-area: main; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, auto); + gap: 1rem; + background: var(--bg-surface); + color: var(--text-main); + padding: 1rem; + box-shadow: var(--panel-shadow); + +} + +.phaseBox { + background: var(--bg-surface); + border: 1px solid var(--border-color); + box-shadow: var(--panel-shadow); + padding: 1rem; + display: flex; + flex-direction: column; + box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05); + + height: 250px; +} + +.phaseBox ul { + list-style: none; + padding: 0; + margin: 0; + + overflow-y: auto; + flex-grow: 1; + + +} + +.phaseBox ul::-webkit-scrollbar { + width: 6px; +} + +.phaseBox ul::-webkit-scrollbar-thumb { + background-color: #ccc; + border-radius: 10px; +} + +.phaseOverviewText { + grid-column: 1 / -1; /* make the title span across both columns */ + font-size: 1.4rem; + font-weight: 600; + margin: 0; /* remove default section margin */ + padding: 0.25rem 0; /* smaller internal space */ +} + +.phaseOverviewText h3{ + margin: 0; /* removes top/bottom whitespace */ + padding: 0; /* keeps spacing tight */ +} + +.phaseBox h3 { + margin-top: 0; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.4rem; +} + +.checked::before { + content: '✔️ '; +} + +.statusIndicator { + display: inline-block; + margin-right: 10px; + user-select: none; + transition: transform 0.1s ease; + font-size: 1.1rem; +} + +.statusIndicator.clickable { + cursor: pointer; +} + +.statusIndicator.clickable:hover { + transform: scale(1.2); +} + +.clickable { + cursor: pointer; +} + +.clickable:hover { + transform: scale(1.2); +} + +.active { + opacity: 1; +} + +.statusItem { + display: flex; + align-items: center; + margin-bottom: 0.4rem; +} + +.itemDescription { + line-height: 1.4; +} + +/* LOGS */ +.logs { + grid-area: logs; + background: var(--bg-surface); + color: var(--text-main); + box-shadow: var(--panel-shadow); + padding: 1rem; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.logs textarea { + width: 100%; + height: 83%; + margin-top: 0.5rem; + background-color: Canvas; + color: CanvasText; + border: 1px solid var(--border-color); +} + +.logs button { + background: var(--bg-surface); + box-shadow: var(--panel-shadow); + margin-top: 0.5rem; + margin-left: 0.5rem; +} + +/* FOOTER */ +.controlsSection { + grid-area: footer; + display: flex; + justify-content: space-between; + gap: 1rem; + background: var(--bg-surface); + color: var(--text-main); + box-shadow: var(--panel-shadow); + padding: 1rem; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.controlsSection button { + background: var(--bg-surface); + box-shadow: var(--panel-shadow); + margin-top: 0.5rem; + margin-left: 0.5rem; +} + +.gestures, +.speech, +.directSpeech { + flex: 1; +} + +.speechInput { + display: flex; + margin-top: 0.5rem; +} + +.speechInput input { + flex: 1; + padding: 0.5rem; + background-color: Canvas; + color: CanvasText; + border: 1px solid var(--border-color); +} + +.speechInput button { + color: white; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + background-color: Canvas; + color: CanvasText; + border: 1px solid var(--border-color); +} + +/* RESPONSIVE */ +@media (max-width: 900px) { + .phaseOverview { + grid-template-columns: 1fr; + } + + .controlsSection { + flex-direction: column; + } +} diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx new file mode 100644 index 0000000..3b79df9 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -0,0 +1,406 @@ +import React, { useCallback, useState } from 'react'; +import styles from './MonitoringPage.module.css'; + +// Store & API +import useProgramStore from "../../utils/programStore"; +import { + nextPhase, + useExperimentLogger, + useStatusLogger, + pauseExperiment, + playExperiment, + type ExperimentStreamData, + type GoalUpdate, + type TriggerUpdate, + type CondNormsStateUpdate, + type PhaseUpdate +} from "./MonitoringPageAPI"; +import { graphReducer, runProgramm } from '../VisProgPage/VisProgLogic.ts'; + +// Types +import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode'; +import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode'; +import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode'; + +// Sub-components +import { + GestureControls, + SpeechPresets, + DirectSpeechInput, + StatusList, + RobotConnected +} from './MonitoringPageComponents'; + +// ---------------------------------------------------------------------- +// 1. State management +// ---------------------------------------------------------------------- + +/** + * Manages the state of the active experiment, including phase progression, + * goal tracking, and stream event listeners. + */ +function useExperimentLogic() { + const getPhaseIds = useProgramStore((s) => s.getPhaseIds); + const getPhaseNames = useProgramStore((s) => s.getPhaseNames); + const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase); + const setProgramState = useProgramStore((state) => state.setProgramState); + + const [loading, setLoading] = useState(false); + const [activeIds, setActiveIds] = useState>({}); + const [goalIndex, setGoalIndex] = useState(0); + const [isPlaying, setIsPlaying] = useState(false); + const [phaseIndex, setPhaseIndex] = useState(0); + const [isFinished, setIsFinished] = useState(false); + + const phaseIds = getPhaseIds(); + const phaseNames = getPhaseNames(); + + // --- Stream Handlers --- + + const handleStreamUpdate = useCallback((data: ExperimentStreamData) => { + if (data.type === 'phase_update' && data.id) { + const payload = data as PhaseUpdate; + console.log(`${data.type} received, id : ${data.id}`); + + if (payload.id === "end") { + setIsFinished(true); + } else { + setIsFinished(false); + const newIndex = getPhaseIds().indexOf(payload.id); + if (newIndex !== -1) { + setPhaseIndex(newIndex); + setGoalIndex(0); + } + } + } + else if (data.type === 'goal_update') { + const payload = data as GoalUpdate; + const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[]; + const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id); + + console.log(`${data.type} received, id : ${data.id}`); + + if (gIndex === -1) { + console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`); + } else { + setGoalIndex(gIndex); + // Mark all previous goals as achieved + setActiveIds((prev) => { + const nextState = { ...prev }; + for (let i = 0; i < gIndex; i++) { + nextState[currentPhaseGoals[i].id] = true; + } + return nextState; + }); + } + } + else if (data.type === 'trigger_update') { + const payload = data as TriggerUpdate; + setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved })); + } + }, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]); + + const handleStatusUpdate = useCallback((data: unknown) => { + const payload = data as CondNormsStateUpdate; + if (payload.type !== 'cond_norms_state_update') return; + + setActiveIds((prev) => { + const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active); + if (!hasChanges) return prev; + + const nextState = { ...prev }; + payload.norms.forEach((u) => { nextState[u.id] = u.active; }); + return nextState; + }); + }, []); + + // Connect listeners + useExperimentLogger(handleStreamUpdate); + useStatusLogger(handleStatusUpdate); + + // --- Actions --- + + const resetExperiment = useCallback(async () => { + try { + setLoading(true); + const phases = graphReducer(); + setProgramState({ phases }); + + setActiveIds({}); + setPhaseIndex(0); + setGoalIndex(0); + setIsFinished(false); + + await runProgramm(); + console.log("Experiment & UI successfully reset."); + } catch (err) { + console.error("Failed to reset program:", err); + } finally { + setLoading(false); + } + }, [setProgramState]); + + const handleControlAction = async (action: "pause" | "play" | "nextPhase") => { + try { + setLoading(true); + switch (action) { + case "pause": + setIsPlaying(false); + await pauseExperiment(); + break; + case "play": + setIsPlaying(true); + await playExperiment(); + break; + case "nextPhase": + await nextPhase(); + break; + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + return { + loading, + isPlaying, + isFinished, + phaseIds, + phaseNames, + phaseIndex, + goalIndex, + activeIds, + setActiveIds, + resetExperiment, + handleControlAction, + }; +} + +// ---------------------------------------------------------------------- +// 2. Smaller Presentation Components +// ---------------------------------------------------------------------- + +/** + * Visual indicator of progress through experiment phases. + */ +function PhaseProgressBar({ + phaseIds, + phaseIndex, + isFinished +}: { + phaseIds: string[], + phaseIndex: number, + isFinished: boolean +}) { + return ( +
+ {phaseIds.map((id, index) => { + let statusClass = ""; + if (isFinished || index < phaseIndex) statusClass = styles.completed; + else if (index === phaseIndex) statusClass = styles.current; + + return ( + + {index + 1} + + ); + })} +
+ ); +} + +/** + * Main control buttons (Play, Pause, Next, Reset). + */ +function ControlPanel({ + loading, + isPlaying, + onAction, + onReset +}: { + loading: boolean, + isPlaying: boolean, + onAction: (a: "pause" | "play" | "nextPhase") => void, + onReset: () => void +}) { + return ( +
+

Experiment Controls

+
+ + + + + + + +
+
+ ); +} + +/** + * Displays lists of Goals, Triggers, and Norms for the current phase. + */ +function PhaseDashboard({ + phaseId, + activeIds, + setActiveIds, + goalIndex +}: { + phaseId: string, + activeIds: Record, + setActiveIds: React.Dispatch>>, + goalIndex: number +}) { + const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth); + const getTriggers = useProgramStore((s) => s.getTriggersInPhase); + const getNorms = useProgramStore((s) => s.getNormsInPhase); + + // Prepare data view models + const goals = getGoalsWithDepth(phaseId).map((g) => ({ + ...g, + id: g.id as string, + name: g.name as string, + achieved: activeIds[g.id as string] ?? false, + level: g.level, // Pass this new property to the UI + })); + + const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({ + ...t, + achieved: activeIds[t.id] ?? false, + })); + + const norms = (getNorms(phaseId) as NormNodeData[]) + .filter(n => !n.condition) + .map(n => ({ ...n, label: n.norm })); + + const conditionalNorms = (getNorms(phaseId) as (NormNodeData & { id: string })[]) + .filter(n => !!n.condition) + .map(n => ({ + ...n, + achieved: activeIds[n.id] ?? false + })); + + return ( + <> + + + + + + ); +} + +// ---------------------------------------------------------------------- +// 3. Main Component +// ---------------------------------------------------------------------- + +const MonitoringPage: React.FC = () => { + const { + loading, + isPlaying, + isFinished, + phaseIds, + phaseNames, + phaseIndex, + goalIndex, + activeIds, + setActiveIds, + resetExperiment, + handleControlAction + } = useExperimentLogic(); + + if (phaseIds.length === 0) { + return

No program loaded.

; + } + + return ( +
+ {/* HEADER */} +
+
+

Experiment Overview

+

+ {isFinished ? ( + Experiment finished + ) : ( + <>Phase {phaseIndex + 1}: {phaseNames[phaseIndex]} + )} +

+ +
+ + + +
+ +
+
+ + {/* MAIN GRID */} +
+
+

Phase Overview

+
+ + {isFinished ? ( +
+

All phases have been successfully completed.

+
+ ) : ( + + )} +
+ + {/* LOGS TODO: add actual logs */} + + + {/* FOOTER */} +
+ + + +
+
+ ); +} + +export default MonitoringPage; \ No newline at end of file diff --git a/src/pages/MonitoringPage/MonitoringPageAPI.ts b/src/pages/MonitoringPage/MonitoringPageAPI.ts new file mode 100644 index 0000000..c210968 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageAPI.ts @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; + +const API_BASE = "http://localhost:8000"; +const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint + +/** + * HELPER: Unified sender function + */ +export const sendAPICall = async (type: string, context: string, endpoint?: string) => { + try { + const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, context }), + }); + if (!response.ok) throw new Error("Backend response error"); + console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`); + } catch (err) { + console.error(`Failed to send api call:`, err); + } +}; + + +/** + * Sends an API call to the CB for going to the next phase. + * In case we can't go to the next phase, the function will throw an error. + */ +export async function nextPhase(): Promise { + const type = "next_phase" + const context = "" + sendAPICall(type, context) +} + + +/** + * Sends an API call to the CB for going to pause experiment +*/ +export async function pauseExperiment(): Promise { + const type = "pause" + const context = "true" + sendAPICall(type, context) +} + +/** + * Sends an API call to the CB for going to resume experiment +*/ +export async function playExperiment(): Promise { + 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; + +/** + * A hook that listens to the experiment stream that updates current state of the program + * via updates sent from the backend + */ +export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) { + const callbackRef = React.useRef(onUpdate); + // Ref is updated every time with on update + React.useEffect(() => { + callbackRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + console.log("Connecting to Experiment Stream..."); + const eventSource = new EventSource(`${API_BASE}/experiment_stream`); + + eventSource.onmessage = (event) => { + try { + const parsedData = JSON.parse(event.data) as ExperimentStreamData; + //call function using the ref + callbackRef.current?.(parsedData); + } catch (err) { + console.warn("Stream parse error:", err); + } + }; + + eventSource.onerror = (err) => { + console.error("SSE Connection Error:", err); + eventSource.close(); + }; + + return () => { + console.log("Closing Experiment Stream..."); + eventSource.close(); + }; + }, []); +} + +/** + * A hook that listens to the status stream that updates active conditional norms + * via updates sent from the backend + */ +export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) { + const callbackRef = React.useRef(onUpdate); + + React.useEffect(() => { + callbackRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + const eventSource = new EventSource(`${API_BASE}/status_stream`); + eventSource.onmessage = (event) => { + try { + const parsedData = JSON.parse(event.data); + callbackRef.current?.(parsedData); + } catch (err) { console.warn("Status stream error:", err); } + }; + return () => eventSource.close(); + }, []); +} \ No newline at end of file diff --git a/src/pages/MonitoringPage/MonitoringPageComponents.tsx b/src/pages/MonitoringPage/MonitoringPageComponents.tsx new file mode 100644 index 0000000..d1d2854 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from 'react'; +import styles from './MonitoringPage.module.css'; +import { sendAPICall } from './MonitoringPageAPI'; + +// --- GESTURE COMPONENT --- +export const GestureControls: React.FC = () => { + const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1"); + + const gestures = [ + { label: "Wave", value: "animations/Stand/Gestures/Hey_1" }, + { label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" }, + { label: "Explain", value: "animations/Stand/Gestures/Explain_4" }, + { label: "You", value: "animations/Stand/Gestures/You_1" }, + { label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" }, + { label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" }, + { label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" }, + { label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" }, + { label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" }, + { label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" }, + ]; + return ( +
+

Gestures

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

Speech Presets

+
    + {phrases.map((phrase, i) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +// --- DIRECT SPEECH (INPUT) COMPONENT --- +export const DirectSpeechInput: React.FC = () => { + const [text, setText] = useState(""); + + const handleSend = () => { + if (!text.trim()) return; + sendAPICall("speech", text); + setText(""); // Clear after sending + }; + + return ( +
+

Direct Pepper Speech

+
+ setText(e.target.value)} + placeholder="Type message..." + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + /> + +
+
+ ); +}; + +// --- interface for goals/triggers/norms/conditional norms --- +export type StatusItem = { + id?: string | number; + achieved?: boolean; + description?: string; + label?: string; + norm?: string; + name?: string; + level?: number; +}; + +interface StatusListProps { + title: string; + items: StatusItem[]; + type: 'goal' | 'trigger' | 'norm'| 'cond_norm'; + activeIds: Record; + setActiveIds?: React.Dispatch>>; + currentGoalIndex?: number; +} + +// --- STATUS LIST COMPONENT --- +export const StatusList: React.FC = ({ + title, + items, + type, + activeIds, + setActiveIds, + currentGoalIndex // Destructure this prop +}) => { + return ( +
+

{title}

+
    + {items.map((item, idx) => { + if (item.id === undefined) return null; + const isActive = !!activeIds[item.id]; + const showIndicator = type !== 'norm'; + const isCurrentGoal = type === 'goal' && idx === currentGoalIndex; + const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive); + + const indentation = (item.level || 0) * 20; + + const handleOverrideClick = () => { + if (!canOverride) return; + if (type === 'cond_norm' && isActive){ + {/* Unachieve conditional norm */} + sendAPICall("override_unachieve", String(item.id)); + } + else { + if(type === 'goal') + if(setActiveIds) + {setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));} + + sendAPICall("override", String(item.id)); + } + }; + + return ( +
  • + {showIndicator && ( + + {isActive ? "✔️" : "❌"} + + )} + + {item.name || item.norm} + {isCurrentGoal && " (Current)"} + +
  • + ); + })} +
+
+ ); +}; + + +// --- 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(null); + + useEffect(() => { + // Open a Server-Sent Events (SSE) connection to receive live ping updates. + // We're expecting a stream of data like that looks like this: `data = False` or `data = True` + const eventSource = new EventSource("http://localhost:8000/robot/ping_stream"); + eventSource.onmessage = (event) => { + + // Expecting messages in JSON format: `true` or `false` + //commented out this log as it clutters console logs, but might be useful to debug + //console.log("received message:", event.data); + try { + const data = JSON.parse(event.data); + + try { + setConnected(data) + } + catch { + console.log("couldnt extract connected from incoming ping data") + } + + } catch { + console.log("Ping message not in correct format:", event.data); + } + }; + + // Clean up the SSE connection when the component unmounts. + return () => eventSource.close(); + }, []); + + return ( +
+

Connection:

+

{connected ? "● Robot is connected" : "● Robot is disconnected"}

+
+ ) +} \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 3e099d8..8a0003c 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -183,6 +183,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; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 4d2921d..590f99e 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -4,20 +4,23 @@ import { Panel, ReactFlow, ReactFlowProvider, - MarkerType, + MarkerType, getOutgoers } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +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 {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx"; +import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx"; +import {WarningsSidebar} from "./visualProgrammingUI/components/WarningSidebar.tsx"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' -import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts'; +import {NodeTypes} from './visualProgrammingUI/NodeRegistry.ts'; import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx'; +import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx'; +import { graphReducer, runProgramm } from './VisProgLogic.ts'; // --| config starting params for flow |-- @@ -42,6 +45,7 @@ const selector = (state: FlowState) => ({ nodes: state.nodes, edges: state.edges, onNodesChange: state.onNodesChange, + onNodesDelete: state.onNodesDelete, onEdgesDelete: state.onEdgesDelete, onEdgesChange: state.onEdgesChange, onConnect: state.onConnect, @@ -67,6 +71,7 @@ const VisProgUI = () => { const { nodes, edges, onNodesChange, + onNodesDelete, onEdgesDelete, onEdgesChange, onConnect, @@ -89,15 +94,36 @@ const VisProgUI = () => { window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }); + const {unregisterWarning, registerWarning} = useFlowStore(); + useEffect(() => { + + if (checkPhaseChain()) { + unregisterWarning(globalWarning,'INCOMPLETE_PROGRAM'); + } else { + // create global warning for incomplete program chain + const incompleteProgramWarning : EditorWarning = { + scope: { + id: globalWarning, + handleId: undefined + }, + type: 'INCOMPLETE_PROGRAM', + severity: "ERROR", + description: "there is no complete phase chain from the startNode to the EndNode" + } + + registerWarning(incompleteProgramWarning); + } + },[edges, registerWarning, unregisterWarning]) return ( -
+
{ onNodeDragStop={endBatchAction} preventScrolling={scrollable} onMove={(_, viewport) => setZoom(viewport.zoom)} + reconnectRadius={15} snapToGrid fitView proOptions={{hideAttribution: true}} + style={{flexGrow: 3}} > {/* contains the drag and drop panel for nodes */} @@ -122,9 +150,13 @@ const VisProgUI = () => { + + + +
); }; @@ -144,42 +176,23 @@ function VisualProgrammingUI() { ); } -// currently outputs the prepared program to the console -function runProgram() { - const phases = graphReducer(); - const program = {phases} - console.log(JSON.stringify(program, null, 2)); - fetch( - "http://localhost:8000/program", - { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(program), - } - ).then((res) => { - if (!res.ok) throw new Error("Failed communicating with the backend.") - console.log("Successfully sent the program to the backend."); +const checkPhaseChain = (): boolean => { + const {nodes, edges} = useFlowStore.getState(); - // 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); -} + function checkForCompleteChain(currentNodeId: string): boolean { + const outgoingPhases = getOutgoers({id: currentNodeId}, nodes, edges) + .filter(node => ["end", "phase"].includes(node.type!)); -/** - * 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) - }); -} + if (outgoingPhases.length === 0) return false; + if (outgoingPhases.some(node => node.type === "end" )) return true; + const next = outgoingPhases.map(node => checkForCompleteChain(node.id)) + .find(result => result); + return !!next; + } + return checkForCompleteChain('start'); +}; /** * houses the entire page, so also UI elements @@ -187,10 +200,44 @@ function graphReducer() { * @constructor */ function VisProgPage() { + const [showSimpleProgram, setShowSimpleProgram] = useState(false); + const [programValidity, setProgramValidity] = useState(true); + const {isProgramValid, severityIndex} = useFlowStore(); + const setProgramState = useProgramStore((state) => state.setProgramState); + + 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 processProgram = () => { + const phases = graphReducer(); // reduce graph + setProgramState({ phases }); // <-- save to store + setShowSimpleProgram(true); // show SimpleProgram + runProgramm(); // send to backend if needed + }; + + if (showSimpleProgram) { + return ( +
+ + +
+ ); + } + + + return ( <> - + ) } diff --git a/src/pages/VisProgPage/VisProgLogic.ts b/src/pages/VisProgPage/VisProgLogic.ts new file mode 100644 index 0000000..69c7f77 --- /dev/null +++ b/src/pages/VisProgPage/VisProgLogic.ts @@ -0,0 +1,43 @@ +import useProgramStore from "../../utils/programStore"; +import orderPhaseNodeArray from "../../utils/orderPhaseNodes"; +import useFlowStore from './visualProgrammingUI/VisProgStores'; +import { NodeReduces } from './visualProgrammingUI/NodeRegistry'; +import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode"; + +/** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ +export function graphReducer() { + const { nodes } = useFlowStore.getState(); + return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode []) + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); +} + + +/** + * Outputs the prepared program to the console and sends it to the backend + */ +export function runProgramm() { + const phases = graphReducer(); + const program = {phases} + console.log(JSON.stringify(program, null, 2)); + fetch( + "http://localhost:8000/program", + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(program), + } + ).then((res) => { + if (!res.ok) throw new Error("Failed communicating with the backend.") + console.log("Successfully sent the program to the backend."); + + // store reduced program in global program store for further use in the UI + // when the program was sent to the backend successfully: + useProgramStore.getState().setProgramState(structuredClone(program)); + }).catch(() => console.log("Failed to send program to the backend.")); + console.log(program); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProgLogic.tsx b/src/pages/VisProgPage/VisProgLogic.tsx new file mode 100644 index 0000000..3753a3f --- /dev/null +++ b/src/pages/VisProgPage/VisProgLogic.tsx @@ -0,0 +1,43 @@ +import useProgramStore from "../../utils/programStore"; +import orderPhaseNodeArray from "../../utils/orderPhaseNodes"; +import useFlowStore from './visualProgrammingUI/VisProgStores'; +import { NodeReduces } from './visualProgrammingUI/NodeRegistry'; +import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode"; + +/** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ +export function graphReducer() { + const { nodes } = useFlowStore.getState(); + return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode []) + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); +} + + +/** + * Outputs the prepared program to the console and sends it to the backend + */ +export function runProgram() { + const phases = graphReducer(); + const program = {phases} + console.log(JSON.stringify(program, null, 2)); + fetch( + "http://localhost:8000/program", + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(program), + } + ).then((res) => { + if (!res.ok) throw new Error("Failed communicating with the backend.") + console.log("Successfully sent the program to the backend."); + + // store reduced program in global program store for further use in the UI + // when the program was sent to the backend successfully: + useProgramStore.getState().setProgramState(structuredClone(program)); + }).catch(() => console.log("Failed to send program to the backend.")); + console.log(program); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts index 6ad705d..4e45148 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -1,10 +1,18 @@ import type {Edge, Node} from "@xyflow/react"; import type {StateCreator, StoreApi } from 'zustand/vanilla'; +import type { + SeverityIndex, + WarningRegistry +} from "./components/EditorWarnings.tsx"; import type {FlowState} from "./VisProgTypes.tsx"; export type FlowSnapshot = { nodes: Node[]; edges: Edge[]; + warnings: { + warningRegistry: WarningRegistry; + severityIndex: SeverityIndex; + } } /** @@ -41,7 +49,11 @@ export const UndoRedo = ( */ const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({ nodes: state.nodes, - edges: state.edges + edges: state.edges, + warnings: { + warningRegistry: state.editorWarningRegistry, + severityIndex: state.severityIndex, + } })); const initialState = config(set, get, api); @@ -78,6 +90,8 @@ export const UndoRedo = ( set({ nodes: snapshot.nodes, edges: snapshot.edges, + editorWarningRegistry: snapshot.warnings.warningRegistry, + severityIndex: snapshot.warnings.severityIndex, }); state.future.push(currentSnapshot); // push current to redo @@ -97,6 +111,8 @@ export const UndoRedo = ( set({ nodes: snapshot.nodes, edges: snapshot.edges, + editorWarningRegistry: snapshot.warnings.warningRegistry, + severityIndex: snapshot.warnings.severityIndex, }); state.past.push(currentSnapshot); // push current to undo diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 2831748..65df21f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -10,6 +10,7 @@ import { } 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, @@ -44,19 +45,18 @@ 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,7 +87,9 @@ const useFlowStore = create(UndoRedo((set, get) => ({ */ onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), - onNodesDelete: (nodes) => nodes.forEach(node => get().unregisterNodeRules(node.id)), + onNodesDelete: (nodes) => nodes.forEach((_node) => { + return; + }), onEdgesDelete: (edges) => { // we make sure any affected nodes get updated to reflect removal of edges @@ -217,19 +219,32 @@ const useFlowStore = create(UndoRedo((set, get) => ({ * Deletes a node by ID, respecting NodeDeletes rules. * Also removes all edges connected to that node. */ - deleteNode: (nodeId) => { + deleteNode: (nodeId, deleteElements) => { get().pushSnapshot(); // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] + + // If there's no function, OR, our function tells us we can delete it, let's do so... if (ourFunction == undefined || ourFunction()) { - set({ - nodes: get().nodes.filter((n) => n.id !== nodeId), - edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), - })} + if (deleteElements){ + deleteElements({ + nodes: get().nodes.filter((n) => n.id === nodeId), + edges: get().edges.filter((e) => e.source !== nodeId && e.target === nodeId)} + ).then(() => { + get().unregisterNodeRules(nodeId); + get().unregisterWarningsForId(nodeId); + }); + } else { + set({ + nodes: get().nodes.filter((n) => n.id !== nodeId), + edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId), + }) + } + } }, /** @@ -341,8 +356,12 @@ const useFlowStore = create(UndoRedo((set, get) => ({ }) return { ruleRegistry: registry }; }) - } + }, + ...editorWarningRegistry(get, set), })) ); + + export default useFlowStore; + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index 8ae3cad..a34a3e7 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -7,8 +7,9 @@ import type { OnReconnect, Node, OnEdgesDelete, - OnNodesDelete + OnNodesDelete, DeleteElementsOptions } from '@xyflow/react'; +import type {EditorWarningRegistry} from "./components/EditorWarnings.tsx"; import type {HandleRule} from "./HandleRuleLogic.ts"; import type { NodeTypes } from './NodeRegistry'; import type {FlowSnapshot} from "./EditorUndoRedo.ts"; @@ -68,7 +69,10 @@ export type FlowState = { * Deletes a node and any connected edges. * @param nodeId - the ID of the node to delete */ - deleteNode: (nodeId: string) => void; + deleteNode: (nodeId: string, deleteElements?: (params: DeleteElementsOptions) => Promise<{ + deletedNodes: Node[] + deletedEdges: Edge[] + }>) => void; /** * Replaces the current nodes array in the store. @@ -94,7 +98,7 @@ export type FlowState = { * @param node - the Node object to add */ addNode: (node: Node) => void; -} & UndoRedoState & HandleRuleRegistry; +} & UndoRedoState & HandleRuleRegistry & EditorWarningRegistry; export type UndoRedoState = { // UndoRedo Types @@ -129,4 +133,7 @@ export type HandleRuleRegistry = { // cleans up all registered rules of all handles of the provided node unregisterNodeRules: (nodeId: string) => void -} \ No newline at end of file +} + + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx new file mode 100644 index 0000000..497aac6 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx @@ -0,0 +1,245 @@ +/* contains all logic for the VisProgEditor warning system +* +* Missing but desirable features: +* - Warning filtering: +* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode +* then hide any startNode, phaseNode, or endNode specific warnings +*/ +import useFlowStore from "../VisProgStores.tsx"; +import type {FlowState} from "../VisProgTypes.tsx"; + +// --| Type definitions |-- + +export type WarningId = NodeId | "GLOBAL_WARNINGS"; +export type NodeId = string; + + +export type WarningType = + | 'MISSING_INPUT' + | 'MISSING_OUTPUT' + | 'PLAN_IS_UNDEFINED' + | 'INCOMPLETE_PROGRAM' + | 'NOT_CONNECTED_TO_PROGRAM' + | string + +export type WarningSeverity = + | 'INFO' // Acceptable, but important to be aware of + | 'WARNING' // Acceptable, but probably undesirable behavior + | 'ERROR' // Prevents running program, should be fixed before running program is allowed + +/** + * warning scope, include a handleId if the warning is handle specific + */ +export type WarningScope = { + id: string; + handleId?: string; +} + +export type EditorWarning = { + scope: WarningScope; + type: WarningType; + severity: WarningSeverity; + description: string; +}; + +/** + * a scoped WarningKey, + * the handleId scoping is only needed for handle specific errors + * + * "`WarningType`:`handleId`" + */ +export type WarningKey = string; // for warnings that can occur on a per-handle basis + +/** + * a composite key used in the severityIndex + * + * "`WarningId`|`WarningKey`" + */ +export type CompositeWarningKey = string; + +export type WarningRegistry = Map>; +export type SeverityIndex = Map>; + +type ZustandSet = (partial: Partial | ((state: FlowState) => Partial)) => 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>(), + severityIndex: new Map([ + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], + ]), + + 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(), + }; +} + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx index 38f03a1..2d9bbd8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeComponents.tsx @@ -1,4 +1,4 @@ -import {NodeToolbar} from '@xyflow/react'; +import {NodeToolbar, useReactFlow} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import {type JSX, useState} from "react"; import {createPortal} from "react-dom"; @@ -30,10 +30,11 @@ type ToolbarProps = { */ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { const {nodes, deleteNode} = useFlowStore(); - + const { deleteElements } = useReactFlow(); const deleteParentNode = () => { - deleteNode(nodeId); + + deleteNode(nodeId, deleteElements); }; const nodeType = nodes.find((node) => node.id === nodeId)?.type as keyof typeof NodeTooltips; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css index e0aa5de..582ec2d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css @@ -1,7 +1,16 @@ +: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.25rem green); + filter: drop-shadow(0 0 0.15rem green); } :global(.singleConnectionHandle.connected) { @@ -16,19 +25,19 @@ :global(.singleConnectionHandle.unconnected){ background: lightsalmon; border-color: #ff6060; - filter: drop-shadow(0 0 0.25rem #ff6060); + filter: drop-shadow(0 0 0.15rem #ff6060); } :global(.react-flow__handle.connectingto) { background: #ff6060; border-color: coral; - filter: drop-shadow(0 0 0.25rem coral); + filter: drop-shadow(0 0 0.15rem coral); } :global(.react-flow__handle.valid) { background: #55dd99; border-color: green; - filter: drop-shadow(0 0 0.25rem green); + filter: drop-shadow(0 0 0.15rem green); } :global(.react-flow__handle) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx index 2d3299d..2026b00 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx @@ -4,7 +4,6 @@ import { type Connection, useNodeId, useNodeConnections } from '@xyflow/react'; -import {useState} from 'react'; import { type HandleRule, useHandleRules} from "../HandleRuleLogic.ts"; import "./RuleBasedHandle.module.css"; @@ -29,21 +28,16 @@ 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 ( { const result = validate(connection as Connection); - setHandleState(result); return result.isSatisfied; }} - title={handleState.message} /> ); } @@ -66,22 +60,18 @@ 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 ( { const result = validate(connection as Connection); - setHandleState(result); return result.isSatisfied; }} - title={handleState.message} /> ); } diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx index baac724..8cf4146 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/SaveLoadPanel.tsx @@ -29,6 +29,8 @@ export default function SaveLoadPanel() { const text = await file.text(); const parsed = JSON.parse(text) as SavedProject; if (!parsed.nodes || !parsed.edges) throw new Error("Invalid file format"); + const {nodes, unregisterWarningsForId} = useFlowStore.getState(); + nodes.forEach((node) => {unregisterWarningsForId(node.id);}); setNodes(parsed.nodes); setEdges(parsed.edges); } catch (e) { diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css new file mode 100644 index 0000000..82168dc --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.module.css @@ -0,0 +1,203 @@ +.warnings-sidebar { + min-width: auto; + max-width: 340px; + margin-right: 0; + height: 100%; + background: canvas; + display: flex; + flex-direction: row; +} + +.warnings-toggle-bar { + background-color: ButtonFace; + justify-items: center; + align-content: center; + width: 1rem; + cursor: pointer; +} + +.warnings-toggle-bar.error:first-child:has(.arrow-right){ + background-color: hsl(from red h s 75%); +} +.warnings-toggle-bar.warning:first-child:has(.arrow-right) { + background-color: hsl(from orange h s 75%); +} +.warnings-toggle-bar.info:first-child:has(.arrow-right) { + background-color: hsl(from steelblue h s 75%); +} + +.warnings-toggle-bar:hover { + background-color: GrayText !important ; + .arrow-left { + border-right-color: ButtonFace; + transition: transform 0.15s ease-in-out; + transform: rotateY(180deg); + } + .arrow-right { + border-left-color: ButtonFace; + transition: transform 0.15s ease-in-out; + transform: rotateY(180deg); + } +} + + +.warnings-content { + width: 320px; + flex: 1; + flex-direction: column; + border-left: 2px solid CanvasText; +} + +.warnings-header { + padding: 12px; + border-bottom: 2px solid CanvasText; +} + +.severity-tabs { + display: flex; + gap: 4px; +} + +.severity-tab { + flex: 1; + padding: 4px; + background: ButtonFace; + color: GrayText; + border: none; + cursor: pointer; +} + +.count { + padding: 4px; + color: GrayText; + border: none; + cursor: pointer; +} + +.severity-tab.active { + color: ButtonText; + border: 2px solid currentColor; + .count { + color: ButtonText; + } +} + +.warning-group-header { + background: ButtonFace; + padding: 6px; + font-weight: bold; +} + +.warnings-list { + flex: 1; + min-height: 0; + overflow-y: scroll; +} + +.warnings-empty { + margin: auto; +} + +.warning-item { + display: flex; + flex-direction: column; + margin: 5px; + gap: 2px; + padding: 0; + border-radius: 5px; + cursor: pointer; + color: GrayText; +} + +.warning-item:hover { + background: ButtonFace; +} + +.warning-item--error { + border: 2px solid red; + background-color: hsl(from red h s 96%); + .item-header{ + background-color: red; + .type{ + color: hsl(from red h s 96%); + } + } + +} + +.warning-item--error:hover { + background-color: hsl(from red h s 75%); +} + +.warning-item--warning { + border: 2px solid orange; + background-color: hsl(from orange h s 96%); + .item-header{ + background-color: orange; + .type{ + color: hsl(from orange h s 96%); + } + } +} + +.warning-item--warning:hover { + background-color: hsl(from orange h s 75%); +} + +.warning-item--info { + border: 2px solid steelblue; + background-color: hsl(from steelblue h s 96%); + .item-header{ + background-color: steelblue; + .type{ + color: hsl(from steelblue h s 96%); + } + } +} + +.warning-item--info:hover { + background-color: hsl(from steelblue h s 75%); +} + +.warning-item .item-header { + padding: 8px 8px; + opacity: 1; + font-weight: bolder; +} +.warning-item .item-header .type{ + padding: 2px 8px; + font-size: 0.9rem; +} + +.warning-item .description { + padding: 5px 10px; + font-size: 0.8rem; +} + +.auto-hide { + background-color: Canvas; + border-top: 2px solid CanvasText; + margin-top: auto; + width: 100%; + height: 2.5rem; + display: flex; + align-items: center; + padding: 0 12px; +} + +/* arrows for toggleBar */ +.arrow-right { + width: 0; + height: 0; + border-top: 0.5rem solid transparent; + border-bottom: 0.5rem solid transparent; + border-left: 0.6rem solid GrayText; +} + +.arrow-left { + width: 0; + height: 0; + border-top: 0.5rem solid transparent; + border-bottom: 0.5rem solid transparent; + border-right: 0.6rem solid GrayText; +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx new file mode 100644 index 0000000..27a4684 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx @@ -0,0 +1,225 @@ +import {useReactFlow, useStoreApi} from "@xyflow/react"; +import clsx from "clsx"; +import {useEffect, useState} from "react"; +import useFlowStore from "../VisProgStores.tsx"; +import { + warningSummary, + type WarningSeverity, + type EditorWarning, globalWarning +} from "./EditorWarnings.tsx"; +import styles from "./WarningSidebar.module.css"; + +/** + * the warning sidebar, shows all warnings + * + * @returns {React.JSX.Element} + * @constructor + */ +export function WarningsSidebar() { + const warnings = useFlowStore.getState().getWarnings(); + const [hide, setHide] = useState(false); + const [severityFilter, setSeverityFilter] = useState('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 ( + + + ); +} + +/** + * 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 ( +
+

Warnings

+
+ {(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => ( + + ))} +
+
+ ); +} + + +/** + * 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 ( +
+ No warnings! +
+ ) + } + return ( +
+
global:
+
+ {splitWarnings.global.map((warning) => ( + + ))} + {splitWarnings.global.length === 0 && "No global warnings!"} +
+
other:
+
+ {splitWarnings.other.map((warning) => ( + + ))} + {splitWarnings.other.length === 0 && "No other warnings!"} +
+
+ ); +} + +/** + * 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 ( +
jumpToNode(props.warning.scope.id)} + > +
+ {props.warning.type} +
+ +
+ {props.warning.description} +
+
+ ); +} + +/** + * 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]); + }); + + + + }; +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 5348b06..4495745 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -113,9 +113,7 @@ export default function BasicBeliefNode(props: NodeProps) { updateNodeData(props.id, {...data, belief: {...data.belief, description: value}}); } - // Use this - const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"] - + const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"]; let placeholder = "" let wrapping = "" @@ -191,8 +189,8 @@ export default function BasicBeliefNode(props: NodeProps) { )} + allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]), + ]} title="Connect to any number of trigger and/or normNode(-s)"/>
); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 5c456b5..3bbfa14 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -1,12 +1,15 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; @@ -27,6 +30,27 @@ export type EndNode = Node * @returns React.JSX.Element */ export default function EndNode(props: NodeProps) { + 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 ( <> @@ -36,7 +60,7 @@ export default function EndNode(props: NodeProps) { + ]} title="Connect to a phaseNode"/> ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index fea9914..1974e99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -1,8 +1,10 @@ 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'; @@ -44,7 +46,7 @@ export type GoalNode = Node * @returns React.JSX.Element */ export default function GoalNode({id, data}: NodeProps) { - const {updateNodeData} = useFlowStore(); + const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore(); const _nodes = useFlowStore().nodes; const text_input_id = `goal_${id}_text_input`; @@ -64,6 +66,24 @@ export default function GoalNode({id, data}: NodeProps) { 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 <>
@@ -118,9 +138,11 @@ export default function GoalNode({id, data}: NodeProps) {
+ ]} title="Connect to any number of phase and/or goalNode(-s)"/> - + diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx index be5d4ec..924517b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -1,6 +1,7 @@ -import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react'; -import {useState} from "react"; +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"; @@ -91,7 +92,7 @@ export const InferredBeliefTooltip = ` */ export default function InferredBeliefNode(props: NodeProps) { const data = props.data; - const { updateNodeData } = useFlowStore(); + const { updateNodeData, registerWarning, unregisterWarning } = useFlowStore(); // start of as an AND operator, true: "AND", false: "OR" const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true); @@ -109,6 +110,29 @@ export default function InferredBeliefNode(props: NodeProps) }); } + 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 ( <> diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 8ee5462..29a03df 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -79,10 +79,10 @@ export default function NormNode(props: NodeProps) { + ]} title="Connect to any number of phaseNode(-s)"/> + ]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/> ; }; diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 50e81b6..e5f2b9b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -1,8 +1,10 @@ import { type NodeProps, Position, - type Node + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect, useRef} from "react"; +import {type EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle, MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; @@ -37,10 +39,107 @@ export type PhaseNode = Node */ export default function PhaseNode(props: NodeProps) { const data = props.data; - const {updateNodeData} = useFlowStore(); + const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore(); const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value}); const label_input_id = `phase_${props.id}_label_input`; + const connections = useNodeConnections({ + id: props.id, + handleType: "target", + handleId: 'data' + }) + + const phaseOutCons = useNodeConnections({ + id: props.id, + handleType: "source", + handleId: 'source', + }) + + const phaseInCons = useNodeConnections({ + id: props.id, + handleType: "target", + handleId: 'target', + }) + + + + useEffect(() => { + const noConnectionWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'data' + }, + type: 'MISSING_INPUT', + severity: "WARNING", + description: "the phaseNode has no incoming goals, norms, and/or triggers" + } + + if (connections.length === 0) { registerWarning(noConnectionWarning); return; } + unregisterWarning(props.id, `${noConnectionWarning.type}:data`); + }, [connections.length, props.id, registerWarning, unregisterWarning]); + + useEffect(() => { + const notConnectedInfo : EditorWarning = { + scope: { + id: props.id, + handleId: undefined, + }, + type: 'NOT_CONNECTED_TO_PROGRAM', + severity: "INFO", + description: "The PhaseNode is not connected to other nodes" + }; + const noIncomingPhaseWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'target' + }, + type: 'MISSING_INPUT', + severity: "WARNING", + description: "the phaseNode has no incoming connection from a phase or the startNode" + } + const noOutgoingPhaseWarning : EditorWarning = { + scope: { + id: props.id, + handleId: 'source' + }, + type: 'MISSING_OUTPUT', + severity: "WARNING", + description: "the phaseNode has no outgoing connection to a phase or the endNode" + } + + // register relevant warning and unregister others + if (phaseInCons.length === 0 && phaseOutCons.length === 0) { + registerWarning(notConnectedInfo); + unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`); + unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`); + return; + } + if (phaseOutCons.length === 0) { + registerWarning(noOutgoingPhaseWarning); + unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`); + unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type); + return; + } + if (phaseInCons.length === 0) { + registerWarning(noIncomingPhaseWarning); + unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`); + unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type); + return; + } + // unregister all warnings if none should be present + unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type); + unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`); + unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`); + }, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]); + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + const { width, height } = ref.current.getBoundingClientRect(); + + console.log('Node width:', width, 'height:', height); + } + }, []); return ( <> @@ -57,14 +156,14 @@ export default function PhaseNode(props: NodeProps) { + ]} title="Connect to a phase or the startNode"/> + ]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/> + ]} title="Connect to a phase or the endNode"/> ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 741b190..925d1dd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -1,12 +1,15 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {type EditorWarning} from "../components/EditorWarnings.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; export type StartNodeData = { @@ -25,6 +28,27 @@ export type StartNode = Node * @returns React.JSX.Element */ export default function StartNode(props: NodeProps) { + 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 ( <> @@ -34,7 +58,7 @@ export default function StartNode(props: NodeProps) { + ]} title="Connect to a phaseNode"/> ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 3004fe8..caa8c58 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -1,8 +1,10 @@ import { type NodeProps, Position, - type Node, + type Node, useNodeConnections } from '@xyflow/react'; +import {useEffect} from "react"; +import type {EditorWarning} from "../components/EditorWarnings.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; @@ -45,12 +47,77 @@ export type TriggerNode = Node */ export default function TriggerNode(props: NodeProps) { const data = props.data; - const {updateNodeData} = useFlowStore(); + const {updateNodeData, registerWarning, unregisterWarning} = 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 <> @@ -65,15 +132,16 @@ export default function TriggerNode(props: NodeProps) {
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
+ ]} title="Connect to any number of phaseNodes"/> ) { rules={[ allowOnlyConnectionsFromType(['goal']), ]} + title="Connect to any number of goalNodes" /> [] }; +export type GoalWithDepth = Record & { level: number }; + /** * the type definition of the programStore */ @@ -15,8 +17,10 @@ export type ProgramState = { // Utility functions: // to avoid having to manually go through the entire state for every instance where data is required getPhaseIds: () => string[]; + getPhaseNames: () => string[]; getNormsInPhase: (currentPhaseId: string) => Record[]; getGoalsInPhase: (currentPhaseId: string) => Record[]; + getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[]; getTriggersInPhase: (currentPhaseId: string) => Record[]; // if more specific utility functions are needed they can be added here: } @@ -43,6 +47,10 @@ const useProgramStore = create((set, get) => ({ * gets the ids of all phases in the program */ getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string), + /** + * gets the names of all phases in the program + */ + getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)), /** * gets the norms for the provided phase */ @@ -65,6 +73,50 @@ const useProgramStore = create((set, get) => ({ } throw new Error(`phase with id:"${currentPhaseId}" not found`) }, + + getGoalsWithDepth: (currentPhaseId: string) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + + if (!phase) { + throw new Error(`phase with id:"${currentPhaseId}" not found`); + } + + const rootGoals = phase["goals"] as Record[]; + const flatList: GoalWithDepth[] = []; + + const isGoal = (item: Record) => { + return item["plan"] !== undefined; + }; + + // Recursive helper function + const traverse = (goals: Record[], depth: number) => { + goals.forEach((goal) => { + // 1. Add the current goal to the list + flatList.push({ ...goal, level: depth }); + + // 2. Check for children + const plan = goal["plan"] as Record | undefined; + + if (plan && Array.isArray(plan["steps"])) { + const steps = plan["steps"] as Record[]; + + // 3. FILTER: Only recurse on steps that are actually goals + // If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list + const childGoals = steps.filter(isGoal); + + if (childGoals.length > 0) { + traverse(childGoals, depth + 1); + } + } + }); + }; + + // Start traversal + traverse(rootGoals, 0); + + return flatList; + }, /** * gets the triggers for the provided phase */ diff --git a/test/pages/monitoringPage/MonitoringPage.test.tsx b/test/pages/monitoringPage/MonitoringPage.test.tsx new file mode 100644 index 0000000..482372a --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPage.test.tsx @@ -0,0 +1,299 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage'; +import useProgramStore from '../../../src/utils/programStore'; +import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI'; +import * as VisProg from '../../../src/pages/VisProgPage/VisProgLogic'; + +// --- Mocks --- + +// Mock the Zustand store +jest.mock('../../../src/utils/programStore', () => ({ + __esModule: true, + default: jest.fn(), +})); + +// Mock the API layer including hooks +jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({ + nextPhase: jest.fn(), + resetPhase: jest.fn(), + pauseExperiment: jest.fn(), + playExperiment: jest.fn(), + // We mock these to capture the callbacks and trigger them manually in tests + useExperimentLogger: jest.fn(), + useStatusLogger: jest.fn(), +})); + +// Mock VisProg functionality +jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({ + graphReducer: jest.fn(), + runProgramm: jest.fn(), +})); + +// Mock Child Components to reduce noise (optional, but keeps unit test focused) +// For this test, we will allow them to render to test data passing, +// but we mock RobotConnected as it has its own side effects +jest.mock('../../../src/pages/MonitoringPage/MonitoringPageComponents', () => { + const original = jest.requireActual('../../../src/pages/MonitoringPage/MonitoringPageComponents'); + return { + ...original, + RobotConnected: () =>
Robot Status
, + }; +}); + +describe('MonitoringPage', () => { + // Capture stream callbacks + let streamUpdateCallback: (data: any) => void; + let statusUpdateCallback: (data: any) => void; + + // Setup default store state + const mockGetPhaseIds = jest.fn(); + const mockGetPhaseNames = jest.fn(); + const mockGetNorms = jest.fn(); + const mockGetGoals = jest.fn(); + const mockGetGoalsWithDepth = jest.fn(); + const mockGetTriggers = jest.fn(); + const mockSetProgramState = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + + // Default Store Implementation + (useProgramStore as unknown as jest.Mock).mockImplementation((selector) => { + const state = { + getPhaseIds: mockGetPhaseIds, + getPhaseNames: mockGetPhaseNames, + getNormsInPhase: mockGetNorms, + getGoalsInPhase: mockGetGoals, + getTriggersInPhase: mockGetTriggers, + getGoalsWithDepth: mockGetGoalsWithDepth, + setProgramState: mockSetProgramState, + }; + return selector(state); + }); + + // Capture the hook callbacks + (MonitoringAPI.useExperimentLogger as jest.Mock).mockImplementation((cb) => { + streamUpdateCallback = cb; + }); + (MonitoringAPI.useStatusLogger as jest.Mock).mockImplementation((cb) => { + statusUpdateCallback = cb; + }); + + // Default mock return values + mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']); + mockGetPhaseNames.mockReturnValue(['Intro', 'Main']); + mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]); + mockGetGoalsWithDepth.mockReturnValue([ + { id: 'g1', name: 'Goal 1', level: 0 }, + { id: 'g2', name: 'Goal 2', level: 0 } + ]); + mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]); + mockGetNorms.mockReturnValue([ + { id: 'n1', norm: 'Norm 1', condition: null }, + { id: 'cn1', norm: 'Cond Norm 1', condition: 'some-cond' } + ]); + }); + + test('renders "No program loaded" when phaseIds are empty', () => { + mockGetPhaseIds.mockReturnValue([]); + render(); + expect(screen.getByText('No program loaded.')).toBeInTheDocument(); + }); + + test('renders the dashboard with initial state', () => { + render(); + + // Check Header + expect(screen.getByText('Phase 1:')).toBeInTheDocument(); + expect(screen.getByText('Intro')).toBeInTheDocument(); + + // Check Lists + expect(screen.getByText(/Goal 1/)).toBeInTheDocument(); + + expect(screen.getByText('Trigger 1')).toBeInTheDocument(); + expect(screen.getByText('Norm 1')).toBeInTheDocument(); + expect(screen.getByText('Cond Norm 1')).toBeInTheDocument(); + }); + + describe('Control Buttons', () => { + test('Pause calls API and updates UI', async () => { + render(); + const pauseBtn = screen.getByText('❚❚'); + + await act(async () => { + fireEvent.click(pauseBtn); + }); + + expect(MonitoringAPI.pauseExperiment).toHaveBeenCalled(); + // Ensure local state toggled (we check if play button is now inactive style or pause active) + }); + + test('Play calls API and updates UI', async () => { + render(); + const playBtn = screen.getByText('▶'); + + await act(async () => { + fireEvent.click(playBtn); + }); + + expect(MonitoringAPI.playExperiment).toHaveBeenCalled(); + }); + + test('Next Phase calls API', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText('⏭')); + }); + expect(MonitoringAPI.nextPhase).toHaveBeenCalled(); + }); + + test('Reset Experiment calls logic and resets state', async () => { + render(); + + // Mock graph reducer return + (VisProg.graphReducer as jest.Mock).mockReturnValue([{ id: 'new-phase' }]); + + await act(async () => { + fireEvent.click(screen.getByText('⟲')); + }); + + expect(VisProg.graphReducer).toHaveBeenCalled(); + expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] }); + expect(VisProg.runProgramm).toHaveBeenCalled(); + }); + + test('Reset Experiment handles errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail')); + + render(); + await act(async () => { + fireEvent.click(screen.getByText('⟲')); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to reset program:', expect.any(Error)); + consoleSpy.mockRestore(); + }); + }); + + describe('Stream Updates (useExperimentLogger)', () => { + test('Handles phase_update to next phase', () => { + render(); + + expect(screen.getByText('Intro')).toBeInTheDocument(); // Phase 0 + + act(() => { + streamUpdateCallback({ type: 'phase_update', id: 'phase-2' }); + }); + + expect(screen.getByText('Main')).toBeInTheDocument(); // Phase 1 + }); + + test('Handles phase_update to "end"', () => { + render(); + + act(() => { + streamUpdateCallback({ type: 'phase_update', id: 'end' }); + }); + + expect(screen.getByText('Experiment finished')).toBeInTheDocument(); + expect(screen.getByText('All phases have been successfully completed.')).toBeInTheDocument(); + }); + + test('Handles phase_update with unknown ID gracefully', () => { + render(); + act(() => { + streamUpdateCallback({ type: 'phase_update', id: 'unknown-phase' }); + }); + // Should remain on current phase + expect(screen.getByText('Intro')).toBeInTheDocument(); + }); + + test('Handles goal_update: advances index and marks previous as achieved', () => { + render(); + + // Initial: Goal 1 (index 0) is current. + // Send update for Goal 2 (index 1). + act(() => { + streamUpdateCallback({ type: 'goal_update', id: 'g2' }); + }); + + // Goal 1 should now be marked achieved (passed via activeIds) + // Goal 2 should be current. + + // We can inspect the "StatusList" props implicitly by checking styling or indicators if not mocked, + // but since we render the full component, we check the class/text. + // Goal 1 should have checkmark (override logic puts checkmark for activeIds) + // The implementation details of StatusList show ✔️ for activeIds. + + const items = screen.getAllByRole('listitem'); + // Helper to find checkmarks within items + expect(items[0]).toHaveTextContent('Goal 1'); + // After update, g1 is active (achieved), g2 is current + // logic: loop i < gIndex (1). activeIds['g1'] = true. + }); + + test('Handles goal_update with unknown ID', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + render(); + act(() => { + streamUpdateCallback({ type: 'goal_update', id: 'unknown-goal' }); + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Goal unknown-goal not found')); + warnSpy.mockRestore(); + }); + + test('Handles trigger_update', () => { + render(); + + // Trigger 1 initially not achieved + act(() => { + streamUpdateCallback({ type: 'trigger_update', id: 't1', achieved: true }); + }); + + // StatusList logic: if activeId is true, show ✔️ + // We look for visual confirmation or check logic + const triggerList = screen.getByText('Triggers').parentElement; + expect(triggerList).toHaveTextContent('✔️'); // Assuming 't1' is the only trigger + }); + }); + + describe('Status Updates (useStatusLogger)', () => { + test('Handles cond_norms_state_update', () => { + render(); + + // Initial state: activeIds empty. + act(() => { + statusUpdateCallback({ + type: 'cond_norms_state_update', + norms: [{ id: 'cn1', active: true }] + }); + }); + + // Conditional Norm 1 should now be active + const cnList = screen.getByText('Conditional Norms').parentElement; + expect(cnList).toHaveTextContent('✔️'); + }); + + test('Ignores status update if no changes detected', () => { + render(); + // First update + act(() => { + statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] }); + }); + + // Second identical update - strictly checking if this causes a rerender is hard in RTL, + // but we ensure no errors and state remains consistent. + act(() => { + statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] }); + }); + + const cnList = screen.getByText('Conditional Norms').parentElement; + expect(cnList).toHaveTextContent('✔️'); + }); + }); +}); + diff --git a/test/pages/monitoringPage/MonitoringPageAPI.test.ts b/test/pages/monitoringPage/MonitoringPageAPI.test.ts new file mode 100644 index 0000000..01a21b7 --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPageAPI.test.ts @@ -0,0 +1,220 @@ +import { renderHook, act, cleanup } from '@testing-library/react'; +import { + sendAPICall, + nextPhase, + pauseExperiment, + playExperiment, + useExperimentLogger, + useStatusLogger +} from '../../../src/pages/MonitoringPage/MonitoringPageAPI'; + +// --- MOCK EVENT SOURCE SETUP --- +// This mocks the browser's EventSource so we can manually 'push' messages to our hooks +const mockInstances: MockEventSource[] = []; + +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; // Added onerror support + closed = false; + + constructor(url: string) { + this.url = url; + mockInstances.push(this); + } + + sendMessage(data: string) { + if (this.onmessage) { + this.onmessage({ data } as MessageEvent); + } + } + + triggerError(err: any) { + if (this.onerror) { + this.onerror(err); + } + } + + close() { + this.closed = true; + } +} + +// Mock global EventSource +beforeAll(() => { + (globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url)); +}); + +// Mock global fetch +beforeEach(() => { + globalThis.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ reply: 'ok' }), + }) + ) as jest.Mock; +}); + +// Cleanup after every test +afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + mockInstances.length = 0; +}); + +describe('MonitoringPageAPI', () => { + + describe('sendAPICall', () => { + test('sends correct POST request', async () => { + await sendAPICall('test_type', 'test_ctx'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/button_pressed', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }), + }) + ); + }); + + test('appends endpoint if provided', async () => { + await sendAPICall('t', 'c', '/extra'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/button_pressed/extra'), + expect.any(Object) + ); + }); + + test('logs error on fetch network failure', async () => { + (globalThis.fetch as jest.Mock).mockRejectedValue('Network error'); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await sendAPICall('t', 'c'); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error'); + }); + + test('throws error if response is not ok', async () => { + (globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false }); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + await sendAPICall('t', 'c'); + + expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error)); + }); + }); + + describe('Helper Functions', () => { + test('nextPhase sends correct params', async () => { + await nextPhase(); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) }) + ); + }); + + test('pauseExperiment sends correct params', async () => { + await pauseExperiment(); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) }) + ); + }); + + test('playExperiment sends correct params', async () => { + await playExperiment(); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) }) + ); + }); + }); + + describe('useExperimentLogger', () => { + test('connects to SSE and receives messages', () => { + const onUpdate = jest.fn(); + + // Hook must be rendered to start the effect + renderHook(() => useExperimentLogger(onUpdate)); + + // Retrieve the mocked instance created by the hook + const eventSource = mockInstances[0]; + expect(eventSource.url).toContain('/experiment_stream'); + + // Simulate incoming message + act(() => { + eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' }); + }); + + test('handles JSON parse errors in stream', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + renderHook(() => useExperimentLogger()); + const eventSource = mockInstances[0]; + + act(() => { + eventSource.sendMessage('invalid-json'); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error)); + }); + + + + test('handles SSE connection error', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + renderHook(() => useExperimentLogger()); + const eventSource = mockInstances[0]; + + act(() => { + eventSource.triggerError('Connection lost'); + }); + + expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost'); + expect(eventSource.closed).toBe(true); + }); + + test('closes EventSource on unmount', () => { + const { unmount } = renderHook(() => useExperimentLogger()); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + + unmount(); + + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); + }); + + describe('useStatusLogger', () => { + test('connects to SSE and receives messages', () => { + const onUpdate = jest.fn(); + renderHook(() => useStatusLogger(onUpdate)); + const eventSource = mockInstances[0]; + + expect(eventSource.url).toContain('/status_stream'); + + act(() => { + eventSource.sendMessage(JSON.stringify({ some: 'data' })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ some: 'data' }); + }); + + test('handles JSON parse errors', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + renderHook(() => useStatusLogger()); + const eventSource = mockInstances[0]; + + act(() => { + eventSource.sendMessage('bad-data'); + }); + + expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error)); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/monitoringPage/MonitoringPageComponents.test.tsx b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx new file mode 100644 index 0000000..f454fe1 --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +// Corrected Imports +import { + GestureControls, + SpeechPresets, + DirectSpeechInput, + StatusList, + RobotConnected +} from '../../../src/pages/MonitoringPage/MonitoringPageComponents'; + +import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI'; + +// Mock the API Call function with the correct path +jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({ + sendAPICall: jest.fn(), +})); + +describe('MonitoringPageComponents', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('GestureControls', () => { + test('renders and sends gesture command', () => { + render(); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'animations/Stand/Gestures/Hey_1' } + }); + + // Click button + fireEvent.click(screen.getByText('Actuate')); + + // Expect the API to be called with that new value + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1'); + }); + }); + + describe('SpeechPresets', () => { + test('renders buttons and sends speech command', () => { + render(); + + const btn = screen.getByText('"Hello, I\'m Pepper"'); + fireEvent.click(btn); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper"); + }); + }); + + describe('DirectSpeechInput', () => { + test('inputs text and sends on button click', () => { + render(); + const input = screen.getByPlaceholderText('Type message...'); + + fireEvent.change(input, { target: { value: 'Custom text' } }); + fireEvent.click(screen.getByText('Send')); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text'); + expect(input).toHaveValue(''); // Should clear + }); + + test('sends on Enter key', () => { + render(); + const input = screen.getByPlaceholderText('Type message...'); + + fireEvent.change(input, { target: { value: 'Enter text' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text'); + }); + + test('does not send empty text', () => { + render(); + fireEvent.click(screen.getByText('Send')); + expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled(); + }); + }); + + describe('StatusList', () => { + const mockSet = jest.fn(); + const items = [ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' } + ]; + + test('renders list items', () => { + render(); + expect(screen.getByText('Test List')).toBeInTheDocument(); + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + test('Goals: click override on inactive item calls API', () => { + render( + + ); + + // Click the X (inactive) + const indicator = screen.getAllByText('❌')[0]; + fireEvent.click(indicator); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1'); + expect(mockSet).toHaveBeenCalled(); + }); + + test('Conditional Norms: click override on ACTIVE item unachieves', () => { + render( + + ); + + const indicator = screen.getByText('✔️'); // It is active + fireEvent.click(indicator); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1'); + }); + + test('Current Goal highlighting', () => { + render( + + ); + // Using regex to handle the "(Current)" text + expect(screen.getByText(/Item 1/)).toBeInTheDocument(); + expect(screen.getByText(/(Current)/)).toBeInTheDocument(); + }); + }); + + describe('RobotConnected', () => { + let mockEventSource: any; + + beforeAll(() => { + Object.defineProperty(window, 'EventSource', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + close: jest.fn(), + onmessage: null, + })), + }); + }); + + beforeEach(() => { + mockEventSource = new window.EventSource('url'); + (window.EventSource as unknown as jest.Mock).mockClear(); + (window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource); + }); + + test('displays disconnected initially', () => { + render(); + expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument(); + }); + + test('updates to connected when SSE receives true', async () => { + render(); + + act(() => { + if(mockEventSource.onmessage) { + mockEventSource.onmessage({ data: 'true' } as MessageEvent); + } + }); + + expect(await screen.findByText('● Robot is connected')).toBeInTheDocument(); + }); + + test('handles invalid JSON gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + render(); + + act(() => { + if(mockEventSource.onmessage) { + mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent); + } + }); + + // Should catch error and log it, state remains disconnected + expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json'); + consoleSpy.mockRestore(); + }); + + test('logs error if state update fails (inner catch block)', async () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + // 1. Force useState to return a setter that throws an error + const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); }); + + // We use mockImplementation to return [currentState, throwingSetter] + const useStateSpy = jest.spyOn(React, 'useState') + .mockImplementation(() => [null, mockThrowingSetter]); + + render(); + + // 2. Trigger the event with VALID JSON ("true") + // This passes the first JSON.parse try/catch, + // but fails when calling setConnected(true) because of our mock. + await act(async () => { + if (mockEventSource.onmessage) { + mockEventSource.onmessage({ data: 'true' } as MessageEvent); + } + }); + + // 3. Verify the specific error log from line 205 + expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data"); + + // Cleanup spies + useStateSpy.mockRestore(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/test/pages/simpleProgram/SimpleProgram.tsx b/test/pages/simpleProgram/SimpleProgram.tsx new file mode 100644 index 0000000..22fcbbf --- /dev/null +++ b/test/pages/simpleProgram/SimpleProgram.tsx @@ -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[]) { + useProgramStore.getState().setProgramState({ phases }); +} + +describe("SimpleProgram", () => { + beforeEach(() => { + loadProgram([]); + }); + + test("shows empty state when no program is loaded", () => { + render(); + 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(); + + 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(); + + 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(); + expect(screen.getByText("◀ Prev")).toBeDisabled(); + }); + + test("next button is disabled on last phase", () => { + loadProgram([ + { id: "phase-1", norms: [], goals: [], triggers: [] }, + ]); + + render(); + expect(screen.getByText("Next ▶")).toBeDisabled(); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts index f7233d8..39b459d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -34,10 +34,17 @@ describe("UndoRedo Middleware", () => { type: 'default', position: {x: 0, y: 0}, data: {label: 'A'} - }, + } ], - edges: [] + edges: [], + warnings: { + warningRegistry: new Map(), + severityIndex: new Map() + } }], + ruleRegistry: new Map(), + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { @@ -53,7 +60,11 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + warnings: { + warningRegistry: {}, + severityIndex: {} + } }); expect(state.future).toEqual([]); }); @@ -80,7 +91,9 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { @@ -114,7 +127,11 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'B'} }], - edges: [] + edges: [], + warnings: { + warningRegistry: {}, + severityIndex: {} + } }); }); @@ -140,7 +157,9 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { @@ -176,7 +195,11 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + warnings: { + warningRegistry: {}, + severityIndex: {} + } }); }); @@ -199,7 +222,9 @@ describe("UndoRedo Middleware", () => { position: {x: 0, y: 0}, data: {label: 'A'} }], - edges: [] + edges: [], + editorWarningRegistry: new Map(), + severityIndex: new Map() }); act(() => { store.getState().beginBatchAction(); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index fa98048..d53d1bc 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,5 +1,9 @@ import {act} from '@testing-library/react'; -import type {Connection, Edge, Node} from "@xyflow/react"; +import { + type Connection, + type Edge, + type Node, +} from "@xyflow/react"; import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts"; import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx"; @@ -398,6 +402,7 @@ describe('FlowStore Functionality', () => { }] }); + act(()=> { deleteNode(nodeId); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx new file mode 100644 index 0000000..8351c8d --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/EditorWarnings.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect} from '@jest/globals'; +import { + type EditorWarning, warningSummary +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; + + +function makeWarning( + overrides?: Partial +): EditorWarning { + 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); + }); + }); +}) diff --git a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx index 9d85323..65458c9 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/SaveLoadPanel.test.tsx @@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => { }); test("onLoad with invalid JSON does not update store", async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const file = new File(["not json"], "bad.json", { type: "application/json" }); file.text = jest.fn(() => Promise.resolve(`{"bad json`)); @@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => { render(); const input = document.querySelector('input[type="file"]') as HTMLInputElement; - expect(input).toBeTruthy(); - - // Give some input + act(() => { fireEvent.change(input, { target: { files: [file] } }); }); await waitFor(() => { expect(window.alert).toHaveBeenCalledTimes(1); - const nodesAfter = useFlowStore.getState().nodes; expect(nodesAfter).toHaveLength(0); - expect(input.value).toBe(""); }); + + // Clean up the spy + consoleSpy.mockRestore(); }); test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx new file mode 100644 index 0000000..9ccf735 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/WarningSidebar.test.tsx @@ -0,0 +1,138 @@ +import {fireEvent, render, screen} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import {useReactFlow, useStoreApi} from "@xyflow/react"; +import { + type EditorWarning, + globalWarning +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx"; +import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; + + +jest.mock('@xyflow/react', () => ({ + useReactFlow: jest.fn(), + useStoreApi: jest.fn(), +})); + +jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'); + +function makeWarning( + overrides?: Partial +): EditorWarning { + 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(); + expect(screen.getByText('Warnings')).toBeInTheDocument(); + }); + + it('renders all warning descriptions', () => { + render(); + 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(); + expect(screen.getByText('global:')).toBeInTheDocument(); + expect(screen.getByText('other:')).toBeInTheDocument(); + }); + + it('shows empty state when no warnings exist', () => { + getStateSpy.mockReturnValueOnce({ + getWarnings: () => [], + } as any); + + render(); + expect(screen.getByText('No warnings!')).toBeInTheDocument(); + }); + + it('filters by severity', () => { + render(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByText('Node warning')); + + expect(setCenter).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index a023769..67d80c6 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -2,7 +2,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 { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; @@ -150,7 +150,7 @@ describe('BasicBeliefNode', () => { expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument(); // For emotion type, we should check that the select has the correct value selected - const selectElement = screen.getByDisplayValue('Happy'); + const selectElement = screen.getByDisplayValue('happy'); expect(selectElement).toBeInTheDocument(); expect((selectElement as HTMLSelectElement).value).toBe('happy'); }); @@ -185,14 +185,14 @@ describe('BasicBeliefNode', () => { /> ); - const selectElement = screen.getByDisplayValue('Happy'); + const selectElement = screen.getByDisplayValue('happy'); expect(selectElement).toBeInTheDocument(); // Check that all emotion options are present - expect(screen.getByText('Happy')).toBeInTheDocument(); - expect(screen.getByText('Angry')).toBeInTheDocument(); - expect(screen.getByText('Sad')).toBeInTheDocument(); - expect(screen.getByText('Cheerful')).toBeInTheDocument(); + expect(screen.getByText('happy')).toBeInTheDocument(); + expect(screen.getByText('angry')).toBeInTheDocument(); + expect(screen.getByText('sad')).toBeInTheDocument(); + expect(screen.getByText('surprise')).toBeInTheDocument(); }); it('should render without wrapping quotes for object type', () => { @@ -382,7 +382,7 @@ describe('BasicBeliefNode', () => { data: { label: 'Belief', droppable: true, - belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + belief: { type: 'emotion', id: 'em1', value: 'sad', label: 'Emotion recognised:' }, hasReduce: true, }, }; @@ -409,13 +409,13 @@ describe('BasicBeliefNode', () => { /> ); - const select = screen.getByDisplayValue('Happy'); - await user.selectOptions(select, 'sad'); + const select = screen.getByDisplayValue('sad'); + await user.selectOptions(select, 'happy'); await waitFor(() => { const state = useFlowStore.getState(); const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; - expect(updatedNode?.data.belief.value).toBe('sad'); + expect(updatedNode?.data.belief.value).toBe('happy'); }); }); @@ -511,13 +511,11 @@ describe('BasicBeliefNode', () => { expect(updatedNode?.data.belief.type).toBe('emotion'); // The component doesn't reset the value when changing types // So it keeps the old value even though it doesn't make sense for emotion type - expect(updatedNode?.data.belief.value).toBe('Happy'); + expect(updatedNode?.data.belief.value).toBe('sad'); }); }); }); - // ... rest of the tests remain the same, just fixing the Integration with Store section ... - describe('Integration with Store', () => { it('should properly update the store when changing belief value', async () => { const mockNode: Node = { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 83bcf34..43530a2 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -14,7 +14,7 @@ import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/vi import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; -import { act } from 'react-dom/test-utils'; +import { act } from '@testing-library/react'; describe('TriggerNode', () => { @@ -137,7 +137,6 @@ describe('TriggerNode', () => { }); }); - describe('TriggerConnects Function', () => { it('should correctly remove a goal from the triggers plan after it has been disconnected', () => { // first, define the goal node and trigger node. @@ -162,7 +161,6 @@ describe('TriggerNode', () => { act(() => { useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null }); }); - // expect the goal id to be part of a goal step of the plan. let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1'); expect(updatedTrigger?.data.plan).toBeDefined(); @@ -181,4 +179,4 @@ describe('TriggerNode', () => { expect(stillHas).toBeUndefined(); }); }); -}); + }); diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index e3382c6..caeda94 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -1,5 +1,9 @@ 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) { @@ -69,8 +73,6 @@ export const mockReactFlow = () => { }; - - beforeAll(() => { useFlowStore.setState({ nodes: [], @@ -79,7 +81,13 @@ beforeAll(() => { future: [], isBatchAction: false, edgeReconnectSuccessful: true, - ruleRegistry: new Map() + ruleRegistry: new Map(), + editorWarningRegistry: new Map(), + severityIndex: new Map([ + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], + ]) as SeverityIndex, }); }); @@ -92,7 +100,13 @@ afterEach(() => { future: [], isBatchAction: false, edgeReconnectSuccessful: true, - ruleRegistry: new Map() + ruleRegistry: new Map(), + editorWarningRegistry: new Map(), + severityIndex: new Map([ + ['INFO', new Set()], + ['WARNING', new Set()], + ['ERROR', new Set()], + ]) as SeverityIndex, }); }); diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index a39e01a..157ea19 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -2,6 +2,9 @@ import { render, type RenderOptions } from '@testing-library/react'; import { type ReactElement, type ReactNode } from 'react'; import { ReactFlowProvider } from '@xyflow/react'; +import {mockReactFlow} from "../setupFlowTests.ts"; + +mockReactFlow(); /** * Custom render function that wraps components with necessary providers diff --git a/test/utils/programStore.test.ts b/test/utils/programStore.test.ts index ba78b88..9865668 100644 --- a/test/utils/programStore.test.ts +++ b/test/utils/programStore.test.ts @@ -113,4 +113,114 @@ describe('useProgramStore', () => { // store should NOT change expect(storedProgram.phases[0]['norms']).toHaveLength(1); }); -}); \ No newline at end of file +}); + +describe('getGoalsWithDepth', () => { + const complexProgram: ReducedProgram = { + phases: [ + { + id: 'phase-nested', + goals: [ + // Level 0: Root Goal 1 + { + id: 'root-1', + name: 'Root Goal 1', + plan: { + steps: [ + // This is an ACTION (no plan), should be ignored + { id: 'action-1', type: 'speech' }, + + // Level 1: Child Goal + { + id: 'child-1', + name: 'Child Goal', + plan: { + steps: [ + // Level 2: Grandchild Goal + { + id: 'grandchild-1', + name: 'Grandchild', + plan: { steps: [] } // Empty plan is still a plan + } + ] + } + } + ] + } + }, + // Level 0: Root Goal 2 (Sibling) + { + id: 'root-2', + name: 'Root Goal 2', + plan: { steps: [] } + } + ] + } + ] + }; + + it('should flatten nested goals and assign correct depth levels', () => { + useProgramStore.getState().setProgramState(complexProgram); + + const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested'); + + // logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2 + expect(goals).toHaveLength(4); + + // Check Root 1 + expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 })); + + // Check Child 1 + expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 })); + + // Check Grandchild 1 + expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 })); + + // Check Root 2 + expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 })); + }); + + it('should ignore steps that are not goals (missing "plan" property)', () => { + useProgramStore.getState().setProgramState(complexProgram); + const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested'); + + // The 'action-1' object should NOT be in the list + const action = goals.find(g => g.id === 'action-1'); + expect(action).toBeUndefined(); + }); + + it('throws if phase does not exist', () => { + useProgramStore.getState().setProgramState(complexProgram); + + expect(() => + useProgramStore.getState().getGoalsWithDepth('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + }); + +it('should return the names of all phases in the program', () => { + // Define a program specifically with names for this test + const programWithNames: ReducedProgram = { + phases: [ + { + id: 'phase-1', + name: 'Introduction Phase', // Assuming the property is 'name' + norms: [], + goals: [], + triggers: [], + }, + { + id: 'phase-2', + name: 'Execution Phase', + norms: [], + goals: [], + triggers: [], + }, + ], + }; + + useProgramStore.getState().setProgramState(programWithNames); + + const phaseNames = useProgramStore.getState().getPhaseNames(); + expect(phaseNames).toEqual(['Introduction Phase', 'Execution Phase']); + }); \ No newline at end of file