feat: The Big One UI #47
515
package-lock.json
generated
515
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
276
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
276
src/pages/MonitoringPage/MonitoringPage.module.css
Normal file
@@ -0,0 +1,276 @@
|
||||
.dashboardContainer {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */
|
||||
grid-template-rows: auto 1fr auto; /* Header, Main, Footer */
|
||||
grid-template-areas:
|
||||
"header logs"
|
||||
"main logs"
|
||||
"footer footer";
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--bg-main);
|
||||
color: var(--text-main);
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* HEADER */
|
||||
.experimentOverview {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
color: color;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--panel-shadow);
|
||||
position: static; /* ensures it scrolls away */
|
||||
}
|
||||
|
||||
.phaseProgress {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.phase {
|
||||
display: inline-block;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin: 0 3px;
|
||||
text-align: center;
|
||||
line-height: 25px;
|
||||
background: gray;
|
||||
}
|
||||
|
||||
.completed {
|
||||
background-color: green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.current {
|
||||
background-color: rgb(255, 123, 0);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.connected {
|
||||
color: green;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pausePlayInactive{
|
||||
background-color: gray;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pausePlayActive{
|
||||
background-color: green;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.next {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.restartExperiment{
|
||||
background-color: red;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* MAIN GRID */
|
||||
.phaseOverview {
|
||||
grid-area: main;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, auto);
|
||||
gap: 1rem;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--panel-shadow);
|
||||
|
||||
}
|
||||
|
||||
.phaseBox {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--panel-shadow);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05);
|
||||
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.phaseBox ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.phaseBox ul::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.phaseBox ul::-webkit-scrollbar-thumb {
|
||||
background-color: #ccc;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.phaseOverviewText {
|
||||
grid-column: 1 / -1; /* make the title span across both columns */
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
margin: 0; /* remove default section margin */
|
||||
padding: 0.25rem 0; /* smaller internal space */
|
||||
}
|
||||
|
||||
.phaseOverviewText h3{
|
||||
margin: 0; /* removes top/bottom whitespace */
|
||||
padding: 0; /* keeps spacing tight */
|
||||
}
|
||||
|
||||
.phaseBox h3 {
|
||||
margin-top: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.checked::before {
|
||||
content: '✔️ ';
|
||||
}
|
||||
|
||||
.statusIndicator {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
user-select: none;
|
||||
transition: transform 0.1s ease;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.statusIndicator.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.statusIndicator.clickable:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clickable:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.statusItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.itemDescription {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* LOGS */
|
||||
.logs {
|
||||
grid-area: logs;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
box-shadow: var(--panel-shadow);
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.logs textarea {
|
||||
width: 100%;
|
||||
height: 83%;
|
||||
margin-top: 0.5rem;
|
||||
background-color: Canvas;
|
||||
color: CanvasText;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.logs button {
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--panel-shadow);
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* FOOTER */
|
||||
.controlsSection {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
box-shadow: var(--panel-shadow);
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.controlsSection button {
|
||||
background: var(--bg-surface);
|
||||
box-shadow: var(--panel-shadow);
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.gestures,
|
||||
.speech,
|
||||
.directSpeech {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.speechInput {
|
||||
display: flex;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.speechInput input {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
background-color: Canvas;
|
||||
color: CanvasText;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.speechInput button {
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
background-color: Canvas;
|
||||
color: CanvasText;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* RESPONSIVE */
|
||||
@media (max-width: 900px) {
|
||||
.phaseOverview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.controlsSection {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
406
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
406
src/pages/MonitoringPage/MonitoringPage.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import styles from './MonitoringPage.module.css';
|
||||
|
||||
// Store & API
|
||||
import useProgramStore from "../../utils/programStore";
|
||||
import {
|
||||
nextPhase,
|
||||
useExperimentLogger,
|
||||
useStatusLogger,
|
||||
pauseExperiment,
|
||||
playExperiment,
|
||||
type ExperimentStreamData,
|
||||
type GoalUpdate,
|
||||
type TriggerUpdate,
|
||||
type CondNormsStateUpdate,
|
||||
type PhaseUpdate
|
||||
} from "./MonitoringPageAPI";
|
||||
import { graphReducer, runProgramm } from '../VisProgPage/VisProgLogic.ts';
|
||||
|
||||
// Types
|
||||
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode';
|
||||
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode';
|
||||
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
||||
|
||||
// Sub-components
|
||||
import {
|
||||
GestureControls,
|
||||
SpeechPresets,
|
||||
DirectSpeechInput,
|
||||
StatusList,
|
||||
RobotConnected
|
||||
} from './MonitoringPageComponents';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 1. State management
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manages the state of the active experiment, including phase progression,
|
||||
* goal tracking, and stream event listeners.
|
||||
*/
|
||||
function useExperimentLogic() {
|
||||
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
|
||||
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
||||
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
|
||||
const [goalIndex, setGoalIndex] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [phaseIndex, setPhaseIndex] = useState(0);
|
||||
const [isFinished, setIsFinished] = useState(false);
|
||||
|
||||
const phaseIds = getPhaseIds();
|
||||
const phaseNames = getPhaseNames();
|
||||
|
||||
// --- Stream Handlers ---
|
||||
|
||||
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
|
||||
if (data.type === 'phase_update' && data.id) {
|
||||
const payload = data as PhaseUpdate;
|
||||
console.log(`${data.type} received, id : ${data.id}`);
|
||||
|
||||
if (payload.id === "end") {
|
||||
setIsFinished(true);
|
||||
} else {
|
||||
setIsFinished(false);
|
||||
const newIndex = getPhaseIds().indexOf(payload.id);
|
||||
if (newIndex !== -1) {
|
||||
setPhaseIndex(newIndex);
|
||||
setGoalIndex(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (data.type === 'goal_update') {
|
||||
const payload = data as GoalUpdate;
|
||||
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
|
||||
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
|
||||
|
||||
console.log(`${data.type} received, id : ${data.id}`);
|
||||
|
||||
if (gIndex === -1) {
|
||||
console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`);
|
||||
} else {
|
||||
setGoalIndex(gIndex);
|
||||
// Mark all previous goals as achieved
|
||||
setActiveIds((prev) => {
|
||||
const nextState = { ...prev };
|
||||
for (let i = 0; i < gIndex; i++) {
|
||||
nextState[currentPhaseGoals[i].id] = true;
|
||||
}
|
||||
return nextState;
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (data.type === 'trigger_update') {
|
||||
const payload = data as TriggerUpdate;
|
||||
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
|
||||
}
|
||||
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
|
||||
|
||||
const handleStatusUpdate = useCallback((data: unknown) => {
|
||||
const payload = data as CondNormsStateUpdate;
|
||||
if (payload.type !== 'cond_norms_state_update') return;
|
||||
|
||||
setActiveIds((prev) => {
|
||||
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
|
||||
if (!hasChanges) return prev;
|
||||
|
||||
const nextState = { ...prev };
|
||||
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
|
||||
return nextState;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Connect listeners
|
||||
useExperimentLogger(handleStreamUpdate);
|
||||
useStatusLogger(handleStatusUpdate);
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
const resetExperiment = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const phases = graphReducer();
|
||||
setProgramState({ phases });
|
||||
|
||||
setActiveIds({});
|
||||
setPhaseIndex(0);
|
||||
setGoalIndex(0);
|
||||
setIsFinished(false);
|
||||
|
||||
await runProgramm();
|
||||
console.log("Experiment & UI successfully reset.");
|
||||
} catch (err) {
|
||||
console.error("Failed to reset program:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setProgramState]);
|
||||
|
||||
const handleControlAction = async (action: "pause" | "play" | "nextPhase") => {
|
||||
try {
|
||||
setLoading(true);
|
||||
switch (action) {
|
||||
case "pause":
|
||||
setIsPlaying(false);
|
||||
await pauseExperiment();
|
||||
break;
|
||||
case "play":
|
||||
setIsPlaying(true);
|
||||
await playExperiment();
|
||||
break;
|
||||
case "nextPhase":
|
||||
await nextPhase();
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
isPlaying,
|
||||
isFinished,
|
||||
phaseIds,
|
||||
phaseNames,
|
||||
phaseIndex,
|
||||
goalIndex,
|
||||
activeIds,
|
||||
setActiveIds,
|
||||
resetExperiment,
|
||||
handleControlAction,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 2. Smaller Presentation Components
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Visual indicator of progress through experiment phases.
|
||||
*/
|
||||
function PhaseProgressBar({
|
||||
phaseIds,
|
||||
phaseIndex,
|
||||
isFinished
|
||||
}: {
|
||||
phaseIds: string[],
|
||||
phaseIndex: number,
|
||||
isFinished: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.phaseProgress}>
|
||||
{phaseIds.map((id, index) => {
|
||||
let statusClass = "";
|
||||
if (isFinished || index < phaseIndex) statusClass = styles.completed;
|
||||
else if (index === phaseIndex) statusClass = styles.current;
|
||||
|
||||
return (
|
||||
<span key={id} className={`${styles.phase} ${statusClass}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main control buttons (Play, Pause, Next, Reset).
|
||||
*/
|
||||
function ControlPanel({
|
||||
loading,
|
||||
isPlaying,
|
||||
onAction,
|
||||
onReset
|
||||
}: {
|
||||
loading: boolean,
|
||||
isPlaying: boolean,
|
||||
onAction: (a: "pause" | "play" | "nextPhase") => void,
|
||||
onReset: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.experimentControls}>
|
||||
<h3>Experiment Controls</h3>
|
||||
<div className={styles.controlsButtons}>
|
||||
<button
|
||||
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
||||
onClick={() => onAction("pause")}
|
||||
disabled={loading}
|
||||
>❚❚</button>
|
||||
|
||||
<button
|
||||
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
||||
onClick={() => onAction("play")}
|
||||
disabled={loading}
|
||||
>▶</button>
|
||||
|
||||
<button
|
||||
className={styles.next}
|
||||
onClick={() => onAction("nextPhase")}
|
||||
disabled={loading}
|
||||
>⏭</button>
|
||||
|
||||
<button
|
||||
className={styles.restartExperiment}
|
||||
onClick={onReset}
|
||||
disabled={loading}
|
||||
>⟲</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays lists of Goals, Triggers, and Norms for the current phase.
|
||||
*/
|
||||
function PhaseDashboard({
|
||||
phaseId,
|
||||
activeIds,
|
||||
setActiveIds,
|
||||
goalIndex
|
||||
}: {
|
||||
phaseId: string,
|
||||
activeIds: Record<string, boolean>,
|
||||
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
|
||||
goalIndex: number
|
||||
}) {
|
||||
const getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth);
|
||||
const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
|
||||
const getNorms = useProgramStore((s) => s.getNormsInPhase);
|
||||
|
||||
// Prepare data view models
|
||||
const goals = getGoalsWithDepth(phaseId).map((g) => ({
|
||||
...g,
|
||||
id: g.id as string,
|
||||
name: g.name as string,
|
||||
achieved: activeIds[g.id as string] ?? false,
|
||||
level: g.level, // Pass this new property to the UI
|
||||
}));
|
||||
|
||||
const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({
|
||||
...t,
|
||||
achieved: activeIds[t.id] ?? false,
|
||||
}));
|
||||
|
||||
const norms = (getNorms(phaseId) as NormNodeData[])
|
||||
.filter(n => !n.condition)
|
||||
.map(n => ({ ...n, label: n.norm }));
|
||||
|
||||
const conditionalNorms = (getNorms(phaseId) as (NormNodeData & { id: string })[])
|
||||
.filter(n => !!n.condition)
|
||||
.map(n => ({
|
||||
...n,
|
||||
achieved: activeIds[n.id] ?? false
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds={setActiveIds} currentGoalIndex={goalIndex} />
|
||||
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
|
||||
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
|
||||
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// 3. Main Component
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const MonitoringPage: React.FC = () => {
|
||||
const {
|
||||
loading,
|
||||
isPlaying,
|
||||
isFinished,
|
||||
phaseIds,
|
||||
phaseNames,
|
||||
phaseIndex,
|
||||
goalIndex,
|
||||
activeIds,
|
||||
setActiveIds,
|
||||
resetExperiment,
|
||||
handleControlAction
|
||||
} = useExperimentLogic();
|
||||
|
||||
if (phaseIds.length === 0) {
|
||||
return <p className={styles.empty}>No program loaded.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardContainer}>
|
||||
{/* HEADER */}
|
||||
<header className={styles.experimentOverview}>
|
||||
<div className={styles.phaseName}>
|
||||
<h2>Experiment Overview</h2>
|
||||
<p>
|
||||
{isFinished ? (
|
||||
<strong>Experiment finished</strong>
|
||||
) : (
|
||||
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
|
||||
)}
|
||||
</p>
|
||||
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
|
||||
</div>
|
||||
|
||||
<ControlPanel
|
||||
loading={loading}
|
||||
isPlaying={isPlaying}
|
||||
onAction={handleControlAction}
|
||||
onReset={resetExperiment}
|
||||
/>
|
||||
|
||||
<div className={styles.connectionStatus}>
|
||||
<RobotConnected />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* MAIN GRID */}
|
||||
<main className={styles.phaseOverview}>
|
||||
<section className={styles.phaseOverviewText}>
|
||||
<h3>Phase Overview</h3>
|
||||
</section>
|
||||
|
||||
{isFinished ? (
|
||||
<div className={styles.finishedMessage}>
|
||||
<p>All phases have been successfully completed.</p>
|
||||
</div>
|
||||
) : (
|
||||
<PhaseDashboard
|
||||
phaseId={phaseIds[phaseIndex]}
|
||||
activeIds={activeIds}
|
||||
setActiveIds={setActiveIds}
|
||||
goalIndex={goalIndex}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* LOGS TODO: add actual logs */}
|
||||
<aside className={styles.logs}>
|
||||
<h3>Logs</h3>
|
||||
<div className={styles.logHeader}>
|
||||
<span>Global:</span>
|
||||
<button>ALL</button>
|
||||
<button>Add</button>
|
||||
<button className={styles.live}>Live</button>
|
||||
</div>
|
||||
<textarea defaultValue="Example Log: much log"></textarea>
|
||||
</aside>
|
||||
|
||||
{/* FOOTER */}
|
||||
<footer className={styles.controlsSection}>
|
||||
<GestureControls />
|
||||
<SpeechPresets />
|
||||
<DirectSpeechInput />
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonitoringPage;
|
||||
121
src/pages/MonitoringPage/MonitoringPageAPI.ts
Normal file
121
src/pages/MonitoringPage/MonitoringPageAPI.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
const API_BASE = "http://localhost:8000";
|
||||
const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint
|
||||
|
||||
/**
|
||||
* HELPER: Unified sender function
|
||||
*/
|
||||
export const sendAPICall = async (type: string, context: string, endpoint?: string) => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type, context }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Backend response error");
|
||||
console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to send api call:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Sends an API call to the CB for going to the next phase.
|
||||
* In case we can't go to the next phase, the function will throw an error.
|
||||
*/
|
||||
export async function nextPhase(): Promise<void> {
|
||||
const type = "next_phase"
|
||||
const context = ""
|
||||
sendAPICall(type, context)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends an API call to the CB for going to pause experiment
|
||||
*/
|
||||
export async function pauseExperiment(): Promise<void> {
|
||||
const type = "pause"
|
||||
const context = "true"
|
||||
sendAPICall(type, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an API call to the CB for going to resume experiment
|
||||
*/
|
||||
export async function playExperiment(): Promise<void> {
|
||||
const type = "pause"
|
||||
const context = "false"
|
||||
sendAPICall(type, context)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Types for the experiment stream messages
|
||||
*/
|
||||
export type PhaseUpdate = { type: 'phase_update'; id: string };
|
||||
export type GoalUpdate = { type: 'goal_update'; id: string };
|
||||
export type TriggerUpdate = { type: 'trigger_update'; id: string; achieved: boolean };
|
||||
export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { id: string; active: boolean }[] };
|
||||
export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* A hook that listens to the experiment stream that updates current state of the program
|
||||
* via updates sent from the backend
|
||||
*/
|
||||
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
||||
const callbackRef = React.useRef(onUpdate);
|
||||
// Ref is updated every time with on update
|
||||
React.useEffect(() => {
|
||||
callbackRef.current = onUpdate;
|
||||
}, [onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Connecting to Experiment Stream...");
|
||||
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
|
||||
//call function using the ref
|
||||
callbackRef.current?.(parsedData);
|
||||
} catch (err) {
|
||||
console.warn("Stream parse error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE Connection Error:", err);
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
return () => {
|
||||
console.log("Closing Experiment Stream...");
|
||||
eventSource.close();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that listens to the status stream that updates active conditional norms
|
||||
* via updates sent from the backend
|
||||
*/
|
||||
export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
||||
const callbackRef = React.useRef(onUpdate);
|
||||
|
||||
React.useEffect(() => {
|
||||
callbackRef.current = onUpdate;
|
||||
}, [onUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(`${API_BASE}/status_stream`);
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const parsedData = JSON.parse(event.data);
|
||||
callbackRef.current?.(parsedData);
|
||||
} catch (err) { console.warn("Status stream error:", err); }
|
||||
};
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
}
|
||||
232
src/pages/MonitoringPage/MonitoringPageComponents.tsx
Normal file
232
src/pages/MonitoringPage/MonitoringPageComponents.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import styles from './MonitoringPage.module.css';
|
||||
import { sendAPICall } from './MonitoringPageAPI';
|
||||
|
||||
// --- GESTURE COMPONENT ---
|
||||
export const GestureControls: React.FC = () => {
|
||||
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
|
||||
|
||||
const gestures = [
|
||||
{ label: "Wave", value: "animations/Stand/Gestures/Hey_1" },
|
||||
{ label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" },
|
||||
{ label: "Explain", value: "animations/Stand/Gestures/Explain_4" },
|
||||
{ label: "You", value: "animations/Stand/Gestures/You_1" },
|
||||
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
|
||||
{ label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" },
|
||||
{ label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" },
|
||||
{ label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" },
|
||||
{ label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" },
|
||||
{ label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" },
|
||||
];
|
||||
return (
|
||||
<div className={styles.gestures}>
|
||||
<h4>Gestures</h4>
|
||||
<div className={styles.gestureInputGroup}>
|
||||
<select
|
||||
value={selectedGesture}
|
||||
onChange={(e) => setSelectedGesture(e.target.value)}
|
||||
>
|
||||
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => sendAPICall("gesture", selectedGesture)}>
|
||||
Actuate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- PRESET SPEECH COMPONENT ---
|
||||
export const SpeechPresets: React.FC = () => {
|
||||
const phrases = [
|
||||
{ label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" },
|
||||
{ label: "Repeat please", text: "Could you repeat that please" },
|
||||
{ label: "About yourself", text: "Tell me something about yourself" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.speech}>
|
||||
<h4>Speech Presets</h4>
|
||||
<ul>
|
||||
{phrases.map((phrase, i) => (
|
||||
<li key={i}>
|
||||
<button
|
||||
className={styles.speechBtn}
|
||||
onClick={() => sendAPICall("speech", phrase.text)}
|
||||
>
|
||||
"{phrase.label}"
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- DIRECT SPEECH (INPUT) COMPONENT ---
|
||||
export const DirectSpeechInput: React.FC = () => {
|
||||
const [text, setText] = useState("");
|
||||
|
||||
const handleSend = () => {
|
||||
if (!text.trim()) return;
|
||||
sendAPICall("speech", text);
|
||||
setText(""); // Clear after sending
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.directSpeech}>
|
||||
<h4>Direct Pepper Speech</h4>
|
||||
<div className={styles.speechInput}>
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Type message..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
/>
|
||||
<button onClick={handleSend}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- interface for goals/triggers/norms/conditional norms ---
|
||||
export type StatusItem = {
|
||||
id?: string | number;
|
||||
achieved?: boolean;
|
||||
description?: string;
|
||||
label?: string;
|
||||
norm?: string;
|
||||
name?: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
interface StatusListProps {
|
||||
title: string;
|
||||
items: StatusItem[];
|
||||
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
|
||||
activeIds: Record<string, boolean>;
|
||||
setActiveIds?: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
currentGoalIndex?: number;
|
||||
}
|
||||
|
||||
// --- STATUS LIST COMPONENT ---
|
||||
export const StatusList: React.FC<StatusListProps> = ({
|
||||
title,
|
||||
items,
|
||||
type,
|
||||
activeIds,
|
||||
setActiveIds,
|
||||
currentGoalIndex // Destructure this prop
|
||||
}) => {
|
||||
return (
|
||||
<section className={styles.phaseBox}>
|
||||
<h3>{title}</h3>
|
||||
<ul>
|
||||
{items.map((item, idx) => {
|
||||
if (item.id === undefined) return null;
|
||||
const isActive = !!activeIds[item.id];
|
||||
const showIndicator = type !== 'norm';
|
||||
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
|
||||
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
|
||||
|
||||
const indentation = (item.level || 0) * 20;
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (!canOverride) return;
|
||||
if (type === 'cond_norm' && isActive){
|
||||
{/* Unachieve conditional norm */}
|
||||
sendAPICall("override_unachieve", String(item.id));
|
||||
}
|
||||
else {
|
||||
if(type === 'goal')
|
||||
if(setActiveIds)
|
||||
{setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));}
|
||||
|
||||
sendAPICall("override", String(item.id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li key={item.id ?? idx}
|
||||
className={styles.statusItem}
|
||||
style={{ paddingLeft: `${indentation}px` }}
|
||||
>
|
||||
{showIndicator && (
|
||||
<span
|
||||
className={`${styles.statusIndicator} ${isActive ? styles.active : styles.inactive} ${canOverride ? styles.clickable : ''}`}
|
||||
onClick={handleOverrideClick}
|
||||
>
|
||||
{isActive ? "✔️" : "❌"}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={styles.itemDescription}
|
||||
style={{
|
||||
// Visual Feedback
|
||||
textDecoration: isCurrentGoal ? 'underline' : 'none',
|
||||
fontWeight: isCurrentGoal ? 'bold' : 'normal',
|
||||
color: isCurrentGoal ? '#007bff' : 'inherit',
|
||||
backgroundColor: isCurrentGoal ? '#e7f3ff' : 'transparent', // Added subtle highlight
|
||||
padding: isCurrentGoal ? '2px 4px' : '0',
|
||||
borderRadius: '4px'
|
||||
}}
|
||||
>
|
||||
{item.name || item.norm}
|
||||
{isCurrentGoal && " (Current)"}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
// --- Robot Connected ---
|
||||
export const RobotConnected = () => {
|
||||
|
||||
/**
|
||||
* The current connection state:
|
||||
* - `true`: Robot is connected.
|
||||
* - `false`: Robot is not connected.
|
||||
* - `null`: Connection status is unknown (initial check in progress).
|
||||
*/
|
||||
const [connected, setConnected] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Open a Server-Sent Events (SSE) connection to receive live ping updates.
|
||||
// We're expecting a stream of data like that looks like this: `data = False` or `data = True`
|
||||
const eventSource = new EventSource("http://localhost:8000/robot/ping_stream");
|
||||
eventSource.onmessage = (event) => {
|
||||
|
||||
// Expecting messages in JSON format: `true` or `false`
|
||||
//commented out this log as it clutters console logs, but might be useful to debug
|
||||
//console.log("received message:", event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
try {
|
||||
setConnected(data)
|
||||
}
|
||||
catch {
|
||||
console.log("couldnt extract connected from incoming ping data")
|
||||
}
|
||||
|
||||
} catch {
|
||||
console.log("Ping message not in correct format:", event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up the SSE connection when the component unmounts.
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Connection:</h3>
|
||||
<p className={connected ? styles.connected : styles.disconnected }>{connected ? "● Robot is connected" : "● Robot is disconnected"}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||
<div className={`${styles.innerEditorContainer} round-lg border-lg flex-row`} style={({'--flow-zoom': zoom} as CSSProperties)}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
|
||||
nodeTypes={NodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodesDelete={onNodesDelete}
|
||||
onEdgesDelete={onEdgesDelete}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onReconnect={onReconnect}
|
||||
@@ -108,9 +134,11 @@ const VisProgUI = () => {
|
||||
onNodeDragStop={endBatchAction}
|
||||
preventScrolling={scrollable}
|
||||
onMove={(_, viewport) => setZoom(viewport.zoom)}
|
||||
reconnectRadius={15}
|
||||
snapToGrid
|
||||
fitView
|
||||
proOptions={{hideAttribution: true}}
|
||||
style={{flexGrow: 3}}
|
||||
>
|
||||
<Panel position="top-center" className={styles.dndPanel}>
|
||||
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
|
||||
@@ -122,9 +150,13 @@ const VisProgUI = () => {
|
||||
<button onClick={() => undo()}>Undo</button>
|
||||
<button onClick={() => redo()}>Redo</button>
|
||||
</Panel>
|
||||
<Panel position="center-right" className={warningStyles.warningsSidebar}>
|
||||
<WarningsSidebar/>
|
||||
</Panel>
|
||||
<Controls/>
|
||||
<Background/>
|
||||
</ReactFlow>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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!));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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<boolean>(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 (
|
||||
<div>
|
||||
<button className={styles.backButton} onClick={() => setShowSimpleProgram(false)}>
|
||||
Back to Editor ◀
|
||||
</button>
|
||||
<MonitoringPage/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<VisualProgrammingUI/>
|
||||
<button onClick={runProgram}>Run Program</button>
|
||||
<button onClick={processProgram} disabled={!programValidity}>Run Program</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/pages/VisProgPage/VisProgLogic.ts
Normal file
43
src/pages/VisProgPage/VisProgLogic.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import useProgramStore from "../../utils/programStore";
|
||||
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores';
|
||||
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
|
||||
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
export function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Outputs the prepared program to the console and sends it to the backend
|
||||
*/
|
||||
export function runProgramm() {
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
fetch(
|
||||
"http://localhost:8000/program",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(program),
|
||||
}
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
|
||||
// store reduced program in global program store for further use in the UI
|
||||
// when the program was sent to the backend successfully:
|
||||
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
console.log(program);
|
||||
}
|
||||
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
43
src/pages/VisProgPage/VisProgLogic.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import useProgramStore from "../../utils/programStore";
|
||||
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
|
||||
import useFlowStore from './visualProgrammingUI/VisProgStores';
|
||||
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
|
||||
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
|
||||
|
||||
/**
|
||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||
*/
|
||||
export function graphReducer() {
|
||||
const { nodes } = useFlowStore.getState();
|
||||
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||
.map((n) => {
|
||||
const reducer = NodeReduces['phase'];
|
||||
return reducer(n, nodes)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Outputs the prepared program to the console and sends it to the backend
|
||||
*/
|
||||
export function runProgram() {
|
||||
const phases = graphReducer();
|
||||
const program = {phases}
|
||||
console.log(JSON.stringify(program, null, 2));
|
||||
fetch(
|
||||
"http://localhost:8000/program",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(program),
|
||||
}
|
||||
).then((res) => {
|
||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||
console.log("Successfully sent the program to the backend.");
|
||||
|
||||
// store reduced program in global program store for further use in the UI
|
||||
// when the program was sent to the backend successfully:
|
||||
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||
}).catch(() => console.log("Failed to send program to the backend."));
|
||||
console.log(program);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -50,13 +51,12 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
|
||||
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<FlowState>(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<FlowState>(UndoRedo((set, get) => ({
|
||||
* Deletes a node by ID, respecting NodeDeletes rules.
|
||||
* Also removes all edges connected to that node.
|
||||
*/
|
||||
deleteNode: (nodeId) => {
|
||||
deleteNode: (nodeId, deleteElements) => {
|
||||
get().pushSnapshot();
|
||||
|
||||
// Let's find our node to check if they have a special deletion function
|
||||
const ourNode = get().nodes.find((n)=>n.id==nodeId);
|
||||
const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1]
|
||||
|
||||
|
||||
|
||||
// If there's no function, OR, our function tells us we can delete it, let's do so...
|
||||
if (ourFunction == undefined || ourFunction()) {
|
||||
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<FlowState>(UndoRedo((set, get) => ({
|
||||
})
|
||||
return { ruleRegistry: registry };
|
||||
})
|
||||
}
|
||||
},
|
||||
...editorWarningRegistry(get, set),
|
||||
}))
|
||||
);
|
||||
|
||||
|
||||
|
||||
export default useFlowStore;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -130,3 +134,6 @@ export type HandleRuleRegistry = {
|
||||
// cleans up all registered rules of all handles of the provided node
|
||||
unregisterNodeRules: (nodeId: string) => void
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/* contains all logic for the VisProgEditor warning system
|
||||
*
|
||||
* Missing but desirable features:
|
||||
* - Warning filtering:
|
||||
* - if there is no completely connected chain of startNode-[PhaseNodes]-EndNode
|
||||
* then hide any startNode, phaseNode, or endNode specific warnings
|
||||
*/
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import type {FlowState} from "../VisProgTypes.tsx";
|
||||
|
||||
// --| Type definitions |--
|
||||
|
||||
export type WarningId = NodeId | "GLOBAL_WARNINGS";
|
||||
export type NodeId = string;
|
||||
|
||||
|
||||
export type WarningType =
|
||||
| 'MISSING_INPUT'
|
||||
| 'MISSING_OUTPUT'
|
||||
| 'PLAN_IS_UNDEFINED'
|
||||
| 'INCOMPLETE_PROGRAM'
|
||||
| 'NOT_CONNECTED_TO_PROGRAM'
|
||||
| string
|
||||
|
||||
export type WarningSeverity =
|
||||
| 'INFO' // Acceptable, but important to be aware of
|
||||
| 'WARNING' // Acceptable, but probably undesirable behavior
|
||||
| 'ERROR' // Prevents running program, should be fixed before running program is allowed
|
||||
|
||||
/**
|
||||
* warning scope, include a handleId if the warning is handle specific
|
||||
*/
|
||||
export type WarningScope = {
|
||||
id: string;
|
||||
handleId?: string;
|
||||
}
|
||||
|
||||
export type EditorWarning = {
|
||||
scope: WarningScope;
|
||||
type: WarningType;
|
||||
severity: WarningSeverity;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* a scoped WarningKey,
|
||||
* the handleId scoping is only needed for handle specific errors
|
||||
*
|
||||
* "`WarningType`:`handleId`"
|
||||
*/
|
||||
export type WarningKey = string; // for warnings that can occur on a per-handle basis
|
||||
|
||||
/**
|
||||
* a composite key used in the severityIndex
|
||||
*
|
||||
* "`WarningId`|`WarningKey`"
|
||||
*/
|
||||
export type CompositeWarningKey = string;
|
||||
|
||||
export type WarningRegistry = Map<WarningId , Map<WarningKey, EditorWarning>>;
|
||||
export type SeverityIndex = Map<WarningSeverity, Set<CompositeWarningKey>>;
|
||||
|
||||
type ZustandSet = (partial: Partial<FlowState> | ((state: FlowState) => Partial<FlowState>)) => void;
|
||||
type ZustandGet = () => FlowState;
|
||||
|
||||
export type EditorWarningRegistry = {
|
||||
/**
|
||||
* stores all editor warnings
|
||||
*/
|
||||
editorWarningRegistry: WarningRegistry;
|
||||
/**
|
||||
* index of warnings by severity
|
||||
*/
|
||||
severityIndex: SeverityIndex;
|
||||
|
||||
/**
|
||||
* gets all warnings and returns them as a list of warnings
|
||||
* @returns {EditorWarning[]}
|
||||
*/
|
||||
getWarnings: () => EditorWarning[];
|
||||
|
||||
/**
|
||||
* gets all warnings with the current severity
|
||||
* @param {WarningSeverity} warningSeverity
|
||||
* @returns {EditorWarning[]}
|
||||
*/
|
||||
getWarningsBySeverity: (warningSeverity: WarningSeverity) => EditorWarning[];
|
||||
|
||||
/**
|
||||
* checks if there are no warnings of breaking severity
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isProgramValid: () => boolean;
|
||||
|
||||
/**
|
||||
* registers a warning to the warningRegistry and the SeverityIndex
|
||||
* @param {EditorWarning} warning
|
||||
*/
|
||||
registerWarning: (warning: EditorWarning) => void;
|
||||
|
||||
/**
|
||||
* unregisters a warning from the warningRegistry and the SeverityIndex
|
||||
* @param {EditorWarning} warning
|
||||
*/
|
||||
unregisterWarning: (id: WarningId, warningKey: WarningKey) => void
|
||||
|
||||
/**
|
||||
* unregisters warnings from the warningRegistry and the SeverityIndex
|
||||
* @param {WarningId} warning
|
||||
*/
|
||||
unregisterWarningsForId: (id: WarningId) => void;
|
||||
}
|
||||
|
||||
// --| implemented logic |--
|
||||
|
||||
/**
|
||||
* the id to use for global editor warnings
|
||||
* @type {string}
|
||||
*/
|
||||
export const globalWarning = "GLOBAL_WARNINGS";
|
||||
|
||||
export function editorWarningRegistry(get: ZustandGet, set: ZustandSet) : EditorWarningRegistry { return {
|
||||
editorWarningRegistry: new Map<NodeId, Map<WarningKey, EditorWarning>>(),
|
||||
severityIndex: new Map([
|
||||
['INFO', new Set<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]),
|
||||
|
||||
getWarningsBySeverity: (warningSeverity) => {
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
const warningKeys = sIndex.get(warningSeverity);
|
||||
const warnings: EditorWarning[] = [];
|
||||
|
||||
warningKeys?.forEach(
|
||||
(compositeKey) => {
|
||||
const [id, warningKey] = compositeKey.split('|');
|
||||
const warning = wRegistry.get(id)?.get(warningKey);
|
||||
|
||||
if (warning) {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return warnings;
|
||||
},
|
||||
|
||||
isProgramValid: () => {
|
||||
const sIndex = get().severityIndex;
|
||||
return (sIndex.get("ERROR")!.size === 0);
|
||||
},
|
||||
|
||||
getWarnings: () => Array.from(get().editorWarningRegistry.values())
|
||||
.flatMap(innerMap => Array.from(innerMap.values())),
|
||||
|
||||
|
||||
registerWarning: (warning) => {
|
||||
const { scope: {id, handleId}, type, severity } = warning;
|
||||
const warningKey = handleId ? `${type}:${handleId}` : type;
|
||||
const compositeKey = `${id}|${warningKey}`;
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
// add to warning registry
|
||||
if (!wRegistry.has(id)) {
|
||||
wRegistry.set(id, new Map());
|
||||
}
|
||||
wRegistry.get(id)!.set(warningKey, warning);
|
||||
|
||||
|
||||
// add to severityIndex
|
||||
if (!sIndex.get(severity)!.has(compositeKey)) {
|
||||
sIndex.get(severity)!.add(compositeKey);
|
||||
}
|
||||
|
||||
set({
|
||||
editorWarningRegistry: wRegistry,
|
||||
severityIndex: sIndex
|
||||
})
|
||||
},
|
||||
|
||||
unregisterWarning: (id, warningKey) => {
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
// verify if the warning was created already
|
||||
const warning = wRegistry.get(id)?.get(warningKey);
|
||||
if (!warning) return;
|
||||
|
||||
// remove from warning registry
|
||||
wRegistry.get(id)!.delete(warningKey);
|
||||
|
||||
|
||||
// remove from severityIndex
|
||||
sIndex.get(warning.severity)!.delete(`${id}|${warningKey}`);
|
||||
|
||||
set({
|
||||
editorWarningRegistry: wRegistry,
|
||||
severityIndex: sIndex
|
||||
})
|
||||
},
|
||||
|
||||
unregisterWarningsForId: (id) => {
|
||||
const wRegistry = new Map([...get().editorWarningRegistry].map(([k, v]) => [k, new Map(v)]));
|
||||
const sIndex = new Map(get().severityIndex);
|
||||
|
||||
const nodeWarnings = wRegistry.get(id);
|
||||
|
||||
// remove from severity index
|
||||
if (nodeWarnings) {
|
||||
nodeWarnings.forEach((warning) => {
|
||||
const warningKey = warning.scope.handleId
|
||||
? `${warning.type}:${warning.scope.handleId}`
|
||||
: warning.type;
|
||||
sIndex.get(warning.severity)?.delete(`${id}|${warningKey}`);
|
||||
});
|
||||
}
|
||||
|
||||
// remove from warning registry
|
||||
wRegistry.delete(id);
|
||||
|
||||
set({
|
||||
editorWarningRegistry: wRegistry,
|
||||
severityIndex: sIndex
|
||||
})
|
||||
},
|
||||
}}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* returns a summary of the warningRegistry
|
||||
* @returns {{info: number, warning: number, error: number, isValid: boolean}}
|
||||
*/
|
||||
export function warningSummary() {
|
||||
const {severityIndex, isProgramValid} = useFlowStore.getState();
|
||||
return {
|
||||
info: severityIndex.get('INFO')!.size,
|
||||
warning: severityIndex.get('WARNING')!.size,
|
||||
error: severityIndex.get('ERROR')!.size,
|
||||
isValid: isProgramValid(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<Handle
|
||||
{...otherProps}
|
||||
id={id}
|
||||
type={type}
|
||||
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
|
||||
className={"multiConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||
isValidConnection={(connection) => {
|
||||
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 (
|
||||
<Handle
|
||||
{...otherProps}
|
||||
id={id}
|
||||
type={type}
|
||||
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected")}
|
||||
className={"singleConnectionHandle" + (connections.length === 0 ? " unconnected" : " connected") + ` ${type}`}
|
||||
isConnectable={connections.length === 0}
|
||||
isValidConnection={(connection) => {
|
||||
const result = validate(connection as Connection);
|
||||
setHandleState(result);
|
||||
return result.isSatisfied;
|
||||
}}
|
||||
title={handleState.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import {useReactFlow, useStoreApi} from "@xyflow/react";
|
||||
import clsx from "clsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import useFlowStore from "../VisProgStores.tsx";
|
||||
import {
|
||||
warningSummary,
|
||||
type WarningSeverity,
|
||||
type EditorWarning, globalWarning
|
||||
} from "./EditorWarnings.tsx";
|
||||
import styles from "./WarningSidebar.module.css";
|
||||
|
||||
/**
|
||||
* the warning sidebar, shows all warnings
|
||||
*
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export function WarningsSidebar() {
|
||||
const warnings = useFlowStore.getState().getWarnings();
|
||||
const [hide, setHide] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<WarningSeverity | 'ALL'>('ALL');
|
||||
const [autoHide, setAutoHide] = useState(false);
|
||||
|
||||
// let autohide change hide status only when autohide is toggled
|
||||
// and allow for user to change the hide state even if autohide is enabled
|
||||
const hasWarnings = warnings.length > 0;
|
||||
useEffect(() => {
|
||||
if (autoHide) {
|
||||
setHide(!hasWarnings);
|
||||
}
|
||||
}, [autoHide, hasWarnings]);
|
||||
|
||||
const filtered = severityFilter === 'ALL'
|
||||
? warnings
|
||||
: warnings.filter(w => w.severity === severityFilter);
|
||||
|
||||
|
||||
const summary = warningSummary();
|
||||
// Finds the first key where the count > 0
|
||||
const getHighestSeverity = () => {
|
||||
if (summary.error > 0) return styles.error;
|
||||
if (summary.warning > 0) return styles.warning;
|
||||
if (summary.info > 0) return styles.info;
|
||||
return '';
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`flex-row`} >
|
||||
<div
|
||||
className={`${styles.warningsToggleBar} ${getHighestSeverity()}`}
|
||||
onClick={() => setHide(!hide)}
|
||||
title={"toggle warnings"}
|
||||
>
|
||||
<div className={`${hide ? styles.arrowRight : styles.arrowLeft}`}></div>
|
||||
</div>
|
||||
<div
|
||||
id="warningSidebar"
|
||||
className={styles.warningsContent}
|
||||
style={hide ? {display: "none"} : {display: "flex"}}
|
||||
>
|
||||
<WarningsHeader
|
||||
severityFilter={severityFilter}
|
||||
onChange={setSeverityFilter}
|
||||
/>
|
||||
|
||||
<WarningsList warnings={filtered} />
|
||||
<div className={styles.autoHide}>
|
||||
<input
|
||||
id="autoHideSwitch"
|
||||
type={"checkbox"}
|
||||
checked={autoHide}
|
||||
onChange={(e) => setAutoHide(e.target.checked)}
|
||||
/><label>Hide if there are no warnings</label>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* the header of the warning sidebar, contains severity filtering buttons
|
||||
*
|
||||
* @param {WarningSeverity | "ALL"} severityFilter
|
||||
* @param {(severity: (WarningSeverity | "ALL")) => void} onChange
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function WarningsHeader({
|
||||
severityFilter,
|
||||
onChange,
|
||||
}: {
|
||||
severityFilter: WarningSeverity | 'ALL';
|
||||
onChange: (severity: WarningSeverity | 'ALL') => void;
|
||||
}) {
|
||||
const summary = warningSummary();
|
||||
|
||||
return (
|
||||
<div className={styles.warningsHeader}>
|
||||
<h3>Warnings</h3>
|
||||
<div className={styles.severityTabs}>
|
||||
{(['ALL', 'ERROR', 'WARNING', 'INFO'] as const).map(severity => (
|
||||
<button
|
||||
key={severity}
|
||||
className={clsx(styles.severityTab, severityFilter === severity && styles.active)}
|
||||
onClick={() => onChange(severity)}
|
||||
>
|
||||
{severity}
|
||||
{severity !== 'ALL' && (
|
||||
<span className={styles.count}>
|
||||
{summary[severity.toLowerCase() as keyof typeof summary]}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* the list of warnings in the warning sidebar
|
||||
*
|
||||
* @param {{warnings: EditorWarning[]}} props
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function WarningsList(props: { warnings: EditorWarning[] }) {
|
||||
const splitWarnings = {
|
||||
global: props.warnings.filter(w => w.scope.id === globalWarning),
|
||||
other: props.warnings.filter(w => w.scope.id !== globalWarning),
|
||||
}
|
||||
if (props.warnings.length === 0) {
|
||||
return (
|
||||
<div className={styles.warningsEmpty}>
|
||||
No warnings!
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={styles.warningsList}>
|
||||
<div className={styles.warningGroupHeader}>global:</div>
|
||||
<div className={styles.warningsGroup}>
|
||||
{splitWarnings.global.map((warning) => (
|
||||
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||
? `:${warning.scope.handleId}`
|
||||
: "")}
|
||||
/>
|
||||
))}
|
||||
{splitWarnings.global.length === 0 && "No global warnings!"}
|
||||
</div>
|
||||
<div className={styles.warningGroupHeader}>other:</div>
|
||||
<div className={styles.warningsGroup}>
|
||||
{splitWarnings.other.map((warning) => (
|
||||
<WarningListItem warning={warning} key={`${warning.scope.id}|${warning.type}` + (warning.scope.handleId
|
||||
? `:${warning.scope.handleId}`
|
||||
: "")}
|
||||
/>
|
||||
))}
|
||||
{splitWarnings.other.length === 0 && "No other warnings!"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* a single warning in the warning sidebar
|
||||
*
|
||||
* @param {{warning: EditorWarning, key: string}} props
|
||||
* @returns {React.JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function WarningListItem(props: { warning: EditorWarning, key: string}) {
|
||||
const jumpToNode = useJumpToNode();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.warningItem, styles[`warning-item--${props.warning.severity.toLowerCase()}`],)}
|
||||
onClick={() => jumpToNode(props.warning.scope.id)}
|
||||
>
|
||||
<div className={styles.itemHeader}>
|
||||
<span className={styles.type}>{props.warning.type}</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.description}>
|
||||
{props.warning.description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* moves the editor to the provided node
|
||||
* @returns {(nodeId: string) => void}
|
||||
*/
|
||||
function useJumpToNode() {
|
||||
const { getNode, setCenter, getViewport } = useReactFlow();
|
||||
const { addSelectedNodes } = useStoreApi().getState();
|
||||
|
||||
|
||||
return (nodeId: string) => {
|
||||
// user can't jump to global warning, so prevent further logic from running if the warning is a globalWarning
|
||||
if (nodeId === globalWarning) return;
|
||||
const node = getNode(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const nodeElement = document.querySelector(`.react-flow__node[data-id="${nodeId}"]`) as HTMLElement;
|
||||
const { position } = node;
|
||||
const viewport = getViewport();
|
||||
const { width, height } = nodeElement.getBoundingClientRect();
|
||||
|
||||
//move to node
|
||||
setCenter(
|
||||
position!.x + ((width / viewport.zoom) / 2),
|
||||
position!.y + ((height / viewport.zoom) / 2),
|
||||
{duration: 300, interpolate: "smooth" }
|
||||
).then(() => {
|
||||
addSelectedNodes([nodeId]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
@@ -113,9 +113,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||
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<BasicBeliefNode>) {
|
||||
)}
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
noMatchingLeftRightBelief,
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
|
||||
]}/>
|
||||
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)"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<EndNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function EndNode(props: NodeProps<EndNode>) {
|
||||
const {registerWarning, unregisterWarning} = useFlowStore.getState();
|
||||
const connections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleId: 'target'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'target'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: "ERROR",
|
||||
description: "the endNode does not have an incoming connection from a phaseNode"
|
||||
}
|
||||
|
||||
if (connections.length === 0) { registerWarning(noConnectionWarning); }
|
||||
else { unregisterWarning(props.id, `${noConnectionWarning.type}:target`); }
|
||||
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
@@ -36,7 +60,7 @@ export default function EndNode(props: NodeProps<EndNode>) {
|
||||
</div>
|
||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||
allowOnlyConnectionsFromType(["phase"])
|
||||
]}/>
|
||||
]} title="Connect to a phaseNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<GoalNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||
const _nodes = useFlowStore().nodes;
|
||||
|
||||
const text_input_id = `goal_${id}_text_input`;
|
||||
@@ -64,6 +66,24 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
updateNodeData(id, {...data, can_fail: value});
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const noPlanWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: id,
|
||||
handleId: undefined
|
||||
},
|
||||
type: 'PLAN_IS_UNDEFINED',
|
||||
severity: 'ERROR',
|
||||
description: "This goalNode is missing a plan, please make sure to create a plan by using the create plan button"
|
||||
};
|
||||
|
||||
if (!data.plan){
|
||||
registerWarning(noPlanWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(id, noPlanWarning.type);
|
||||
},[data.plan, id, registerWarning, unregisterWarning])
|
||||
return <>
|
||||
<Toolbar nodeId={id} allowDelete={true}/>
|
||||
<div className={`${styles.defaultNode} ${styles.nodeGoal} flex-col gap-sm`}>
|
||||
@@ -118,9 +138,11 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
||||
</div>
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="GoalSource" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||
]}/>
|
||||
]} title="Connect to any number of phase and/or goalNode(-s)"/>
|
||||
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[allowOnlyConnectionsFromType(["goal"])]}/>
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="GoalTarget" rules={[
|
||||
allowOnlyConnectionsFromType(["goal"])]
|
||||
} title="Connect to any number of goalNode(-s)"/>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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<InferredBeliefNode>) {
|
||||
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<InferredBeliefNode>)
|
||||
});
|
||||
}
|
||||
|
||||
const beliefConnections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noBeliefsWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: undefined
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: `This AND/OR node is missing one or more beliefs,
|
||||
please make sure to use both inputs of an AND/OR node`
|
||||
};
|
||||
|
||||
if (beliefConnections.length < 2){
|
||||
registerWarning(noBeliefsWarning);
|
||||
return;
|
||||
}
|
||||
unregisterWarning(props.id, noBeliefsWarning.type);
|
||||
},[beliefConnections.length, props.id, registerWarning, unregisterWarning])
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
|
||||
@@ -79,10 +79,10 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
||||
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="norms" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
||||
]}/>
|
||||
]} title="Connect to any number of phaseNode(-s)"/>
|
||||
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
|
||||
]}/>
|
||||
]} title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"/>
|
||||
</div>
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -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<PhaseNodeData>
|
||||
*/
|
||||
export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
const data = props.data;
|
||||
const {updateNodeData} = useFlowStore();
|
||||
const {updateNodeData, registerWarning, unregisterWarning} = useFlowStore();
|
||||
const updateLabel = (value: string) => updateNodeData(props.id, {...data, label: value});
|
||||
const label_input_id = `phase_${props.id}_label_input`;
|
||||
|
||||
const connections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
handleId: 'data'
|
||||
})
|
||||
|
||||
const phaseOutCons = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "source",
|
||||
handleId: 'source',
|
||||
})
|
||||
|
||||
const phaseInCons = useNodeConnections({
|
||||
id: props.id,
|
||||
handleType: "target",
|
||||
handleId: 'target',
|
||||
})
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const noConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'data'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: "WARNING",
|
||||
description: "the phaseNode has no incoming goals, norms, and/or triggers"
|
||||
}
|
||||
|
||||
if (connections.length === 0) { registerWarning(noConnectionWarning); return; }
|
||||
unregisterWarning(props.id, `${noConnectionWarning.type}:data`);
|
||||
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||
|
||||
useEffect(() => {
|
||||
const notConnectedInfo : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: undefined,
|
||||
},
|
||||
type: 'NOT_CONNECTED_TO_PROGRAM',
|
||||
severity: "INFO",
|
||||
description: "The PhaseNode is not connected to other nodes"
|
||||
};
|
||||
const noIncomingPhaseWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'target'
|
||||
},
|
||||
type: 'MISSING_INPUT',
|
||||
severity: "WARNING",
|
||||
description: "the phaseNode has no incoming connection from a phase or the startNode"
|
||||
}
|
||||
const noOutgoingPhaseWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'source'
|
||||
},
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: "WARNING",
|
||||
description: "the phaseNode has no outgoing connection to a phase or the endNode"
|
||||
}
|
||||
|
||||
// register relevant warning and unregister others
|
||||
if (phaseInCons.length === 0 && phaseOutCons.length === 0) {
|
||||
registerWarning(notConnectedInfo);
|
||||
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||
return;
|
||||
}
|
||||
if (phaseOutCons.length === 0) {
|
||||
registerWarning(noOutgoingPhaseWarning);
|
||||
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||
return;
|
||||
}
|
||||
if (phaseInCons.length === 0) {
|
||||
registerWarning(noIncomingPhaseWarning);
|
||||
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||
return;
|
||||
}
|
||||
// unregister all warnings if none should be present
|
||||
unregisterWarning(notConnectedInfo.scope.id, notConnectedInfo.type);
|
||||
unregisterWarning(props.id, `${noOutgoingPhaseWarning.type}:${noOutgoingPhaseWarning.scope.handleId}`);
|
||||
unregisterWarning(props.id, `${noIncomingPhaseWarning.type}:${noIncomingPhaseWarning.scope.handleId}`);
|
||||
}, [phaseInCons.length, phaseOutCons.length, props.id, registerWarning, unregisterWarning]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const { width, height } = ref.current.getBoundingClientRect();
|
||||
|
||||
console.log('Node width:', width, 'height:', height);
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
@@ -57,14 +156,14 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||
noSelfConnections,
|
||||
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||
]}/>
|
||||
]} title="Connect to a phase or the startNode"/>
|
||||
<MultiConnectionHandle type="target" position={Position.Bottom} id="data" rules={[
|
||||
allowOnlyConnectionsFromType(["norm", "goal", "trigger"])
|
||||
]}/>
|
||||
]} title="Connect to any number of norm, goal, and TriggerNode(-s)"/>
|
||||
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
noSelfConnections,
|
||||
allowOnlyConnectionsFromType(["phase", "end"]),
|
||||
]}/>
|
||||
]} title="Connect to a phase or the endNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<StartNodeData>
|
||||
* @returns React.JSX.Element
|
||||
*/
|
||||
export default function StartNode(props: NodeProps<StartNode>) {
|
||||
const {registerWarning, unregisterWarning} = useFlowStore.getState();
|
||||
const connections = useNodeConnections({
|
||||
id: props.id,
|
||||
handleId: 'source'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const noConnectionWarning : EditorWarning = {
|
||||
scope: {
|
||||
id: props.id,
|
||||
handleId: 'source'
|
||||
},
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: "ERROR",
|
||||
description: "the startNode does not have an outgoing connection to a phaseNode"
|
||||
}
|
||||
|
||||
if (connections.length === 0) { registerWarning(noConnectionWarning); }
|
||||
else { unregisterWarning(props.id, `${noConnectionWarning.type}:source`); }
|
||||
}, [connections.length, props.id, registerWarning, unregisterWarning]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar nodeId={props.id} allowDelete={false}/>
|
||||
@@ -34,7 +58,7 @@ export default function StartNode(props: NodeProps<StartNode>) {
|
||||
</div>
|
||||
<SingleConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"target"}])
|
||||
]}/>
|
||||
]} title="Connect to a phaseNode"/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<TriggerNodeData>
|
||||
*/
|
||||
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
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 <>
|
||||
|
||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||
@@ -65,15 +132,16 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
||||
<MultiConnectionHandle type="source" position={Position.Right} id="TriggerSource" rules={[
|
||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]),
|
||||
]}/>
|
||||
]} title="Connect to any number of phaseNodes"/>
|
||||
<SingleConnectionHandle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id="TriggerBeliefs"
|
||||
style={{ left: '40%' }}
|
||||
rules={[
|
||||
allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]),
|
||||
allowOnlyConnectionsFromType(['basic_belief','inferred_belief']),
|
||||
]}
|
||||
title="Connect to a beliefNode or a set of beliefs combined using the AND/OR node"
|
||||
/>
|
||||
|
||||
<MultiConnectionHandle
|
||||
@@ -84,6 +152,7 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||
rules={[
|
||||
allowOnlyConnectionsFromType(['goal']),
|
||||
]}
|
||||
title="Connect to any number of goalNodes"
|
||||
/>
|
||||
|
||||
<PlanEditorDialog
|
||||
|
||||
@@ -3,6 +3,8 @@ import {create} from "zustand";
|
||||
// the type of a reduced program
|
||||
export type ReducedProgram = { phases: Record<string, unknown>[] };
|
||||
|
||||
export type GoalWithDepth = Record<string, unknown> & { level: number };
|
||||
|
||||
/**
|
||||
* the type definition of the programStore
|
||||
*/
|
||||
@@ -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<string, unknown>[];
|
||||
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[];
|
||||
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||
// if more specific utility functions are needed they can be added here:
|
||||
}
|
||||
@@ -43,6 +47,10 @@ const useProgramStore = create<ProgramState>((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<ProgramState>((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<string, unknown>[];
|
||||
const flatList: GoalWithDepth[] = [];
|
||||
|
||||
const isGoal = (item: Record<string, unknown>) => {
|
||||
return item["plan"] !== undefined;
|
||||
};
|
||||
|
||||
// Recursive helper function
|
||||
const traverse = (goals: Record<string, unknown>[], depth: number) => {
|
||||
goals.forEach((goal) => {
|
||||
// 1. Add the current goal to the list
|
||||
flatList.push({ ...goal, level: depth });
|
||||
|
||||
// 2. Check for children
|
||||
const plan = goal["plan"] as Record<string, unknown> | undefined;
|
||||
|
||||
if (plan && Array.isArray(plan["steps"])) {
|
||||
const steps = plan["steps"] as Record<string, unknown>[];
|
||||
|
||||
// 3. FILTER: Only recurse on steps that are actually goals
|
||||
// If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list
|
||||
const childGoals = steps.filter(isGoal);
|
||||
|
||||
if (childGoals.length > 0) {
|
||||
traverse(childGoals, depth + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Start traversal
|
||||
traverse(rootGoals, 0);
|
||||
|
||||
return flatList;
|
||||
},
|
||||
/**
|
||||
* gets the triggers for the provided phase
|
||||
*/
|
||||
|
||||
299
test/pages/monitoringPage/MonitoringPage.test.tsx
Normal file
299
test/pages/monitoringPage/MonitoringPage.test.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage';
|
||||
import useProgramStore from '../../../src/utils/programStore';
|
||||
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
||||
import * as VisProg from '../../../src/pages/VisProgPage/VisProgLogic';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
// Mock the Zustand store
|
||||
jest.mock('../../../src/utils/programStore', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the API layer including hooks
|
||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
|
||||
nextPhase: jest.fn(),
|
||||
resetPhase: jest.fn(),
|
||||
pauseExperiment: jest.fn(),
|
||||
playExperiment: jest.fn(),
|
||||
// We mock these to capture the callbacks and trigger them manually in tests
|
||||
useExperimentLogger: jest.fn(),
|
||||
useStatusLogger: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock VisProg functionality
|
||||
jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({
|
||||
graphReducer: jest.fn(),
|
||||
runProgramm: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock Child Components to reduce noise (optional, but keeps unit test focused)
|
||||
// For this test, we will allow them to render to test data passing,
|
||||
// but we mock RobotConnected as it has its own side effects
|
||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageComponents', () => {
|
||||
const original = jest.requireActual('../../../src/pages/MonitoringPage/MonitoringPageComponents');
|
||||
return {
|
||||
...original,
|
||||
RobotConnected: () => <div data-testid="robot-connected-mock">Robot Status</div>,
|
||||
};
|
||||
});
|
||||
|
||||
describe('MonitoringPage', () => {
|
||||
// Capture stream callbacks
|
||||
let streamUpdateCallback: (data: any) => void;
|
||||
let statusUpdateCallback: (data: any) => void;
|
||||
|
||||
// Setup default store state
|
||||
const mockGetPhaseIds = jest.fn();
|
||||
const mockGetPhaseNames = jest.fn();
|
||||
const mockGetNorms = jest.fn();
|
||||
const mockGetGoals = jest.fn();
|
||||
const mockGetGoalsWithDepth = jest.fn();
|
||||
const mockGetTriggers = jest.fn();
|
||||
const mockSetProgramState = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default Store Implementation
|
||||
(useProgramStore as unknown as jest.Mock).mockImplementation((selector) => {
|
||||
const state = {
|
||||
getPhaseIds: mockGetPhaseIds,
|
||||
getPhaseNames: mockGetPhaseNames,
|
||||
getNormsInPhase: mockGetNorms,
|
||||
getGoalsInPhase: mockGetGoals,
|
||||
getTriggersInPhase: mockGetTriggers,
|
||||
getGoalsWithDepth: mockGetGoalsWithDepth,
|
||||
setProgramState: mockSetProgramState,
|
||||
};
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
// Capture the hook callbacks
|
||||
(MonitoringAPI.useExperimentLogger as jest.Mock).mockImplementation((cb) => {
|
||||
streamUpdateCallback = cb;
|
||||
});
|
||||
(MonitoringAPI.useStatusLogger as jest.Mock).mockImplementation((cb) => {
|
||||
statusUpdateCallback = cb;
|
||||
});
|
||||
|
||||
// Default mock return values
|
||||
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
|
||||
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
|
||||
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1'}, { id: 'g2', name: 'Goal 2'}]);
|
||||
mockGetGoalsWithDepth.mockReturnValue([
|
||||
{ id: 'g1', name: 'Goal 1', level: 0 },
|
||||
{ id: 'g2', name: 'Goal 2', level: 0 }
|
||||
]);
|
||||
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
|
||||
mockGetNorms.mockReturnValue([
|
||||
{ id: 'n1', norm: 'Norm 1', condition: null },
|
||||
{ id: 'cn1', norm: 'Cond Norm 1', condition: 'some-cond' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('renders "No program loaded" when phaseIds are empty', () => {
|
||||
mockGetPhaseIds.mockReturnValue([]);
|
||||
render(<MonitoringPage />);
|
||||
expect(screen.getByText('No program loaded.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the dashboard with initial state', () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
// Check Header
|
||||
expect(screen.getByText('Phase 1:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Intro')).toBeInTheDocument();
|
||||
|
||||
// Check Lists
|
||||
expect(screen.getByText(/Goal 1/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Norm 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cond Norm 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Control Buttons', () => {
|
||||
test('Pause calls API and updates UI', async () => {
|
||||
render(<MonitoringPage />);
|
||||
const pauseBtn = screen.getByText('❚❚');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(pauseBtn);
|
||||
});
|
||||
|
||||
expect(MonitoringAPI.pauseExperiment).toHaveBeenCalled();
|
||||
// Ensure local state toggled (we check if play button is now inactive style or pause active)
|
||||
});
|
||||
|
||||
test('Play calls API and updates UI', async () => {
|
||||
render(<MonitoringPage />);
|
||||
const playBtn = screen.getByText('▶');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(playBtn);
|
||||
});
|
||||
|
||||
expect(MonitoringAPI.playExperiment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Next Phase calls API', async () => {
|
||||
render(<MonitoringPage />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('⏭'));
|
||||
});
|
||||
expect(MonitoringAPI.nextPhase).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Reset Experiment calls logic and resets state', async () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
// Mock graph reducer return
|
||||
(VisProg.graphReducer as jest.Mock).mockReturnValue([{ id: 'new-phase' }]);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('⟲'));
|
||||
});
|
||||
|
||||
expect(VisProg.graphReducer).toHaveBeenCalled();
|
||||
expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] });
|
||||
expect(VisProg.runProgramm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Reset Experiment handles errors gracefully', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
(VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail'));
|
||||
|
||||
render(<MonitoringPage />);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('⟲'));
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to reset program:', expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stream Updates (useExperimentLogger)', () => {
|
||||
test('Handles phase_update to next phase', () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
expect(screen.getByText('Intro')).toBeInTheDocument(); // Phase 0
|
||||
|
||||
act(() => {
|
||||
streamUpdateCallback({ type: 'phase_update', id: 'phase-2' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('Main')).toBeInTheDocument(); // Phase 1
|
||||
});
|
||||
|
||||
test('Handles phase_update to "end"', () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
act(() => {
|
||||
streamUpdateCallback({ type: 'phase_update', id: 'end' });
|
||||
});
|
||||
|
||||
expect(screen.getByText('Experiment finished')).toBeInTheDocument();
|
||||
expect(screen.getByText('All phases have been successfully completed.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Handles phase_update with unknown ID gracefully', () => {
|
||||
render(<MonitoringPage />);
|
||||
act(() => {
|
||||
streamUpdateCallback({ type: 'phase_update', id: 'unknown-phase' });
|
||||
});
|
||||
// Should remain on current phase
|
||||
expect(screen.getByText('Intro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Handles goal_update: advances index and marks previous as achieved', () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
// Initial: Goal 1 (index 0) is current.
|
||||
// Send update for Goal 2 (index 1).
|
||||
act(() => {
|
||||
streamUpdateCallback({ type: 'goal_update', id: 'g2' });
|
||||
});
|
||||
|
||||
// Goal 1 should now be marked achieved (passed via activeIds)
|
||||
// Goal 2 should be current.
|
||||
|
||||
// We can inspect the "StatusList" props implicitly by checking styling or indicators if not mocked,
|
||||
// but since we render the full component, we check the class/text.
|
||||
// Goal 1 should have checkmark (override logic puts checkmark for activeIds)
|
||||
// The implementation details of StatusList show ✔️ for activeIds.
|
||||
|
||||
const items = screen.getAllByRole('listitem');
|
||||
// Helper to find checkmarks within items
|
||||
expect(items[0]).toHaveTextContent('Goal 1');
|
||||
// After update, g1 is active (achieved), g2 is current
|
||||
// logic: loop i < gIndex (1). activeIds['g1'] = true.
|
||||
});
|
||||
|
||||
test('Handles goal_update with unknown ID', () => {
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
render(<MonitoringPage />);
|
||||
act(() => {
|
||||
streamUpdateCallback({ type: 'goal_update', id: 'unknown-goal' });
|
||||
});
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Goal unknown-goal not found'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Handles trigger_update', () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
// Trigger 1 initially not achieved
|
||||
act(() => {
|
||||
streamUpdateCallback({ type: 'trigger_update', id: 't1', achieved: true });
|
||||
});
|
||||
|
||||
// StatusList logic: if activeId is true, show ✔️
|
||||
// We look for visual confirmation or check logic
|
||||
const triggerList = screen.getByText('Triggers').parentElement;
|
||||
expect(triggerList).toHaveTextContent('✔️'); // Assuming 't1' is the only trigger
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Updates (useStatusLogger)', () => {
|
||||
test('Handles cond_norms_state_update', () => {
|
||||
render(<MonitoringPage />);
|
||||
|
||||
// Initial state: activeIds empty.
|
||||
act(() => {
|
||||
statusUpdateCallback({
|
||||
type: 'cond_norms_state_update',
|
||||
norms: [{ id: 'cn1', active: true }]
|
||||
});
|
||||
});
|
||||
|
||||
// Conditional Norm 1 should now be active
|
||||
const cnList = screen.getByText('Conditional Norms').parentElement;
|
||||
expect(cnList).toHaveTextContent('✔️');
|
||||
});
|
||||
|
||||
test('Ignores status update if no changes detected', () => {
|
||||
render(<MonitoringPage />);
|
||||
// First update
|
||||
act(() => {
|
||||
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
|
||||
});
|
||||
|
||||
// Second identical update - strictly checking if this causes a rerender is hard in RTL,
|
||||
// but we ensure no errors and state remains consistent.
|
||||
act(() => {
|
||||
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
|
||||
});
|
||||
|
||||
const cnList = screen.getByText('Conditional Norms').parentElement;
|
||||
expect(cnList).toHaveTextContent('✔️');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
220
test/pages/monitoringPage/MonitoringPageAPI.test.ts
Normal file
220
test/pages/monitoringPage/MonitoringPageAPI.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { renderHook, act, cleanup } from '@testing-library/react';
|
||||
import {
|
||||
sendAPICall,
|
||||
nextPhase,
|
||||
pauseExperiment,
|
||||
playExperiment,
|
||||
useExperimentLogger,
|
||||
useStatusLogger
|
||||
} from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
||||
|
||||
// --- MOCK EVENT SOURCE SETUP ---
|
||||
// This mocks the browser's EventSource so we can manually 'push' messages to our hooks
|
||||
const mockInstances: MockEventSource[] = [];
|
||||
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null; // Added onerror support
|
||||
closed = false;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
mockInstances.push(this);
|
||||
}
|
||||
|
||||
sendMessage(data: string) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage({ data } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
triggerError(err: any) {
|
||||
if (this.onerror) {
|
||||
this.onerror(err);
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock global EventSource
|
||||
beforeAll(() => {
|
||||
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
|
||||
});
|
||||
|
||||
// Mock global fetch
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ reply: 'ok' }),
|
||||
})
|
||||
) as jest.Mock;
|
||||
});
|
||||
|
||||
// Cleanup after every test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.restoreAllMocks();
|
||||
mockInstances.length = 0;
|
||||
});
|
||||
|
||||
describe('MonitoringPageAPI', () => {
|
||||
|
||||
describe('sendAPICall', () => {
|
||||
test('sends correct POST request', async () => {
|
||||
await sendAPICall('test_type', 'test_ctx');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/button_pressed',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('appends endpoint if provided', async () => {
|
||||
await sendAPICall('t', 'c', '/extra');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/button_pressed/extra'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('logs error on fetch network failure', async () => {
|
||||
(globalThis.fetch as jest.Mock).mockRejectedValue('Network error');
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await sendAPICall('t', 'c');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error');
|
||||
});
|
||||
|
||||
test('throws error if response is not ok', async () => {
|
||||
(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false });
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await sendAPICall('t', 'c');
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
test('nextPhase sends correct params', async () => {
|
||||
await nextPhase();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) })
|
||||
);
|
||||
});
|
||||
|
||||
test('pauseExperiment sends correct params', async () => {
|
||||
await pauseExperiment();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) })
|
||||
);
|
||||
});
|
||||
|
||||
test('playExperiment sends correct params', async () => {
|
||||
await playExperiment();
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useExperimentLogger', () => {
|
||||
test('connects to SSE and receives messages', () => {
|
||||
const onUpdate = jest.fn();
|
||||
|
||||
// Hook must be rendered to start the effect
|
||||
renderHook(() => useExperimentLogger(onUpdate));
|
||||
|
||||
// Retrieve the mocked instance created by the hook
|
||||
const eventSource = mockInstances[0];
|
||||
expect(eventSource.url).toContain('/experiment_stream');
|
||||
|
||||
// Simulate incoming message
|
||||
act(() => {
|
||||
eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' });
|
||||
});
|
||||
|
||||
test('handles JSON parse errors in stream', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
renderHook(() => useExperimentLogger());
|
||||
const eventSource = mockInstances[0];
|
||||
|
||||
act(() => {
|
||||
eventSource.sendMessage('invalid-json');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error));
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('handles SSE connection error', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
renderHook(() => useExperimentLogger());
|
||||
const eventSource = mockInstances[0];
|
||||
|
||||
act(() => {
|
||||
eventSource.triggerError('Connection lost');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost');
|
||||
expect(eventSource.closed).toBe(true);
|
||||
});
|
||||
|
||||
test('closes EventSource on unmount', () => {
|
||||
const { unmount } = renderHook(() => useExperimentLogger());
|
||||
const eventSource = mockInstances[0];
|
||||
const closeSpy = jest.spyOn(eventSource, 'close');
|
||||
|
||||
unmount();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(eventSource.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStatusLogger', () => {
|
||||
test('connects to SSE and receives messages', () => {
|
||||
const onUpdate = jest.fn();
|
||||
renderHook(() => useStatusLogger(onUpdate));
|
||||
const eventSource = mockInstances[0];
|
||||
|
||||
expect(eventSource.url).toContain('/status_stream');
|
||||
|
||||
act(() => {
|
||||
eventSource.sendMessage(JSON.stringify({ some: 'data' }));
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith({ some: 'data' });
|
||||
});
|
||||
|
||||
test('handles JSON parse errors', () => {
|
||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
renderHook(() => useStatusLogger());
|
||||
const eventSource = mockInstances[0];
|
||||
|
||||
act(() => {
|
||||
eventSource.sendMessage('bad-data');
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error));
|
||||
});
|
||||
});
|
||||
});
|
||||
226
test/pages/monitoringPage/MonitoringPageComponents.test.tsx
Normal file
226
test/pages/monitoringPage/MonitoringPageComponents.test.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Corrected Imports
|
||||
import {
|
||||
GestureControls,
|
||||
SpeechPresets,
|
||||
DirectSpeechInput,
|
||||
StatusList,
|
||||
RobotConnected
|
||||
} from '../../../src/pages/MonitoringPage/MonitoringPageComponents';
|
||||
|
||||
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
||||
|
||||
// Mock the API Call function with the correct path
|
||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
|
||||
sendAPICall: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('MonitoringPageComponents', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GestureControls', () => {
|
||||
test('renders and sends gesture command', () => {
|
||||
render(<GestureControls />);
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: 'animations/Stand/Gestures/Hey_1' }
|
||||
});
|
||||
|
||||
// Click button
|
||||
fireEvent.click(screen.getByText('Actuate'));
|
||||
|
||||
// Expect the API to be called with that new value
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpeechPresets', () => {
|
||||
test('renders buttons and sends speech command', () => {
|
||||
render(<SpeechPresets />);
|
||||
|
||||
const btn = screen.getByText('"Hello, I\'m Pepper"');
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper");
|
||||
});
|
||||
});
|
||||
|
||||
describe('DirectSpeechInput', () => {
|
||||
test('inputs text and sends on button click', () => {
|
||||
render(<DirectSpeechInput />);
|
||||
const input = screen.getByPlaceholderText('Type message...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Custom text' } });
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text');
|
||||
expect(input).toHaveValue(''); // Should clear
|
||||
});
|
||||
|
||||
test('sends on Enter key', () => {
|
||||
render(<DirectSpeechInput />);
|
||||
const input = screen.getByPlaceholderText('Type message...');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'Enter text' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text');
|
||||
});
|
||||
|
||||
test('does not send empty text', () => {
|
||||
render(<DirectSpeechInput />);
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('StatusList', () => {
|
||||
const mockSet = jest.fn();
|
||||
const items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' }
|
||||
];
|
||||
|
||||
test('renders list items', () => {
|
||||
render(<StatusList title="Test List" items={items} type="goal" activeIds={{}} />);
|
||||
expect(screen.getByText('Test List')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Goals: click override on inactive item calls API', () => {
|
||||
render(
|
||||
<StatusList
|
||||
title="Goals"
|
||||
items={items}
|
||||
type="goal"
|
||||
activeIds={{}}
|
||||
setActiveIds={mockSet}
|
||||
/>
|
||||
);
|
||||
|
||||
// Click the X (inactive)
|
||||
const indicator = screen.getAllByText('❌')[0];
|
||||
fireEvent.click(indicator);
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1');
|
||||
expect(mockSet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Conditional Norms: click override on ACTIVE item unachieves', () => {
|
||||
render(
|
||||
<StatusList
|
||||
title="CN"
|
||||
items={items}
|
||||
type="cond_norm"
|
||||
activeIds={{ '1': true }}
|
||||
/>
|
||||
);
|
||||
|
||||
const indicator = screen.getByText('✔️'); // It is active
|
||||
fireEvent.click(indicator);
|
||||
|
||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1');
|
||||
});
|
||||
|
||||
test('Current Goal highlighting', () => {
|
||||
render(
|
||||
<StatusList
|
||||
title="Goals"
|
||||
items={items}
|
||||
type="goal"
|
||||
activeIds={{}}
|
||||
currentGoalIndex={0}
|
||||
/>
|
||||
);
|
||||
// Using regex to handle the "(Current)" text
|
||||
expect(screen.getByText(/Item 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/(Current)/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RobotConnected', () => {
|
||||
let mockEventSource: any;
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'EventSource', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
close: jest.fn(),
|
||||
onmessage: null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventSource = new window.EventSource('url');
|
||||
(window.EventSource as unknown as jest.Mock).mockClear();
|
||||
(window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource);
|
||||
});
|
||||
|
||||
test('displays disconnected initially', () => {
|
||||
render(<RobotConnected />);
|
||||
expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates to connected when SSE receives true', async () => {
|
||||
render(<RobotConnected />);
|
||||
|
||||
act(() => {
|
||||
if(mockEventSource.onmessage) {
|
||||
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
|
||||
}
|
||||
});
|
||||
|
||||
expect(await screen.findByText('● Robot is connected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles invalid JSON gracefully', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
render(<RobotConnected />);
|
||||
|
||||
act(() => {
|
||||
if(mockEventSource.onmessage) {
|
||||
mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// Should catch error and log it, state remains disconnected
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('logs error if state update fails (inner catch block)', async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
// 1. Force useState to return a setter that throws an error
|
||||
const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); });
|
||||
|
||||
// We use mockImplementation to return [currentState, throwingSetter]
|
||||
const useStateSpy = jest.spyOn(React, 'useState')
|
||||
.mockImplementation(() => [null, mockThrowingSetter]);
|
||||
|
||||
render(<RobotConnected />);
|
||||
|
||||
// 2. Trigger the event with VALID JSON ("true")
|
||||
// This passes the first JSON.parse try/catch,
|
||||
// but fails when calling setConnected(true) because of our mock.
|
||||
await act(async () => {
|
||||
if (mockEventSource.onmessage) {
|
||||
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Verify the specific error log from line 205
|
||||
expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data");
|
||||
|
||||
// Cleanup spies
|
||||
useStateSpy.mockRestore();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
83
test/pages/simpleProgram/SimpleProgram.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram";
|
||||
import useProgramStore from "../../../src/utils/programStore";
|
||||
|
||||
/**
|
||||
* Helper to preload the program store before rendering.
|
||||
*/
|
||||
function loadProgram(phases: Record<string, unknown>[]) {
|
||||
useProgramStore.getState().setProgramState({ phases });
|
||||
}
|
||||
|
||||
describe("SimpleProgram", () => {
|
||||
beforeEach(() => {
|
||||
loadProgram([]);
|
||||
});
|
||||
|
||||
test("shows empty state when no program is loaded", () => {
|
||||
render(<SimpleProgram />);
|
||||
expect(screen.getByText("No program loaded.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders first phase content", () => {
|
||||
loadProgram([
|
||||
{
|
||||
id: "phase-1",
|
||||
norms: [{ id: "n1", norm: "Be polite" }],
|
||||
goals: [{ id: "g1", description: "Finish task", achieved: true }],
|
||||
triggers: [{ id: "t1", label: "Keyword trigger" }],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
|
||||
expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Be polite")).toBeInTheDocument();
|
||||
expect(screen.getByText("Finish task")).toBeInTheDocument();
|
||||
expect(screen.getByText("Keyword trigger")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("allows navigating between phases", () => {
|
||||
loadProgram([
|
||||
{
|
||||
id: "phase-1",
|
||||
norms: [],
|
||||
goals: [],
|
||||
triggers: [],
|
||||
},
|
||||
{
|
||||
id: "phase-2",
|
||||
norms: [{ id: "n2", norm: "Be careful" }],
|
||||
goals: [],
|
||||
triggers: [],
|
||||
},
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
|
||||
expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("Next ▶"));
|
||||
|
||||
expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Be careful")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("prev button is disabled on first phase", () => {
|
||||
loadProgram([
|
||||
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
expect(screen.getByText("◀ Prev")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("next button is disabled on last phase", () => {
|
||||
loadProgram([
|
||||
{ id: "phase-1", norms: [], goals: [], triggers: [] },
|
||||
]);
|
||||
|
||||
render(<SimpleProgram />);
|
||||
expect(screen.getByText("Next ▶")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -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(); });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect} from '@jest/globals';
|
||||
import {
|
||||
type EditorWarning, warningSummary
|
||||
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
|
||||
|
||||
function makeWarning(
|
||||
overrides?: Partial<EditorWarning>
|
||||
): EditorWarning {
|
||||
return {
|
||||
scope: { id: 'node-1' },
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: 'Missing input',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("editorWarnings", () => {
|
||||
describe('registerWarning', () => {
|
||||
it('registers a node-level warning', () => {
|
||||
const warning = makeWarning();
|
||||
const {registerWarning, getWarnings} = useFlowStore.getState()
|
||||
registerWarning(warning);
|
||||
|
||||
const warnings = getWarnings();
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toEqual(warning);
|
||||
});
|
||||
|
||||
it('registers a handle-level warning with scoped key', () => {
|
||||
const warning = makeWarning({
|
||||
scope: { id: 'node-1', handleId: 'input-1' },
|
||||
});
|
||||
const {registerWarning} = useFlowStore.getState()
|
||||
registerWarning(warning);
|
||||
const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1');
|
||||
expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true);
|
||||
});
|
||||
|
||||
it('updates severityIndex correctly', () => {
|
||||
const {registerWarning, severityIndex} = useFlowStore.getState()
|
||||
registerWarning(makeWarning());
|
||||
expect(severityIndex.get('ERROR')!.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWarningsBySeverity', () => {
|
||||
it('returns only warnings of requested severity', () => {
|
||||
const {registerWarning, getWarningsBySeverity} = useFlowStore.getState()
|
||||
registerWarning(
|
||||
makeWarning({ severity: 'ERROR' })
|
||||
);
|
||||
|
||||
registerWarning(
|
||||
makeWarning({
|
||||
severity: 'WARNING',
|
||||
type: 'MISSING_OUTPUT',
|
||||
})
|
||||
);
|
||||
|
||||
const errors = getWarningsBySeverity('ERROR');
|
||||
const warnings = getWarningsBySeverity('WARNING');
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProgramValid', () => {
|
||||
it('returns true when no ERROR warnings exist', () => {
|
||||
expect(useFlowStore.getState().isProgramValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when ERROR warnings exist', () => {
|
||||
const {registerWarning, isProgramValid} = useFlowStore.getState()
|
||||
registerWarning(makeWarning());
|
||||
expect(isProgramValid()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterWarning', () => {
|
||||
it('removes warning from registry and severityIndex', () => {
|
||||
const warning = makeWarning();
|
||||
const {
|
||||
registerWarning,
|
||||
getWarnings,
|
||||
unregisterWarning,
|
||||
severityIndex
|
||||
} = useFlowStore.getState()
|
||||
|
||||
registerWarning(warning);
|
||||
|
||||
unregisterWarning('node-1', 'MISSING_INPUT');
|
||||
|
||||
expect(getWarnings()).toHaveLength(0);
|
||||
expect(severityIndex.get('ERROR')!.size).toBe(0);
|
||||
});
|
||||
|
||||
it('does nothing if warning does not exist', () => {
|
||||
expect(() =>
|
||||
useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST')
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unregisterWarningsForId', () => {
|
||||
it('removes all warnings for a node', () => {
|
||||
const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState()
|
||||
registerWarning(
|
||||
makeWarning({
|
||||
scope: { id: 'node-1', handleId: 'h1' },
|
||||
})
|
||||
);
|
||||
|
||||
registerWarning(
|
||||
makeWarning({
|
||||
scope: { id: 'node-1' },
|
||||
type: 'MISSING_OUTPUT',
|
||||
severity: 'WARNING',
|
||||
})
|
||||
);
|
||||
|
||||
unregisterWarningsForId('node-1');
|
||||
|
||||
expect(getWarnings()).toHaveLength(0);
|
||||
expect(
|
||||
severityIndex.get('ERROR')!.size
|
||||
).toBe(0);
|
||||
expect(
|
||||
severityIndex.get('WARNING')!.size
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('warningSummary', () => {
|
||||
it('returns correct counts and validity', () => {
|
||||
const {registerWarning} = useFlowStore.getState()
|
||||
registerWarning(
|
||||
makeWarning({ severity: 'ERROR' })
|
||||
);
|
||||
|
||||
const summary = warningSummary();
|
||||
|
||||
expect(summary.error).toBe(1);
|
||||
expect(summary.warning).toBe(0);
|
||||
expect(summary.info).toBe(0);
|
||||
expect(summary.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
|
||||
});
|
||||
|
||||
test("onLoad with invalid JSON does not update store", async () => {
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const file = new File(["not json"], "bad.json", { type: "application/json" });
|
||||
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
|
||||
|
||||
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
|
||||
|
||||
render(<SaveLoadPanel />);
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
|
||||
// Give some input
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.alert).toHaveBeenCalledTimes(1);
|
||||
|
||||
const nodesAfter = useFlowStore.getState().nodes;
|
||||
expect(nodesAfter).toHaveLength(0);
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
|
||||
// Clean up the spy
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import {fireEvent, render, screen} from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import {useReactFlow, useStoreApi} from "@xyflow/react";
|
||||
import {
|
||||
type EditorWarning,
|
||||
globalWarning
|
||||
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
|
||||
import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx";
|
||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
||||
|
||||
|
||||
jest.mock('@xyflow/react', () => ({
|
||||
useReactFlow: jest.fn(),
|
||||
useStoreApi: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx');
|
||||
|
||||
function makeWarning(
|
||||
overrides?: Partial<EditorWarning>
|
||||
): EditorWarning {
|
||||
return {
|
||||
scope: { id: 'node-1' },
|
||||
type: 'MISSING_INPUT',
|
||||
severity: 'ERROR',
|
||||
description: 'Missing input',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('WarningsSidebar', () => {
|
||||
let getStateSpy: jest.SpyInstance;
|
||||
|
||||
const setCenter = jest.fn(() => Promise.resolve());
|
||||
const getNode = jest.fn();
|
||||
const addSelectedNodes = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// React Flow hooks
|
||||
(useReactFlow as jest.Mock).mockReturnValue({
|
||||
getNode,
|
||||
setCenter,
|
||||
});
|
||||
(useStoreApi as jest.Mock).mockReturnValue({
|
||||
getState: () => ({ addSelectedNodes }),
|
||||
});
|
||||
|
||||
// Use spyOn to override store
|
||||
const mockWarnings = [
|
||||
makeWarning({ description: 'Node warning', scope: { id: 'node-1' } }),
|
||||
makeWarning({
|
||||
description: 'Global warning',
|
||||
scope: { id: globalWarning },
|
||||
type: 'INCOMPLETE_PROGRAM',
|
||||
severity: 'WARNING',
|
||||
}),
|
||||
makeWarning({
|
||||
description: 'Info warning',
|
||||
scope: { id: 'node-2' },
|
||||
severity: 'INFO',
|
||||
}),
|
||||
];
|
||||
|
||||
getStateSpy = jest
|
||||
.spyOn(useFlowStore, 'getState')
|
||||
.mockReturnValue({
|
||||
getWarnings: () => mockWarnings,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('renders warnings header', () => {
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('Warnings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all warning descriptions', () => {
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('Node warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Global warning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Info warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('splits global and other warnings correctly', () => {
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('global:')).toBeInTheDocument();
|
||||
expect(screen.getByText('other:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no warnings exist', () => {
|
||||
getStateSpy.mockReturnValueOnce({
|
||||
getWarnings: () => [],
|
||||
} as any);
|
||||
|
||||
render(<WarningsSidebar />);
|
||||
expect(screen.getByText('No warnings!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters by severity', () => {
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('ERROR'));
|
||||
|
||||
expect(screen.getByText('Node warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Info warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters INFO severity correctly', () => {
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('INFO'));
|
||||
|
||||
expect(screen.getByText('Info warning')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Node warning')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking global warning does NOT jump', () => {
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('Global warning'));
|
||||
|
||||
expect(setCenter).not.toHaveBeenCalled();
|
||||
expect(addSelectedNodes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing if node does not exist', () => {
|
||||
getNode.mockReturnValue(undefined);
|
||||
|
||||
render(<WarningsSidebar />);
|
||||
fireEvent.click(screen.getByText('Node warning'));
|
||||
|
||||
expect(setCenter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<BasicBeliefNodeData>;
|
||||
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<BasicBeliefNodeData> = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]) 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<CompositeWarningKey>()],
|
||||
['WARNING', new Set<CompositeWarningKey>()],
|
||||
['ERROR', new Set<CompositeWarningKey>()],
|
||||
]) as SeverityIndex,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -114,3 +114,113 @@ describe('useProgramStore', () => {
|
||||
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGoalsWithDepth', () => {
|
||||
const complexProgram: ReducedProgram = {
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-nested',
|
||||
goals: [
|
||||
// Level 0: Root Goal 1
|
||||
{
|
||||
id: 'root-1',
|
||||
name: 'Root Goal 1',
|
||||
plan: {
|
||||
steps: [
|
||||
// This is an ACTION (no plan), should be ignored
|
||||
{ id: 'action-1', type: 'speech' },
|
||||
|
||||
// Level 1: Child Goal
|
||||
{
|
||||
id: 'child-1',
|
||||
name: 'Child Goal',
|
||||
plan: {
|
||||
steps: [
|
||||
// Level 2: Grandchild Goal
|
||||
{
|
||||
id: 'grandchild-1',
|
||||
name: 'Grandchild',
|
||||
plan: { steps: [] } // Empty plan is still a plan
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
// Level 0: Root Goal 2 (Sibling)
|
||||
{
|
||||
id: 'root-2',
|
||||
name: 'Root Goal 2',
|
||||
plan: { steps: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
it('should flatten nested goals and assign correct depth levels', () => {
|
||||
useProgramStore.getState().setProgramState(complexProgram);
|
||||
|
||||
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
|
||||
|
||||
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
|
||||
expect(goals).toHaveLength(4);
|
||||
|
||||
// Check Root 1
|
||||
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
|
||||
|
||||
// Check Child 1
|
||||
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
|
||||
|
||||
// Check Grandchild 1
|
||||
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
|
||||
|
||||
// Check Root 2
|
||||
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
|
||||
});
|
||||
|
||||
it('should ignore steps that are not goals (missing "plan" property)', () => {
|
||||
useProgramStore.getState().setProgramState(complexProgram);
|
||||
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
|
||||
|
||||
// The 'action-1' object should NOT be in the list
|
||||
const action = goals.find(g => g.id === 'action-1');
|
||||
expect(action).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws if phase does not exist', () => {
|
||||
useProgramStore.getState().setProgramState(complexProgram);
|
||||
|
||||
expect(() =>
|
||||
useProgramStore.getState().getGoalsWithDepth('missing-phase')
|
||||
).toThrow('phase with id:"missing-phase" not found');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the names of all phases in the program', () => {
|
||||
// Define a program specifically with names for this test
|
||||
const programWithNames: ReducedProgram = {
|
||||
phases: [
|
||||
{
|
||||
id: 'phase-1',
|
||||
name: 'Introduction Phase', // Assuming the property is 'name'
|
||||
norms: [],
|
||||
goals: [],
|
||||
triggers: [],
|
||||
},
|
||||
{
|
||||
id: 'phase-2',
|
||||
name: 'Execution Phase',
|
||||
norms: [],
|
||||
goals: [],
|
||||
triggers: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
useProgramStore.getState().setProgramState(programWithNames);
|
||||
|
||||
const phaseNames = useProgramStore.getState().getPhaseNames();
|
||||
expect(phaseNames).toEqual(['Introduction Phase', 'Execution Phase']);
|
||||
});
|
||||
Reference in New Issue
Block a user